#!/usr/bin/env bash
# ============================================================================
# Rollin Host — Wizard de Setup VPS
#
# Uso:
#   bash <(curl -sSL setup.agenciarollin.com)
#
# Padrão de UX (estilo SetupOrion): menu numerado, com hint do que cada stack
# faz, instala uma por vez e volta pro menu. Cores Rollin.
#
# Fluxo:
#   1. Pré-vôo (root, OS suportado)
#   2. Atualiza pacotes + dependências base
#   3. Configura swap se a RAM for baixa
#   4. Firewall UFW (22/80/443)
#   5. Instala Docker + Compose + cria a rede 'rollin-net'
#   6. Pede e-mail pra Let's Encrypt
#   7. Menu interativo: você digita o número da stack, ele instala, volta
#      pro menu. Repita até terminar. Digite 99 pra sair.
#
# Stacks v1 (curadas pro perfil Rollin — n8n / agentes IA / atendimento):
#   01 Traefik       — reverse proxy + HTTPS (RECOMENDADO 1º)
#   02 Portainer     — painel web Docker
#   03 n8n           — automação de fluxos + agentes IA
#   04 Evolution API — integração WhatsApp
#   05 Chatwoot      — atendimento omnichannel
#   06 Typebot       — builder visual de chatbots
#   07 Flowise       — orquestração de fluxos LLM
#   08 Uptime Kuma   — monitoramento de uptime
#   09 NocoDB        — banco de dados no-code (estilo Airtable)
#
# Adicionar mais stacks: crie uma função install_<nome>(), adicione no menu
# (show_menu) e no switch (case) — o resto da estrutura já cuida do HTTPS,
# rede, credenciais, log etc.
#
# Idempotente. Log: /var/log/rollin-setup.log. Credenciais: /root/rollin-credentials.txt
# ============================================================================
set -euo pipefail

ROLLIN_VERSION="2026.06.03c"
LOG_FILE="/var/log/rollin-setup.log"
CRED_FILE="/root/rollin-credentials.txt"
NET="rollin-net"
TZ_DEFAULT="America/Sao_Paulo"
ACME_EMAIL=""

# Modo staging do Let's Encrypt — APENAS pra testes (reset repetido da VPS).
# Ativado com:  RH_ACME_STAGING=1 bash setup.sh
# O CA staging tem limite altíssimo e não esgota o rate limit de produção; em
# troca, o cert é "de mentira" (navegador reclama — normal em teste). Em branco
# = produção (cert real), que é o que os colaboradores usam.
ACME_STAGING="${RH_ACME_STAGING:-}"
ACME_CASERVER_LINE=""
if [ -n "$ACME_STAGING" ]; then
    ACME_CASERVER_LINE=$'\n      - --certificatesresolvers.le.acme.caserver=https://acme-staging-v02.api.letsencrypt.org/directory'
fi

# =============================================================================
# Modo NON-INTERACTIVE — pra cloud-init / fulfillment automatizado.
# =============================================================================
# Quando RH_NONINTERACTIVE=1, todos os prompts são pulados — config vem de
# env vars. Cloud-init (módulo Contabo no WHMCS) seta essas variáveis antes
# do `bash <(curl ...)` pra entregar uma VPS já configurada no primeiro boot.
#
# Vars suportadas em modo headless:
#   RH_NONINTERACTIVE=1                            obrigatório pra ativar o modo
#   RH_STACK=traefik,n8n,evolution                 CSV de stacks (ordem importa)
#   RH_ACME_EMAIL=admin@dominio.com                e-mail pro Let's Encrypt
#   RH_BASE_DOMAIN=meucliente.com                  opcional — se passado, usa
#                                                  ${stack}.${RH_BASE_DOMAIN}.
#                                                  Senão, monta sslip.io com IP.
#
# Domínio default em modo headless: ${stack}.${IP-dashed}.sslip.io
# Ex: n8n.123-45-67-89.sslip.io (Let's Encrypt valida sslip.io normalmente)
# =============================================================================
RH_NONINTERACTIVE="${RH_NONINTERACTIVE:-}"
RH_STACK="${RH_STACK:-}"
RH_ACME_EMAIL_ENV="${RH_ACME_EMAIL:-}"
RH_BASE_DOMAIN="${RH_BASE_DOMAIN:-}"
RH_DOMAIN="${RH_DOMAIN:-}"     # domínio EXATO p/ instalar 1 app (loja 1-clique). Ex: meu-n8n.app.rollinhost.com.br. Tem prioridade sobre RH_BASE_DOMAIN/sslip.
RH_PUBLIC_IP=""                # detectado lazy em rh_detect_public_ip()
RH_CURRENT_STACK=""            # setado pelo main() antes de cada install_*

# Detecta IP público pra montar domínios sslip.io em modo headless.
# Tenta serviços externos primeiro (mais confiável), cai pra `ip route` local.
rh_detect_public_ip() {
    if [ -n "${RH_PUBLIC_IP}" ]; then return 0; fi
    RH_PUBLIC_IP=$(curl -fsSL --max-time 8 https://api.ipify.org   2>/dev/null) \
        || RH_PUBLIC_IP=$(curl -fsSL --max-time 8 https://ifconfig.me 2>/dev/null) \
        || RH_PUBLIC_IP=$(curl -fsSL --max-time 8 https://ipv4.icanhazip.com 2>/dev/null) \
        || RH_PUBLIC_IP=$(ip route get 1.1.1.1 2>/dev/null | awk 'NR==1{for(i=1;i<=NF;i++)if($i=="src"){print $(i+1); exit}}') \
        || RH_PUBLIC_IP=$(hostname -I 2>/dev/null | awk '{print $1}')
    if [ -z "$RH_PUBLIC_IP" ]; then
        RH_PUBLIC_IP="127.0.0.1"   # fallback impossível mas evita string vazia
    fi
    return 0
}

# Gera subdomínio automático pra uma stack em modo headless.
# Se RH_BASE_DOMAIN está setado:  ${stack}.${RH_BASE_DOMAIN}
# Senão:                          ${stack}.${IP-com-traços}.sslip.io
rh_auto_domain() {
    local stack="${1:-app}"
    if [ -n "$RH_BASE_DOMAIN" ]; then
        echo "${stack}.${RH_BASE_DOMAIN}"
        return 0
    fi
    rh_detect_public_ip
    local ip_dashed="${RH_PUBLIC_IP//./-}"
    echo "${stack}.${ip_dashed}.sslip.io"
}

# NÃO usar `exec > >(tee ...)` aqui: esse process substitution conflita com o
# `bash <(curl -fsSL ...)` do comando público (que TAMBÉM é process substitution)
# e trava/bufferiza o wizard no meio. O usuário vê tudo no terminal; as
# credenciais ficam salvas em $CRED_FILE.

# ---------- Cores (paleta Rollin: roxo, dourado, verde, vermelho) ----------
if [ -t 1 ]; then
    C_R=$'\e[31m'; C_G=$'\e[32m'; C_Y=$'\e[33m'; C_B=$'\e[34m'
    C_M=$'\e[35m'; C_C=$'\e[36m'; C_W=$'\e[1;37m'; C_DIM=$'\e[2m'; C_N=$'\e[0m'
else
    C_R=''; C_G=''; C_Y=''; C_B=''; C_M=''; C_C=''; C_W=''; C_DIM=''; C_N=''
fi
log()  { echo "${C_G}✓${C_N} $*"; }
warn() { echo "${C_Y}⚠${C_N} $*"; }
err()  { echo "${C_R}✗${C_N} $*" >&2; }
hr()   { echo; echo "${C_M}━━━━━━━━━━ $* ━━━━━━━━━━${C_N}"; }

banner() {
    local subtitle="${1:-}"
cat <<EOF
${C_M}
╔════════════════════════════════════════════════════════════════════════╗
║                                                                        ║
║      ${C_W}██████╗  ██████╗ ██╗     ██╗     ██╗███╗   ██╗${C_M}                    ║
║      ${C_W}██╔══██╗██╔═══██╗██║     ██║     ██║████╗  ██║${C_M}                    ║
║      ${C_W}██████╔╝██║   ██║██║     ██║     ██║██╔██╗ ██║${C_M}   H  O  S  T       ║
║      ${C_W}██╔══██╗██║   ██║██║     ██║     ██║██║╚██╗██║${C_M}                    ║
║      ${C_W}██║  ██║╚██████╔╝███████╗███████╗██║██║ ╚████║${C_M}                    ║
║      ${C_W}╚═╝  ╚═╝ ╚═════╝ ╚══════╝╚══════╝╚═╝╚═╝  ╚═══╝${C_M}                    ║
║                                                                        ║
║       ${C_C}A primeira cloud especializada em IA na América Latina${C_M}           ║
║                                                                        ║
║       ${C_DIM}─────────────────────────────────────────────────────────${C_M}        ║
║       ${C_W}Suporte${C_M}    suporte@rollinhost.com.br                             ║
║       ${C_W}WhatsApp${C_M}   (19) 3199-2720                                        ║
║       ${C_W}Site${C_M}       rollinhost.com.br                                     ║
║       ${C_DIM}─────────────────────────────────────────────────────────${C_M}        ║
║                                                                        ║
╚════════════════════════════════════════════════════════════════════════╝${C_N}

            ${C_W}${subtitle}${C_N}   ${C_DIM}v${ROLLIN_VERSION}${C_N}

EOF
}

footer() {
cat <<EOF

${C_M}─────────────────────────────────────────────────────────────────────────${C_N}
  ${C_W}Rollin Host${C_N}  ${C_DIM}·${C_N}  A primeira cloud especializada em IA
  ${C_W}Suporte${C_N} suporte@rollinhost.com.br   ${C_W}WhatsApp${C_N} (19) 3199-2720
  ${C_W}Site${C_N} rollinhost.com.br
${C_M}─────────────────────────────────────────────────────────────────────────${C_N}

EOF
}

# ---------- Pre-flight ----------
require_root() {
    if [ "$(id -u)" -ne 0 ]; then
        err "Precisa rodar como root. Tente:"
        err "  sudo bash <(curl -sSL setup.agenciarollin.com)"
        exit 1
    fi
}

detect_os() {
    [ -f /etc/os-release ] || { err "Sistema sem /etc/os-release."; exit 1; }
    . /etc/os-release
    case "${ID:-}" in
        ubuntu|debian) log "Sistema: ${PRETTY_NAME:-$ID}" ;;
        *) err "Sistema $ID não suportado. Use Ubuntu 22+ ou Debian 11+."; exit 1 ;;
    esac
}

install_deps() {
    hr "Dependências base"
    # VPS recém-criada roda apt automático no 1º boot (unattended-upgrades /
    # cloud-init) e segura o lock do dpkg → "Could not get lock". Aguarda liberar.
    local n=0
    while fuser /var/lib/dpkg/lock-frontend /var/lib/dpkg/lock /var/lib/apt/lists/lock >/dev/null 2>&1; do
        [ "$n" -eq 0 ] && warn "Sistema rodando atualizações automáticas — aguardando o apt liberar..."
        sleep 5; n=$((n+1)); [ "$n" -ge 90 ] && break   # no máximo ~7 min
    done
    # Retry do update/install (cobre lock tardio ou fuser ausente na imagem)
    n=0
    until apt-get update -qq 2>/dev/null; do
        n=$((n+1)); [ "$n" -ge 60 ] && { err "apt indisponível (lock do sistema ou rede). Espere ~1 min e rode de novo."; exit 1; }
        sleep 5
    done
    n=0
    until DEBIAN_FRONTEND=noninteractive apt-get install -y -qq \
            curl wget ca-certificates gnupg lsb-release jq ufw apache2-utils tzdata python3 >/dev/null 2>&1; do
        n=$((n+1)); [ "$n" -ge 60 ] && { err "apt install falhou (lock ou rede). Espere ~1 min e rode de novo."; exit 1; }
        sleep 5
    done
    timedatectl set-timezone "$TZ_DEFAULT" 2>/dev/null || true
    log "Pacotes base OK"
}

# ---------- Helpers ----------
rand_pwd() { tr -dc 'A-Za-z0-9' </dev/urandom | head -c "${1:-24}"; echo; }

# Substitui (ou acrescenta) KEY=VALUE num arquivo .env. Seguro pra valores com
# /, +, = (JWT/base64): apaga a linha antiga por âncora exata e reescreve.
set_env_var() {
    local f="$1" k="$2" v="$3"
    [ -f "$f" ] || : > "$f"
    grep -v "^${k}=" "$f" > "${f}.rhtmp" 2>/dev/null || true
    mv "${f}.rhtmp" "$f"
    printf '%s=%s\n' "$k" "$v" >> "$f"
}

# Minta uma chave JWT do Supabase (HS256 assinada com o JWT_SECRET), no MESMO
# formato das anon/service_role keys oficiais. Validade de 10 anos.
# Uso: mint_supabase_jwt "<jwt_secret>" "anon|service_role"
mint_supabase_jwt() {
    RH_JWT_SECRET="$1" RH_JWT_ROLE="$2" python3 - <<'PY'
import os, hmac, hashlib, base64, json, time
b64 = lambda b: base64.urlsafe_b64encode(b).rstrip(b'=')
secret = os.environ['RH_JWT_SECRET'].encode()
role   = os.environ['RH_JWT_ROLE']
now    = int(time.time())
header  = b64(json.dumps({"alg":"HS256","typ":"JWT"}, separators=(',', ':')).encode())
payload = b64(json.dumps({"role": role, "iss": "supabase", "iat": now, "exp": now + 60*60*24*365*10}, separators=(',', ':')).encode())
signing = header + b'.' + payload
sig     = b64(hmac.new(secret, signing, hashlib.sha256).digest())
print((signing + b'.' + sig).decode())
PY
}

save_cred() {
    [ -f "$CRED_FILE" ] || { touch "$CRED_FILE"; chmod 600 "$CRED_FILE"; }
    {
        echo "# $(date '+%F %T')  $1"
        shift
        for kv in "$@"; do echo "  $kv"; done
        echo
    } >> "$CRED_FILE"
}

# IP do servidor de monitoramento (stats.rollinhost) — libera node-exporter/cAdvisor só pra ele.
RH_MONITORING_IP="147.93.3.186"

# Sobe o cAdvisor (métricas POR CONTAINER) consumido pelo Prometheus do
# stats.rollinhost. Rede host + porta 9101 (ufw libera só pro IP do monitoramento,
# e host-network respeita o ufw — diferente de -p, que fura). Idempotente.
ensure_cadvisor() {
    command -v docker >/dev/null 2>&1 || return 0
    if docker ps --format '{{.Names}}' 2>/dev/null | grep -qx rollin-cadvisor; then return 0; fi
    log "Subindo agente de métricas por container (cAdvisor :9101)"
    docker rm -f rollin-cadvisor >/dev/null 2>&1 || true
    docker run -d --name rollin-cadvisor --restart unless-stopped \
        --network host --privileged --device /dev/kmsg \
        -v /:/rootfs:ro -v /var/run:/var/run:ro -v /sys:/sys:ro \
        -v /var/lib/docker/:/var/lib/docker:ro -v /dev/disk/:/dev/disk:ro \
        gcr.io/cadvisor/cadvisor:v0.49.1 \
            -port=9101 -docker_only=true -store_container_labels=false \
            -whitelisted_container_labels=com.docker.compose.project,com.docker.compose.service >/dev/null 2>&1 \
        || warn "cAdvisor não subiu — segue sem histórico por app."
    # Firewall: 9101 só pro servidor de monitoramento (host-network respeita ufw).
    command -v ufw >/dev/null 2>&1 && \
        ufw allow from "$RH_MONITORING_IP" to any port 9101 proto tcp comment 'Rollin cAdvisor' >/dev/null 2>&1 || true
}

# Remove volume Docker órfão de uma instalação anterior (sem container ativo).
# Por quê: ao reinstalar uma stack o script gera uma senha NOVA de Postgres,
# mas se o volume PGDATA velho ainda existe o Postgres ignora a senha nova
# (só inicializa no primeiro boot) → "password authentication failed". Como o
# volume velho é inacessível com a senha nova, é seguro descartá-lo.
# Uso: drop_orphan_volume <nome_do_volume> <nome_do_container_guard>
drop_orphan_volume() {
    local vol="$1" guard="$2"
    if docker volume inspect "$vol" >/dev/null 2>&1 \
       && ! docker ps -a --format '{{.Names}}' | grep -q "^${guard}$"; then
        warn "Volume '$vol' de instalação anterior detectado — removendo (senha nova não casaria)"
        docker volume rm "$vol" >/dev/null 2>&1 || true
    fi
    return 0
}

# Guard universal: detecta stack já instalada e pergunta o que fazer.
# Substitui o `if docker ps -a ... return` que existia só no Traefik —
# antes disso, reinstalar uma stack sobrescrevia /opt/rollin/<stack>/ +
# trocava senhas silenciosamente (cliente perdia o acesso).
#
# Uso: check_existing_install <nome_amigavel> <container_guard> <dir_stack> [vol1 vol2 ...]
# Retorna:
#   0 = stack NÃO existe (segue install normal) OU usuário escolheu reconfigurar
#   1 = stack EXISTE e usuário pulou ou desinstalou (caller deve `return`)
#
# Modo headless (RH_NONINTERACTIVE=1): se já existe, PULA por padrão (não
# sobrescreve). Pra forçar reinstalação setar RH_FORCE_REINSTALL=1.
check_existing_install() {
    local name="$1" guard="$2" dir="$3"; shift 3
    local volumes=("$@")

    docker ps -a --format '{{.Names}}' | grep -q "^${guard}$" || return 0

    # Stack já existe. Decide o que fazer.
    if [ "$RH_NONINTERACTIVE" = "1" ]; then
        if [ "${RH_FORCE_REINSTALL:-}" = "1" ]; then
            warn "$name já existe — RH_FORCE_REINSTALL=1, reinstalando (senhas vão mudar)"
            return 0
        fi
        log "$name já instalado — pulando (use RH_FORCE_REINSTALL=1 pra forçar)"
        return 1
    fi

    echo
    warn "$name já está instalada nesta VPS."
    echo "  ${C_DIM}Container ${guard} ativo, configs em ${dir}${C_N}"
    echo
    echo "  ${C_W}[r]${C_N} Reconfigurar (mantém dados, troca domínio/senhas — pode quebrar quem tá usando)"
    echo "  ${C_W}[d]${C_N} Desinstalar (apaga containers + volumes + configs — sem volta)"
    echo "  ${C_W}[p]${C_N} Pular (não fazer nada, volta ao menu) ${C_DIM}[default]${C_N}"
    echo
    ask_input "Escolha (r/d/p)"
    case "${REPLY,,}" in
        r|reconfig*|reconfigurar)
            warn "Reconfigurando — senhas vão mudar. Se algo já estiver em produção, anote a antiga ANTES de seguir."
            confirm "Confirma reconfigurar?" || { log "Cancelado — pulando"; return 1; }
            return 0
            ;;
        d|desinstal*|del*|remove*)
            warn "Vou apagar $name COMPLETAMENTE (containers, volumes, configs)."
            confirm "Tem certeza? Não tem como desfazer." || { log "Cancelado — pulando"; return 1; }
            if [ -d "$dir" ] && [ -f "$dir/docker-compose.yml" ]; then
                run_with_spinner "Derrubando $name" 30 \
                    "cd '$dir' && docker compose down -v --remove-orphans"
            fi
            local v
            for v in "${volumes[@]}"; do
                docker volume rm "$v" >/dev/null 2>&1 || true
            done
            [ -d "$dir" ] && rm -rf "$dir"
            # Remove entradas antigas do CRED_FILE pra essa stack (limpa resumo)
            if [ -f "$CRED_FILE" ]; then
                local tmp; tmp=$(mktemp)
                awk -v stack="$name" '
                    BEGIN { keep = 1 }
                    /^# / {
                        keep = (index($0, stack) == 0)
                        if (keep) print
                        next
                    }
                    keep { print }
                ' "$CRED_FILE" > "$tmp" && mv "$tmp" "$CRED_FILE"
            fi
            log "$name desinstalada."
            return 1
            ;;
        *)
            log "Pulando $name (mantém como está)."
            return 1
            ;;
    esac
}

# Spinner animado com ETA. Roda um comando em background, mostra tempo
# decorrido e estimativa. Em TTY (ssh -t) anima; sem TTY printa uma linha.
SPINNER_CHARS=('⠋' '⠙' '⠹' '⠸' '⠼' '⠴' '⠦' '⠧' '⠇' '⠏')

run_with_spinner() {
    # run_with_spinner "label visível" eta_segundos "comando completo"
    local label="$1" eta="${2:-60}" cmd="$3"
    local start logfile pid code total
    start=$(date +%s)
    logfile=$(mktemp /tmp/rh-task-XXXXXX.log)

    # Roda em background, captura todo o output (stdout+stderr) no logfile
    bash -c "$cmd" > "$logfile" 2>&1 &
    pid=$!

    if [ -t 1 ]; then
        # TTY: spinner animado
        local i=0 elapsed pct char
        while kill -0 "$pid" 2>/dev/null; do
            elapsed=$(( $(date +%s) - start ))
            pct=$(( elapsed * 100 / eta ))
            [ $pct -gt 99 ] && pct=99
            char="${SPINNER_CHARS[$((i % ${#SPINNER_CHARS[@]}))]}"
            printf "\r  ${C_C}%s${C_N} %s  ${C_DIM}%ss decorridos / ~%ss estimado (%s%%)${C_N}    " \
                   "$char" "$label" "$elapsed" "$eta" "$pct"
            sleep 0.12
            i=$((i + 1))
        done
    else
        # Sem TTY: uma linha só, depois espera
        printf "  ${C_C}...${C_N} %s ${C_DIM}(estimado: %ss)${C_N}\n" "$label" "$eta"
    fi

    code=0
    wait "$pid" || code=$?
    total=$(( $(date +%s) - start ))

    if [ "$code" -eq 0 ]; then
        printf "\r  ${C_G}✓${C_N} %s  ${C_DIM}(%ss)${C_N}                                                              \n" "$label" "$total"
        rm -f "$logfile"
        return 0
    fi

    printf "\r  ${C_R}✗${C_N} %s  ${C_DIM}(falhou em %ss)${C_N}                                                       \n" "$label" "$total"
    err "Últimas 30 linhas do log:"
    sed 's/^/    /' < "$logfile" | tail -30
    err "Log completo: $logfile"
    return "$code"
}

# Banner explicativo ANTES de cada download — avisa tamanho, tempo e que NÃO
# deve fechar a janela. Faz a experiência ficar previsível: o cliente não fica
# olhando o spinner sem saber se vai durar 30 segundos ou 8 minutos.
print_download_banner() {
    # print_download_banner "Nome da stack" "imagens/tamanho" "tempo estimado"
    local name="$1" size="$2" time="$3"
    echo
    echo "  ${C_M}╭─────────────────────────────────────────────────────────────────────╮${C_N}"
    printf "  ${C_M}│${C_N}  ${C_W}📦  Instalando: %-52s${C_M}│${C_N}\n" "$name"
    echo "  ${C_M}│${C_N}                                                                     ${C_M}│${C_N}"
    printf "  ${C_M}│${C_N}     ${C_C}•${C_N} Imagens:        ${C_DIM}%-49s${C_M}│${C_N}\n" "$size"
    printf "  ${C_M}│${C_N}     ${C_C}•${C_N} Tempo estimado: ${C_DIM}%-49s${C_M}│${C_N}\n" "$time"
    echo "  ${C_M}│${C_N}                                                                     ${C_M}│${C_N}"
    echo "  ${C_M}│${C_N}     ${C_Y}⚠  NÃO feche esta janela — o download segue em andamento${C_N}      ${C_M}│${C_N}"
    echo "  ${C_M}│${C_N}     ${C_DIM}(pode parecer parado durante a extração — é normal)${C_N}          ${C_M}│${C_N}"
    echo "  ${C_M}╰─────────────────────────────────────────────────────────────────────╯${C_N}"
    echo
}

# Imprime o prompt via printf em STDOUT (em vez de `read -p` que escreve em
# stderr e fica buffered em SSH não-interativo). Assim a pergunta SEMPRE
# aparece no terminal antes do read bloquear — o usuário sabe que tem que
# digitar algo. Sem isso a UX ficava invisível em ssh sem -t.

ask_input() {
    # ask_input "Texto da pergunta" [valor padrão]
    # IMPORTANTE: termina com `return 0` explícito — sem isso o último
    # `[ -z "$REPLY" ] && ...` pode retornar 1 quando o usuário digita algo,
    # o que com `set -e` no topo do script MATARIA o processo na hora
    # (SSH fecharia a conexão sem aviso). Esse era o bug do "saiu sem pedir
    # o menu" depois do e-mail.
    local prompt="$1" default="${2:-}"
    echo
    if [ -n "$default" ]; then
        printf "  ${C_C}?${C_N} ${C_W}%s${C_N}\n" "$prompt"
        printf "    ${C_DIM}(padrão: %s — ENTER aceita)${C_N}\n" "$default"
    else
        printf "  ${C_C}?${C_N} ${C_W}%s${C_N}\n" "$prompt"
    fi
    printf "  ${C_C}>${C_N} "
    read -r REPLY || true
    if [ -z "$REPLY" ] && [ -n "$default" ]; then
        REPLY="$default"
    fi
    return 0
}

confirm() {
    # confirm "pergunta sim/não"  → retorna 0 se sim
    # Em modo headless: sempre sim (não tem como confirmar).
    if [ "$RH_NONINTERACTIVE" = "1" ]; then
        return 0
    fi
    echo
    printf "  ${C_C}?${C_N} ${C_W}%s${C_N}\n" "$1"
    printf "    ${C_DIM}[s = sim, n = não — ENTER assume não]${C_N}\n"
    printf "  ${C_C}>${C_N} "
    local r
    read -r r
    [[ "$r" =~ ^[sSyY] ]]
}

# Valida formato de subdomínio. Pega erro comum de digitar e-mail em vez de
# subdomínio (portainer@rollinhost.com.br ← errado).
validate_domain() {
    local d
    d=$(echo "$1" | tr '[:upper:]' '[:lower:]' | xargs)  # lowercase + trim
    if [[ "$d" == *@* ]]; then
        err "❌ '$d' parece um e-mail (tem @). Pra subdomínio use ponto:"
        err "   formato correto:  subdominio.seudominio.com.br"
        err "   (ex: portainer.rollinhost.com.br — com PONTO, não @)"
        return 1
    fi
    if ! [[ "$d" =~ ^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?(\.[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?)+$ ]]; then
        err "❌ Domínio inválido: '$d'"
        err "   Use só letras minúsculas, números, hífen e ponto."
        err "   Formato: subdominio.dominio.com.br"
        return 1
    fi
    return 0
}

# Pede um subdomínio, valida o formato e mostra uma tela de CONFIRMAÇÃO com
# opção de corrigir antes de prosseguir — assim o usuário pega erro de digitação
# na hora, não lá no fim quando a URL não funciona. Devolve o valor já limpo
# (minúsculas + sem espaços) em REPLY. Retorna 1 se deixar em branco (= pular).
ask_domain() {
    # === Bypass non-interactive ===
    # Em modo headless, gera o domínio automaticamente baseado em RH_CURRENT_STACK
    # + IP público (sslip.io) ou RH_BASE_DOMAIN. Pula o loop de confirmação.
    if [ "$RH_NONINTERACTIVE" = "1" ]; then
        local stack="${RH_CURRENT_STACK:-app}"
        # RH_DOMAIN (loja 1-clique): domínio exato p/ o app. Não se aplica ao
        # Traefik (prepend automático), que não usa domínio próprio.
        if [ -n "$RH_DOMAIN" ] && [ "$stack" != "traefik" ]; then
            REPLY="$RH_DOMAIN"
        else
            REPLY=$(rh_auto_domain "$stack")
        fi
        log "Domínio (modo headless): ${C_W}$REPLY${C_N}"
        return 0
    fi
    local prompt="$1" d
    while true; do
        ask_input "$prompt"
        d=$(printf '%s' "$REPLY" | tr '[:upper:]' '[:lower:]' | xargs)
        if [ -z "$d" ]; then
            REPLY=""; return 1
        fi
        if ! validate_domain "$d"; then
            warn "Vamos digitar de novo."
            continue
        fi
        echo
        echo "  ${C_W}Confira o subdomínio digitado:${C_N}"
        echo "    ${C_C}➜  $d${C_N}"
        if confirm "Está correto?"; then
            REPLY="$d"
            return 0
        fi
        warn "Sem problema — digita de novo, com calma."
    done
}

dns_warning() {
    # === Bypass non-interactive ===
    # sslip.io sempre resolve pro IP embutido — não precisa apontar DNS.
    # RH_BASE_DOMAIN é responsabilidade do operador (deveria já apontar).
    if [ "$RH_NONINTERACTIVE" = "1" ]; then
        return 0
    fi
    # Aceita 1 OU MAIS domínios como argumentos separados (ex: Typebot passa
    # builder + viewer). Valida cada um antes de avisar. NÃO passar os domínios
    # numa string só ("a e b") — quebra a validação.
    local d
    for d in "$@"; do
        validate_domain "$d" || return 1
    done

    # Avisa que precisa apontar DNS antes de instalar uma stack com domínio
    echo
    echo "  ${C_Y}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${C_N}"
    echo "  ${C_Y}⚠  ATENÇÃO — passo obrigatório antes de continuar${C_N}"
    echo "  ${C_Y}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${C_N}"
    echo
    if [ "$#" -gt 1 ]; then
        echo "  Aponte ${C_W}estes domínios${C_N} pro IP desta VPS:"
        for d in "$@"; do echo "    • ${C_W}$d${C_N}"; done
    else
        echo "  Aponte o domínio ${C_W}$1${C_N} pro IP desta VPS:"
    fi
    echo "    ↳ registro tipo ${C_W}A${C_N} no seu DNS (Cloudflare, Registro.br, etc.)"
    echo "    ↳ ${C_R}NÃO use proxy do Cloudflare (nuvem laranja)${C_N} — deixa em DNS-only"
    echo "    ↳ espere ~30 segundos pra propagar"
    echo
    echo "  ${C_DIM}Sem isso, o Let's Encrypt não consegue emitir o HTTPS.${C_N}"
    confirm "Já apontou o DNS e ele propagou?" \
        || { warn "Tranquilo — aponta o DNS e volta ao menu quando estiver pronto."; return 1; }
    return 0
}

# ---------- Swap ----------
setup_swap() {
    hr "Swap"
    if swapon --show 2>/dev/null | grep -q '/'; then log "Swap já configurado"; return; fi
    local mem_mb; mem_mb=$(awk '/MemTotal/ {print int($2/1024)}' /proc/meminfo)
    if [ "$mem_mb" -ge 4096 ]; then log "RAM ${mem_mb}MB suficiente — swap pulado"; return; fi
    local size="2G"; [ "$mem_mb" -lt 2048 ] && size="4G"
    fallocate -l "$size" /swapfile
    chmod 600 /swapfile
    mkswap /swapfile >/dev/null
    swapon /swapfile
    grep -q '/swapfile' /etc/fstab || echo "/swapfile none swap sw 0 0" >> /etc/fstab
    log "Swap de $size criado"
}

# ---------- Firewall ----------
setup_firewall() {
    hr "Firewall (UFW)"
    ufw --force reset >/dev/null
    ufw default deny incoming >/dev/null
    ufw default allow outgoing >/dev/null
    ufw allow 22/tcp  comment 'ssh'   >/dev/null
    ufw allow 80/tcp  comment 'http'  >/dev/null
    ufw allow 443/tcp comment 'https' >/dev/null
    ufw --force enable >/dev/null
    log "UFW ativo (22, 80, 443)"
}

# ---------- Banner MOTD (login SSH) ----------
# Instala um banner Rollin Host que aparece a cada login SSH (em vez do MOTD
# padrão da Contabo/Hetzner). Identidade visual na sessão do cliente sempre.
setup_motd() {
    # Roda silencioso — o cliente vê o resultado naturalmente no próximo login
    # SSH (banner Rollin) e no prompt (rln-vps-XXX). Sem cabeçalho de seção.

    # Hostname: troca pré-fixos de provedor (Contabo/Hetzner/etc.) por rln-vps-*.
    # Cliente NÃO deve ver "vmi3313312" no prompt — tem que ver "rln-vps-XXX".
    local cur new_hn id
    cur=$(hostname)
    case "$cur" in
        rln-vps-*|rollin-*)
            : # já está branded, nada a fazer
            ;;
        vmi*|v22*|*.contaboserver.net|*hetzner*|*.your-server.de|ubuntu*|cloud*|debian*|localhost*)
            # Extrai número do hostname provedor, ou gera aleatório
            id=$(echo "$cur" | grep -oE '[0-9]+' | head -1)
            [ -z "$id" ] && id=$(tr -dc '0-9' </dev/urandom | head -c 5)
            new_hn="rln-vps-$id"
            hostnamectl set-hostname "$new_hn" 2>/dev/null || hostname "$new_hn"
            # Atualiza /etc/hosts pra o novo hostname resolver (evita warning sudo)
            if grep -q "127.0.1.1" /etc/hosts; then
                sed -i "s/127.0.1.1.*/127.0.1.1 $new_hn/" /etc/hosts
            else
                echo "127.0.1.1 $new_hn" >> /etc/hosts
            fi
            ;;
        *)
            : # hostname customizado pelo cliente, respeitar
            ;;
    esac

    # Banner em formato CAIXA — idêntico ao que o módulo Contabo do WHMCS
    # injeta via cloud-init (contabo.php:462). Mantém UX consistente entre
    # VPS provisionada pelo painel Rollin e VPS instalada manualmente via
    # setup.sh. O grande banner ASCII só aparece DURANTE o install (one-shot);
    # esse aqui é o de cada login.
    cat > /etc/update-motd.d/00-rollin-host <<'MOTD_SCRIPT'
#!/bin/bash
printf "\n"
printf "  +------------------------------------------+\n"
printf "  |                                          |\n"
printf "  |          R O L L I N   H O S T           |\n"
printf "  |          ---------------------           |\n"
printf "  |          Cloud Server  -  Brasil         |\n"
printf "  |                                          |\n"
printf "  |          painel.rollinhost.com.br        |\n"
printf "  |          contato@rollinhost.com.br       |\n"
printf "  |                                          |\n"
printf "  +------------------------------------------+\n"
printf "\n"
MOTD_SCRIPT
    chmod +x /etc/update-motd.d/00-rollin-host

    # Cria /etc/issue.net (banner pré-login, opcional mas adiciona presença)
    cat > /etc/issue.net <<'ISSUE'
Rollin Host Cloud Server
Acesso autorizado somente. Atividades sao registradas.
ISSUE

    # Zera o /etc/motd estático (alguns sistemas exibem ele junto).
    : > /etc/motd

    # Remove banners de outros provedores (Contabo, Hetzner) que aparecem
    # na pasta — mantém só os helpers padrão do Ubuntu e o nosso.
    find /etc/update-motd.d/ -type f \
        \( -iname "*contabo*" -o -iname "*hetzner*" -o -iname "*aws*" -o -iname "*provider*" \) \
        -delete 2>/dev/null || true

    # Desliga a "Canonical news" feed (texto promocional que polui o MOTD).
    [ -f /etc/default/motd-news ] && \
        sed -i 's/^ENABLED=1/ENABLED=0/' /etc/default/motd-news 2>/dev/null || true
}

# ---------- Docker ----------
install_docker() {
    hr "Docker + Compose"
    if command -v docker >/dev/null 2>&1; then
        log "Docker já presente: $(docker --version)"
    else
        curl -fsSL https://get.docker.com | sh >/dev/null 2>&1
        systemctl enable --now docker
        log "Docker instalado"
    fi
    docker compose version >/dev/null 2>&1 || { err "Docker Compose v2 não encontrado"; exit 1; }
    docker network inspect "$NET" >/dev/null 2>&1 \
        || docker network create "$NET" >/dev/null
    log "Rede docker '$NET' pronta"
}

# ============================================================================
# STACKS — cada install_<nome>() é auto-contida:
#   - pede o(s) subdomínio(s)
#   - cria pasta /opt/rollin/<nome>/
#   - gera senhas aleatórias e salva em $CRED_FILE
#   - escreve docker-compose.yml com labels do Traefik (HTTPS auto)
#   - sobe a stack
# ============================================================================

# ---------- 01 Traefik ----------
install_traefik() {
    hr "01 · Traefik (reverse proxy + HTTPS)"
    check_existing_install "Traefik" "rollin-traefik" "/opt/rollin/traefik" || return 0
    mkdir -p /opt/rollin/traefik/letsencrypt
    touch /opt/rollin/traefik/letsencrypt/acme.json
    chmod 600 /opt/rollin/traefik/letsencrypt/acme.json

    # Dashboard do Traefik é OPCIONAL: dá uma UI web com routers, certs e
    # serviços (útil pra debug), mas adiciona uma URL admin a mais. Protegida
    # por basic auth gerada automaticamente. Default: NÃO expor.
    local dash_domain="" dash_user="" dash_pwd="" dash_hash=""
    local dash_extra_cmd="" dash_labels=""
    if confirm "Expor o dashboard do Traefik num subdomínio? (debug visual de routers e certs)"; then
        if ask_domain "Subdomínio do dashboard (ex: traefik.seudominio.com.br):"; then
            dash_domain="$REPLY"
        else
            dash_domain=""
        fi
        if [ -n "$dash_domain" ] && dns_warning "$dash_domain"; then
            dash_user="admin"
            dash_pwd=$(rand_pwd 16)
            # htpasswd gera hash bcrypt com $$ — dentro do docker-compose
            # o `$` precisa virar `$$` pra não ser interpretado como variável.
            dash_hash=$(htpasswd -nbB "$dash_user" "$dash_pwd" | sed -e 's/\$/\$\$/g')
            dash_extra_cmd=$'\n      - --api.dashboard=true\n      - --api.insecure=false'
            dash_labels=$(cat <<LABELS

    labels:
      - traefik.enable=true
      - traefik.http.routers.dashboard.rule=Host(\`$dash_domain\`)
      - traefik.http.routers.dashboard.entrypoints=websecure
      - traefik.http.routers.dashboard.tls.certresolver=le
      - traefik.http.routers.dashboard.service=api@internal
      - traefik.http.routers.dashboard.middlewares=dashboard-auth
      - traefik.http.middlewares.dashboard-auth.basicauth.users=$dash_hash
LABELS
)
        else
            warn "Dashboard pulado — Traefik vai subir só como proxy"
            dash_domain=""
        fi
    fi

    cat > /opt/rollin/traefik/docker-compose.yml <<COMPOSE
services:
  traefik:
    # traefik:v3.7.1 — as versões 3.4.x e anteriores travavam a negociação de
    # versão da API Docker em 1.24, que o daemon 28+/29+ RECUSA ("client version
    # 1.24 is too old, minimum is 1.40") deixando o Traefik cego pros containers
    # (sem router, sem cert, 404/cert default). A 3.7.x corrigiu a negociação e
    # resolve sozinha, sem precisar mexer no daemon. NÃO usar :latest (muda sem
    # aviso e pode quebrar) — versão fixa garante reprodutibilidade.
    image: traefik:v3.7.1
    container_name: rollin-traefik
    restart: unless-stopped
    ports: ["80:80", "443:443"]
    volumes:
      - /var/run/docker.sock:/var/run/docker.sock:ro
      - ./letsencrypt:/letsencrypt
    command:
      - --providers.docker=true
      - --providers.docker.exposedbydefault=false
      - --providers.docker.network=$NET
      - --entrypoints.web.address=:80
      - --entrypoints.web.http.redirections.entrypoint.to=websecure
      - --entrypoints.web.http.redirections.entrypoint.scheme=https
      - --entrypoints.websecure.address=:443
      - --certificatesresolvers.le.acme.tlschallenge=true
      - --certificatesresolvers.le.acme.email=$ACME_EMAIL
      - --certificatesresolvers.le.acme.storage=/letsencrypt/acme.json${ACME_CASERVER_LINE}${dash_extra_cmd}${dash_labels}
    networks: [rollin]

networks:
  rollin:
    external: true
    name: $NET
COMPOSE
    print_download_banner "Traefik" "1 imagem · ~150 MB" "30 a 60 segundos"
    run_with_spinner "Subindo Traefik" 60 \
        "cd /opt/rollin/traefik && docker compose up -d --quiet-pull"

    if [ -n "$dash_domain" ]; then
        save_cred "Traefik (dashboard)" \
            "url=https://$dash_domain" \
            "user=$dash_user" \
            "password=$dash_pwd"
        log "Traefik rodando — dashboard: https://$dash_domain  user=$dash_user"
    else
        log "Traefik rodando — HTTPS automático pras próximas stacks"
    fi
}

# ---------- 02 Portainer ----------
install_easypanel() {
    hr "17 · Easypanel (painel completo)"

    # Easypanel vira o DONO da VPS: instala o proprio Traefik nas portas 80/443 e
    # gerencia o Docker. Por isso NAO convive com o Traefik do rollin — os apps
    # (Hermes, n8n, etc.) sobem DENTRO do Easypanel, pela interface dele.
    warn "O Easypanel e um painel completo e vira o PROXY da VPS (portas 80/443)."
    echo "  ${C_DIM}Ele gerencia o Docker sozinho — entao NAO roda junto com o Traefik do${C_N}"
    echo "  ${C_DIM}rollin nem com as outras stacks deste menu. Depois de instalado, voce${C_N}"
    echo "  ${C_DIM}sobe seus apps (Hermes, n8n...) DENTRO do Easypanel.${C_N}"
    echo

    if docker ps -a --format '{{.Names}}' 2>/dev/null | grep -qi 'easypanel'; then
        warn "Easypanel ja parece instalado nesta VPS. Pulando."
        return 0
    fi

    # Conflito: Traefik do rollin rodando OU portas 80/443 ja ocupadas
    local conflict=""
    if docker ps --format '{{.Names}}' 2>/dev/null | grep -q '^rollin-traefik$'; then
        conflict="o Traefik do rollin (rollin-traefik) esta rodando"
    elif ss -ltnH 2>/dev/null | grep -qE ':(80|443)([^0-9]|$)'; then
        conflict="as portas 80/443 ja estao em uso"
    fi

    if [ -n "$conflict" ]; then
        warn "Conflito: $conflict."
        echo "  ${C_DIM}O Easypanel precisa das portas 80/443. Pra instalar, e preciso DERRUBAR${C_N}"
        echo "  ${C_DIM}o Traefik do rollin — e as stacks atras dele PARAM de responder.${C_N}"
        if [ "$RH_NONINTERACTIVE" = "1" ]; then
            err "Headless: nao vou derrubar o Traefik automaticamente. Use uma VPS limpa ou remova o Traefik antes."
            return 1
        fi
        ask_input "Derrubar o Traefik do rollin agora pra instalar o Easypanel? (digite SIM pra confirmar)"
        if [ "$REPLY" != "SIM" ]; then
            warn "Cancelado — Easypanel nao instalado."
            return 0
        fi
        log "Derrubando o Traefik do rollin..."
        ( cd /opt/rollin/traefik 2>/dev/null && docker compose down ) >/dev/null 2>&1
        docker rm -f rollin-traefik >/dev/null 2>&1 || true
    fi

    print_download_banner "Easypanel" "instalador oficial" "1 a 3 minutos"
    log "Instalando o Easypanel (get.easypanel.io)..."
    if curl -sSL https://get.easypanel.io | sh; then
        local ip; ip=$(curl -s --max-time 8 https://api.ipify.org 2>/dev/null); [ -z "$ip" ] && ip=$(hostname -I 2>/dev/null | awk '{print $1}')
        save_cred "Easypanel" "url=http://${ip}:3000" "setup=crie o admin no primeiro acesso e configure seu dominio no painel"
        log "Easypanel instalado! Acesse: ${C_W}http://${ip}:3000${C_N}"
        echo "  ${C_DIM}No 1o acesso: crie o admin, aponte seu dominio e suba seus apps por la.${C_N}"
    else
        err "Falha ao instalar o Easypanel. Veja a saida acima."
        return 1
    fi
}

install_portainer() {
    hr "02 · Portainer"
    check_existing_install "Portainer" "rollin-portainer" "/opt/rollin/portainer" || return 0
    local domain
    ask_domain "Subdomínio do Portainer (ex: portainer.seudominio.com.br):" \
        || { warn "Sem domínio — pulado"; return 0; }
    domain="$REPLY"
    dns_warning "$domain" || return 0

    mkdir -p /opt/rollin/portainer
    cat > /opt/rollin/portainer/docker-compose.yml <<COMPOSE
services:
  portainer:
    image: portainer/portainer-ce:latest
    container_name: rollin-portainer
    restart: unless-stopped
    volumes:
      - /var/run/docker.sock:/var/run/docker.sock
      - portainer_data:/data
    labels:
      - traefik.enable=true
      - traefik.http.routers.portainer.rule=Host(\`$domain\`)
      - traefik.http.routers.portainer.entrypoints=websecure
      - traefik.http.routers.portainer.tls.certresolver=le
      - traefik.http.services.portainer.loadbalancer.server.port=9000
    networks: [rollin]

volumes:
  portainer_data:

networks:
  rollin:
    external: true
    name: $NET
COMPOSE
    print_download_banner "Portainer" "1 imagem · ~120 MB" "30 a 60 segundos"
    run_with_spinner "Subindo Portainer" 60 \
        "cd /opt/rollin/portainer && docker compose up -d --quiet-pull"
    save_cred "Portainer" "url=https://$domain" "setup=crie admin na primeira visita (≤5 min)"
    log "Portainer no ar: https://$domain"
}

# ---------- 03 n8n ----------
install_n8n() {
    hr "03 · n8n"
    check_existing_install "n8n" "rollin-n8n" "/opt/rollin/n8n" || return 0
    local domain
    ask_domain "Subdomínio do n8n (ex: n8n.seudominio.com.br):" \
        || { warn "Sem domínio — pulado"; return 0; }
    domain="$REPLY"
    dns_warning "$domain" || return 0

    local pg_pwd  n8n_user="admin"  n8n_pwd  n8n_enc
    pg_pwd=$(rand_pwd 24); n8n_pwd=$(rand_pwd 16); n8n_enc=$(rand_pwd 40)

    mkdir -p /opt/rollin/n8n
    cat > /opt/rollin/n8n/docker-compose.yml <<COMPOSE
services:
  postgres:
    image: postgres:16-alpine
    container_name: rollin-n8n-pg
    restart: unless-stopped
    environment:
      POSTGRES_DB: n8n
      POSTGRES_USER: n8n
      POSTGRES_PASSWORD: $pg_pwd
    volumes: [n8n_pg:/var/lib/postgresql/data]
    networks: [rollin]

  n8n:
    image: n8nio/n8n:latest
    container_name: rollin-n8n
    restart: unless-stopped
    depends_on: [postgres]
    environment:
      N8N_HOST: $domain
      N8N_PORT: 5678
      N8N_PROTOCOL: https
      WEBHOOK_URL: https://$domain/
      N8N_EDITOR_BASE_URL: https://$domain/
      GENERIC_TIMEZONE: $TZ_DEFAULT
      TZ: $TZ_DEFAULT
      N8N_ENCRYPTION_KEY: $n8n_enc
      N8N_USER_MANAGEMENT_DISABLED: "false"
      DB_TYPE: postgresdb
      DB_POSTGRESDB_HOST: postgres
      DB_POSTGRESDB_DATABASE: n8n
      DB_POSTGRESDB_USER: n8n
      DB_POSTGRESDB_PASSWORD: $pg_pwd
    volumes: [n8n_data:/home/node/.n8n]
    labels:
      - traefik.enable=true
      - traefik.http.routers.n8n.rule=Host(\`$domain\`)
      - traefik.http.routers.n8n.entrypoints=websecure
      - traefik.http.routers.n8n.tls.certresolver=le
      - traefik.http.services.n8n.loadbalancer.server.port=5678
    networks: [rollin]

volumes:
  n8n_data:
  n8n_pg:

networks:
  rollin:
    external: true
    name: $NET
COMPOSE
    # Descarta PGDATA órfão de instalação anterior (senha nova não casaria).
    drop_orphan_volume "n8n_n8n_pg" "rollin-n8n-pg"

    print_download_banner "n8n + Postgres" "2 imagens · ~750 MB" "2 a 4 minutos"
    run_with_spinner "Subindo n8n + Postgres" 240 \
        "cd /opt/rollin/n8n && docker compose up -d --quiet-pull"
    # n8n usa cadastro próprio no 1º acesso (user management nativo) — sem basic auth.
    save_cred "n8n" "url=https://$domain" "setup=crie sua conta de dono no primeiro acesso" "encryption_key=$n8n_enc" "postgres_pwd=$pg_pwd"
    log "n8n no ar: https://$domain  (crie a conta de dono no 1º acesso)"
}

# ---------- 04 Evolution API ----------
install_evolution() {
    hr "04 · Evolution API (WhatsApp)"
    check_existing_install "Evolution API" "rollin-evolution" "/opt/rollin/evolution" || return 0
    local domain
    ask_domain "Subdomínio da Evolution (ex: evolution.seudominio.com.br):" \
        || { warn "Sem domínio — pulado"; return 0; }
    domain="$REPLY"
    dns_warning "$domain" || return 0

    # Evolution API v2: ao contrário da v1 (standalone), a v2 EXIGE Postgres +
    # Redis. Sem `DATABASE_PROVIDER` válido ela entra em loop de crash
    # ("Error: Database provider invalid") e nem fica de pé → 000/404.
    local api_key pg_pwd redis_pwd
    api_key=$(rand_pwd 32); pg_pwd=$(rand_pwd 24); redis_pwd=$(rand_pwd 24)
    mkdir -p /opt/rollin/evolution
    cat > /opt/rollin/evolution/docker-compose.yml <<COMPOSE
services:
  evo-postgres:
    image: postgres:16-alpine
    container_name: rollin-evo-pg
    restart: unless-stopped
    environment:
      POSTGRES_DB: evolution
      POSTGRES_USER: evolution
      POSTGRES_PASSWORD: $pg_pwd
    volumes: [evo_pg:/var/lib/postgresql/data]
    networks: [rollin]

  evo-redis:
    image: redis:7-alpine
    container_name: rollin-evo-redis
    restart: unless-stopped
    command: ["redis-server", "--requirepass", "$redis_pwd"]
    networks: [rollin]

  evolution:
    # Fork ativo da comunidade (evoapicloud) - resolve bugs do QR/manager UI que o
    # repo atendai original (parado em fev/2025) tem. Pinada na ultima estavel.
    image: evoapicloud/evolution-api:v2.3.7
    container_name: rollin-evolution
    restart: unless-stopped
    depends_on: [evo-postgres, evo-redis]
    environment:
      SERVER_URL: https://$domain
      AUTHENTICATION_API_KEY: $api_key
      # Banco (obrigatório na v2)
      DATABASE_ENABLED: "true"
      DATABASE_PROVIDER: postgresql
      DATABASE_CONNECTION_URI: postgresql://evolution:$pg_pwd@evo-postgres:5432/evolution?schema=public
      DATABASE_CONNECTION_CLIENT_NAME: evolution
      DATABASE_SAVE_DATA_INSTANCE: "true"
      DATABASE_SAVE_DATA_NEW_MESSAGE: "true"
      DATABASE_SAVE_MESSAGE_UPDATE: "true"
      DATABASE_SAVE_DATA_CONTACTS: "true"
      DATABASE_SAVE_DATA_CHATS: "true"
      # Cache em Redis (recomendado na v2)
      CACHE_REDIS_ENABLED: "true"
      CACHE_REDIS_URI: redis://default:$redis_pwd@evo-redis:6379/6
      CACHE_REDIS_PREFIX_KEY: evolution
      CACHE_REDIS_SAVE_INSTANCES: "false"
      CACHE_LOCAL_ENABLED: "false"
      QRCODE_LIMIT: "30"
      LANGUAGE: pt-BR
    volumes:
      - evolution_instances:/evolution/instances
    labels:
      - traefik.enable=true
      - traefik.http.routers.evolution.rule=Host(\`$domain\`)
      - traefik.http.routers.evolution.entrypoints=websecure
      - traefik.http.routers.evolution.tls.certresolver=le
      - traefik.http.services.evolution.loadbalancer.server.port=8080
    networks: [rollin]

volumes:
  evo_pg:
  evolution_instances:

networks:
  rollin:
    external: true
    name: $NET
COMPOSE
    # Descarta PGDATA órfão de instalação anterior (senha nova não casaria).
    drop_orphan_volume "evolution_evo_pg" "rollin-evo-pg"

    print_download_banner "Evolution API v2 (API + Postgres + Redis)" "3 imagens · ~600 MB" "2 a 3 minutos"
    run_with_spinner "Subindo Evolution API (roda migração do banco)" 180 \
        "cd /opt/rollin/evolution && docker compose up -d --quiet-pull"
    # URL aponta pro /manager — a UI da v2 (a raiz / não serve interface).
    save_cred "Evolution API" "url=https://$domain/manager" "api_key=$api_key" "postgres_pwd=$pg_pwd" "redis_pwd=$redis_pwd"
    log "Evolution API no ar: https://$domain/manager  (API key salva)"
}

# ---------- 05 Chatwoot ----------
install_chatwoot() {
    hr "05 · Chatwoot (atendimento omnichannel)"
    check_existing_install "Chatwoot" "rollin-chatwoot" "/opt/rollin/chatwoot" || return 0
    local domain
    ask_domain "Subdomínio do Chatwoot (ex: chat.seudominio.com.br):" \
        || { warn "Sem domínio — pulado"; return 0; }
    domain="$REPLY"
    dns_warning "$domain" || return 0

    local pg_pwd  redis_pwd  secret
    pg_pwd=$(rand_pwd 24); redis_pwd=$(rand_pwd 24); secret=$(rand_pwd 64)
    mkdir -p /opt/rollin/chatwoot
    cat > /opt/rollin/chatwoot/docker-compose.yml <<COMPOSE
services:
  cw-postgres:
    # pgvector/pgvector:pg16 — Chatwoot 3+ usa a extensao vector no schema
    # (PG::UndefinedFile: vector.control). A imagem postgres:14-alpine padrao
    # NAO tem essa extensao. Essa imagem oficial do pgvector ja vem com tudo.
    image: pgvector/pgvector:pg16
    container_name: rollin-cw-pg
    restart: unless-stopped
    environment:
      POSTGRES_DB: chatwoot
      POSTGRES_USER: chatwoot
      POSTGRES_PASSWORD: $pg_pwd
    volumes: [cw_pg:/var/lib/postgresql/data]
    networks: [rollin]

  cw-redis:
    image: redis:7-alpine
    container_name: rollin-cw-redis
    restart: unless-stopped
    command: ["redis-server", "--requirepass", "$redis_pwd"]
    networks: [rollin]

  chatwoot:
    image: chatwoot/chatwoot:latest
    container_name: rollin-chatwoot
    restart: unless-stopped
    depends_on: [cw-postgres, cw-redis]
    entrypoint: docker/entrypoints/rails.sh
    command: ["bundle", "exec", "rails", "s", "-p", "3000", "-b", "0.0.0.0"]
    environment:
      RAILS_ENV: production
      FRONTEND_URL: https://$domain
      SECRET_KEY_BASE: $secret
      POSTGRES_HOST: cw-postgres
      POSTGRES_USERNAME: chatwoot
      POSTGRES_PASSWORD: $pg_pwd
      POSTGRES_DATABASE: chatwoot
      REDIS_URL: redis://:$redis_pwd@cw-redis:6379
      DEFAULT_LOCALE: pt_BR
      TZ: $TZ_DEFAULT
    volumes: [cw_storage:/app/storage]
    labels:
      - traefik.enable=true
      - traefik.http.routers.chatwoot.rule=Host(\`$domain\`)
      - traefik.http.routers.chatwoot.entrypoints=websecure
      - traefik.http.routers.chatwoot.tls.certresolver=le
      - traefik.http.services.chatwoot.loadbalancer.server.port=3000
    networks: [rollin]

  chatwoot-worker:
    image: chatwoot/chatwoot:latest
    container_name: rollin-cw-worker
    restart: unless-stopped
    depends_on: [cw-postgres, cw-redis, chatwoot]
    entrypoint: docker/entrypoints/rails.sh
    command: ["bundle", "exec", "sidekiq", "-C", "config/sidekiq.yml"]
    environment:
      RAILS_ENV: production
      SECRET_KEY_BASE: $secret
      POSTGRES_HOST: cw-postgres
      POSTGRES_USERNAME: chatwoot
      POSTGRES_PASSWORD: $pg_pwd
      POSTGRES_DATABASE: chatwoot
      REDIS_URL: redis://:$redis_pwd@cw-redis:6379
    volumes: [cw_storage:/app/storage]
    networks: [rollin]

volumes:
  cw_pg:
  cw_storage:

networks:
  rollin:
    external: true
    name: $NET
COMPOSE
    # Descarta PGDATA órfão de instalação anterior, senão a senha nova gerada
    # acima não bate com o banco já inicializado (password authentication failed).
    drop_orphan_volume "chatwoot_cw_pg" "rollin-cw-pg"

    # Chatwoot é o pesado — imagem ~1.2GB + postgres + redis. O `docker compose
    # run` aqui dispara o pull dos 3, depois roda o rails db:chatwoot_prepare.
    print_download_banner "Chatwoot (Rails + Postgres + Redis)" "3 imagens · ~1.5 GB" "5 a 8 minutos"
    run_with_spinner "Baixando imagens e preparando banco do Chatwoot" 420 \
        "cd /opt/rollin/chatwoot && docker compose run --rm chatwoot bundle exec rails db:chatwoot_prepare"
    run_with_spinner "Subindo Chatwoot (web + worker)" 60 \
        "cd /opt/rollin/chatwoot && docker compose up -d --quiet-pull"
    save_cred "Chatwoot" "url=https://$domain" "postgres_pwd=$pg_pwd" "redis_pwd=$redis_pwd" "secret_key=$secret" "setup=crie superadmin em https://$domain/installation/onboarding"
    log "Chatwoot no ar: https://$domain (crie o superadmin no primeiro acesso)"
}

# ---------- 06 Typebot ----------
install_typebot() {
    hr "06 · Typebot (builder de chatbots)"
    check_existing_install "Typebot" "rollin-tb-builder" "/opt/rollin/typebot" || return 0
    # 3 domínios: builder (editor), viewer (bots publicados) e storage S3 (MinIO).
    # O storage PRECISA ser público pras URLs de mídia funcionarem (ex: WhatsApp
    # baixar a imagem). Em headless (loja 1-clique) vêm em RH_TB_BUILDER/VIEWER/STORAGE.
    local b_domain v_domain s_domain
    if [ "$RH_NONINTERACTIVE" = "1" ]; then
        b_domain="${RH_TB_BUILDER:-}"; v_domain="${RH_TB_VIEWER:-}"; s_domain="${RH_TB_STORAGE:-}"
        if [ -z "$b_domain" ] || [ -z "$v_domain" ] || [ -z "$s_domain" ]; then
            warn "Headless: RH_TB_BUILDER/VIEWER/STORAGE obrigatórios. Pulando Typebot."
            return 0
        fi
        log "Typebot domínios (headless): builder=$b_domain · viewer=$v_domain · storage=$s_domain"
    else
        ask_domain "Subdomínio do BUILDER do Typebot (ex: builder.seudominio.com.br):" \
            || { warn "Sem domínio — pulado"; return 0; }
        b_domain="$REPLY"
        ask_domain "Subdomínio do VIEWER do Typebot (ex: bot.seudominio.com.br):" \
            || { warn "Sem domínio — pulado"; return 0; }
        v_domain="$REPLY"
        ask_domain "Subdomínio do STORAGE/S3 do Typebot (ex: tb-s3.seudominio.com.br):" \
            || { warn "Sem domínio — pulado"; return 0; }
        s_domain="$REPLY"
        dns_warning "$b_domain" "$v_domain" "$s_domain" || return 0
    fi

    # === Typebot v3+ EXIGE SMTP + ADMIN_EMAIL pra fazer login (magic link).
    #     Sem isso, a tela de login mostra "Did you forget to setup an
    #     authentication provider..." e ninguem consegue acessar.
    #     Pedimos pro operador os dados de SMTP (Gmail App Password e similar). ===
    local tb_admin_email tb_smtp_host tb_smtp_port tb_smtp_user tb_smtp_pass tb_smtp_secure

    # --- Modo HEADLESS: le envs RH_TYPEBOT_* (sem prompts) ---
    if [ "$RH_NONINTERACTIVE" = "1" ]; then
        tb_admin_email="${RH_TYPEBOT_ADMIN_EMAIL:-${ACME_EMAIL:-}}"
        tb_smtp_host="${RH_TYPEBOT_SMTP_HOST:-smtp.gmail.com}"
        tb_smtp_port="${RH_TYPEBOT_SMTP_PORT:-587}"
        tb_smtp_user="${RH_TYPEBOT_SMTP_USER:-$tb_admin_email}"
        tb_smtp_pass="${RH_TYPEBOT_SMTP_PASS:-}"; tb_smtp_pass="${tb_smtp_pass// /}"  # remove espacos: App Password colada com espacos quebra o SMTP do Gmail
        if [ -z "$tb_admin_email" ] || [ -z "$tb_smtp_pass" ]; then
            warn "Headless: RH_TYPEBOT_ADMIN_EMAIL e RH_TYPEBOT_SMTP_PASS sao obrigatorios. Pulando Typebot."
            return 0
        fi
        # Typebot v3+ espera SMTP_SECURE como "true"/"false" (NAO tls/ssl).
        # porta 465 = SSL puro = true; demais (587/STARTTLS) = false
        tb_smtp_secure="false"; [ "$tb_smtp_port" = "465" ] && tb_smtp_secure="true"
        log "Typebot SMTP (headless): $tb_smtp_user @ $tb_smtp_host:$tb_smtp_port (secure=$tb_smtp_secure)"
    else
        # --- Modo INTERATIVO: prompts + test SMTP + retry ---
        echo
        echo "  ${C_W}Configuracao de autenticacao do Typebot${C_N}"
        echo "  ${C_DIM}Typebot v3+ exige SMTP pra enviar magic link de login.${C_N}"
        echo "  ${C_DIM}Sugestao Gmail: criar 'Senha de app' em myaccount.google.com/security${C_N}"
        echo "  ${C_DIM}(precisa 2FA ativada). Outros providers: Outlook smtp-mail.outlook.com:587,${C_N}"
        echo "  ${C_DIM}Brevo smtp-relay.brevo.com:587, SendGrid smtp.sendgrid.net:587.${C_N}"
        echo

        local _tries=0
        while [ "$_tries" -lt 3 ]; do
            ask_input "E-mail do administrador do Typebot (recebera o magic link)"
            tb_admin_email="$REPLY"
            ask_input "SMTP host (default: smtp.gmail.com)"
            tb_smtp_host="${REPLY:-smtp.gmail.com}"
            ask_input "SMTP porta (default: 587)"
            tb_smtp_port="${REPLY:-587}"
            ask_input "SMTP usuario (geralmente o proprio e-mail)"
            tb_smtp_user="${REPLY:-$tb_admin_email}"
            ask_input "SMTP senha (App Password do Gmail — 16 chars sem espacos)"
            tb_smtp_pass="${REPLY// /}"  # remove espacos: App Password colada com espacos quebra o SMTP do Gmail

            if [ -z "$tb_admin_email" ] || [ -z "$tb_smtp_pass" ]; then
                warn "ADMIN_EMAIL e SMTP_PASSWORD sao obrigatorios. Tentativa $((_tries+1))/3."
                _tries=$((_tries+1)); continue
            fi

            # Typebot v3+ espera SMTP_SECURE como "true"/"false" (NAO tls/ssl).
            tb_smtp_secure="false"; [ "$tb_smtp_port" = "465" ] && tb_smtp_secure="true"

            # Test SMTP antes de gerar docker-compose
            echo "  ${C_DIM}Testando conexao SMTP...${C_N}"
            if RH_HOST="$tb_smtp_host" RH_PORT="$tb_smtp_port" \
               RH_USER="$tb_smtp_user" RH_PASS="$tb_smtp_pass" \
               RH_SEC="$tb_smtp_secure" python3 - <<'PYTEST' 2>/tmp/rh-smtp-err.log
import os, smtplib, sys
h=os.environ['RH_HOST']; p=int(os.environ['RH_PORT'])
u=os.environ['RH_USER']; pw=os.environ['RH_PASS']; sec=os.environ['RH_SEC']
try:
    s = smtplib.SMTP_SSL(h, p, timeout=10) if sec == 'true' else smtplib.SMTP(h, p, timeout=10)
    if sec != 'true': s.starttls()
    s.login(u, pw); s.quit()
    sys.exit(0)
except Exception as e:
    print(str(e), file=sys.stderr); sys.exit(1)
PYTEST
            then
                log "SMTP OK — login via magic link vai funcionar"
                break
            else
                _err=$(cat /tmp/rh-smtp-err.log 2>/dev/null | head -1)
                warn "SMTP falhou: $_err"
                echo "  ${C_DIM}Causas comuns:${C_N}"
                echo "  ${C_DIM}  - Gmail sem 2FA: ative em myaccount.google.com/security${C_N}"
                echo "  ${C_DIM}  - Senha errada: App Password tem 16 chars (sem espacos)${C_N}"
                echo "  ${C_DIM}  - Host/porta: confira na doc do seu provider de email${C_N}"
                _tries=$((_tries+1))
                if [ "$_tries" -lt 3 ]; then
                    confirm "Quer tentar de novo? (n = pular Typebot)" || { warn "Typebot pulado."; return 0; }
                fi
            fi
        done

        if [ "$_tries" -ge 3 ]; then
            warn "3 tentativas falharam. Typebot pulado — configure depois manualmente."
            return 0
        fi
    fi

    local pg_pwd enc_key minio_pwd
    pg_pwd=$(rand_pwd 24); enc_key=$(rand_pwd 32); minio_pwd=$(rand_pwd 32)
    mkdir -p /opt/rollin/typebot
    cat > /opt/rollin/typebot/docker-compose.yml <<COMPOSE
services:
  tb-postgres:
    image: postgres:16-alpine
    container_name: rollin-tb-pg
    restart: unless-stopped
    environment:
      POSTGRES_DB: typebot
      POSTGRES_USER: typebot
      POSTGRES_PASSWORD: $pg_pwd
    volumes: [tb_pg:/var/lib/postgresql/data]
    networks: [rollin]

  typebot-builder:
    image: baptistearno/typebot-builder:latest
    container_name: rollin-tb-builder
    restart: unless-stopped
    depends_on: [tb-postgres]
    environment:
      DATABASE_URL: postgresql://typebot:$pg_pwd@tb-postgres:5432/typebot
      ENCRYPTION_SECRET: $enc_key
      NEXTAUTH_URL: https://$b_domain
      NEXT_PUBLIC_VIEWER_URL: https://$v_domain
      DISABLE_SIGNUP: "false"
      # Admin + SMTP (sem isso, login via magic link nao funciona)
      ADMIN_EMAIL: $tb_admin_email
      SMTP_HOST: $tb_smtp_host
      SMTP_PORT: "$tb_smtp_port"
      SMTP_USERNAME: $tb_smtp_user
      SMTP_PASSWORD: $tb_smtp_pass
      SMTP_SECURE: "$tb_smtp_secure"
      NEXT_PUBLIC_SMTP_FROM: "'Rollin Host' <$tb_smtp_user>"
      # Storage S3 (MinIO) — sem isso o upload de imagem some silenciosamente
      S3_ACCESS_KEY: typebot
      S3_SECRET_KEY: $minio_pwd
      S3_BUCKET: typebot
      S3_ENDPOINT: $s_domain
      S3_PORT: "443"
      S3_SSL: "true"
      S3_REGION: us-east-1
    labels:
      - traefik.enable=true
      - traefik.http.routers.tb-builder.rule=Host(\`$b_domain\`)
      - traefik.http.routers.tb-builder.entrypoints=websecure
      - traefik.http.routers.tb-builder.tls.certresolver=le
      - traefik.http.services.tb-builder.loadbalancer.server.port=3000
    networks: [rollin]

  typebot-viewer:
    image: baptistearno/typebot-viewer:latest
    container_name: rollin-tb-viewer
    restart: unless-stopped
    depends_on: [tb-postgres]
    environment:
      DATABASE_URL: postgresql://typebot:$pg_pwd@tb-postgres:5432/typebot
      ENCRYPTION_SECRET: $enc_key
      NEXTAUTH_URL: https://$b_domain
      NEXT_PUBLIC_VIEWER_URL: https://$v_domain
      # SMTP precisa estar tambem no viewer pra coletar emails em forms
      SMTP_HOST: $tb_smtp_host
      SMTP_PORT: "$tb_smtp_port"
      SMTP_USERNAME: $tb_smtp_user
      SMTP_PASSWORD: $tb_smtp_pass
      SMTP_SECURE: "$tb_smtp_secure"
      NEXT_PUBLIC_SMTP_FROM: "'Rollin Host' <$tb_smtp_user>"
      # Storage S3 (MinIO) — builder e viewer precisam das mesmas credenciais
      S3_ACCESS_KEY: typebot
      S3_SECRET_KEY: $minio_pwd
      S3_BUCKET: typebot
      S3_ENDPOINT: $s_domain
      S3_PORT: "443"
      S3_SSL: "true"
      S3_REGION: us-east-1
    labels:
      - traefik.enable=true
      - traefik.http.routers.tb-viewer.rule=Host(\`$v_domain\`)
      - traefik.http.routers.tb-viewer.entrypoints=websecure
      - traefik.http.routers.tb-viewer.tls.certresolver=le
      - traefik.http.services.tb-viewer.loadbalancer.server.port=3000
    networks: [rollin]

  tb-minio:
    image: minio/minio:latest
    container_name: rollin-tb-minio
    restart: unless-stopped
    command: server /data --console-address ":9001"
    environment:
      MINIO_ROOT_USER: typebot
      MINIO_ROOT_PASSWORD: $minio_pwd
    volumes: [tb_minio:/data]
    labels:
      - traefik.enable=true
      - traefik.http.routers.tb-minio.rule=Host(\`$s_domain\`)
      - traefik.http.routers.tb-minio.entrypoints=websecure
      - traefik.http.routers.tb-minio.tls.certresolver=le
      - traefik.http.services.tb-minio.loadbalancer.server.port=9000
    networks: [rollin]

volumes:
  tb_pg:
  tb_minio:

networks:
  rollin:
    external: true
    name: $NET
COMPOSE
    # Descarta PGDATA órfão de instalação anterior (senha nova não casaria).
    drop_orphan_volume "typebot_tb_pg" "rollin-tb-pg"

    print_download_banner "Typebot (builder + viewer + MinIO + Postgres)" "4 imagens · ~1.2 GB" "3 a 5 minutos"
    run_with_spinner "Subindo Typebot (builder + viewer + MinIO + Postgres)" 300 \
        "cd /opt/rollin/typebot && docker compose up -d --quiet-pull"
    # Cria o bucket S3 + leitura pública (sem isso o upload de imagem some).
    # PITFALL: a imagem minio/mc tem ENTRYPOINT 'mc' — precisa --entrypoint sh.
    run_with_spinner "Criando bucket de storage (MinIO)" 60 \
        "sleep 6 && docker run --rm --network $NET --entrypoint sh minio/mc -c 'mc alias set local http://rollin-tb-minio:9000 typebot $minio_pwd && mc mb -p local/typebot && mc anonymous set download local/typebot'"
    save_cred "Typebot" "builder=https://$b_domain" "viewer=https://$v_domain" "storage_s3=https://$s_domain" "user=$tb_admin_email" "setup=Abra o endereço e faça login com seu e-mail: o Typebot envia um link de acesso (confira a caixa de entrada)." "smtp_host=$tb_smtp_host" "smtp_user=$tb_smtp_user" "encryption_secret=$enc_key" "postgres_pwd=$pg_pwd" "minio_user=typebot" "minio_pwd=$minio_pwd"
    log "Typebot no ar: builder https://$b_domain · viewer https://$v_domain · storage https://$s_domain"
}

# ---------- Supabase (self-hosted, 1-clique) ----------
# A Rollin GERA todos os segredos que o concorrente joga na cara do cliente
# (JWT secret, anon/service_role keys, senhas, chaves de vault). O cliente só
# escolhe o nome. Usamos o docker/ OFICIAL do Supabase (sparse checkout) e só
# automatizamos o .env + roteamento Traefik.
#   SEGURANÇA: o compose oficial publica portas no host (kong 8000/8443, pooler
#   5432/6543). Como o Docker FURA o ufw, zeramos essas portas com `!override []`
#   no override e expomos só via Traefik (rede interna). Nada de Postgres público.
install_supabase() {
    hr "Supabase (self-hosted)"
    check_existing_install "Supabase" "supabase-kong" "/opt/rollin/supabase" || return 0

    # Aviso de RAM (Supabase full = ~10 containers, recomenda ~2 GB).
    local ram_mb
    ram_mb=$(free -m 2>/dev/null | awk '/^Mem:/{print $2}')
    if [ -n "$ram_mb" ] && [ "$ram_mb" -lt 1900 ]; then
        warn "Supabase recomenda ~2 GB de RAM (detectado ${ram_mb} MB) — pode ficar instável."
        if [ "$RH_NONINTERACTIVE" != "1" ]; then
            confirm "Instalar mesmo assim?" || { warn "Supabase pulado."; return 0; }
        fi
    fi

    local domain
    if [ "$RH_NONINTERACTIVE" = "1" ]; then
        domain="${RH_DOMAIN:-}"
        [ -z "$domain" ] && { warn "Headless: RH_DOMAIN obrigatório. Pulando Supabase."; return 0; }
        log "Supabase domínio (headless): $domain"
    else
        ask_domain "Subdomínio do Supabase (ex: supabase.seudominio.com.br):" \
            || { warn "Sem domínio — pulado"; return 0; }
        domain="$REPLY"
        dns_warning "$domain" || return 0
    fi

    # --- Segredos gerados (o que o concorrente pede pro cliente preencher) ---
    local pg_pwd jwt_secret anon_key service_key dash_user dash_pwd secret_key_base vault_key lf_public lf_private pooler_pwd
    pg_pwd=$(rand_pwd 24); jwt_secret=$(rand_pwd 48); dash_user="admin"; dash_pwd=$(rand_pwd 16)
    secret_key_base=$(rand_pwd 64); vault_key=$(rand_pwd 32)
    lf_public=$(rand_pwd 32); lf_private=$(rand_pwd 32); pooler_pwd=$(rand_pwd 24)

    # anon/service_role = JWTs assinados com o jwt_secret
    anon_key=$(mint_supabase_jwt "$jwt_secret" "anon")
    service_key=$(mint_supabase_jwt "$jwt_secret" "service_role")
    if [ -z "$anon_key" ] || [ -z "$service_key" ]; then
        err "Falha ao gerar as chaves JWT do Supabase (python3 indisponível?)."; return 1
    fi

    # --- Baixa o docker/ OFICIAL do Supabase (sparse, sem clonar o monorepo todo) ---
    rm -rf /tmp/rh-supabase-src
    run_with_spinner "Baixando o Supabase oficial" 180 \
        "git clone --depth 1 --filter=blob:none --sparse https://github.com/supabase/supabase /tmp/rh-supabase-src && cd /tmp/rh-supabase-src && git sparse-checkout set docker"
    if [ ! -f /tmp/rh-supabase-src/docker/docker-compose.yml ]; then
        err "Não consegui baixar o Supabase oficial (rede/git)."; rm -rf /tmp/rh-supabase-src; return 1
    fi
    mkdir -p /opt/rollin/supabase
    cp -a /tmp/rh-supabase-src/docker/. /opt/rollin/supabase/
    cp -f /opt/rollin/supabase/.env.example /opt/rollin/supabase/.env
    rm -rf /tmp/rh-supabase-src

    # --- Automatiza o .env (o que era a tela de 30 campos) ---
    local envf=/opt/rollin/supabase/.env
    set_env_var "$envf" POSTGRES_PASSWORD "$pg_pwd"
    set_env_var "$envf" JWT_SECRET "$jwt_secret"
    set_env_var "$envf" ANON_KEY "$anon_key"
    set_env_var "$envf" SERVICE_ROLE_KEY "$service_key"
    set_env_var "$envf" DASHBOARD_USERNAME "$dash_user"
    set_env_var "$envf" DASHBOARD_PASSWORD "$dash_pwd"
    set_env_var "$envf" SECRET_KEY_BASE "$secret_key_base"
    set_env_var "$envf" VAULT_ENC_KEY "$vault_key"
    set_env_var "$envf" LOGFLARE_PUBLIC_ACCESS_TOKEN "$lf_public"
    set_env_var "$envf" LOGFLARE_PRIVATE_ACCESS_TOKEN "$lf_private"
    set_env_var "$envf" POOLER_TENANT_ID "rollin"
    set_env_var "$envf" POOLER_DB_POOL_SIZE "5"
    set_env_var "$envf" SITE_URL "https://$domain"
    set_env_var "$envf" API_EXTERNAL_URL "https://$domain"
    set_env_var "$envf" SUPABASE_PUBLIC_URL "https://$domain"
    set_env_var "$envf" STUDIO_DEFAULT_ORGANIZATION "Rollin Host"
    set_env_var "$envf" STUDIO_DEFAULT_PROJECT "$domain"

    # --- Override: zera as portas no host + roteia o Kong pelo Traefik ---
    # `!override []` substitui (não concatena) a lista de ports da imagem base.
    cat > /opt/rollin/supabase/docker-compose.override.yml <<COMPOSE
services:
  kong:
    ports: !override []
    networks: [default, rollin]
    labels:
      - traefik.enable=true
      - traefik.docker.network=$NET
      - traefik.http.routers.supabase.rule=Host(\`$domain\`)
      - traefik.http.routers.supabase.entrypoints=websecure
      - traefik.http.routers.supabase.tls.certresolver=le
      - traefik.http.services.supabase.loadbalancer.server.port=8000
  db:
    ports: !override []
  supavisor:
    ports: !override []
networks:
  rollin:
    external: true
    name: $NET
COMPOSE

    print_download_banner "Supabase (Studio, Kong, Auth, Postgres, Storage…)" "10+ imagens · ~3 GB" "5 a 12 minutos"
    run_with_spinner "Subindo o Supabase (pode demorar)" 900 \
        "cd /opt/rollin/supabase && docker compose up -d --quiet-pull"

    save_cred "Supabase" "url=https://$domain" "user=$dash_user" "password=$dash_pwd" \
        "setup=Abra o endereço e entre no Studio com o usuário e a senha acima." \
        "anon_key=$anon_key" "service_role_key=$service_key" "jwt_secret=$jwt_secret" "postgres_pwd=$pg_pwd"
    log "Supabase no ar: https://$domain  (Studio: $dash_user / $dash_pwd)"
}

# ---------- 07 Flowise ----------
install_flowise() {
    hr "07 · Flowise (orquestração LLM)"
    check_existing_install "Flowise" "rollin-flowise" "/opt/rollin/flowise" || return 0
    local domain
    ask_domain "Subdomínio do Flowise (ex: flowise.seudominio.com.br):" \
        || { warn "Sem domínio — pulado"; return 0; }
    domain="$REPLY"
    dns_warning "$domain" || return 0

    local fw_user="admin"; local fw_pwd; fw_pwd=$(rand_pwd 16)
    mkdir -p /opt/rollin/flowise
    cat > /opt/rollin/flowise/docker-compose.yml <<COMPOSE
services:
  flowise:
    image: flowiseai/flowise:latest
    container_name: rollin-flowise
    restart: unless-stopped
    environment:
      PORT: 3000
      FLOWISE_USERNAME: $fw_user
      FLOWISE_PASSWORD: $fw_pwd
    volumes: [flowise_data:/root/.flowise]
    labels:
      - traefik.enable=true
      - traefik.http.routers.flowise.rule=Host(\`$domain\`)
      - traefik.http.routers.flowise.entrypoints=websecure
      - traefik.http.routers.flowise.tls.certresolver=le
      - traefik.http.services.flowise.loadbalancer.server.port=3000
    networks: [rollin]

volumes:
  flowise_data:

networks:
  rollin:
    external: true
    name: $NET
COMPOSE
    print_download_banner "Flowise" "1 imagem · ~600 MB" "2 a 3 minutos"
    run_with_spinner "Subindo Flowise" 180 \
        "cd /opt/rollin/flowise && docker compose up -d --quiet-pull"
    save_cred "Flowise" "url=https://$domain" "user=$fw_user" "password=$fw_pwd"
    log "Flowise no ar: https://$domain  user=$fw_user  pwd=$fw_pwd"
}

# ---------- 08 Uptime Kuma ----------
install_uptime_kuma() {
    hr "08 · Uptime Kuma (monitoramento)"
    check_existing_install "Uptime Kuma" "rollin-uptime-kuma" "/opt/rollin/uptime-kuma" || return 0
    local domain
    ask_domain "Subdomínio do Uptime Kuma (ex: status.seudominio.com.br):" \
        || { warn "Sem domínio — pulado"; return 0; }
    domain="$REPLY"
    dns_warning "$domain" || return 0

    mkdir -p /opt/rollin/uptime-kuma
    cat > /opt/rollin/uptime-kuma/docker-compose.yml <<COMPOSE
services:
  uptime-kuma:
    image: louislam/uptime-kuma:1
    container_name: rollin-uptime-kuma
    restart: unless-stopped
    volumes: [uptime_data:/app/data]
    labels:
      - traefik.enable=true
      - traefik.http.routers.uptime.rule=Host(\`$domain\`)
      - traefik.http.routers.uptime.entrypoints=websecure
      - traefik.http.routers.uptime.tls.certresolver=le
      - traefik.http.services.uptime.loadbalancer.server.port=3001
    networks: [rollin]

volumes:
  uptime_data:

networks:
  rollin:
    external: true
    name: $NET
COMPOSE
    print_download_banner "Uptime Kuma" "1 imagem · ~250 MB" "1 a 2 minutos"
    run_with_spinner "Subindo Uptime Kuma" 120 \
        "cd /opt/rollin/uptime-kuma && docker compose up -d --quiet-pull"
    save_cred "Uptime Kuma" "url=https://$domain" "setup=crie admin no primeiro acesso"
    log "Uptime Kuma no ar: https://$domain (crie admin no 1º acesso)"
}

# ---------- 09 NocoDB ----------
install_nocodb() {
    hr "09 · NocoDB (banco no-code estilo Airtable)"
    check_existing_install "NocoDB" "rollin-nocodb" "/opt/rollin/nocodb" || return 0
    local domain
    ask_domain "Subdomínio do NocoDB (ex: nocodb.seudominio.com.br):" \
        || { warn "Sem domínio — pulado"; return 0; }
    domain="$REPLY"
    dns_warning "$domain" || return 0

    local pg_pwd; pg_pwd=$(rand_pwd 24)
    mkdir -p /opt/rollin/nocodb
    cat > /opt/rollin/nocodb/docker-compose.yml <<COMPOSE
services:
  noco-postgres:
    image: postgres:16-alpine
    container_name: rollin-noco-pg
    restart: unless-stopped
    environment:
      POSTGRES_DB: nocodb
      POSTGRES_USER: nocodb
      POSTGRES_PASSWORD: $pg_pwd
    volumes: [noco_pg:/var/lib/postgresql/data]
    networks: [rollin]

  nocodb:
    image: nocodb/nocodb:latest
    container_name: rollin-nocodb
    restart: unless-stopped
    depends_on: [noco-postgres]
    environment:
      NC_DB: "pg://noco-postgres:5432?u=nocodb&p=$pg_pwd&d=nocodb"
      NC_PUBLIC_URL: https://$domain
    volumes: [noco_data:/usr/app/data]
    labels:
      - traefik.enable=true
      - traefik.http.routers.nocodb.rule=Host(\`$domain\`)
      - traefik.http.routers.nocodb.entrypoints=websecure
      - traefik.http.routers.nocodb.tls.certresolver=le
      - traefik.http.services.nocodb.loadbalancer.server.port=8080
    networks: [rollin]

volumes:
  noco_pg:
  noco_data:

networks:
  rollin:
    external: true
    name: $NET
COMPOSE
    # Descarta PGDATA órfão de instalação anterior (senha nova não casaria).
    drop_orphan_volume "nocodb_noco_pg" "rollin-noco-pg"

    print_download_banner "NocoDB + Postgres" "2 imagens · ~950 MB" "3 a 4 minutos"
    run_with_spinner "Subindo NocoDB + Postgres" 240 \
        "cd /opt/rollin/nocodb && docker compose up -d --quiet-pull"
    save_cred "NocoDB" "url=https://$domain" "postgres_pwd=$pg_pwd" "setup=crie super-admin no 1º acesso"
    log "NocoDB no ar: https://$domain"
}

# ---------- code-server (VS Code no navegador, com Python + Node) ----------
# Build de uma imagem a partir do code-server oficial, acrescentando Python3 e
# Node/npm — assim a pessoa programa direto na VPS. Login por senha (PASSWORD).
install_codeserver() {
    hr "code-server (VS Code no navegador)"
    check_existing_install "code-server" "rollin-code-server" "/opt/rollin/codeserver" || return 0
    local domain
    ask_domain "Subdomínio do code-server (ex: code.seudominio.com.br):" \
        || { warn "Sem domínio — pulado"; return 0; }
    domain="$REPLY"
    dns_warning "$domain" || return 0

    local pwd; pwd=$(rand_pwd 24)
    mkdir -p /opt/rollin/codeserver
    cat > /opt/rollin/codeserver/Dockerfile <<'DOCKER'
FROM codercom/code-server:latest
USER root
RUN apt-get update \
 && apt-get install -y --no-install-recommends python3 python3-pip python3-venv nodejs npm git build-essential \
 && rm -rf /var/lib/apt/lists/* \
 && mkdir -p /home/coder/.config /home/coder/project \
 && chown -R coder:coder /home/coder
USER coder
DOCKER
    cat > /opt/rollin/codeserver/docker-compose.yml <<COMPOSE
services:
  code-server:
    build: .
    image: rollin-code-server
    container_name: rollin-code-server
    restart: unless-stopped
    environment:
      PASSWORD: $pwd
    volumes:
      - cs_config:/home/coder/.config
      - cs_project:/home/coder/project
    labels:
      - traefik.enable=true
      - traefik.http.routers.codeserver.rule=Host(\`$domain\`)
      - traefik.http.routers.codeserver.entrypoints=websecure
      - traefik.http.routers.codeserver.tls.certresolver=le
      - traefik.http.services.codeserver.loadbalancer.server.port=8080
    networks: [rollin]

volumes:
  cs_config:
  cs_project:

networks:
  rollin:
    external: true
    name: $NET
COMPOSE
    print_download_banner "VS Code (code-server) + Python + Node" "build da imagem · ~600 MB" "3 a 5 minutos"
    run_with_spinner "Preparando o VS Code (build + Python + Node)" 480 \
        "cd /opt/rollin/codeserver && docker compose up -d --build"
    save_cred "code-server" "url=https://$domain" "password=$pwd" "setup=Entre com a senha acima. Seus arquivos ficam na pasta 'project'."
    log "code-server no ar: https://$domain"
}

# ---------- Adminer (painel visual de bancos) ----------
# Sem banco próprio: conecta nos bancos que já rodam na VPS (mesma rede docker),
# usando o nome do container como "Servidor".
install_adminer() {
    hr "Adminer (painel visual de bancos)"
    check_existing_install "Adminer" "rollin-adminer" "/opt/rollin/adminer" || return 0
    local domain
    ask_domain "Subdomínio do Adminer (ex: db.seudominio.com.br):" \
        || { warn "Sem domínio — pulado"; return 0; }
    domain="$REPLY"
    dns_warning "$domain" || return 0

    mkdir -p /opt/rollin/adminer
    cat > /opt/rollin/adminer/docker-compose.yml <<COMPOSE
services:
  adminer:
    image: adminer:latest
    container_name: rollin-adminer
    restart: unless-stopped
    labels:
      - traefik.enable=true
      - traefik.http.routers.adminer.rule=Host(\`$domain\`)
      - traefik.http.routers.adminer.entrypoints=websecure
      - traefik.http.routers.adminer.tls.certresolver=le
      - traefik.http.services.adminer.loadbalancer.server.port=8080
    networks: [rollin]

networks:
  rollin:
    external: true
    name: $NET
COMPOSE
    print_download_banner "Adminer" "1 imagem · ~90 MB" "1 minuto"
    run_with_spinner "Subindo o Adminer" 120 \
        "cd /opt/rollin/adminer && docker compose up -d --quiet-pull"
    save_cred "Adminer" "url=https://$domain" "setup=No login: Sistema = tipo do banco; Servidor = nome do container (ex: rollin-noco-pg) na mesma VPS; usuário e senha do seu banco."
    log "Adminer no ar: https://$domain"
}

# ---------- MinIO (armazenamento compatível com S3) ----------
# Console público (9001) via Traefik; API S3 (9000) fica interna na rede docker
# pros outros apps consumirem (mais seguro que expor o S3 publicamente).
install_minio() {
    hr "MinIO (armazenamento compatível com S3)"
    check_existing_install "MinIO" "rollin-minio" "/opt/rollin/minio" || return 0
    local domain
    ask_domain "Subdomínio do MinIO (ex: storage.seudominio.com.br):" \
        || { warn "Sem domínio — pulado"; return 0; }
    domain="$REPLY"
    dns_warning "$domain" || return 0

    local user="rollinadmin"; local pwd; pwd=$(rand_pwd 28)
    mkdir -p /opt/rollin/minio
    cat > /opt/rollin/minio/docker-compose.yml <<COMPOSE
services:
  minio:
    image: minio/minio:latest
    container_name: rollin-minio
    restart: unless-stopped
    command: server /data --console-address ":9001"
    environment:
      MINIO_ROOT_USER: $user
      MINIO_ROOT_PASSWORD: $pwd
      MINIO_BROWSER_REDIRECT_URL: https://$domain
    volumes: [minio_data:/data]
    labels:
      - traefik.enable=true
      - traefik.http.routers.minio.rule=Host(\`$domain\`)
      - traefik.http.routers.minio.entrypoints=websecure
      - traefik.http.routers.minio.tls.certresolver=le
      - traefik.http.services.minio.loadbalancer.server.port=9001
    networks: [rollin]

volumes:
  minio_data:

networks:
  rollin:
    external: true
    name: $NET
COMPOSE
    print_download_banner "MinIO" "1 imagem · ~200 MB" "1 a 2 minutos"
    run_with_spinner "Subindo o MinIO" 180 \
        "cd /opt/rollin/minio && docker compose up -d --quiet-pull"
    save_cred "MinIO" "url=https://$domain" "user=$user" "password=$pwd" "setup=Painel de administração do storage. A API S3 fica interna (minio:9000) pros seus apps na mesma VPS."
    log "MinIO no ar: https://$domain"
}

# ---------- Nextcloud (Drive privado) ----------
install_nextcloud() {
    hr "Nextcloud (seu Drive privado)"
    check_existing_install "Nextcloud" "rollin-nextcloud" "/opt/rollin/nextcloud" || return 0
    local domain
    ask_domain "Subdomínio do Nextcloud (ex: drive.seudominio.com.br):" \
        || { warn "Sem domínio — pulado"; return 0; }
    domain="$REPLY"
    dns_warning "$domain" || return 0

    local pg_pwd adm_pwd
    pg_pwd=$(rand_pwd 24); adm_pwd=$(rand_pwd 20)
    mkdir -p /opt/rollin/nextcloud
    cat > /opt/rollin/nextcloud/docker-compose.yml <<COMPOSE
services:
  nc-db:
    image: postgres:16-alpine
    container_name: rollin-nc-db
    restart: unless-stopped
    environment:
      POSTGRES_DB: nextcloud
      POSTGRES_USER: nextcloud
      POSTGRES_PASSWORD: $pg_pwd
    volumes: [nc_db:/var/lib/postgresql/data]
    networks: [rollin]

  nextcloud:
    image: nextcloud:latest
    container_name: rollin-nextcloud
    restart: unless-stopped
    depends_on: [nc-db]
    environment:
      POSTGRES_HOST: nc-db
      POSTGRES_DB: nextcloud
      POSTGRES_USER: nextcloud
      POSTGRES_PASSWORD: $pg_pwd
      NEXTCLOUD_ADMIN_USER: admin
      NEXTCLOUD_ADMIN_PASSWORD: $adm_pwd
      NEXTCLOUD_TRUSTED_DOMAINS: $domain
      OVERWRITEPROTOCOL: https
      OVERWRITEHOST: $domain
      OVERWRITECLIURL: https://$domain
      TRUSTED_PROXIES: 172.16.0.0/12
    volumes: [nc_data:/var/www/html]
    labels:
      - traefik.enable=true
      - traefik.http.routers.nextcloud.rule=Host(\`$domain\`)
      - traefik.http.routers.nextcloud.entrypoints=websecure
      - traefik.http.routers.nextcloud.tls.certresolver=le
      - traefik.http.services.nextcloud.loadbalancer.server.port=80
    networks: [rollin]

volumes:
  nc_db:
  nc_data:

networks:
  rollin:
    external: true
    name: $NET
COMPOSE
    drop_orphan_volume "nextcloud_nc_db" "rollin-nc-db"
    print_download_banner "Nextcloud + Postgres" "2 imagens · ~1.2 GB" "3 a 5 minutos"
    run_with_spinner "Subindo o Nextcloud" 360 \
        "cd /opt/rollin/nextcloud && docker compose up -d --quiet-pull"
    save_cred "Nextcloud" "url=https://$domain" "user=admin" "password=$adm_pwd" "setup=O primeiro acesso pode levar 1-2 min inicializando. Depois entre com 'admin' e a senha acima."
    log "Nextcloud no ar: https://$domain"
}

# ---------- Vaultwarden (gerenciador de senhas) ----------
install_vaultwarden() {
    hr "Vaultwarden (gerenciador de senhas)"
    check_existing_install "Vaultwarden" "rollin-vaultwarden" "/opt/rollin/vaultwarden" || return 0
    local domain
    ask_domain "Subdomínio do Vaultwarden (ex: senhas.seudominio.com.br):" \
        || { warn "Sem domínio — pulado"; return 0; }
    domain="$REPLY"
    dns_warning "$domain" || return 0

    local token; token=$(rand_pwd 40)
    mkdir -p /opt/rollin/vaultwarden
    cat > /opt/rollin/vaultwarden/docker-compose.yml <<COMPOSE
services:
  vaultwarden:
    image: vaultwarden/server:latest
    container_name: rollin-vaultwarden
    restart: unless-stopped
    environment:
      DOMAIN: https://$domain
      ADMIN_TOKEN: $token
      SIGNUPS_ALLOWED: "true"
    volumes: [vw_data:/data]
    labels:
      - traefik.enable=true
      - traefik.http.routers.vaultwarden.rule=Host(\`$domain\`)
      - traefik.http.routers.vaultwarden.entrypoints=websecure
      - traefik.http.routers.vaultwarden.tls.certresolver=le
      - traefik.http.services.vaultwarden.loadbalancer.server.port=80
    networks: [rollin]

volumes:
  vw_data:

networks:
  rollin:
    external: true
    name: $NET
COMPOSE
    print_download_banner "Vaultwarden" "1 imagem · ~180 MB" "1 a 2 minutos"
    run_with_spinner "Subindo o Vaultwarden" 180 \
        "cd /opt/rollin/vaultwarden && docker compose up -d --quiet-pull"
    save_cred "Vaultwarden" "url=https://$domain" "password=$token" "setup=Crie sua conta no 1º acesso. O painel /admin usa o token acima. Depois de cadastrar sua equipe, desligue novos cadastros."
    log "Vaultwarden no ar: https://$domain"
}

# ---------- MariaDB (banco interno) ----------
install_mariadb() {
    hr "MariaDB (banco interno)"
    check_existing_install "MariaDB" "rollin-mariadb" "/opt/rollin/mariadb" || return 0
    local root_pwd pwd; root_pwd=$(rand_pwd 24); pwd=$(rand_pwd 24)
    mkdir -p /opt/rollin/mariadb
    cat > /opt/rollin/mariadb/docker-compose.yml <<COMPOSE
services:
  mariadb:
    image: mariadb:11
    container_name: rollin-mariadb
    restart: unless-stopped
    environment:
      MARIADB_ROOT_PASSWORD: $root_pwd
      MARIADB_DATABASE: rollin
      MARIADB_USER: rollin
      MARIADB_PASSWORD: $pwd
    volumes: [mariadb_data:/var/lib/mysql]
    networks: [rollin]
volumes:
  mariadb_data:
networks:
  rollin:
    external: true
    name: $NET
COMPOSE
    drop_orphan_volume "mariadb_mariadb_data" "rollin-mariadb"
    print_download_banner "MariaDB" "1 imagem · ~400 MB" "1 minuto"
    run_with_spinner "Subindo MariaDB" 120 "cd /opt/rollin/mariadb && docker compose up -d --quiet-pull"
    save_cred "MariaDB" "url=mysql://rollin:$pwd@rollin-mariadb:3306/rollin" "host=rollin-mariadb" "port=3306" "user=rollin" "password=$pwd" "setup=Conexão interna (rede da VPS). root_password=$root_pwd. NÃO exposto na internet."
    log "MariaDB no ar (interno): rollin-mariadb:3306 user=rollin"
}

# ---------- phpMyAdmin ----------
install_phpmyadmin() {
    hr "phpMyAdmin"
    check_existing_install "phpMyAdmin" "rollin-phpmyadmin" "/opt/rollin/phpmyadmin" || return 0
    local domain
    ask_domain "Subdomínio do phpMyAdmin (ex: mysql.seudominio.com.br):" || { warn "Sem domínio — pulado"; return 0; }
    domain="$REPLY"; dns_warning "$domain" || return 0
    mkdir -p /opt/rollin/phpmyadmin
    cat > /opt/rollin/phpmyadmin/docker-compose.yml <<COMPOSE
services:
  phpmyadmin:
    image: phpmyadmin:latest
    container_name: rollin-phpmyadmin
    restart: unless-stopped
    environment:
      PMA_ARBITRARY: "1"
      UPLOAD_LIMIT: 256M
    labels:
      - traefik.enable=true
      - traefik.http.routers.phpmyadmin.rule=Host(\`$domain\`)
      - traefik.http.routers.phpmyadmin.entrypoints=websecure
      - traefik.http.routers.phpmyadmin.tls.certresolver=le
      - traefik.http.services.phpmyadmin.loadbalancer.server.port=80
    networks: [rollin]
networks:
  rollin:
    external: true
    name: $NET
COMPOSE
    print_download_banner "phpMyAdmin" "1 imagem · ~550 MB" "1 minuto"
    run_with_spinner "Subindo phpMyAdmin" 120 "cd /opt/rollin/phpmyadmin && docker compose up -d --quiet-pull"
    save_cred "phpMyAdmin" "url=https://$domain" "setup=No login: Servidor = nome do container do banco (ex: rollin-mysql ou rollin-mariadb); usuário e senha do banco."
    log "phpMyAdmin no ar: https://$domain"
}

# ---------- pgAdmin ----------
install_pgadmin() {
    hr "pgAdmin"
    check_existing_install "pgAdmin" "rollin-pgadmin" "/opt/rollin/pgadmin" || return 0
    local domain
    ask_domain "Subdomínio do pgAdmin (ex: pg.seudominio.com.br):" || { warn "Sem domínio — pulado"; return 0; }
    domain="$REPLY"; dns_warning "$domain" || return 0
    local email pwd; email="admin@$domain"; pwd=$(rand_pwd 20)
    mkdir -p /opt/rollin/pgadmin
    cat > /opt/rollin/pgadmin/docker-compose.yml <<COMPOSE
services:
  pgadmin:
    image: dpage/pgadmin4:latest
    container_name: rollin-pgadmin
    restart: unless-stopped
    environment:
      PGADMIN_DEFAULT_EMAIL: $email
      PGADMIN_DEFAULT_PASSWORD: $pwd
    volumes: [pgadmin_data:/var/lib/pgadmin]
    labels:
      - traefik.enable=true
      - traefik.http.routers.pgadmin.rule=Host(\`$domain\`)
      - traefik.http.routers.pgadmin.entrypoints=websecure
      - traefik.http.routers.pgadmin.tls.certresolver=le
      - traefik.http.services.pgadmin.loadbalancer.server.port=80
    networks: [rollin]
volumes:
  pgadmin_data:
networks:
  rollin:
    external: true
    name: $NET
COMPOSE
    print_download_banner "pgAdmin" "1 imagem · ~450 MB" "1 a 2 minutos"
    run_with_spinner "Subindo pgAdmin" 180 "cd /opt/rollin/pgadmin && docker compose up -d --quiet-pull"
    save_cred "pgAdmin" "url=https://$domain" "user=$email" "password=$pwd" "setup=Depois de logar, adicione um servidor apontando pra rollin-postgres (host), porta 5432, com o usuário/senha do seu Postgres."
    log "pgAdmin no ar: https://$domain"
}

# ---------- Mongo Express ----------
install_mongoexpress() {
    hr "Mongo Express"
    check_existing_install "Mongo Express" "rollin-mongo-express" "/opt/rollin/mongoexpress" || return 0
    local domain
    ask_domain "Subdomínio do Mongo Express (ex: mongo.seudominio.com.br):" || { warn "Sem domínio — pulado"; return 0; }
    domain="$REPLY"; dns_warning "$domain" || return 0
    local user="admin" pwd; pwd=$(rand_pwd 18)
    mkdir -p /opt/rollin/mongoexpress
    cat > /opt/rollin/mongoexpress/docker-compose.yml <<COMPOSE
services:
  mongo-express:
    image: mongo-express:latest
    container_name: rollin-mongo-express
    restart: unless-stopped
    environment:
      ME_CONFIG_MONGODB_URL: mongodb://rollin-mongodb:27017
      ME_CONFIG_MONGODB_ENABLE_ADMIN: "true"
      ME_CONFIG_BASICAUTH_USERNAME: $user
      ME_CONFIG_BASICAUTH_PASSWORD: $pwd
    labels:
      - traefik.enable=true
      - traefik.http.routers.mongoexpress.rule=Host(\`$domain\`)
      - traefik.http.routers.mongoexpress.entrypoints=websecure
      - traefik.http.routers.mongoexpress.tls.certresolver=le
      - traefik.http.services.mongoexpress.loadbalancer.server.port=8081
    networks: [rollin]
networks:
  rollin:
    external: true
    name: $NET
COMPOSE
    print_download_banner "Mongo Express" "1 imagem · ~150 MB" "1 minuto"
    run_with_spinner "Subindo Mongo Express" 120 "cd /opt/rollin/mongoexpress && docker compose up -d --quiet-pull"
    save_cred "Mongo Express" "url=https://$domain" "user=$user" "password=$pwd" "setup=Requer o MongoDB instalado na VPS (aponta pra rollin-mongodb:27017). Entre com o usuário/senha acima."
    log "Mongo Express no ar: https://$domain"
}

# ---------- Ghost (blog + newsletter) ----------
install_ghost() {
    hr "Ghost (blog + newsletter)"
    check_existing_install "Ghost" "rollin-ghost" "/opt/rollin/ghost" || return 0
    local domain
    ask_domain "Subdomínio do Ghost (ex: blog.seudominio.com.br):" || { warn "Sem domínio — pulado"; return 0; }
    domain="$REPLY"; dns_warning "$domain" || return 0
    local db_pwd; db_pwd=$(rand_pwd 24)
    mkdir -p /opt/rollin/ghost
    cat > /opt/rollin/ghost/docker-compose.yml <<COMPOSE
services:
  ghost-db:
    image: mysql:8
    container_name: rollin-ghost-db
    restart: unless-stopped
    environment:
      MYSQL_DATABASE: ghost
      MYSQL_USER: ghost
      MYSQL_PASSWORD: $db_pwd
      MYSQL_ROOT_PASSWORD: $db_pwd
    volumes: [ghost_db:/var/lib/mysql]
    networks: [rollin]

  ghost:
    image: ghost:5-alpine
    container_name: rollin-ghost
    restart: unless-stopped
    depends_on: [ghost-db]
    environment:
      url: https://$domain
      database__client: mysql
      database__connection__host: ghost-db
      database__connection__user: ghost
      database__connection__password: $db_pwd
      database__connection__database: ghost
    volumes: [ghost_data:/var/lib/ghost/content]
    labels:
      - traefik.enable=true
      - traefik.http.routers.ghost.rule=Host(\`$domain\`)
      - traefik.http.routers.ghost.entrypoints=websecure
      - traefik.http.routers.ghost.tls.certresolver=le
      - traefik.http.services.ghost.loadbalancer.server.port=2368
    networks: [rollin]
volumes:
  ghost_db:
  ghost_data:
networks:
  rollin:
    external: true
    name: $NET
COMPOSE
    drop_orphan_volume "ghost_ghost_db" "rollin-ghost-db"
    print_download_banner "Ghost + MySQL" "2 imagens · ~900 MB" "2 a 3 minutos"
    run_with_spinner "Subindo Ghost" 240 "cd /opt/rollin/ghost && docker compose up -d --quiet-pull"
    save_cred "Ghost" "url=https://$domain" "setup=Crie o admin acessando https://$domain/ghost no 1º acesso."
    log "Ghost no ar: https://$domain (admin em /ghost)"
}

# ---------- Directus (CMS headless) ----------
install_directus() {
    hr "Directus (CMS headless)"
    check_existing_install "Directus" "rollin-directus" "/opt/rollin/directus" || return 0
    local domain
    ask_domain "Subdomínio do Directus (ex: cms.seudominio.com.br):" || { warn "Sem domínio — pulado"; return 0; }
    domain="$REPLY"; dns_warning "$domain" || return 0
    local db_pwd key secret adm; db_pwd=$(rand_pwd 24); key=$(rand_pwd 24); secret=$(rand_pwd 32); adm=$(rand_pwd 18)
    mkdir -p /opt/rollin/directus
    cat > /opt/rollin/directus/docker-compose.yml <<COMPOSE
services:
  directus-db:
    image: postgres:16-alpine
    container_name: rollin-directus-db
    restart: unless-stopped
    environment:
      POSTGRES_DB: directus
      POSTGRES_USER: directus
      POSTGRES_PASSWORD: $db_pwd
    volumes: [directus_db:/var/lib/postgresql/data]
    networks: [rollin]

  directus:
    image: directus/directus:latest
    container_name: rollin-directus
    restart: unless-stopped
    depends_on: [directus-db]
    environment:
      KEY: $key
      SECRET: $secret
      DB_CLIENT: pg
      DB_HOST: directus-db
      DB_PORT: "5432"
      DB_DATABASE: directus
      DB_USER: directus
      DB_PASSWORD: $db_pwd
      ADMIN_EMAIL: admin@$domain
      ADMIN_PASSWORD: $adm
      PUBLIC_URL: https://$domain
    volumes: [directus_uploads:/directus/uploads]
    labels:
      - traefik.enable=true
      - traefik.http.routers.directus.rule=Host(\`$domain\`)
      - traefik.http.routers.directus.entrypoints=websecure
      - traefik.http.routers.directus.tls.certresolver=le
      - traefik.http.services.directus.loadbalancer.server.port=8055
    networks: [rollin]
volumes:
  directus_db:
  directus_uploads:
networks:
  rollin:
    external: true
    name: $NET
COMPOSE
    drop_orphan_volume "directus_directus_db" "rollin-directus-db"
    print_download_banner "Directus + Postgres" "2 imagens · ~700 MB" "2 a 3 minutos"
    run_with_spinner "Subindo Directus" 240 "cd /opt/rollin/directus && docker compose up -d --quiet-pull"
    save_cred "Directus" "url=https://$domain" "user=admin@$domain" "password=$adm" "setup=Entre com o e-mail/senha acima."
    log "Directus no ar: https://$domain"
}

# ---------- Stirling PDF ----------
install_stirlingpdf() {
    hr "Stirling PDF (ferramentas de PDF)"
    check_existing_install "Stirling PDF" "rollin-stirlingpdf" "/opt/rollin/stirlingpdf" || return 0
    local domain
    ask_domain "Subdomínio do Stirling PDF (ex: pdf.seudominio.com.br):" || { warn "Sem domínio — pulado"; return 0; }
    domain="$REPLY"; dns_warning "$domain" || return 0
    mkdir -p /opt/rollin/stirlingpdf
    cat > /opt/rollin/stirlingpdf/docker-compose.yml <<COMPOSE
services:
  stirlingpdf:
    image: stirlingtools/stirling-pdf:latest
    container_name: rollin-stirlingpdf
    restart: unless-stopped
    environment:
      DOCKER_ENABLE_SECURITY: "false"
    volumes:
      - stirling_data:/usr/share/tessdata
      - stirling_configs:/configs
    labels:
      - traefik.enable=true
      - traefik.http.routers.stirlingpdf.rule=Host(\`$domain\`)
      - traefik.http.routers.stirlingpdf.entrypoints=websecure
      - traefik.http.routers.stirlingpdf.tls.certresolver=le
      - traefik.http.services.stirlingpdf.loadbalancer.server.port=8080
    networks: [rollin]
volumes:
  stirling_data:
  stirling_configs:
networks:
  rollin:
    external: true
    name: $NET
COMPOSE
    print_download_banner "Stirling PDF" "1 imagem · ~700 MB" "2 a 3 minutos"
    run_with_spinner "Subindo Stirling PDF" 240 "cd /opt/rollin/stirlingpdf && docker compose up -d --quiet-pull"
    save_cred "Stirling PDF" "url=https://$domain" "setup=Ferramentas de PDF direto no navegador — sem login."
    log "Stirling PDF no ar: https://$domain"
}

# ---------- Baserow (Airtable open-source) ----------
install_baserow() {
    hr "Baserow (planilha-banco)"
    check_existing_install "Baserow" "rollin-baserow" "/opt/rollin/baserow" || return 0
    local domain
    ask_domain "Subdomínio do Baserow (ex: base.seudominio.com.br):" || { warn "Sem domínio — pulado"; return 0; }
    domain="$REPLY"; dns_warning "$domain" || return 0
    mkdir -p /opt/rollin/baserow
    cat > /opt/rollin/baserow/docker-compose.yml <<COMPOSE
services:
  baserow:
    image: baserow/baserow:latest
    container_name: rollin-baserow
    restart: unless-stopped
    environment:
      BASEROW_PUBLIC_URL: https://$domain
    volumes: [baserow_data:/baserow/data]
    labels:
      - traefik.enable=true
      - traefik.http.routers.baserow.rule=Host(\`$domain\`)
      - traefik.http.routers.baserow.entrypoints=websecure
      - traefik.http.routers.baserow.tls.certresolver=le
      - traefik.http.services.baserow.loadbalancer.server.port=80
    networks: [rollin]
volumes:
  baserow_data:
networks:
  rollin:
    external: true
    name: $NET
COMPOSE
    print_download_banner "Baserow" "1 imagem · ~1.3 GB" "3 a 5 minutos"
    run_with_spinner "Subindo Baserow" 360 "cd /opt/rollin/baserow && docker compose up -d --quiet-pull"
    save_cred "Baserow" "url=https://$domain" "setup=Crie sua conta no 1º acesso. A inicialização (banco embutido) leva 2-3 min."
    log "Baserow no ar: https://$domain"
}

# ---------- Formbricks (formulários / pesquisas) ----------
install_formbricks() {
    hr "Formbricks (formulários / pesquisas)"
    check_existing_install "Formbricks" "rollin-formbricks" "/opt/rollin/formbricks" || return 0
    local domain
    ask_domain "Subdomínio do Formbricks (ex: forms.seudominio.com.br):" || { warn "Sem domínio — pulado"; return 0; }
    domain="$REPLY"; dns_warning "$domain" || return 0
    local db_pwd nextauth enckey; db_pwd=$(rand_pwd 24); nextauth=$(rand_pwd 32); enckey=$(rand_pwd 32)
    mkdir -p /opt/rollin/formbricks
    cat > /opt/rollin/formbricks/docker-compose.yml <<COMPOSE
services:
  formbricks-db:
    image: postgres:16-alpine
    container_name: rollin-formbricks-db
    restart: unless-stopped
    environment:
      POSTGRES_DB: formbricks
      POSTGRES_USER: formbricks
      POSTGRES_PASSWORD: $db_pwd
    volumes: [formbricks_db:/var/lib/postgresql/data]
    networks: [rollin]

  formbricks:
    image: ghcr.io/formbricks/formbricks:latest
    container_name: rollin-formbricks
    restart: unless-stopped
    depends_on: [formbricks-db]
    environment:
      DATABASE_URL: postgresql://formbricks:$db_pwd@formbricks-db:5432/formbricks
      NEXTAUTH_SECRET: $nextauth
      ENCRYPTION_KEY: $enckey
      NEXTAUTH_URL: https://$domain
      WEBAPP_URL: https://$domain
    labels:
      - traefik.enable=true
      - traefik.http.routers.formbricks.rule=Host(\`$domain\`)
      - traefik.http.routers.formbricks.entrypoints=websecure
      - traefik.http.routers.formbricks.tls.certresolver=le
      - traefik.http.services.formbricks.loadbalancer.server.port=3000
    networks: [rollin]
volumes:
  formbricks_db:
networks:
  rollin:
    external: true
    name: $NET
COMPOSE
    drop_orphan_volume "formbricks_formbricks_db" "rollin-formbricks-db"
    print_download_banner "Formbricks + Postgres" "2 imagens · ~800 MB" "2 a 4 minutos"
    run_with_spinner "Subindo Formbricks" 300 "cd /opt/rollin/formbricks && docker compose up -d --quiet-pull"
    save_cred "Formbricks" "url=https://$domain" "setup=Crie sua conta no 1º acesso. A inicialização (migrações do banco) pode levar 1-2 min."
    log "Formbricks no ar: https://$domain"
}

# ---------- Gitea (Git self-hosted) ----------
install_gitea() {
    hr "Gitea (Git self-hosted)"
    check_existing_install "Gitea" "rollin-gitea" "/opt/rollin/gitea" || return 0
    local domain
    ask_domain "Subdomínio do Gitea (ex: git.seudominio.com.br):" || { warn "Sem domínio — pulado"; return 0; }
    domain="$REPLY"; dns_warning "$domain" || return 0
    mkdir -p /opt/rollin/gitea
    cat > /opt/rollin/gitea/docker-compose.yml <<COMPOSE
services:
  gitea:
    image: gitea/gitea:latest
    container_name: rollin-gitea
    restart: unless-stopped
    environment:
      USER_UID: "1000"
      USER_GID: "1000"
      GITEA__server__DOMAIN: $domain
      GITEA__server__ROOT_URL: https://$domain/
      GITEA__server__DISABLE_SSH: "true"
      GITEA__database__DB_TYPE: sqlite3
    volumes: [gitea_data:/data]
    labels:
      - traefik.enable=true
      - traefik.http.routers.gitea.rule=Host(\`$domain\`)
      - traefik.http.routers.gitea.entrypoints=websecure
      - traefik.http.routers.gitea.tls.certresolver=le
      - traefik.http.services.gitea.loadbalancer.server.port=3000
    networks: [rollin]
volumes:
  gitea_data:
networks:
  rollin:
    external: true
    name: $NET
COMPOSE
    print_download_banner "Gitea" "1 imagem · ~250 MB" "1 a 2 minutos"
    run_with_spinner "Subindo Gitea" 180 "cd /opt/rollin/gitea && docker compose up -d --quiet-pull"
    save_cred "Gitea" "url=https://$domain" "setup=No 1º acesso confirme a instalação (SQLite já vem pronto) e crie o admin — o primeiro usuário registrado vira administrador."
    log "Gitea no ar: https://$domain"
}

# ---------- MeiliSearch (busca) ----------
install_meilisearch() {
    hr "MeiliSearch (busca instantânea)"
    check_existing_install "MeiliSearch" "rollin-meilisearch" "/opt/rollin/meilisearch" || return 0
    local domain
    ask_domain "Subdomínio do MeiliSearch (ex: busca.seudominio.com.br):" || { warn "Sem domínio — pulado"; return 0; }
    domain="$REPLY"; dns_warning "$domain" || return 0
    local key; key=$(rand_pwd 40)
    mkdir -p /opt/rollin/meilisearch
    cat > /opt/rollin/meilisearch/docker-compose.yml <<COMPOSE
services:
  meilisearch:
    image: getmeili/meilisearch:latest
    container_name: rollin-meilisearch
    restart: unless-stopped
    environment:
      MEILI_MASTER_KEY: $key
      MEILI_ENV: production
    volumes: [meili_data:/meili_data]
    labels:
      - traefik.enable=true
      - traefik.http.routers.meilisearch.rule=Host(\`$domain\`)
      - traefik.http.routers.meilisearch.entrypoints=websecure
      - traefik.http.routers.meilisearch.tls.certresolver=le
      - traefik.http.services.meilisearch.loadbalancer.server.port=7700
    networks: [rollin]
volumes:
  meili_data:
networks:
  rollin:
    external: true
    name: $NET
COMPOSE
    print_download_banner "MeiliSearch" "1 imagem · ~150 MB" "1 minuto"
    run_with_spinner "Subindo MeiliSearch" 120 "cd /opt/rollin/meilisearch && docker compose up -d --quiet-pull"
    save_cred "MeiliSearch" "url=https://$domain" "api_key=$key" "setup=Use a chave acima no header Authorization: Bearer <chave> nas requisições."
    log "MeiliSearch no ar: https://$domain"
}

# ---------- Metabase (BI) ----------
install_metabase() {
    hr "Metabase (BI / dashboards)"
    check_existing_install "Metabase" "rollin-metabase" "/opt/rollin/metabase" || return 0
    local domain
    ask_domain "Subdomínio do Metabase (ex: bi.seudominio.com.br):" || { warn "Sem domínio — pulado"; return 0; }
    domain="$REPLY"; dns_warning "$domain" || return 0
    local db_pwd; db_pwd=$(rand_pwd 24)
    mkdir -p /opt/rollin/metabase
    cat > /opt/rollin/metabase/docker-compose.yml <<COMPOSE
services:
  metabase-db:
    image: postgres:16-alpine
    container_name: rollin-metabase-db
    restart: unless-stopped
    environment:
      POSTGRES_DB: metabase
      POSTGRES_USER: metabase
      POSTGRES_PASSWORD: $db_pwd
    volumes: [metabase_db:/var/lib/postgresql/data]
    networks: [rollin]

  metabase:
    image: metabase/metabase:latest
    container_name: rollin-metabase
    restart: unless-stopped
    depends_on: [metabase-db]
    environment:
      MB_DB_TYPE: postgres
      MB_DB_DBNAME: metabase
      MB_DB_PORT: "5432"
      MB_DB_USER: metabase
      MB_DB_PASS: $db_pwd
      MB_DB_HOST: metabase-db
    labels:
      - traefik.enable=true
      - traefik.http.routers.metabase.rule=Host(\`$domain\`)
      - traefik.http.routers.metabase.entrypoints=websecure
      - traefik.http.routers.metabase.tls.certresolver=le
      - traefik.http.services.metabase.loadbalancer.server.port=3000
    networks: [rollin]
volumes:
  metabase_db:
networks:
  rollin:
    external: true
    name: $NET
COMPOSE
    drop_orphan_volume "metabase_metabase_db" "rollin-metabase-db"
    print_download_banner "Metabase + Postgres" "2 imagens · ~700 MB" "2 a 4 minutos"
    run_with_spinner "Subindo Metabase" 300 "cd /opt/rollin/metabase && docker compose up -d --quiet-pull"
    save_cred "Metabase" "url=https://$domain" "setup=Crie o admin no 1º acesso. A inicialização leva 1-2 min."
    log "Metabase no ar: https://$domain"
}

# ---------- Umami (analytics) ----------
install_umami() {
    hr "Umami (analytics sem cookies)"
    check_existing_install "Umami" "rollin-umami" "/opt/rollin/umami" || return 0
    local domain
    ask_domain "Subdomínio do Umami (ex: analytics.seudominio.com.br):" || { warn "Sem domínio — pulado"; return 0; }
    domain="$REPLY"; dns_warning "$domain" || return 0
    local db_pwd secret; db_pwd=$(rand_pwd 24); secret=$(rand_pwd 32)
    mkdir -p /opt/rollin/umami
    cat > /opt/rollin/umami/docker-compose.yml <<COMPOSE
services:
  umami-db:
    image: postgres:16-alpine
    container_name: rollin-umami-db
    restart: unless-stopped
    environment:
      POSTGRES_DB: umami
      POSTGRES_USER: umami
      POSTGRES_PASSWORD: $db_pwd
    volumes: [umami_db:/var/lib/postgresql/data]
    networks: [rollin]

  umami:
    image: ghcr.io/umami-software/umami:postgresql-latest
    container_name: rollin-umami
    restart: unless-stopped
    depends_on: [umami-db]
    environment:
      DATABASE_URL: postgresql://umami:$db_pwd@umami-db:5432/umami
      DATABASE_TYPE: postgresql
      APP_SECRET: $secret
    labels:
      - traefik.enable=true
      - traefik.http.routers.umami.rule=Host(\`$domain\`)
      - traefik.http.routers.umami.entrypoints=websecure
      - traefik.http.routers.umami.tls.certresolver=le
      - traefik.http.services.umami.loadbalancer.server.port=3000
    networks: [rollin]
volumes:
  umami_db:
networks:
  rollin:
    external: true
    name: $NET
COMPOSE
    drop_orphan_volume "umami_umami_db" "rollin-umami-db"
    print_download_banner "Umami + Postgres" "2 imagens · ~500 MB" "2 a 3 minutos"
    run_with_spinner "Subindo Umami" 240 "cd /opt/rollin/umami && docker compose up -d --quiet-pull"
    save_cred "Umami" "url=https://$domain" "user=admin" "password=umami" "setup=Login padrão admin / umami — TROQUE a senha no 1º acesso."
    log "Umami no ar: https://$domain"
}

# ---------- SFTPGo (transferência de arquivos) ----------
install_sftpgo() {
    hr "SFTPGo (transferência de arquivos)"
    check_existing_install "SFTPGo" "rollin-sftpgo" "/opt/rollin/sftpgo" || return 0
    local domain
    ask_domain "Subdomínio do painel SFTPGo (ex: arquivos.seudominio.com.br):" || { warn "Sem domínio — pulado"; return 0; }
    domain="$REPLY"; dns_warning "$domain" || return 0
    mkdir -p /opt/rollin/sftpgo
    cat > /opt/rollin/sftpgo/docker-compose.yml <<COMPOSE
services:
  sftpgo:
    image: drakkan/sftpgo:latest
    container_name: rollin-sftpgo
    restart: unless-stopped
    ports:
      - "2022:2022"
    environment:
      SFTPGO_HTTPD__BINDINGS__0__PORT: "8080"
      SFTPGO_SFTPD__BINDINGS__0__PORT: "2022"
    volumes:
      - sftpgo_data:/srv/sftpgo
      - sftpgo_home:/var/lib/sftpgo
    labels:
      - traefik.enable=true
      - traefik.http.routers.sftpgo.rule=Host(\`$domain\`)
      - traefik.http.routers.sftpgo.entrypoints=websecure
      - traefik.http.routers.sftpgo.tls.certresolver=le
      - traefik.http.services.sftpgo.loadbalancer.server.port=8080
    networks: [rollin]
volumes:
  sftpgo_data:
  sftpgo_home:
networks:
  rollin:
    external: true
    name: $NET
COMPOSE
    print_download_banner "SFTPGo" "1 imagem · ~120 MB" "1 minuto"
    run_with_spinner "Subindo SFTPGo" 120 "cd /opt/rollin/sftpgo && docker compose up -d --quiet-pull"
    save_cred "SFTPGo" "url=https://$domain/web/admin" "setup=No 1º acesso ao painel, crie o admin. Transferência SFTP disponível na porta 2022 (libere no firewall se precisar)."
    log "SFTPGo no ar: painel https://$domain/web/admin · SFTP porta 2022"
}

# ---------- WordPress ----------
install_wordpress() {
    hr "WordPress"
    check_existing_install "WordPress" "rollin-wordpress" "/opt/rollin/wordpress" || return 0
    local domain
    ask_domain "Subdomínio do WordPress (ex: site.seudominio.com.br):" || { warn "Sem domínio — pulado"; return 0; }
    domain="$REPLY"; dns_warning "$domain" || return 0
    local db_pwd; db_pwd=$(rand_pwd 24)
    mkdir -p /opt/rollin/wordpress
    cat > /opt/rollin/wordpress/docker-compose.yml <<COMPOSE
services:
  wp-db:
    image: mariadb:11
    container_name: rollin-wp-db
    restart: unless-stopped
    environment:
      MARIADB_DATABASE: wordpress
      MARIADB_USER: wordpress
      MARIADB_PASSWORD: $db_pwd
      MARIADB_ROOT_PASSWORD: $db_pwd
    volumes: [wp_db:/var/lib/mysql]
    networks: [rollin]

  wordpress:
    image: wordpress:latest
    container_name: rollin-wordpress
    restart: unless-stopped
    depends_on: [wp-db]
    environment:
      WORDPRESS_DB_HOST: wp-db
      WORDPRESS_DB_USER: wordpress
      WORDPRESS_DB_PASSWORD: $db_pwd
      WORDPRESS_DB_NAME: wordpress
      WORDPRESS_CONFIG_EXTRA: |
        define('WP_HOME','https://$domain');
        define('WP_SITEURL','https://$domain');
        define('FORCE_SSL_ADMIN', true);
    volumes: [wp_data:/var/www/html]
    labels:
      - traefik.enable=true
      - traefik.http.routers.wordpress.rule=Host(\`$domain\`)
      - traefik.http.routers.wordpress.entrypoints=websecure
      - traefik.http.routers.wordpress.tls.certresolver=le
      - traefik.http.services.wordpress.loadbalancer.server.port=80
    networks: [rollin]
volumes:
  wp_db:
  wp_data:
networks:
  rollin:
    external: true
    name: $NET
COMPOSE
    drop_orphan_volume "wordpress_wp_db" "rollin-wp-db"
    print_download_banner "WordPress + MariaDB" "2 imagens · ~950 MB" "2 a 3 minutos"
    run_with_spinner "Subindo WordPress" 240 "cd /opt/rollin/wordpress && docker compose up -d --quiet-pull"
    save_cred "WordPress" "url=https://$domain" "setup=Complete a instalação no 1º acesso (idioma + criar o admin)."
    log "WordPress no ar: https://$domain"
}

# ---------- Wiki.js ----------
install_wikijs() {
    hr "Wiki.js"
    check_existing_install "Wiki.js" "rollin-wikijs" "/opt/rollin/wikijs" || return 0
    local domain
    ask_domain "Subdomínio do Wiki.js (ex: wiki.seudominio.com.br):" || { warn "Sem domínio — pulado"; return 0; }
    domain="$REPLY"; dns_warning "$domain" || return 0
    local db_pwd; db_pwd=$(rand_pwd 24)
    mkdir -p /opt/rollin/wikijs
    cat > /opt/rollin/wikijs/docker-compose.yml <<COMPOSE
services:
  wikijs-db:
    image: postgres:16-alpine
    container_name: rollin-wikijs-db
    restart: unless-stopped
    environment:
      POSTGRES_DB: wiki
      POSTGRES_USER: wiki
      POSTGRES_PASSWORD: $db_pwd
    volumes: [wikijs_db:/var/lib/postgresql/data]
    networks: [rollin]

  wikijs:
    image: ghcr.io/requarks/wiki:2
    container_name: rollin-wikijs
    restart: unless-stopped
    depends_on: [wikijs-db]
    environment:
      DB_TYPE: postgres
      DB_HOST: wikijs-db
      DB_PORT: "5432"
      DB_USER: wiki
      DB_PASS: $db_pwd
      DB_NAME: wiki
    labels:
      - traefik.enable=true
      - traefik.http.routers.wikijs.rule=Host(\`$domain\`)
      - traefik.http.routers.wikijs.entrypoints=websecure
      - traefik.http.routers.wikijs.tls.certresolver=le
      - traefik.http.services.wikijs.loadbalancer.server.port=3000
    networks: [rollin]
volumes:
  wikijs_db:
networks:
  rollin:
    external: true
    name: $NET
COMPOSE
    drop_orphan_volume "wikijs_wikijs_db" "rollin-wikijs-db"
    print_download_banner "Wiki.js + Postgres" "2 imagens · ~700 MB" "2 a 3 minutos"
    run_with_spinner "Subindo Wiki.js" 240 "cd /opt/rollin/wikijs && docker compose up -d --quiet-pull"
    save_cred "Wiki.js" "url=https://$domain" "setup=No 1º acesso, complete a configuração e crie o administrador."
    log "Wiki.js no ar: https://$domain"
}

# ---------- BookStack ----------
install_bookstack() {
    hr "BookStack"
    check_existing_install "BookStack" "rollin-bookstack" "/opt/rollin/bookstack" || return 0
    local domain
    ask_domain "Subdomínio do BookStack (ex: docs.seudominio.com.br):" || { warn "Sem domínio — pulado"; return 0; }
    domain="$REPLY"; dns_warning "$domain" || return 0
    local db_pwd app_key; db_pwd=$(rand_pwd 24); app_key="base64:$(openssl rand -base64 32)"
    mkdir -p /opt/rollin/bookstack
    cat > /opt/rollin/bookstack/docker-compose.yml <<COMPOSE
services:
  bookstack-db:
    image: mariadb:11
    container_name: rollin-bookstack-db
    restart: unless-stopped
    environment:
      MARIADB_DATABASE: bookstack
      MARIADB_USER: bookstack
      MARIADB_PASSWORD: $db_pwd
      MARIADB_ROOT_PASSWORD: $db_pwd
    volumes: [bookstack_db:/var/lib/mysql]
    networks: [rollin]

  bookstack:
    image: lscr.io/linuxserver/bookstack:latest
    container_name: rollin-bookstack
    restart: unless-stopped
    depends_on: [bookstack-db]
    environment:
      PUID: "1000"
      PGID: "1000"
      APP_URL: https://$domain
      APP_KEY: $app_key
      DB_HOST: bookstack-db
      DB_PORT: "3306"
      DB_USERNAME: bookstack
      DB_PASSWORD: $db_pwd
      DB_DATABASE: bookstack
    volumes: [bookstack_data:/config]
    labels:
      - traefik.enable=true
      - traefik.http.routers.bookstack.rule=Host(\`$domain\`)
      - traefik.http.routers.bookstack.entrypoints=websecure
      - traefik.http.routers.bookstack.tls.certresolver=le
      - traefik.http.services.bookstack.loadbalancer.server.port=80
    networks: [rollin]
volumes:
  bookstack_db:
  bookstack_data:
networks:
  rollin:
    external: true
    name: $NET
COMPOSE
    drop_orphan_volume "bookstack_bookstack_db" "rollin-bookstack-db"
    print_download_banner "BookStack + MariaDB" "2 imagens · ~800 MB" "2 a 3 minutos"
    run_with_spinner "Subindo BookStack" 240 "cd /opt/rollin/bookstack && docker compose up -d --quiet-pull"
    save_cred "BookStack" "url=https://$domain" "user=admin@admin.com" "password=password" "setup=Login padrão admin@admin.com / password — TROQUE assim que entrar."
    log "BookStack no ar: https://$domain"
}

# ---------- EspoCRM ----------
install_espocrm() {
    hr "EspoCRM"
    check_existing_install "EspoCRM" "rollin-espocrm" "/opt/rollin/espocrm" || return 0
    local domain
    ask_domain "Subdomínio do EspoCRM (ex: crm.seudominio.com.br):" || { warn "Sem domínio — pulado"; return 0; }
    domain="$REPLY"; dns_warning "$domain" || return 0
    local db_pwd adm; db_pwd=$(rand_pwd 24); adm=$(rand_pwd 18)
    mkdir -p /opt/rollin/espocrm
    cat > /opt/rollin/espocrm/docker-compose.yml <<COMPOSE
services:
  espocrm-db:
    image: mariadb:11
    container_name: rollin-espocrm-db
    restart: unless-stopped
    environment:
      MARIADB_DATABASE: espocrm
      MARIADB_USER: espocrm
      MARIADB_PASSWORD: $db_pwd
      MARIADB_ROOT_PASSWORD: $db_pwd
    volumes: [espocrm_db:/var/lib/mysql]
    networks: [rollin]

  espocrm:
    image: espocrm/espocrm:latest
    container_name: rollin-espocrm
    restart: unless-stopped
    depends_on: [espocrm-db]
    environment:
      ESPOCRM_DATABASE_HOST: espocrm-db
      ESPOCRM_DATABASE_USER: espocrm
      ESPOCRM_DATABASE_PASSWORD: $db_pwd
      ESPOCRM_ADMIN_USERNAME: admin
      ESPOCRM_ADMIN_PASSWORD: $adm
      ESPOCRM_SITE_URL: https://$domain
    volumes: [espocrm_data:/var/www/html]
    labels:
      - traefik.enable=true
      - traefik.http.routers.espocrm.rule=Host(\`$domain\`)
      - traefik.http.routers.espocrm.entrypoints=websecure
      - traefik.http.routers.espocrm.tls.certresolver=le
      - traefik.http.services.espocrm.loadbalancer.server.port=80
    networks: [rollin]
volumes:
  espocrm_db:
  espocrm_data:
networks:
  rollin:
    external: true
    name: $NET
COMPOSE
    drop_orphan_volume "espocrm_espocrm_db" "rollin-espocrm-db"
    print_download_banner "EspoCRM + MariaDB" "2 imagens · ~700 MB" "2 a 4 minutos"
    run_with_spinner "Subindo EspoCRM" 300 "cd /opt/rollin/espocrm && docker compose up -d --quiet-pull"
    save_cred "EspoCRM" "url=https://$domain" "user=admin" "password=$adm" "setup=A instalação inicial pode levar 1-2 min. Entre com admin e a senha acima."
    log "EspoCRM no ar: https://$domain"
}

# ---------- Vikunja (tarefas/projetos) ----------
install_vikunja() {
    hr "Vikunja (tarefas / projetos)"
    check_existing_install "Vikunja" "rollin-vikunja" "/opt/rollin/vikunja" || return 0
    local domain
    ask_domain "Subdomínio do Vikunja (ex: tarefas.seudominio.com.br):" || { warn "Sem domínio — pulado"; return 0; }
    domain="$REPLY"; dns_warning "$domain" || return 0
    local db_pwd jwt; db_pwd=$(rand_pwd 24); jwt=$(rand_pwd 40)
    mkdir -p /opt/rollin/vikunja
    cat > /opt/rollin/vikunja/docker-compose.yml <<COMPOSE
services:
  vikunja-db:
    image: postgres:16-alpine
    container_name: rollin-vikunja-db
    restart: unless-stopped
    environment:
      POSTGRES_DB: vikunja
      POSTGRES_USER: vikunja
      POSTGRES_PASSWORD: $db_pwd
    volumes: [vikunja_db:/var/lib/postgresql/data]
    networks: [rollin]

  vikunja:
    image: vikunja/vikunja:latest
    container_name: rollin-vikunja
    restart: unless-stopped
    depends_on: [vikunja-db]
    environment:
      VIKUNJA_DATABASE_TYPE: postgres
      VIKUNJA_DATABASE_HOST: vikunja-db
      VIKUNJA_DATABASE_USER: vikunja
      VIKUNJA_DATABASE_PASSWORD: $db_pwd
      VIKUNJA_DATABASE_DATABASE: vikunja
      VIKUNJA_SERVICE_PUBLICURL: https://$domain
      VIKUNJA_SERVICE_JWTSECRET: $jwt
    volumes: [vikunja_files:/app/vikunja/files]
    labels:
      - traefik.enable=true
      - traefik.http.routers.vikunja.rule=Host(\`$domain\`)
      - traefik.http.routers.vikunja.entrypoints=websecure
      - traefik.http.routers.vikunja.tls.certresolver=le
      - traefik.http.services.vikunja.loadbalancer.server.port=3456
    networks: [rollin]
volumes:
  vikunja_db:
  vikunja_files:
networks:
  rollin:
    external: true
    name: $NET
COMPOSE
    drop_orphan_volume "vikunja_vikunja_db" "rollin-vikunja-db"
    print_download_banner "Vikunja + Postgres" "2 imagens · ~350 MB" "1 a 2 minutos"
    run_with_spinner "Subindo Vikunja" 180 "cd /opt/rollin/vikunja && docker compose up -d --quiet-pull"
    save_cred "Vikunja" "url=https://$domain" "setup=Crie sua conta no 1º acesso (o primeiro cadastro é o dono)."
    log "Vikunja no ar: https://$domain"
}

# ---------- Focalboard (kanban) ----------
install_focalboard() {
    hr "Focalboard (quadros kanban)"
    check_existing_install "Focalboard" "rollin-focalboard" "/opt/rollin/focalboard" || return 0
    local domain
    ask_domain "Subdomínio do Focalboard (ex: quadros.seudominio.com.br):" || { warn "Sem domínio — pulado"; return 0; }
    domain="$REPLY"; dns_warning "$domain" || return 0
    mkdir -p /opt/rollin/focalboard
    cat > /opt/rollin/focalboard/docker-compose.yml <<COMPOSE
services:
  focalboard:
    image: mattermost/focalboard:latest
    container_name: rollin-focalboard
    restart: unless-stopped
    volumes: [focalboard_data:/data]
    labels:
      - traefik.enable=true
      - traefik.http.routers.focalboard.rule=Host(\`$domain\`)
      - traefik.http.routers.focalboard.entrypoints=websecure
      - traefik.http.routers.focalboard.tls.certresolver=le
      - traefik.http.services.focalboard.loadbalancer.server.port=8000
    networks: [rollin]
volumes:
  focalboard_data:
networks:
  rollin:
    external: true
    name: $NET
COMPOSE
    print_download_banner "Focalboard" "1 imagem · ~200 MB" "1 minuto"
    run_with_spinner "Subindo Focalboard" 120 "cd /opt/rollin/focalboard && docker compose up -d --quiet-pull"
    save_cred "Focalboard" "url=https://$domain" "setup=Crie sua conta no 1º acesso."
    log "Focalboard no ar: https://$domain"
}

# ---------- Grafana ----------
install_grafana() {
    hr "Grafana (dashboards)"
    check_existing_install "Grafana" "rollin-grafana" "/opt/rollin/grafana" || return 0
    local domain
    ask_domain "Subdomínio do Grafana (ex: dash.seudominio.com.br):" || { warn "Sem domínio — pulado"; return 0; }
    domain="$REPLY"; dns_warning "$domain" || return 0
    local pwd; pwd=$(rand_pwd 20)
    mkdir -p /opt/rollin/grafana
    cat > /opt/rollin/grafana/docker-compose.yml <<COMPOSE
services:
  grafana:
    image: grafana/grafana:latest
    container_name: rollin-grafana
    restart: unless-stopped
    environment:
      GF_SECURITY_ADMIN_USER: admin
      GF_SECURITY_ADMIN_PASSWORD: $pwd
      GF_SERVER_ROOT_URL: https://$domain
      GF_SERVER_DOMAIN: $domain
    volumes: [grafana_data:/var/lib/grafana]
    labels:
      - traefik.enable=true
      - traefik.http.routers.grafana.rule=Host(\`$domain\`)
      - traefik.http.routers.grafana.entrypoints=websecure
      - traefik.http.routers.grafana.tls.certresolver=le
      - traefik.http.services.grafana.loadbalancer.server.port=3000
    networks: [rollin]
volumes:
  grafana_data:
networks:
  rollin:
    external: true
    name: $NET
COMPOSE
    print_download_banner "Grafana" "1 imagem · ~450 MB" "1 minuto"
    run_with_spinner "Subindo Grafana" 120 "cd /opt/rollin/grafana && docker compose up -d --quiet-pull"
    save_cred "Grafana" "url=https://$domain" "user=admin" "password=$pwd" "setup=Entre com admin e a senha acima."
    log "Grafana no ar: https://$domain"
}

# ---------- Typesense (busca) ----------
install_typesense() {
    hr "Typesense (busca)"
    check_existing_install "Typesense" "rollin-typesense" "/opt/rollin/typesense" || return 0
    local domain
    ask_domain "Subdomínio do Typesense (ex: search.seudominio.com.br):" || { warn "Sem domínio — pulado"; return 0; }
    domain="$REPLY"; dns_warning "$domain" || return 0
    local key; key=$(rand_pwd 40)
    mkdir -p /opt/rollin/typesense
    cat > /opt/rollin/typesense/docker-compose.yml <<COMPOSE
services:
  typesense:
    image: typesense/typesense:0.25.2
    container_name: rollin-typesense
    restart: unless-stopped
    command: ["--data-dir", "/data", "--api-key=$key", "--enable-cors"]
    volumes: [typesense_data:/data]
    labels:
      - traefik.enable=true
      - traefik.http.routers.typesense.rule=Host(\`$domain\`)
      - traefik.http.routers.typesense.entrypoints=websecure
      - traefik.http.routers.typesense.tls.certresolver=le
      - traefik.http.services.typesense.loadbalancer.server.port=8108
    networks: [rollin]
volumes:
  typesense_data:
networks:
  rollin:
    external: true
    name: $NET
COMPOSE
    print_download_banner "Typesense" "1 imagem · ~120 MB" "1 minuto"
    run_with_spinner "Subindo Typesense" 120 "cd /opt/rollin/typesense && docker compose up -d --quiet-pull"
    save_cred "Typesense" "url=https://$domain" "api_key=$key" "setup=Use a API Key acima no header X-TYPESENSE-API-KEY."
    log "Typesense no ar: https://$domain"
}

# ---------- RabbitMQ ----------
install_rabbitmq() {
    hr "RabbitMQ (fila de mensagens)"
    check_existing_install "RabbitMQ" "rollin-rabbitmq" "/opt/rollin/rabbitmq" || return 0
    local domain
    ask_domain "Subdomínio do painel RabbitMQ (ex: mq.seudominio.com.br):" || { warn "Sem domínio — pulado"; return 0; }
    domain="$REPLY"; dns_warning "$domain" || return 0
    local user="rollin" pwd; pwd=$(rand_pwd 24)
    mkdir -p /opt/rollin/rabbitmq
    cat > /opt/rollin/rabbitmq/docker-compose.yml <<COMPOSE
services:
  rabbitmq:
    image: rabbitmq:3-management
    container_name: rollin-rabbitmq
    restart: unless-stopped
    environment:
      RABBITMQ_DEFAULT_USER: $user
      RABBITMQ_DEFAULT_PASS: $pwd
    volumes: [rabbitmq_data:/var/lib/rabbitmq]
    labels:
      - traefik.enable=true
      - traefik.http.routers.rabbitmq.rule=Host(\`$domain\`)
      - traefik.http.routers.rabbitmq.entrypoints=websecure
      - traefik.http.routers.rabbitmq.tls.certresolver=le
      - traefik.http.services.rabbitmq.loadbalancer.server.port=15672
    networks: [rollin]
volumes:
  rabbitmq_data:
networks:
  rollin:
    external: true
    name: $NET
COMPOSE
    print_download_banner "RabbitMQ" "1 imagem · ~250 MB" "1 minuto"
    run_with_spinner "Subindo RabbitMQ" 120 "cd /opt/rollin/rabbitmq && docker compose up -d --quiet-pull"
    save_cred "RabbitMQ" "url=https://$domain" "user=$user" "password=$pwd" "setup=Painel de gestão. Apps conectam em rollin-rabbitmq:5672 (interno) com esse usuário/senha."
    log "RabbitMQ no ar: painel https://$domain · AMQP interno rollin-rabbitmq:5672"
}

# ---------- Matomo (analytics) ----------
install_matomo() {
    hr "Matomo (analytics)"
    check_existing_install "Matomo" "rollin-matomo" "/opt/rollin/matomo" || return 0
    local domain
    ask_domain "Subdomínio do Matomo (ex: stats.seudominio.com.br):" || { warn "Sem domínio — pulado"; return 0; }
    domain="$REPLY"; dns_warning "$domain" || return 0
    local db_pwd; db_pwd=$(rand_pwd 24)
    mkdir -p /opt/rollin/matomo
    cat > /opt/rollin/matomo/docker-compose.yml <<COMPOSE
services:
  matomo-db:
    image: mariadb:11
    container_name: rollin-matomo-db
    restart: unless-stopped
    environment:
      MARIADB_DATABASE: matomo
      MARIADB_USER: matomo
      MARIADB_PASSWORD: $db_pwd
      MARIADB_ROOT_PASSWORD: $db_pwd
    volumes: [matomo_db:/var/lib/mysql]
    networks: [rollin]

  matomo:
    image: matomo:latest
    container_name: rollin-matomo
    restart: unless-stopped
    depends_on: [matomo-db]
    environment:
      MATOMO_DATABASE_HOST: matomo-db
      MATOMO_DATABASE_ADAPTER: mysql
      MATOMO_DATABASE_USERNAME: matomo
      MATOMO_DATABASE_PASSWORD: $db_pwd
      MATOMO_DATABASE_DBNAME: matomo
    volumes: [matomo_data:/var/www/html]
    labels:
      - traefik.enable=true
      - traefik.http.routers.matomo.rule=Host(\`$domain\`)
      - traefik.http.routers.matomo.entrypoints=websecure
      - traefik.http.routers.matomo.tls.certresolver=le
      - traefik.http.services.matomo.loadbalancer.server.port=80
    networks: [rollin]
volumes:
  matomo_db:
  matomo_data:
networks:
  rollin:
    external: true
    name: $NET
COMPOSE
    drop_orphan_volume "matomo_matomo_db" "rollin-matomo-db"
    print_download_banner "Matomo + MariaDB" "2 imagens · ~700 MB" "2 a 3 minutos"
    run_with_spinner "Subindo Matomo" 240 "cd /opt/rollin/matomo && docker compose up -d --quiet-pull"
    save_cred "Matomo" "url=https://$domain" "setup=Complete a instalação no 1º acesso. No passo do banco use: Servidor=matomo-db, Login=matomo, e a senha do banco (nas credenciais)."
    log "Matomo no ar: https://$domain"
}

# ---------- File Browser ----------
install_filebrowser() {
    hr "File Browser (gerenciador de arquivos)"
    check_existing_install "File Browser" "rollin-filebrowser" "/opt/rollin/filebrowser" || return 0
    local domain
    ask_domain "Subdomínio do File Browser (ex: files.seudominio.com.br):" || { warn "Sem domínio — pulado"; return 0; }
    domain="$REPLY"; dns_warning "$domain" || return 0
    mkdir -p /opt/rollin/filebrowser
    cat > /opt/rollin/filebrowser/docker-compose.yml <<COMPOSE
services:
  filebrowser:
    image: filebrowser/filebrowser:latest
    container_name: rollin-filebrowser
    restart: unless-stopped
    environment:
      FB_DATABASE: /database/filebrowser.db
    volumes:
      - filebrowser_files:/srv
      - filebrowser_db:/database
    labels:
      - traefik.enable=true
      - traefik.http.routers.filebrowser.rule=Host(\`$domain\`)
      - traefik.http.routers.filebrowser.entrypoints=websecure
      - traefik.http.routers.filebrowser.tls.certresolver=le
      - traefik.http.services.filebrowser.loadbalancer.server.port=80
    networks: [rollin]
volumes:
  filebrowser_files:
  filebrowser_db:
networks:
  rollin:
    external: true
    name: $NET
COMPOSE
    print_download_banner "File Browser" "1 imagem · ~40 MB" "1 minuto"
    run_with_spinner "Subindo File Browser" 120 "cd /opt/rollin/filebrowser && docker compose up -d --quiet-pull"
    save_cred "File Browser" "url=https://$domain" "user=admin" "password=admin" "setup=Login padrão admin / admin — TROQUE assim que entrar."
    log "File Browser no ar: https://$domain"
}

# ---------- PhotoPrism (galeria de fotos) ----------
install_photoprism() {
    hr "PhotoPrism (galeria de fotos)"
    check_existing_install "PhotoPrism" "rollin-photoprism" "/opt/rollin/photoprism" || return 0
    local domain
    ask_domain "Subdomínio do PhotoPrism (ex: fotos.seudominio.com.br):" || { warn "Sem domínio — pulado"; return 0; }
    domain="$REPLY"; dns_warning "$domain" || return 0
    local pwd; pwd=$(rand_pwd 20)
    mkdir -p /opt/rollin/photoprism
    cat > /opt/rollin/photoprism/docker-compose.yml <<COMPOSE
services:
  photoprism:
    image: photoprism/photoprism:latest
    container_name: rollin-photoprism
    restart: unless-stopped
    environment:
      PHOTOPRISM_ADMIN_USER: admin
      PHOTOPRISM_ADMIN_PASSWORD: $pwd
      PHOTOPRISM_SITE_URL: https://$domain/
      PHOTOPRISM_ORIGINALS_LIMIT: "5000"
      PHOTOPRISM_HTTP_COMPRESSION: gzip
      PHOTOPRISM_DATABASE_DRIVER: sqlite
    volumes:
      - photoprism_originals:/photoprism/originals
      - photoprism_storage:/photoprism/storage
    labels:
      - traefik.enable=true
      - traefik.http.routers.photoprism.rule=Host(\`$domain\`)
      - traefik.http.routers.photoprism.entrypoints=websecure
      - traefik.http.routers.photoprism.tls.certresolver=le
      - traefik.http.services.photoprism.loadbalancer.server.port=2342
    networks: [rollin]
volumes:
  photoprism_originals:
  photoprism_storage:
networks:
  rollin:
    external: true
    name: $NET
COMPOSE
    print_download_banner "PhotoPrism" "1 imagem · ~1 GB" "2 a 4 minutos"
    run_with_spinner "Subindo PhotoPrism" 300 "cd /opt/rollin/photoprism && docker compose up -d --quiet-pull"
    save_cred "PhotoPrism" "url=https://$domain" "user=admin" "password=$pwd" "setup=Entre com admin e a senha acima."
    log "PhotoPrism no ar: https://$domain"
}

# Helper: gera "user:hash" pro basicauth do Traefik, com o $ do hash DUPLICADO
# ($$) pra sobreviver à interpolação do docker compose. Usado nos apps que não
# têm login próprio (Node-RED etc.) — nada fica exposto sem senha.
rh_basicauth_user() {
    printf '%s:%s' "$1" "$(openssl passwd -apr1 "$2" | sed 's/[$]/$$/g')"
}

# ---------- Node.js (ambiente) ----------
install_node() {
    hr "Node.js (ambiente)"
    check_existing_install "Node.js" "rollin-node" "/opt/rollin/node" || return 0
    mkdir -p /opt/rollin/node
    cat > /opt/rollin/node/docker-compose.yml <<COMPOSE
services:
  node:
    image: node:20
    container_name: rollin-node
    restart: unless-stopped
    working_dir: /app
    command: ["tail", "-f", "/dev/null"]
    volumes: [node_app:/app]
    networks: [rollin]
volumes:
  node_app:
networks:
  rollin:
    external: true
    name: $NET
COMPOSE
    print_download_banner "Node.js 20" "1 imagem · ~400 MB" "1 minuto"
    run_with_spinner "Subindo ambiente Node.js" 120 "cd /opt/rollin/node && docker compose up -d --quiet-pull"
    save_cred "Node.js" "host=rollin-node" "setup=Ambiente Node.js 20. Seu código vai na pasta /app (volume). Use: docker exec -it rollin-node bash. Para publicar um app web, prefira o Deploy de repositório (Conexões)."
    log "Node.js no ar (ambiente): container rollin-node"
}

# ---------- Java (JDK) ----------
install_java() {
    hr "Java (JDK 21)"
    check_existing_install "Java" "rollin-java" "/opt/rollin/java" || return 0
    mkdir -p /opt/rollin/java
    cat > /opt/rollin/java/docker-compose.yml <<COMPOSE
services:
  java:
    image: eclipse-temurin:21-jdk
    container_name: rollin-java
    restart: unless-stopped
    working_dir: /app
    command: ["tail", "-f", "/dev/null"]
    volumes: [java_app:/app]
    networks: [rollin]
volumes:
  java_app:
networks:
  rollin:
    external: true
    name: $NET
COMPOSE
    print_download_banner "Java (JDK 21)" "1 imagem · ~450 MB" "1 minuto"
    run_with_spinner "Subindo ambiente Java" 120 "cd /opt/rollin/java && docker compose up -d --quiet-pull"
    save_cred "Java" "host=rollin-java" "setup=Ambiente Java JDK 21. Código em /app. Use: docker exec -it rollin-java bash (java e javac disponíveis)."
    log "Java no ar (ambiente): container rollin-java"
}

# ---------- Ruby (ambiente) ----------
install_ruby() {
    hr "Ruby (ambiente)"
    check_existing_install "Ruby" "rollin-ruby" "/opt/rollin/ruby" || return 0
    mkdir -p /opt/rollin/ruby
    cat > /opt/rollin/ruby/docker-compose.yml <<COMPOSE
services:
  ruby:
    image: ruby:3.3
    container_name: rollin-ruby
    restart: unless-stopped
    working_dir: /app
    command: ["tail", "-f", "/dev/null"]
    volumes: [ruby_app:/app]
    networks: [rollin]
volumes:
  ruby_app:
networks:
  rollin:
    external: true
    name: $NET
COMPOSE
    print_download_banner "Ruby 3.3" "1 imagem · ~350 MB" "1 minuto"
    run_with_spinner "Subindo ambiente Ruby" 120 "cd /opt/rollin/ruby && docker compose up -d --quiet-pull"
    save_cred "Ruby" "host=rollin-ruby" "setup=Ambiente Ruby 3.3. Código em /app. Use: docker exec -it rollin-ruby bash (ruby, gem, bundler)."
    log "Ruby no ar (ambiente): container rollin-ruby"
}

# ---------- Apache Tomcat ----------
install_tomcat() {
    hr "Apache Tomcat"
    check_existing_install "Apache Tomcat" "rollin-tomcat" "/opt/rollin/tomcat" || return 0
    local domain
    ask_domain "Subdomínio do Tomcat (ex: java.seudominio.com.br):" || { warn "Sem domínio — pulado"; return 0; }
    domain="$REPLY"; dns_warning "$domain" || return 0
    mkdir -p /opt/rollin/tomcat
    cat > /opt/rollin/tomcat/docker-compose.yml <<COMPOSE
services:
  tomcat:
    image: tomcat:10.1-jdk21
    container_name: rollin-tomcat
    restart: unless-stopped
    command: ["sh","-c","cp -rn /usr/local/tomcat/webapps.dist/. /usr/local/tomcat/webapps/ 2>/dev/null; exec catalina.sh run"]
    volumes: [tomcat_webapps:/usr/local/tomcat/webapps]
    labels:
      - traefik.enable=true
      - traefik.http.routers.tomcat.rule=Host(\`$domain\`)
      - traefik.http.routers.tomcat.entrypoints=websecure
      - traefik.http.routers.tomcat.tls.certresolver=le
      - traefik.http.services.tomcat.loadbalancer.server.port=8080
    networks: [rollin]
volumes:
  tomcat_webapps:
networks:
  rollin:
    external: true
    name: $NET
COMPOSE
    print_download_banner "Apache Tomcat" "1 imagem · ~500 MB" "1 a 2 minutos"
    run_with_spinner "Subindo Tomcat" 180 "cd /opt/rollin/tomcat && docker compose up -d --quiet-pull"
    save_cred "Apache Tomcat" "url=https://$domain" "setup=Publique seus .war na pasta webapps (docker cp <arquivo>.war rollin-tomcat:/usr/local/tomcat/webapps/). O /manager exige configurar o tomcat-users.xml."
    log "Tomcat no ar: https://$domain"
}

# ---------- Apache TomEE ----------
install_tomee() {
    hr "Apache TomEE"
    check_existing_install "Apache TomEE" "rollin-tomee" "/opt/rollin/tomee" || return 0
    local domain
    ask_domain "Subdomínio do TomEE (ex: jakarta.seudominio.com.br):" || { warn "Sem domínio — pulado"; return 0; }
    domain="$REPLY"; dns_warning "$domain" || return 0
    mkdir -p /opt/rollin/tomee
    cat > /opt/rollin/tomee/docker-compose.yml <<COMPOSE
services:
  tomee:
    image: tomee:latest
    container_name: rollin-tomee
    restart: unless-stopped
    volumes: [tomee_webapps:/usr/local/tomee/webapps]
    labels:
      - traefik.enable=true
      - traefik.http.routers.tomee.rule=Host(\`$domain\`)
      - traefik.http.routers.tomee.entrypoints=websecure
      - traefik.http.routers.tomee.tls.certresolver=le
      - traefik.http.services.tomee.loadbalancer.server.port=8080
    networks: [rollin]
volumes:
  tomee_webapps:
networks:
  rollin:
    external: true
    name: $NET
COMPOSE
    print_download_banner "Apache TomEE" "1 imagem · ~600 MB" "1 a 2 minutos"
    run_with_spinner "Subindo TomEE" 180 "cd /opt/rollin/tomee && docker compose up -d --quiet-pull"
    save_cred "Apache TomEE" "url=https://$domain" "setup=Publique seus .war em /usr/local/tomee/webapps (docker cp). Servidor Jakarta EE (Plume)."
    log "TomEE no ar: https://$domain"
}

# ---------- Node-RED (protegido por basic-auth) ----------
install_nodered() {
    hr "Node-RED"
    check_existing_install "Node-RED" "rollin-nodered" "/opt/rollin/nodered" || return 0
    local domain
    ask_domain "Subdomínio do Node-RED (ex: fluxos.seudominio.com.br):" || { warn "Sem domínio — pulado"; return 0; }
    domain="$REPLY"; dns_warning "$domain" || return 0
    local user="rollin" pwd ba; pwd=$(rand_pwd 18); ba=$(rh_basicauth_user "$user" "$pwd")
    mkdir -p /opt/rollin/nodered
    cat > /opt/rollin/nodered/docker-compose.yml <<COMPOSE
services:
  nodered:
    image: nodered/node-red:latest
    container_name: rollin-nodered
    restart: unless-stopped
    volumes: [nodered_data:/data]
    labels:
      - traefik.enable=true
      - traefik.http.routers.nodered.rule=Host(\`$domain\`)
      - traefik.http.routers.nodered.entrypoints=websecure
      - traefik.http.routers.nodered.tls.certresolver=le
      - traefik.http.routers.nodered.middlewares=nodered-auth
      - traefik.http.middlewares.nodered-auth.basicauth.users=$ba
      - traefik.http.services.nodered.loadbalancer.server.port=1880
    networks: [rollin]
volumes:
  nodered_data:
networks:
  rollin:
    external: true
    name: $NET
COMPOSE
    print_download_banner "Node-RED" "1 imagem · ~500 MB" "1 a 2 minutos"
    run_with_spinner "Subindo Node-RED" 180 "cd /opt/rollin/nodered && docker compose up -d --quiet-pull"
    save_cred "Node-RED" "url=https://$domain" "user=$user" "password=$pwd" "setup=O acesso é protegido por senha (usuário/senha acima)."
    log "Node-RED no ar: https://$domain"
}

# ---------- Gotify (notificações push) ----------
install_gotify() {
    hr "Gotify (notificações push)"
    check_existing_install "Gotify" "rollin-gotify" "/opt/rollin/gotify" || return 0
    local domain
    ask_domain "Subdomínio do Gotify (ex: avisos.seudominio.com.br):" || { warn "Sem domínio — pulado"; return 0; }
    domain="$REPLY"; dns_warning "$domain" || return 0
    local pwd; pwd=$(rand_pwd 18)
    mkdir -p /opt/rollin/gotify
    cat > /opt/rollin/gotify/docker-compose.yml <<COMPOSE
services:
  gotify:
    image: gotify/server:latest
    container_name: rollin-gotify
    restart: unless-stopped
    environment:
      GOTIFY_DEFAULTUSER_NAME: admin
      GOTIFY_DEFAULTUSER_PASS: $pwd
    volumes: [gotify_data:/app/data]
    labels:
      - traefik.enable=true
      - traefik.http.routers.gotify.rule=Host(\`$domain\`)
      - traefik.http.routers.gotify.entrypoints=websecure
      - traefik.http.routers.gotify.tls.certresolver=le
      - traefik.http.services.gotify.loadbalancer.server.port=80
    networks: [rollin]
volumes:
  gotify_data:
networks:
  rollin:
    external: true
    name: $NET
COMPOSE
    print_download_banner "Gotify" "1 imagem · ~30 MB" "1 minuto"
    run_with_spinner "Subindo Gotify" 120 "cd /opt/rollin/gotify && docker compose up -d --quiet-pull"
    save_cred "Gotify" "url=https://$domain" "user=admin" "password=$pwd" "setup=Entre com admin e a senha acima."
    log "Gotify no ar: https://$domain"
}

# ---------- IT-Tools (caixa de ferramentas do dev) ----------
install_ittools() {
    hr "IT-Tools"
    check_existing_install "IT-Tools" "rollin-ittools" "/opt/rollin/ittools" || return 0
    local domain
    ask_domain "Subdomínio do IT-Tools (ex: tools.seudominio.com.br):" || { warn "Sem domínio — pulado"; return 0; }
    domain="$REPLY"; dns_warning "$domain" || return 0
    mkdir -p /opt/rollin/ittools
    cat > /opt/rollin/ittools/docker-compose.yml <<COMPOSE
services:
  ittools:
    image: corentinth/it-tools:latest
    container_name: rollin-ittools
    restart: unless-stopped
    labels:
      - traefik.enable=true
      - traefik.http.routers.ittools.rule=Host(\`$domain\`)
      - traefik.http.routers.ittools.entrypoints=websecure
      - traefik.http.routers.ittools.tls.certresolver=le
      - traefik.http.services.ittools.loadbalancer.server.port=80
    networks: [rollin]
networks:
  rollin:
    external: true
    name: $NET
COMPOSE
    print_download_banner "IT-Tools" "1 imagem · ~50 MB" "1 minuto"
    run_with_spinner "Subindo IT-Tools" 120 "cd /opt/rollin/ittools && docker compose up -d --quiet-pull"
    save_cred "IT-Tools" "url=https://$domain" "setup=Ferramentas do dev no navegador — sem login (roda tudo no seu navegador)."
    log "IT-Tools no ar: https://$domain"
}

# ---------- Jellyfin (servidor de mídia) ----------
install_jellyfin() {
    hr "Jellyfin (servidor de mídia)"
    check_existing_install "Jellyfin" "rollin-jellyfin" "/opt/rollin/jellyfin" || return 0
    local domain
    ask_domain "Subdomínio do Jellyfin (ex: media.seudominio.com.br):" || { warn "Sem domínio — pulado"; return 0; }
    domain="$REPLY"; dns_warning "$domain" || return 0
    mkdir -p /opt/rollin/jellyfin
    cat > /opt/rollin/jellyfin/docker-compose.yml <<COMPOSE
services:
  jellyfin:
    image: jellyfin/jellyfin:latest
    container_name: rollin-jellyfin
    restart: unless-stopped
    environment:
      JELLYFIN_PublishedServerUrl: https://$domain
    volumes:
      - jellyfin_config:/config
      - jellyfin_cache:/cache
      - jellyfin_media:/media
    labels:
      - traefik.enable=true
      - traefik.http.routers.jellyfin.rule=Host(\`$domain\`)
      - traefik.http.routers.jellyfin.entrypoints=websecure
      - traefik.http.routers.jellyfin.tls.certresolver=le
      - traefik.http.services.jellyfin.loadbalancer.server.port=8096
    networks: [rollin]
volumes:
  jellyfin_config:
  jellyfin_cache:
  jellyfin_media:
networks:
  rollin:
    external: true
    name: $NET
COMPOSE
    print_download_banner "Jellyfin" "1 imagem · ~600 MB" "2 a 3 minutos"
    run_with_spinner "Subindo Jellyfin" 240 "cd /opt/rollin/jellyfin && docker compose up -d --quiet-pull"
    save_cred "Jellyfin" "url=https://$domain" "setup=Siga o assistente do 1º acesso para criar o admin. Coloque suas mídias em /media (volume)."
    log "Jellyfin no ar: https://$domain"
}

# ---------- SQL Server (banco interno) ----------
install_mssql() {
    hr "SQL Server (banco interno)"
    check_existing_install "SQL Server" "rollin-mssql" "/opt/rollin/mssql" || return 0
    local sa_pwd; sa_pwd=$(rand_pwd 24)
    mkdir -p /opt/rollin/mssql
    cat > /opt/rollin/mssql/docker-compose.yml <<COMPOSE
services:
  mssql:
    image: mcr.microsoft.com/mssql/server:2022-latest
    container_name: rollin-mssql
    restart: unless-stopped
    environment:
      ACCEPT_EULA: "Y"
      MSSQL_SA_PASSWORD: $sa_pwd
      MSSQL_PID: Express
    volumes: [mssql_data:/var/opt/mssql]
    networks: [rollin]
volumes:
  mssql_data:
networks:
  rollin:
    external: true
    name: $NET
COMPOSE
    print_download_banner "SQL Server 2022 Express" "1 imagem · ~1.5 GB" "2 a 4 minutos"
    run_with_spinner "Subindo SQL Server" 300 "cd /opt/rollin/mssql && docker compose up -d --quiet-pull"
    save_cred "SQL Server" "host=rollin-mssql" "port=1433" "user=sa" "password=$sa_pwd" "setup=Conexão interna (rede da VPS). NÃO exposto na internet."
    log "SQL Server no ar (interno): rollin-mssql:1433 user=sa"
}

# ---------- Firebird (banco interno) ----------
install_firebird() {
    hr "Firebird (banco interno)"
    check_existing_install "Firebird" "rollin-firebird" "/opt/rollin/firebird" || return 0
    local root_pwd pwd; root_pwd=$(rand_pwd 24); pwd=$(rand_pwd 24)
    mkdir -p /opt/rollin/firebird
    cat > /opt/rollin/firebird/docker-compose.yml <<COMPOSE
services:
  firebird:
    image: firebirdsql/firebird:latest
    container_name: rollin-firebird
    restart: unless-stopped
    environment:
      FIREBIRD_ROOT_PASSWORD: $root_pwd
      FIREBIRD_DATABASE: rollin.fdb
      FIREBIRD_USER: rollin
      FIREBIRD_PASSWORD: $pwd
    volumes: [firebird_data:/var/lib/firebird/data]
    networks: [rollin]
volumes:
  firebird_data:
networks:
  rollin:
    external: true
    name: $NET
COMPOSE
    print_download_banner "Firebird" "1 imagem · ~200 MB" "1 minuto"
    run_with_spinner "Subindo Firebird" 120 "cd /opt/rollin/firebird && docker compose up -d --quiet-pull"
    save_cred "Firebird" "host=rollin-firebird" "port=3050" "user=rollin" "password=$pwd" "setup=Conexão interna (rede da VPS). Banco rollin.fdb. root_password (SYSDBA)=$root_pwd. NÃO exposto na internet."
    log "Firebird no ar (interno): rollin-firebird:3050 user=rollin"
}

# ---------- Kanboard ----------
install_kanboard() {
    hr "Kanboard"
    check_existing_install "Kanboard" "rollin-kanboard" "/opt/rollin/kanboard" || return 0
    local domain
    ask_domain "Subdomínio do Kanboard (ex: projetos.seudominio.com.br):" || { warn "Sem domínio — pulado"; return 0; }
    domain="$REPLY"; dns_warning "$domain" || return 0
    mkdir -p /opt/rollin/kanboard
    cat > /opt/rollin/kanboard/docker-compose.yml <<COMPOSE
services:
  kanboard:
    image: kanboard/kanboard:latest
    container_name: rollin-kanboard
    restart: unless-stopped
    volumes:
      - kanboard_data:/var/www/app/data
      - kanboard_plugins:/var/www/app/plugins
    labels:
      - traefik.enable=true
      - traefik.http.routers.kanboard.rule=Host(\`$domain\`)
      - traefik.http.routers.kanboard.entrypoints=websecure
      - traefik.http.routers.kanboard.tls.certresolver=le
      - traefik.http.services.kanboard.loadbalancer.server.port=80
    networks: [rollin]
volumes:
  kanboard_data:
  kanboard_plugins:
networks:
  rollin:
    external: true
    name: $NET
COMPOSE
    print_download_banner "Kanboard" "1 imagem · ~120 MB" "1 minuto"
    run_with_spinner "Subindo Kanboard" 120 "cd /opt/rollin/kanboard && docker compose up -d --quiet-pull"
    save_cred "Kanboard" "url=https://$domain" "user=admin" "password=admin" "setup=Login padrão admin / admin — TROQUE assim que entrar."
    log "Kanboard no ar: https://$domain"
}

# ---------- Planka ----------
install_planka() {
    hr "Planka (kanban)"
    check_existing_install "Planka" "rollin-planka" "/opt/rollin/planka" || return 0
    local domain
    ask_domain "Subdomínio do Planka (ex: kanban.seudominio.com.br):" || { warn "Sem domínio — pulado"; return 0; }
    domain="$REPLY"; dns_warning "$domain" || return 0
    local db_pwd secret adm; db_pwd=$(rand_pwd 24); secret=$(rand_pwd 40); adm=$(rand_pwd 16)
    mkdir -p /opt/rollin/planka
    cat > /opt/rollin/planka/docker-compose.yml <<COMPOSE
services:
  planka-db:
    image: postgres:16-alpine
    container_name: rollin-planka-db
    restart: unless-stopped
    environment:
      POSTGRES_DB: planka
      POSTGRES_USER: planka
      POSTGRES_PASSWORD: $db_pwd
    volumes: [planka_db:/var/lib/postgresql/data]
    networks: [rollin]

  planka:
    image: ghcr.io/plankanban/planka:latest
    container_name: rollin-planka
    restart: unless-stopped
    depends_on: [planka-db]
    environment:
      BASE_URL: https://$domain
      DATABASE_URL: postgresql://planka:$db_pwd@planka-db/planka
      SECRET_KEY: $secret
      DEFAULT_ADMIN_EMAIL: admin@$domain
      DEFAULT_ADMIN_PASSWORD: $adm
      DEFAULT_ADMIN_NAME: Administrador
      DEFAULT_ADMIN_USERNAME: admin
    volumes:
      - planka_avatars:/app/public/user-avatars
      - planka_backgrounds:/app/public/project-background-images
      - planka_attachments:/app/private/attachments
    labels:
      - traefik.enable=true
      - traefik.http.routers.planka.rule=Host(\`$domain\`)
      - traefik.http.routers.planka.entrypoints=websecure
      - traefik.http.routers.planka.tls.certresolver=le
      - traefik.http.services.planka.loadbalancer.server.port=1337
    networks: [rollin]
volumes:
  planka_db:
  planka_avatars:
  planka_backgrounds:
  planka_attachments:
networks:
  rollin:
    external: true
    name: $NET
COMPOSE
    drop_orphan_volume "planka_planka_db" "rollin-planka-db"
    print_download_banner "Planka + Postgres" "2 imagens · ~500 MB" "2 a 3 minutos"
    run_with_spinner "Subindo Planka" 240 "cd /opt/rollin/planka && docker compose up -d --quiet-pull"
    save_cred "Planka" "url=https://$domain" "user=admin@$domain" "password=$adm" "setup=Entre com o e-mail/senha acima."
    log "Planka no ar: https://$domain"
}

# ---------- Navidrome (música) ----------
install_navidrome() {
    hr "Navidrome (servidor de música)"
    check_existing_install "Navidrome" "rollin-navidrome" "/opt/rollin/navidrome" || return 0
    local domain
    ask_domain "Subdomínio do Navidrome (ex: musica.seudominio.com.br):" || { warn "Sem domínio — pulado"; return 0; }
    domain="$REPLY"; dns_warning "$domain" || return 0
    mkdir -p /opt/rollin/navidrome
    cat > /opt/rollin/navidrome/docker-compose.yml <<COMPOSE
services:
  navidrome:
    image: deluan/navidrome:latest
    container_name: rollin-navidrome
    restart: unless-stopped
    environment:
      ND_SCANSCHEDULE: 1h
    volumes:
      - navidrome_data:/data
      - navidrome_music:/music
    labels:
      - traefik.enable=true
      - traefik.http.routers.navidrome.rule=Host(\`$domain\`)
      - traefik.http.routers.navidrome.entrypoints=websecure
      - traefik.http.routers.navidrome.tls.certresolver=le
      - traefik.http.services.navidrome.loadbalancer.server.port=4533
    networks: [rollin]
volumes:
  navidrome_data:
  navidrome_music:
networks:
  rollin:
    external: true
    name: $NET
COMPOSE
    print_download_banner "Navidrome" "1 imagem · ~60 MB" "1 minuto"
    run_with_spinner "Subindo Navidrome" 120 "cd /opt/rollin/navidrome && docker compose up -d --quiet-pull"
    save_cred "Navidrome" "url=https://$domain" "setup=Crie o admin no 1º acesso. Coloque suas músicas em /music (volume)."
    log "Navidrome no ar: https://$domain"
}

# ---------- Firefly III (finanças) ----------
install_firefly() {
    hr "Firefly III (finanças)"
    check_existing_install "Firefly III" "rollin-firefly" "/opt/rollin/firefly" || return 0
    local domain
    ask_domain "Subdomínio do Firefly III (ex: financas.seudominio.com.br):" || { warn "Sem domínio — pulado"; return 0; }
    domain="$REPLY"; dns_warning "$domain" || return 0
    local db_pwd app_key; db_pwd=$(rand_pwd 24); app_key=$(rand_pwd 32)
    mkdir -p /opt/rollin/firefly
    cat > /opt/rollin/firefly/docker-compose.yml <<COMPOSE
services:
  firefly-db:
    image: mariadb:11
    container_name: rollin-firefly-db
    restart: unless-stopped
    environment:
      MARIADB_DATABASE: firefly
      MARIADB_USER: firefly
      MARIADB_PASSWORD: $db_pwd
      MARIADB_ROOT_PASSWORD: $db_pwd
    volumes: [firefly_db:/var/lib/mysql]
    networks: [rollin]

  firefly:
    image: fireflyiii/core:latest
    container_name: rollin-firefly
    restart: unless-stopped
    depends_on: [firefly-db]
    environment:
      APP_KEY: $app_key
      APP_URL: https://$domain
      TZ: America/Sao_Paulo
      TRUSTED_PROXIES: "**"
      DB_CONNECTION: mysql
      DB_HOST: firefly-db
      DB_PORT: "3306"
      DB_DATABASE: firefly
      DB_USERNAME: firefly
      DB_PASSWORD: $db_pwd
    volumes: [firefly_upload:/var/www/html/storage/upload]
    labels:
      - traefik.enable=true
      - traefik.http.routers.firefly.rule=Host(\`$domain\`)
      - traefik.http.routers.firefly.entrypoints=websecure
      - traefik.http.routers.firefly.tls.certresolver=le
      - traefik.http.services.firefly.loadbalancer.server.port=8080
    networks: [rollin]
volumes:
  firefly_db:
  firefly_upload:
networks:
  rollin:
    external: true
    name: $NET
COMPOSE
    drop_orphan_volume "firefly_firefly_db" "rollin-firefly-db"
    print_download_banner "Firefly III + MariaDB" "2 imagens · ~700 MB" "2 a 3 minutos"
    run_with_spinner "Subindo Firefly III" 240 "cd /opt/rollin/firefly && docker compose up -d --quiet-pull"
    save_cred "Firefly III" "url=https://$domain" "setup=Crie sua conta no 1º acesso (o primeiro cadastro vira o dono)."
    log "Firefly III no ar: https://$domain"
}

# ---------- Mattermost (chat de equipe) ----------
install_mattermost() {
    hr "Mattermost (chat de equipe)"
    check_existing_install "Mattermost" "rollin-mattermost" "/opt/rollin/mattermost" || return 0
    local domain
    ask_domain "Subdomínio do Mattermost (ex: chat.seudominio.com.br):" || { warn "Sem domínio — pulado"; return 0; }
    domain="$REPLY"; dns_warning "$domain" || return 0
    local db_pwd; db_pwd=$(rand_pwd 24)
    mkdir -p /opt/rollin/mattermost
    cat > /opt/rollin/mattermost/docker-compose.yml <<COMPOSE
services:
  mm-db:
    image: postgres:16-alpine
    container_name: rollin-mm-db
    restart: unless-stopped
    environment:
      POSTGRES_DB: mattermost
      POSTGRES_USER: mmuser
      POSTGRES_PASSWORD: $db_pwd
    volumes: [mm_db:/var/lib/postgresql/data]
    networks: [rollin]

  mattermost:
    image: mattermost/mattermost-team-edition:latest
    container_name: rollin-mattermost
    restart: unless-stopped
    depends_on: [mm-db]
    environment:
      MM_SQLSETTINGS_DRIVERNAME: postgres
      MM_SQLSETTINGS_DATASOURCE: postgres://mmuser:$db_pwd@mm-db:5432/mattermost?sslmode=disable&connect_timeout=10
      MM_SERVICESETTINGS_SITEURL: https://$domain
    volumes:
      - mm_config:/mattermost/config
      - mm_data:/mattermost/data
      - mm_logs:/mattermost/logs
      - mm_plugins:/mattermost/plugins
      - mm_client_plugins:/mattermost/client/plugins
    labels:
      - traefik.enable=true
      - traefik.http.routers.mattermost.rule=Host(\`$domain\`)
      - traefik.http.routers.mattermost.entrypoints=websecure
      - traefik.http.routers.mattermost.tls.certresolver=le
      - traefik.http.services.mattermost.loadbalancer.server.port=8065
    networks: [rollin]
volumes:
  mm_db:
  mm_config:
  mm_data:
  mm_logs:
  mm_plugins:
  mm_client_plugins:
networks:
  rollin:
    external: true
    name: $NET
COMPOSE
    drop_orphan_volume "mattermost_mm_db" "rollin-mm-db"
    print_download_banner "Mattermost + Postgres" "2 imagens · ~600 MB" "2 a 3 minutos"
    run_with_spinner "Subindo Mattermost" 240 "cd /opt/rollin/mattermost && docker compose up -d --quiet-pull"
    save_cred "Mattermost" "url=https://$domain" "setup=Crie o admin no 1º acesso (o primeiro cadastro vira administrador)."
    log "Mattermost no ar: https://$domain"
}

# ---------- Memos (notas rápidas) ----------
install_memos() {
    hr "Memos (notas rápidas)"
    check_existing_install "Memos" "rollin-memos" "/opt/rollin/memos" || return 0
    local domain
    ask_domain "Subdomínio do Memos (ex: notas.seudominio.com.br):" || { warn "Sem domínio — pulado"; return 0; }
    domain="$REPLY"; dns_warning "$domain" || return 0
    mkdir -p /opt/rollin/memos
    cat > /opt/rollin/memos/docker-compose.yml <<COMPOSE
services:
  memos:
    image: neosmemo/memos:stable
    container_name: rollin-memos
    restart: unless-stopped
    volumes: [memos_data:/var/opt/memos]
    labels:
      - traefik.enable=true
      - traefik.http.routers.memos.rule=Host(\`$domain\`)
      - traefik.http.routers.memos.entrypoints=websecure
      - traefik.http.routers.memos.tls.certresolver=le
      - traefik.http.services.memos.loadbalancer.server.port=5230
    networks: [rollin]
volumes:
  memos_data:
networks:
  rollin:
    external: true
    name: $NET
COMPOSE
    print_download_banner "Memos" "1 imagem · ~40 MB" "1 minuto"
    run_with_spinner "Subindo Memos" 120 "cd /opt/rollin/memos && docker compose up -d --quiet-pull"
    save_cred "Memos" "url=https://$domain" "setup=Crie o admin no 1º acesso (o primeiro cadastro vira dono)."
    log "Memos no ar: https://$domain"
}

# ---------- PrivateBin ----------
install_privatebin() {
    hr "PrivateBin"
    check_existing_install "PrivateBin" "rollin-privatebin" "/opt/rollin/privatebin" || return 0
    local domain
    ask_domain "Subdomínio do PrivateBin (ex: paste.seudominio.com.br):" || { warn "Sem domínio — pulado"; return 0; }
    domain="$REPLY"; dns_warning "$domain" || return 0
    mkdir -p /opt/rollin/privatebin
    cat > /opt/rollin/privatebin/docker-compose.yml <<COMPOSE
services:
  privatebin:
    image: privatebin/nginx-fpm-alpine:latest
    container_name: rollin-privatebin
    restart: unless-stopped
    volumes: [privatebin_data:/srv/data]
    labels:
      - traefik.enable=true
      - traefik.http.routers.privatebin.rule=Host(\`$domain\`)
      - traefik.http.routers.privatebin.entrypoints=websecure
      - traefik.http.routers.privatebin.tls.certresolver=le
      - traefik.http.services.privatebin.loadbalancer.server.port=8080
    networks: [rollin]
volumes:
  privatebin_data:
networks:
  rollin:
    external: true
    name: $NET
COMPOSE
    print_download_banner "PrivateBin" "1 imagem · ~80 MB" "1 minuto"
    run_with_spinner "Subindo PrivateBin" 120 "cd /opt/rollin/privatebin && docker compose up -d --quiet-pull"
    save_cred "PrivateBin" "url=https://$domain" "setup=Cole um texto/senha, defina expiração e compartilhe o link — criptografado no navegador. Sem login."
    log "PrivateBin no ar: https://$domain"
}

# ---------- Dozzle (logs de containers, protegido) ----------
install_dozzle() {
    hr "Dozzle (logs de containers)"
    check_existing_install "Dozzle" "rollin-dozzle" "/opt/rollin/dozzle" || return 0
    local domain
    ask_domain "Subdomínio do Dozzle (ex: logs.seudominio.com.br):" || { warn "Sem domínio — pulado"; return 0; }
    domain="$REPLY"; dns_warning "$domain" || return 0
    local user="rollin" pwd ba; pwd=$(rand_pwd 18); ba=$(rh_basicauth_user "$user" "$pwd")
    mkdir -p /opt/rollin/dozzle
    cat > /opt/rollin/dozzle/docker-compose.yml <<COMPOSE
services:
  dozzle:
    image: amir20/dozzle:latest
    container_name: rollin-dozzle
    restart: unless-stopped
    volumes:
      - /var/run/docker.sock:/var/run/docker.sock:ro
    labels:
      - traefik.enable=true
      - traefik.http.routers.dozzle.rule=Host(\`$domain\`)
      - traefik.http.routers.dozzle.entrypoints=websecure
      - traefik.http.routers.dozzle.tls.certresolver=le
      - traefik.http.routers.dozzle.middlewares=dozzle-auth
      - traefik.http.middlewares.dozzle-auth.basicauth.users=$ba
      - traefik.http.services.dozzle.loadbalancer.server.port=8080
    networks: [rollin]
networks:
  rollin:
    external: true
    name: $NET
COMPOSE
    print_download_banner "Dozzle" "1 imagem · ~15 MB" "1 minuto"
    run_with_spinner "Subindo Dozzle" 120 "cd /opt/rollin/dozzle && docker compose up -d --quiet-pull"
    save_cred "Dozzle" "url=https://$domain" "user=$user" "password=$pwd" "setup=Acesso protegido por senha (usuário/senha acima)."
    log "Dozzle no ar: https://$domain"
}

# ---------- AnythingLLM (IA sobre documentos) ----------
install_anythingllm() {
    hr "AnythingLLM (IA sobre documentos)"
    check_existing_install "AnythingLLM" "rollin-anythingllm" "/opt/rollin/anythingllm" || return 0
    local domain
    ask_domain "Subdomínio do AnythingLLM (ex: docsia.seudominio.com.br):" || { warn "Sem domínio — pulado"; return 0; }
    domain="$REPLY"; dns_warning "$domain" || return 0
    mkdir -p /opt/rollin/anythingllm
    cat > /opt/rollin/anythingllm/docker-compose.yml <<COMPOSE
services:
  anythingllm:
    image: mintplexlabs/anythingllm:latest
    container_name: rollin-anythingllm
    restart: unless-stopped
    environment:
      STORAGE_DIR: /app/server/storage
    volumes: [anythingllm_storage:/app/server/storage]
    labels:
      - traefik.enable=true
      - traefik.http.routers.anythingllm.rule=Host(\`$domain\`)
      - traefik.http.routers.anythingllm.entrypoints=websecure
      - traefik.http.routers.anythingllm.tls.certresolver=le
      - traefik.http.services.anythingllm.loadbalancer.server.port=3001
    networks: [rollin]
volumes:
  anythingllm_storage:
networks:
  rollin:
    external: true
    name: $NET
COMPOSE
    print_download_banner "AnythingLLM" "1 imagem · ~1 GB" "2 a 4 minutos"
    run_with_spinner "Subindo AnythingLLM" 300 "cd /opt/rollin/anythingllm && docker compose up -d --quiet-pull"
    save_cred "AnythingLLM" "url=https://$domain" "setup=Siga o assistente do 1º acesso (defina uma senha e conecte um provedor de IA, ex.: seu Ollama/LiteLLM da VPS)."
    log "AnythingLLM no ar: https://$domain"
}

# ---------- Miniflux (leitor RSS) ----------
install_miniflux() {
    hr "Miniflux (leitor RSS)"
    check_existing_install "Miniflux" "rollin-miniflux" "/opt/rollin/miniflux" || return 0
    local domain
    ask_domain "Subdomínio do Miniflux (ex: rss.seudominio.com.br):" || { warn "Sem domínio — pulado"; return 0; }
    domain="$REPLY"; dns_warning "$domain" || return 0
    local db_pwd adm; db_pwd=$(rand_pwd 24); adm=$(rand_pwd 16)
    mkdir -p /opt/rollin/miniflux
    cat > /opt/rollin/miniflux/docker-compose.yml <<COMPOSE
services:
  miniflux-db:
    image: postgres:16-alpine
    container_name: rollin-miniflux-db
    restart: unless-stopped
    environment:
      POSTGRES_DB: miniflux
      POSTGRES_USER: miniflux
      POSTGRES_PASSWORD: $db_pwd
    volumes: [miniflux_db:/var/lib/postgresql/data]
    networks: [rollin]

  miniflux:
    image: miniflux/miniflux:latest
    container_name: rollin-miniflux
    restart: unless-stopped
    depends_on: [miniflux-db]
    environment:
      DATABASE_URL: postgres://miniflux:$db_pwd@miniflux-db/miniflux?sslmode=disable
      RUN_MIGRATIONS: "1"
      CREATE_ADMIN: "1"
      ADMIN_USERNAME: admin
      ADMIN_PASSWORD: $adm
      BASE_URL: https://$domain
    labels:
      - traefik.enable=true
      - traefik.http.routers.miniflux.rule=Host(\`$domain\`)
      - traefik.http.routers.miniflux.entrypoints=websecure
      - traefik.http.routers.miniflux.tls.certresolver=le
      - traefik.http.services.miniflux.loadbalancer.server.port=8080
    networks: [rollin]
volumes:
  miniflux_db:
networks:
  rollin:
    external: true
    name: $NET
COMPOSE
    drop_orphan_volume "miniflux_miniflux_db" "rollin-miniflux-db"
    print_download_banner "Miniflux + Postgres" "2 imagens · ~300 MB" "1 a 2 minutos"
    run_with_spinner "Subindo Miniflux" 180 "cd /opt/rollin/miniflux && docker compose up -d --quiet-pull"
    save_cred "Miniflux" "url=https://$domain" "user=admin" "password=$adm" "setup=Entre com admin e a senha acima."
    log "Miniflux no ar: https://$domain"
}

# ---------- Linkwarden (favoritos) ----------
install_linkwarden() {
    hr "Linkwarden (favoritos)"
    check_existing_install "Linkwarden" "rollin-linkwarden" "/opt/rollin/linkwarden" || return 0
    local domain
    ask_domain "Subdomínio do Linkwarden (ex: links.seudominio.com.br):" || { warn "Sem domínio — pulado"; return 0; }
    domain="$REPLY"; dns_warning "$domain" || return 0
    local db_pwd secret; db_pwd=$(rand_pwd 24); secret=$(rand_pwd 32)
    mkdir -p /opt/rollin/linkwarden
    cat > /opt/rollin/linkwarden/docker-compose.yml <<COMPOSE
services:
  linkwarden-db:
    image: postgres:16-alpine
    container_name: rollin-linkwarden-db
    restart: unless-stopped
    environment:
      POSTGRES_DB: linkwarden
      POSTGRES_USER: linkwarden
      POSTGRES_PASSWORD: $db_pwd
    volumes: [linkwarden_db:/var/lib/postgresql/data]
    networks: [rollin]

  linkwarden:
    image: ghcr.io/linkwarden/linkwarden:latest
    container_name: rollin-linkwarden
    restart: unless-stopped
    depends_on: [linkwarden-db]
    environment:
      DATABASE_URL: postgresql://linkwarden:$db_pwd@linkwarden-db:5432/linkwarden
      NEXTAUTH_SECRET: $secret
      NEXTAUTH_URL: https://$domain/api/v1/auth
    volumes: [linkwarden_data:/data/data]
    labels:
      - traefik.enable=true
      - traefik.http.routers.linkwarden.rule=Host(\`$domain\`)
      - traefik.http.routers.linkwarden.entrypoints=websecure
      - traefik.http.routers.linkwarden.tls.certresolver=le
      - traefik.http.services.linkwarden.loadbalancer.server.port=3000
    networks: [rollin]
volumes:
  linkwarden_db:
  linkwarden_data:
networks:
  rollin:
    external: true
    name: $NET
COMPOSE
    drop_orphan_volume "linkwarden_linkwarden_db" "rollin-linkwarden-db"
    print_download_banner "Linkwarden + Postgres" "2 imagens · ~800 MB" "2 a 4 minutos"
    run_with_spinner "Subindo Linkwarden" 300 "cd /opt/rollin/linkwarden && docker compose up -d --quiet-pull"
    save_cred "Linkwarden" "url=https://$domain" "setup=Crie sua conta no 1º acesso."
    log "Linkwarden no ar: https://$domain"
}

# ---------- Wallabag (ler depois) ----------
install_wallabag() {
    hr "Wallabag (ler depois)"
    check_existing_install "Wallabag" "rollin-wallabag" "/opt/rollin/wallabag" || return 0
    local domain
    ask_domain "Subdomínio do Wallabag (ex: ler.seudominio.com.br):" || { warn "Sem domínio — pulado"; return 0; }
    domain="$REPLY"; dns_warning "$domain" || return 0
    local db_pwd; db_pwd=$(rand_pwd 24)
    mkdir -p /opt/rollin/wallabag
    cat > /opt/rollin/wallabag/docker-compose.yml <<COMPOSE
services:
  wallabag-db:
    image: mariadb:11
    container_name: rollin-wallabag-db
    restart: unless-stopped
    environment:
      MARIADB_DATABASE: wallabag
      MARIADB_USER: wallabag
      MARIADB_PASSWORD: $db_pwd
      MARIADB_ROOT_PASSWORD: $db_pwd
    volumes: [wallabag_db:/var/lib/mysql]
    networks: [rollin]

  wallabag:
    image: wallabag/wallabag:latest
    container_name: rollin-wallabag
    restart: unless-stopped
    depends_on: [wallabag-db]
    environment:
      SYMFONY__ENV__DATABASE_DRIVER: pdo_mysql
      SYMFONY__ENV__DATABASE_HOST: wallabag-db
      SYMFONY__ENV__DATABASE_PORT: "3306"
      SYMFONY__ENV__DATABASE_NAME: wallabag
      SYMFONY__ENV__DATABASE_USER: wallabag
      SYMFONY__ENV__DATABASE_PASSWORD: $db_pwd
      SYMFONY__ENV__DOMAIN_NAME: https://$domain
    volumes: [wallabag_data:/var/www/wallabag/data]
    labels:
      - traefik.enable=true
      - traefik.http.routers.wallabag.rule=Host(\`$domain\`)
      - traefik.http.routers.wallabag.entrypoints=websecure
      - traefik.http.routers.wallabag.tls.certresolver=le
      - traefik.http.services.wallabag.loadbalancer.server.port=80
    networks: [rollin]
volumes:
  wallabag_db:
  wallabag_data:
networks:
  rollin:
    external: true
    name: $NET
COMPOSE
    drop_orphan_volume "wallabag_wallabag_db" "rollin-wallabag-db"
    print_download_banner "Wallabag + MariaDB" "2 imagens · ~700 MB" "2 a 3 minutos"
    run_with_spinner "Subindo Wallabag" 240 "cd /opt/rollin/wallabag && docker compose up -d --quiet-pull"
    save_cred "Wallabag" "url=https://$domain" "user=wallabag" "password=wallabag" "setup=Login padrão wallabag / wallabag — TROQUE assim que entrar."
    log "Wallabag no ar: https://$domain"
}

# ---------- WuzAPI (WhatsApp API em Go) ----------
# API multi-device baseada no whatsmeow. SQLite embutido (volume /app/data).
# Dashboard em /dashboard, login/QR em /login, Swagger em /api.
install_wuzapi() {
    hr "WuzAPI (WhatsApp API)"
    check_existing_install "WuzAPI" "rollin-wuzapi" "/opt/rollin/wuzapi" || return 0
    local domain
    ask_domain "Subdomínio do WuzAPI (ex: wuzapi.seudominio.com.br):" || { warn "Sem domínio — pulado"; return 0; }
    domain="$REPLY"; dns_warning "$domain" || return 0
    local token enckey; token=$(rand_pwd 32); enckey=$(rand_pwd 32)
    mkdir -p /opt/rollin/wuzapi
    cat > /opt/rollin/wuzapi/docker-compose.yml <<COMPOSE
services:
  wuzapi:
    image: asternic/wuzapi:latest
    container_name: rollin-wuzapi
    restart: unless-stopped
    environment:
      WUZAPI_ADMIN_TOKEN: $token
      WUZAPI_GLOBAL_ENCRYPTION_KEY: $enckey
      TZ: America/Sao_Paulo
    volumes: [wuzapi_data:/app/data]
    labels:
      - traefik.enable=true
      - traefik.http.routers.wuzapi.rule=Host(\`$domain\`)
      - traefik.http.routers.wuzapi.entrypoints=websecure
      - traefik.http.routers.wuzapi.tls.certresolver=le
      - traefik.http.services.wuzapi.loadbalancer.server.port=8080
    networks: [rollin]
volumes:
  wuzapi_data:
networks:
  rollin:
    external: true
    name: $NET
COMPOSE
    print_download_banner "WuzAPI" "1 imagem · ~50 MB" "1 minuto"
    run_with_spinner "Subindo WuzAPI" 120 "cd /opt/rollin/wuzapi && docker compose up -d --quiet-pull"
    save_cred "WuzAPI" "url=https://$domain/dashboard" "api_key=$token" "setup=Dashboard em /dashboard e Swagger em /api. Use o Admin Token acima (header Authorization) para criar usuários/instâncias; cada instância conecta escaneando o QR em /login."
    log "WuzAPI no ar: https://$domain/dashboard"
}

# ---------- WuzAPI EDGE (compila do fonte com whatsmeow do main = fix do Passkey) ----------
# Quando a Meta mexe no pareamento (ex.: Passkey jun/2026), a imagem oficial do
# WuzAPI demora a republicar. Esta versão CLONA o wuzapi e força o whatsmeow do
# main (onde entrou "pair: add support for passkeys") antes de buildar.
install_wuzapiedge() {
    hr "WuzAPI edge (whatsmeow atualizado)"
    check_existing_install "WuzAPI (edge)" "rollin-wuzapi-edge" "/opt/rollin/wuzapiedge" || return 0
    local domain
    ask_domain "Subdomínio do WuzAPI edge (ex: wuzapi.seudominio.com.br):" || { warn "Sem domínio — pulado"; return 0; }
    domain="$REPLY"; dns_warning "$domain" || return 0
    local token enckey; token=$(rand_pwd 32); enckey=$(rand_pwd 32)
    mkdir -p /opt/rollin/wuzapiedge
    cat > /opt/rollin/wuzapiedge/Dockerfile <<'DOCKER'
FROM golang:1.25-bookworm AS build
RUN apt-get update && apt-get install -y --no-install-recommends gcc g++ pkg-config ca-certificates git \
 && rm -rf /var/lib/apt/lists/*
WORKDIR /app
# Clona o WuzAPI e FORÇA o whatsmeow do main (commit do Passkey, 30/jun/2026)
RUN git clone --depth 1 https://github.com/asternic/wuzapi.git .
RUN go get go.mau.fi/whatsmeow@main && go mod tidy
RUN CGO_ENABLED=1 go build -o wuzapi .

FROM debian:bookworm-slim
RUN apt-get update && apt-get install -y --no-install-recommends \
      ca-certificates netcat-openbsd postgresql-client openssl curl ffmpeg tzdata \
 && rm -rf /var/lib/apt/lists/*
ENV TZ=America/Sao_Paulo
WORKDIR /app
COPY --from=build /app/wuzapi /app/wuzapi
COPY --from=build /app/static /app/static
RUN chmod +x /app/wuzapi
ENTRYPOINT ["/app/wuzapi", "--logtype=console", "--color=true"]
DOCKER
    cat > /opt/rollin/wuzapiedge/docker-compose.yml <<COMPOSE
services:
  wuzapi:
    build: .
    image: rollin-wuzapi-edge
    container_name: rollin-wuzapi-edge
    restart: unless-stopped
    environment:
      WUZAPI_ADMIN_TOKEN: $token
      WUZAPI_GLOBAL_ENCRYPTION_KEY: $enckey
      TZ: America/Sao_Paulo
    volumes: [wuzapiedge_data:/app/data]
    labels:
      - traefik.enable=true
      - traefik.http.routers.wuzapiedge.rule=Host(\`$domain\`)
      - traefik.http.routers.wuzapiedge.entrypoints=websecure
      - traefik.http.routers.wuzapiedge.tls.certresolver=le
      - traefik.http.services.wuzapiedge.loadbalancer.server.port=8080
    networks: [rollin]
volumes:
  wuzapiedge_data:
networks:
  rollin:
    external: true
    name: $NET
COMPOSE
    print_download_banner "WuzAPI edge (compila do fonte + whatsmeow do main)" "build Go · ~800 MB" "4 a 7 minutos"
    run_with_spinner "Compilando WuzAPI edge (Go + whatsmeow atualizado)" 600 \
        "cd /opt/rollin/wuzapiedge && docker compose up -d --build"
    save_cred "WuzAPI (edge)" "url=https://$domain/dashboard" "api_key=$token" "setup=Compilado do fonte com o whatsmeow do main (fix do Passkey). Dashboard em /dashboard; use o Admin Token acima no header Authorization; QR em /login. Se números novos ainda travarem, o whatsmeow ainda estava sem o fix na hora do build — reinstale pra recompilar."
    log "WuzAPI edge no ar: https://$domain/dashboard"
}

# ===== Helper de app web simples (1 container, porta única) =====
# Uso: rh_simple_web <stack> <container> <image> <port> <"Título"> <"tamanho"> <"tempo"> <cred_name>
# Cria compose só com Traefik (sem volume/env), sobe e grava credencial de URL.
rh_simple_web() {
    local stack="$1" cname="$2" image="$3" port="$4" title="$5" size="$6" eta="$7" cred="$8" domain
    check_existing_install "$cred" "$cname" "/opt/rollin/$stack" || return 0
    ask_domain "Subdomínio do $cred (ex: $stack.seudominio.com.br):" || { warn "Sem domínio — pulado"; return 0; }
    domain="$REPLY"; dns_warning "$domain" || return 0
    mkdir -p "/opt/rollin/$stack"
    cat > "/opt/rollin/$stack/docker-compose.yml" <<COMPOSE
services:
  $stack:
    image: $image
    container_name: $cname
    restart: unless-stopped
    labels:
      - traefik.enable=true
      - traefik.http.routers.$stack.rule=Host(\`$domain\`)
      - traefik.http.routers.$stack.entrypoints=websecure
      - traefik.http.routers.$stack.tls.certresolver=le
      - traefik.http.services.$stack.loadbalancer.server.port=$port
    networks: [rollin]
networks:
  rollin:
    external: true
    name: $NET
COMPOSE
    print_download_banner "$title" "$size" "$eta"
    run_with_spinner "Subindo $title" 180 "cd /opt/rollin/$stack && docker compose up -d --quiet-pull"
    save_cred "$cred" "url=https://$domain" "setup=Roda direto no navegador — sem login."
    log "$cred no ar: https://$domain"
}

# ---------- Excalidraw / Draw.io / CyberChef (client-side, sem login) ----------
install_excalidraw() { hr "Excalidraw (quadro branco)"; rh_simple_web excalidraw rollin-excalidraw "excalidraw/excalidraw:latest" 80 "Excalidraw" "1 imagem · ~90 MB" "1 minuto" "Excalidraw"; }
install_drawio()     { hr "Draw.io (diagramas)";      rh_simple_web drawio rollin-drawio "jgraph/drawio:latest" 8080 "Draw.io" "1 imagem · ~250 MB" "1 minuto" "Draw.io"; }
install_cyberchef()  { hr "CyberChef";                rh_simple_web cyberchef rollin-cyberchef "mpepping/cyberchef:latest" 8000 "CyberChef" "1 imagem · ~30 MB" "1 minuto" "CyberChef"; }
install_pairdrop()   { hr "PairDrop (enviar arquivos)"; rh_simple_web pairdrop rollin-pairdrop "ghcr.io/schlagmichdoch/pairdrop:latest" 3000 "PairDrop" "1 imagem · ~120 MB" "1 minuto" "PairDrop"; }

# ---------- Trilium Notes ----------
install_trilium() {
    hr "Trilium Notes"
    check_existing_install "Trilium" "rollin-trilium" "/opt/rollin/trilium" || return 0
    local domain
    ask_domain "Subdomínio do Trilium (ex: notas.seudominio.com.br):" || { warn "Sem domínio — pulado"; return 0; }
    domain="$REPLY"; dns_warning "$domain" || return 0
    mkdir -p /opt/rollin/trilium
    cat > /opt/rollin/trilium/docker-compose.yml <<COMPOSE
services:
  trilium:
    image: triliumnext/notes:stable
    container_name: rollin-trilium
    restart: unless-stopped
    volumes: [trilium_data:/home/node/trilium-data]
    labels:
      - traefik.enable=true
      - traefik.http.routers.trilium.rule=Host(\`$domain\`)
      - traefik.http.routers.trilium.entrypoints=websecure
      - traefik.http.routers.trilium.tls.certresolver=le
      - traefik.http.services.trilium.loadbalancer.server.port=8080
    networks: [rollin]
volumes:
  trilium_data:
networks:
  rollin:
    external: true
    name: $NET
COMPOSE
    print_download_banner "Trilium Notes" "1 imagem · ~250 MB" "1 minuto"
    run_with_spinner "Subindo Trilium" 120 "cd /opt/rollin/trilium && docker compose up -d --quiet-pull"
    save_cred "Trilium" "url=https://$domain" "setup=No 1º acesso, defina a senha do seu cofre de notas."
    log "Trilium no ar: https://$domain"
}

# ---------- Actual Budget ----------
install_actualbudget() {
    hr "Actual Budget (orçamento)"
    check_existing_install "Actual Budget" "rollin-actualbudget" "/opt/rollin/actualbudget" || return 0
    local domain
    ask_domain "Subdomínio do Actual (ex: orcamento.seudominio.com.br):" || { warn "Sem domínio — pulado"; return 0; }
    domain="$REPLY"; dns_warning "$domain" || return 0
    mkdir -p /opt/rollin/actualbudget
    cat > /opt/rollin/actualbudget/docker-compose.yml <<COMPOSE
services:
  actualbudget:
    image: actualbudget/actual-server:latest
    container_name: rollin-actualbudget
    restart: unless-stopped
    volumes: [actual_data:/data]
    labels:
      - traefik.enable=true
      - traefik.http.routers.actualbudget.rule=Host(\`$domain\`)
      - traefik.http.routers.actualbudget.entrypoints=websecure
      - traefik.http.routers.actualbudget.tls.certresolver=le
      - traefik.http.services.actualbudget.loadbalancer.server.port=5006
    networks: [rollin]
volumes:
  actual_data:
networks:
  rollin:
    external: true
    name: $NET
COMPOSE
    print_download_banner "Actual Budget" "1 imagem · ~250 MB" "1 minuto"
    run_with_spinner "Subindo Actual Budget" 120 "cd /opt/rollin/actualbudget && docker compose up -d --quiet-pull"
    save_cred "Actual Budget" "url=https://$domain" "setup=No 1º acesso, defina a senha do servidor."
    log "Actual Budget no ar: https://$domain"
}

# ---------- Homarr (dashboard) ----------
install_homarr() {
    hr "Homarr (dashboard)"
    check_existing_install "Homarr" "rollin-homarr" "/opt/rollin/homarr" || return 0
    local domain
    ask_domain "Subdomínio do Homarr (ex: painel.seudominio.com.br):" || { warn "Sem domínio — pulado"; return 0; }
    domain="$REPLY"; dns_warning "$domain" || return 0
    mkdir -p /opt/rollin/homarr
    cat > /opt/rollin/homarr/docker-compose.yml <<COMPOSE
services:
  homarr:
    image: ghcr.io/ajnart/homarr:latest
    container_name: rollin-homarr
    restart: unless-stopped
    volumes:
      - homarr_configs:/app/data/configs
      - homarr_data:/data
      - homarr_icons:/app/public/icons
    labels:
      - traefik.enable=true
      - traefik.http.routers.homarr.rule=Host(\`$domain\`)
      - traefik.http.routers.homarr.entrypoints=websecure
      - traefik.http.routers.homarr.tls.certresolver=le
      - traefik.http.services.homarr.loadbalancer.server.port=7575
    networks: [rollin]
volumes:
  homarr_configs:
  homarr_data:
  homarr_icons:
networks:
  rollin:
    external: true
    name: $NET
COMPOSE
    print_download_banner "Homarr" "1 imagem · ~300 MB" "1 a 2 minutos"
    run_with_spinner "Subindo Homarr" 180 "cd /opt/rollin/homarr && docker compose up -d --quiet-pull"
    save_cred "Homarr" "url=https://$domain" "setup=No 1º acesso, crie o administrador do painel."
    log "Homarr no ar: https://$domain"
}

# ---------- SearXNG (busca privada) ----------
install_searxng() {
    hr "SearXNG (busca privada)"
    check_existing_install "SearXNG" "rollin-searxng" "/opt/rollin/searxng" || return 0
    local domain
    ask_domain "Subdomínio do SearXNG (ex: buscar.seudominio.com.br):" || { warn "Sem domínio — pulado"; return 0; }
    domain="$REPLY"; dns_warning "$domain" || return 0
    mkdir -p /opt/rollin/searxng
    cat > /opt/rollin/searxng/docker-compose.yml <<COMPOSE
services:
  searxng:
    image: searxng/searxng:latest
    container_name: rollin-searxng
    restart: unless-stopped
    environment:
      SEARXNG_BASE_URL: https://$domain/
    volumes: [searxng_config:/etc/searxng]
    labels:
      - traefik.enable=true
      - traefik.http.routers.searxng.rule=Host(\`$domain\`)
      - traefik.http.routers.searxng.entrypoints=websecure
      - traefik.http.routers.searxng.tls.certresolver=le
      - traefik.http.services.searxng.loadbalancer.server.port=8080
    networks: [rollin]
volumes:
  searxng_config:
networks:
  rollin:
    external: true
    name: $NET
COMPOSE
    print_download_banner "SearXNG" "1 imagem · ~200 MB" "1 minuto"
    run_with_spinner "Subindo SearXNG" 120 "cd /opt/rollin/searxng && docker compose up -d --quiet-pull"
    save_cred "SearXNG" "url=https://$domain" "setup=Buscador privado pronto — sem login."
    log "SearXNG no ar: https://$domain"
}

# ---------- ntfy (notificações push) ----------
install_ntfy() {
    hr "ntfy (notificações push)"
    check_existing_install "ntfy" "rollin-ntfy" "/opt/rollin/ntfy" || return 0
    local domain
    ask_domain "Subdomínio do ntfy (ex: push.seudominio.com.br):" || { warn "Sem domínio — pulado"; return 0; }
    domain="$REPLY"; dns_warning "$domain" || return 0
    mkdir -p /opt/rollin/ntfy
    cat > /opt/rollin/ntfy/docker-compose.yml <<COMPOSE
services:
  ntfy:
    image: binwiederhier/ntfy:latest
    container_name: rollin-ntfy
    restart: unless-stopped
    command: ["serve"]
    environment:
      NTFY_BASE_URL: https://$domain
      NTFY_BEHIND_PROXY: "true"
      NTFY_CACHE_FILE: /var/cache/ntfy/cache.db
    volumes:
      - ntfy_cache:/var/cache/ntfy
      - ntfy_etc:/etc/ntfy
    labels:
      - traefik.enable=true
      - traefik.http.routers.ntfy.rule=Host(\`$domain\`)
      - traefik.http.routers.ntfy.entrypoints=websecure
      - traefik.http.routers.ntfy.tls.certresolver=le
      - traefik.http.services.ntfy.loadbalancer.server.port=80
    networks: [rollin]
volumes:
  ntfy_cache:
  ntfy_etc:
networks:
  rollin:
    external: true
    name: $NET
COMPOSE
    print_download_banner "ntfy" "1 imagem · ~30 MB" "1 minuto"
    run_with_spinner "Subindo ntfy" 120 "cd /opt/rollin/ntfy && docker compose up -d --quiet-pull"
    save_cred "ntfy" "url=https://$domain" "setup=Envie notificações via POST em https://$domain/<topico>. Para exigir login, configure usuários no ntfy (docs)."
    log "ntfy no ar: https://$domain"
}

# ---------- Netdata (monitoramento, protegido) ----------
install_netdata() {
    hr "Netdata (monitoramento)"
    check_existing_install "Netdata" "rollin-netdata" "/opt/rollin/netdata" || return 0
    local domain
    ask_domain "Subdomínio do Netdata (ex: metrics.seudominio.com.br):" || { warn "Sem domínio — pulado"; return 0; }
    domain="$REPLY"; dns_warning "$domain" || return 0
    local user="rollin" pwd ba; pwd=$(rand_pwd 18); ba=$(rh_basicauth_user "$user" "$pwd")
    mkdir -p /opt/rollin/netdata
    cat > /opt/rollin/netdata/docker-compose.yml <<COMPOSE
services:
  netdata:
    image: netdata/netdata:latest
    container_name: rollin-netdata
    restart: unless-stopped
    hostname: $domain
    cap_add: [SYS_PTRACE]
    security_opt: [apparmor:unconfined]
    volumes:
      - netdata_config:/etc/netdata
      - netdata_lib:/var/lib/netdata
      - netdata_cache:/var/cache/netdata
      - /etc/passwd:/host/etc/passwd:ro
      - /etc/group:/host/etc/group:ro
      - /proc:/host/proc:ro
      - /sys:/host/sys:ro
      - /etc/os-release:/host/etc/os-release:ro
      - /var/run/docker.sock:/var/run/docker.sock:ro
    labels:
      - traefik.enable=true
      - traefik.http.routers.netdata.rule=Host(\`$domain\`)
      - traefik.http.routers.netdata.entrypoints=websecure
      - traefik.http.routers.netdata.tls.certresolver=le
      - traefik.http.routers.netdata.middlewares=netdata-auth
      - traefik.http.middlewares.netdata-auth.basicauth.users=$ba
      - traefik.http.services.netdata.loadbalancer.server.port=19999
    networks: [rollin]
volumes:
  netdata_config:
  netdata_lib:
  netdata_cache:
networks:
  rollin:
    external: true
    name: $NET
COMPOSE
    print_download_banner "Netdata" "1 imagem · ~200 MB" "1 minuto"
    run_with_spinner "Subindo Netdata" 120 "cd /opt/rollin/netdata && docker compose up -d --quiet-pull"
    save_cred "Netdata" "url=https://$domain" "user=$user" "password=$pwd" "setup=Acesso protegido por senha (usuário/senha acima)."
    log "Netdata no ar: https://$domain"
}

# ---------- Docker Registry (protegido) ----------
install_registry() {
    hr "Docker Registry (imagens privadas)"
    check_existing_install "Docker Registry" "rollin-registry" "/opt/rollin/registry" || return 0
    local domain
    ask_domain "Subdomínio do Registry (ex: registry.seudominio.com.br):" || { warn "Sem domínio — pulado"; return 0; }
    domain="$REPLY"; dns_warning "$domain" || return 0
    local user="rollin" pwd ba; pwd=$(rand_pwd 20); ba=$(rh_basicauth_user "$user" "$pwd")
    mkdir -p /opt/rollin/registry
    cat > /opt/rollin/registry/docker-compose.yml <<COMPOSE
services:
  registry:
    image: registry:2
    container_name: rollin-registry
    restart: unless-stopped
    environment:
      REGISTRY_STORAGE_DELETE_ENABLED: "true"
    volumes: [registry_data:/var/lib/registry]
    labels:
      - traefik.enable=true
      - traefik.http.routers.registry.rule=Host(\`$domain\`)
      - traefik.http.routers.registry.entrypoints=websecure
      - traefik.http.routers.registry.tls.certresolver=le
      - traefik.http.routers.registry.middlewares=registry-auth
      - traefik.http.middlewares.registry-auth.basicauth.users=$ba
      - traefik.http.services.registry.loadbalancer.server.port=5000
    networks: [rollin]
volumes:
  registry_data:
networks:
  rollin:
    external: true
    name: $NET
COMPOSE
    print_download_banner "Docker Registry" "1 imagem · ~30 MB" "1 minuto"
    run_with_spinner "Subindo Docker Registry" 120 "cd /opt/rollin/registry && docker compose up -d --quiet-pull"
    save_cred "Docker Registry" "url=https://$domain" "user=$user" "password=$pwd" "setup=Faça: docker login $domain (usuário/senha acima). Depois docker tag e docker push $domain/sua-imagem."
    log "Docker Registry no ar: https://$domain"
}

# ---------- Beszel (monitor de servidores) ----------
install_beszel() {
    hr "Beszel (monitor de servidores)"
    check_existing_install "Beszel" "rollin-beszel" "/opt/rollin/beszel" || return 0
    local domain
    ask_domain "Subdomínio do Beszel (ex: status.seudominio.com.br):" || { warn "Sem domínio — pulado"; return 0; }
    domain="$REPLY"; dns_warning "$domain" || return 0
    mkdir -p /opt/rollin/beszel
    cat > /opt/rollin/beszel/docker-compose.yml <<COMPOSE
services:
  beszel:
    image: henrygd/beszel:latest
    container_name: rollin-beszel
    restart: unless-stopped
    volumes: [beszel_data:/beszel_data]
    labels:
      - traefik.enable=true
      - traefik.http.routers.beszel.rule=Host(\`$domain\`)
      - traefik.http.routers.beszel.entrypoints=websecure
      - traefik.http.routers.beszel.tls.certresolver=le
      - traefik.http.services.beszel.loadbalancer.server.port=8090
    networks: [rollin]
volumes:
  beszel_data:
networks:
  rollin:
    external: true
    name: $NET
COMPOSE
    print_download_banner "Beszel" "1 imagem · ~50 MB" "1 minuto"
    run_with_spinner "Subindo Beszel" 120 "cd /opt/rollin/beszel && docker compose up -d --quiet-pull"
    save_cred "Beszel" "url=https://$domain" "setup=Crie o admin no 1º acesso. Para coletar dados da VPS, adicione um sistema no painel (agente)."
    log "Beszel no ar: https://$domain"
}

# ---------- Mealie (receitas) ----------
install_mealie() {
    hr "Mealie (receitas)"
    check_existing_install "Mealie" "rollin-mealie" "/opt/rollin/mealie" || return 0
    local domain
    ask_domain "Subdomínio do Mealie (ex: receitas.seudominio.com.br):" || { warn "Sem domínio — pulado"; return 0; }
    domain="$REPLY"; dns_warning "$domain" || return 0
    local db_pwd; db_pwd=$(rand_pwd 24)
    mkdir -p /opt/rollin/mealie
    cat > /opt/rollin/mealie/docker-compose.yml <<COMPOSE
services:
  mealie-db:
    image: postgres:16-alpine
    container_name: rollin-mealie-db
    restart: unless-stopped
    environment:
      POSTGRES_DB: mealie
      POSTGRES_USER: mealie
      POSTGRES_PASSWORD: $db_pwd
    volumes: [mealie_db:/var/lib/postgresql/data]
    networks: [rollin]

  mealie:
    image: ghcr.io/mealie-recipes/mealie:latest
    container_name: rollin-mealie
    restart: unless-stopped
    depends_on: [mealie-db]
    environment:
      DB_ENGINE: postgres
      POSTGRES_SERVER: mealie-db
      POSTGRES_USER: mealie
      POSTGRES_PASSWORD: $db_pwd
      POSTGRES_DB: mealie
      BASE_URL: https://$domain
    volumes: [mealie_data:/app/data]
    labels:
      - traefik.enable=true
      - traefik.http.routers.mealie.rule=Host(\`$domain\`)
      - traefik.http.routers.mealie.entrypoints=websecure
      - traefik.http.routers.mealie.tls.certresolver=le
      - traefik.http.services.mealie.loadbalancer.server.port=9000
    networks: [rollin]
volumes:
  mealie_db:
  mealie_data:
networks:
  rollin:
    external: true
    name: $NET
COMPOSE
    drop_orphan_volume "mealie_mealie_db" "rollin-mealie-db"
    print_download_banner "Mealie + Postgres" "2 imagens · ~600 MB" "2 a 3 minutos"
    run_with_spinner "Subindo Mealie" 240 "cd /opt/rollin/mealie && docker compose up -d --quiet-pull"
    save_cred "Mealie" "url=https://$domain" "user=changeme@example.com" "password=MyPassword" "setup=Login inicial changeme@example.com / MyPassword — TROQUE assim que entrar."
    log "Mealie no ar: https://$domain"
}

# ---------- PrestaShop (loja/e-commerce) ----------
install_prestashop() {
    hr "PrestaShop (e-commerce)"
    check_existing_install "PrestaShop" "rollin-prestashop" "/opt/rollin/prestashop" || return 0
    local domain
    ask_domain "Subdomínio do PrestaShop (ex: loja.seudominio.com.br):" || { warn "Sem domínio — pulado"; return 0; }
    domain="$REPLY"; dns_warning "$domain" || return 0
    local db_pwd adm; db_pwd=$(rand_pwd 24); adm=$(rand_pwd 16)
    mkdir -p /opt/rollin/prestashop
    cat > /opt/rollin/prestashop/docker-compose.yml <<COMPOSE
services:
  ps-db:
    image: mariadb:11
    container_name: rollin-ps-db
    restart: unless-stopped
    environment:
      MARIADB_DATABASE: prestashop
      MARIADB_USER: prestashop
      MARIADB_PASSWORD: $db_pwd
      MARIADB_ROOT_PASSWORD: $db_pwd
    volumes: [ps_db:/var/lib/mysql]
    networks: [rollin]

  prestashop:
    image: prestashop/prestashop:latest
    container_name: rollin-prestashop
    restart: unless-stopped
    depends_on: [ps-db]
    environment:
      DB_SERVER: ps-db
      DB_NAME: prestashop
      DB_USER: prestashop
      DB_PASSWD: $db_pwd
      PS_INSTALL_AUTO: "1"
      PS_DOMAIN: $domain
      PS_ENABLE_SSL: "1"
      PS_FOLDER_ADMIN: admin-rollin
      PS_FOLDER_INSTALL: install
      ADMIN_MAIL: admin@$domain
      ADMIN_PASSWD: $adm
    volumes: [ps_data:/var/www/html]
    labels:
      - traefik.enable=true
      - traefik.http.routers.prestashop.rule=Host(\`$domain\`)
      - traefik.http.routers.prestashop.entrypoints=websecure
      - traefik.http.routers.prestashop.tls.certresolver=le
      - traefik.http.services.prestashop.loadbalancer.server.port=80
    networks: [rollin]
volumes:
  ps_db:
  ps_data:
networks:
  rollin:
    external: true
    name: $NET
COMPOSE
    drop_orphan_volume "prestashop_ps_db" "rollin-ps-db"
    print_download_banner "PrestaShop + MariaDB" "2 imagens · ~1.2 GB" "4 a 6 minutos"
    run_with_spinner "Subindo PrestaShop (instalação automática)" 420 "cd /opt/rollin/prestashop && docker compose up -d --quiet-pull"
    save_cred "PrestaShop" "url=https://$domain" "user=admin@$domain" "password=$adm" "setup=A instalação automática leva alguns minutos. Admin em https://$domain/admin-rollin (usuário/senha acima)."
    log "PrestaShop no ar: https://$domain (admin em /admin-rollin)"
}

# ---------- Immich (Google Fotos self-hosted) ----------
install_immich() {
    hr "Immich (galeria de fotos)"
    check_existing_install "Immich" "rollin-immich-server" "/opt/rollin/immich" || return 0
    local domain
    ask_domain "Subdomínio do Immich (ex: fotos.seudominio.com.br):" || { warn "Sem domínio — pulado"; return 0; }
    domain="$REPLY"; dns_warning "$domain" || return 0
    local pg; pg=$(rand_pwd 24)
    mkdir -p /opt/rollin/immich
    cat > /opt/rollin/immich/docker-compose.yml <<COMPOSE
services:
  immich-db:
    image: ghcr.io/immich-app/postgres:14-vectorchord0.4.3-pgvectors0.2.0
    container_name: rollin-immich-db
    restart: unless-stopped
    environment:
      POSTGRES_USER: immich
      POSTGRES_PASSWORD: $pg
      POSTGRES_DB: immich
    volumes: [immich_db:/var/lib/postgresql/data]
    networks: [rollin]

  immich-redis:
    image: docker.io/valkey/valkey:8-bookworm
    container_name: rollin-immich-redis
    restart: unless-stopped
    networks: [rollin]

  immich-ml:
    image: ghcr.io/immich-app/immich-machine-learning:release
    container_name: rollin-immich-ml
    restart: unless-stopped
    volumes: [immich_ml:/cache]
    networks: [rollin]

  immich-server:
    image: ghcr.io/immich-app/immich-server:release
    container_name: rollin-immich-server
    restart: unless-stopped
    depends_on: [immich-db, immich-redis]
    environment:
      DB_HOSTNAME: immich-db
      DB_USERNAME: immich
      DB_PASSWORD: $pg
      DB_DATABASE_NAME: immich
      REDIS_HOSTNAME: immich-redis
    volumes: [immich_upload:/usr/src/app/upload]
    labels:
      - traefik.enable=true
      - traefik.http.routers.immich.rule=Host(\`$domain\`)
      - traefik.http.routers.immich.entrypoints=websecure
      - traefik.http.routers.immich.tls.certresolver=le
      - traefik.http.services.immich.loadbalancer.server.port=2283
    networks: [rollin]
volumes:
  immich_db:
  immich_ml:
  immich_upload:
networks:
  rollin:
    external: true
    name: $NET
COMPOSE
    drop_orphan_volume "immich_immich_db" "rollin-immich-db"
    print_download_banner "Immich" "4 imagens · ~3 GB" "4 a 7 minutos"
    run_with_spinner "Subindo Immich" 480 "cd /opt/rollin/immich && docker compose up -d --quiet-pull"
    save_cred "Immich" "url=https://$domain" "setup=Crie o admin no 1º acesso. Envie fotos pelo app (Android/iOS) ou pela web."
    log "Immich no ar: https://$domain"
}

# ---------- LibreChat (chat de IA multi-modelo) ----------
install_librechat() {
    hr "LibreChat (chat de IA)"
    check_existing_install "LibreChat" "rollin-librechat" "/opt/rollin/librechat" || return 0
    local domain
    ask_domain "Subdomínio do LibreChat (ex: chat.seudominio.com.br):" || { warn "Sem domínio — pulado"; return 0; }
    domain="$REPLY"; dns_warning "$domain" || return 0
    local creds_key creds_iv jwt jwtr
    creds_key=$(openssl rand -hex 32); creds_iv=$(openssl rand -hex 16); jwt=$(openssl rand -hex 32); jwtr=$(openssl rand -hex 32)
    mkdir -p /opt/rollin/librechat
    cat > /opt/rollin/librechat/docker-compose.yml <<COMPOSE
services:
  librechat-mongo:
    image: mongo:7
    container_name: rollin-librechat-mongo
    restart: unless-stopped
    command: mongod --quiet
    volumes: [librechat_mongo:/data/db]
    networks: [rollin]

  librechat:
    image: ghcr.io/danny-avila/librechat:latest
    container_name: rollin-librechat
    restart: unless-stopped
    depends_on: [librechat-mongo]
    environment:
      HOST: 0.0.0.0
      PORT: "3080"
      MONGO_URI: mongodb://librechat-mongo:27017/LibreChat
      CREDS_KEY: $creds_key
      CREDS_IV: $creds_iv
      JWT_SECRET: $jwt
      JWT_REFRESH_SECRET: $jwtr
      ALLOW_REGISTRATION: "true"
      DOMAIN_CLIENT: https://$domain
      DOMAIN_SERVER: https://$domain
    volumes:
      - librechat_images:/app/client/public/images
      - librechat_uploads:/app/uploads
      - librechat_logs:/app/api/logs
    labels:
      - traefik.enable=true
      - traefik.http.routers.librechat.rule=Host(\`$domain\`)
      - traefik.http.routers.librechat.entrypoints=websecure
      - traefik.http.routers.librechat.tls.certresolver=le
      - traefik.http.services.librechat.loadbalancer.server.port=3080
    networks: [rollin]
volumes:
  librechat_mongo:
  librechat_images:
  librechat_uploads:
  librechat_logs:
networks:
  rollin:
    external: true
    name: $NET
COMPOSE
    print_download_banner "LibreChat + MongoDB" "2 imagens · ~1 GB" "2 a 3 minutos"
    run_with_spinner "Subindo LibreChat" 300 "cd /opt/rollin/librechat && docker compose up -d --quiet-pull"
    save_cred "LibreChat" "url=https://$domain" "setup=Crie sua conta no 1º acesso. Configure os provedores de IA (ex.: seu Ollama/LiteLLM) nas configurações."
    log "LibreChat no ar: https://$domain"
}

# ---------- Activepieces (Zapier open-source) ----------
install_activepieces() {
    hr "Activepieces (automação)"
    check_existing_install "Activepieces" "rollin-activepieces" "/opt/rollin/activepieces" || return 0
    local domain
    ask_domain "Subdomínio do Activepieces (ex: pieces.seudominio.com.br):" || { warn "Sem domínio — pulado"; return 0; }
    domain="$REPLY"; dns_warning "$domain" || return 0
    local pg jwt enckey; pg=$(rand_pwd 24); jwt=$(openssl rand -hex 32); enckey=$(openssl rand -hex 16)
    mkdir -p /opt/rollin/activepieces
    cat > /opt/rollin/activepieces/docker-compose.yml <<COMPOSE
services:
  ap-postgres:
    image: postgres:14
    container_name: rollin-ap-postgres
    restart: unless-stopped
    environment:
      POSTGRES_DB: activepieces
      POSTGRES_USER: activepieces
      POSTGRES_PASSWORD: $pg
    volumes: [ap_pg:/var/lib/postgresql/data]
    networks: [rollin]

  ap-redis:
    image: redis:7
    container_name: rollin-ap-redis
    restart: unless-stopped
    volumes: [ap_redis:/data]
    networks: [rollin]

  activepieces:
    image: ghcr.io/activepieces/activepieces:latest
    container_name: rollin-activepieces
    restart: unless-stopped
    depends_on: [ap-postgres, ap-redis]
    environment:
      AP_FRONTEND_URL: https://$domain
      AP_JWT_SECRET: $jwt
      AP_ENCRYPTION_KEY: $enckey
      AP_POSTGRES_DATABASE: activepieces
      AP_POSTGRES_HOST: ap-postgres
      AP_POSTGRES_PORT: "5432"
      AP_POSTGRES_USERNAME: activepieces
      AP_POSTGRES_PASSWORD: $pg
      AP_REDIS_HOST: ap-redis
      AP_REDIS_PORT: "6379"
      AP_EXECUTION_MODE: UNSANDBOXED
      AP_TELEMETRY_ENABLED: "false"
    labels:
      - traefik.enable=true
      - traefik.http.routers.activepieces.rule=Host(\`$domain\`)
      - traefik.http.routers.activepieces.entrypoints=websecure
      - traefik.http.routers.activepieces.tls.certresolver=le
      - traefik.http.services.activepieces.loadbalancer.server.port=80
    networks: [rollin]
volumes:
  ap_pg:
  ap_redis:
networks:
  rollin:
    external: true
    name: $NET
COMPOSE
    drop_orphan_volume "activepieces_ap_pg" "rollin-ap-postgres"
    print_download_banner "Activepieces + Postgres + Redis" "3 imagens · ~900 MB" "2 a 4 minutos"
    run_with_spinner "Subindo Activepieces" 300 "cd /opt/rollin/activepieces && docker compose up -d --quiet-pull"
    save_cred "Activepieces" "url=https://$domain" "setup=Crie o admin no 1º acesso."
    log "Activepieces no ar: https://$domain"
}

# ---------- Penpot (Figma open-source) ----------
install_penpot() {
    hr "Penpot (design/Figma)"
    check_existing_install "Penpot" "rollin-penpot-frontend" "/opt/rollin/penpot" || return 0
    local domain
    ask_domain "Subdomínio do Penpot (ex: design.seudominio.com.br):" || { warn "Sem domínio — pulado"; return 0; }
    domain="$REPLY"; dns_warning "$domain" || return 0
    local pg secret; pg=$(rand_pwd 24); secret=$(rand_pwd 40)
    mkdir -p /opt/rollin/penpot
    cat > /opt/rollin/penpot/docker-compose.yml <<COMPOSE
services:
  penpot-db:
    image: postgres:15
    container_name: rollin-penpot-db
    restart: unless-stopped
    environment:
      POSTGRES_DB: penpot
      POSTGRES_USER: penpot
      POSTGRES_PASSWORD: $pg
    volumes: [penpot_db:/var/lib/postgresql/data]
    networks: [rollin]

  penpot-redis:
    image: redis:7
    container_name: rollin-penpot-redis
    restart: unless-stopped
    networks: [rollin]

  penpot-backend:
    image: penpotapp/backend:latest
    container_name: rollin-penpot-backend
    restart: unless-stopped
    depends_on: [penpot-db, penpot-redis]
    environment:
      PENPOT_FLAGS: enable-registration enable-login-with-password disable-email-verification
      PENPOT_SECRET_KEY: $secret
      PENPOT_PUBLIC_URI: https://$domain
      PENPOT_DATABASE_URI: postgresql://penpot-db/penpot
      PENPOT_DATABASE_USERNAME: penpot
      PENPOT_DATABASE_PASSWORD: $pg
      PENPOT_REDIS_URI: redis://penpot-redis/0
      PENPOT_ASSETS_STORAGE_BACKEND: assets-fs
      PENPOT_STORAGE_ASSETS_FS_DIRECTORY: /opt/data/assets
      PENPOT_TELEMETRY_ENABLED: "false"
    volumes: [penpot_assets:/opt/data/assets]
    networks: [rollin]

  penpot-exporter:
    image: penpotapp/exporter:latest
    container_name: rollin-penpot-exporter
    restart: unless-stopped
    depends_on: [penpot-redis]
    environment:
      PENPOT_PUBLIC_URI: http://penpot-frontend
      PENPOT_REDIS_URI: redis://penpot-redis/0
    networks: [rollin]

  penpot-frontend:
    image: penpotapp/frontend:latest
    container_name: rollin-penpot-frontend
    restart: unless-stopped
    depends_on: [penpot-backend, penpot-exporter]
    environment:
      PENPOT_FLAGS: enable-registration enable-login-with-password disable-email-verification
      PENPOT_BACKEND_URI: http://penpot-backend:6060
      PENPOT_EXPORTER_URI: http://penpot-exporter:6061
    volumes: [penpot_assets:/opt/data/assets]
    labels:
      - traefik.enable=true
      - traefik.http.routers.penpot.rule=Host(\`$domain\`)
      - traefik.http.routers.penpot.entrypoints=websecure
      - traefik.http.routers.penpot.tls.certresolver=le
      - traefik.http.services.penpot.loadbalancer.server.port=80
    networks: [rollin]
volumes:
  penpot_db:
  penpot_assets:
networks:
  rollin:
    external: true
    name: $NET
COMPOSE
    drop_orphan_volume "penpot_penpot_db" "rollin-penpot-db"
    print_download_banner "Penpot" "4 imagens · ~1.5 GB" "3 a 5 minutos"
    run_with_spinner "Subindo Penpot" 360 "cd /opt/rollin/penpot && docker compose up -d --quiet-pull"
    save_cred "Penpot" "url=https://$domain" "setup=Crie sua conta no 1º acesso (cadastro liberado)."
    log "Penpot no ar: https://$domain"
}

# ---------- Docmost (wiki/Confluence) ----------
install_docmost() {
    hr "Docmost (wiki)"
    check_existing_install "Docmost" "rollin-docmost" "/opt/rollin/docmost" || return 0
    local domain
    ask_domain "Subdomínio do Docmost (ex: wiki.seudominio.com.br):" || { warn "Sem domínio — pulado"; return 0; }
    domain="$REPLY"; dns_warning "$domain" || return 0
    local pg secret; pg=$(rand_pwd 24); secret=$(openssl rand -hex 32)
    mkdir -p /opt/rollin/docmost
    cat > /opt/rollin/docmost/docker-compose.yml <<COMPOSE
services:
  docmost-db:
    image: postgres:16-alpine
    container_name: rollin-docmost-db
    restart: unless-stopped
    environment:
      POSTGRES_DB: docmost
      POSTGRES_USER: docmost
      POSTGRES_PASSWORD: $pg
    volumes: [docmost_db:/var/lib/postgresql/data]
    networks: [rollin]

  docmost-redis:
    image: redis:7-alpine
    container_name: rollin-docmost-redis
    restart: unless-stopped
    networks: [rollin]

  docmost:
    image: docmost/docmost:latest
    container_name: rollin-docmost
    restart: unless-stopped
    depends_on: [docmost-db, docmost-redis]
    environment:
      APP_URL: https://$domain
      APP_SECRET: $secret
      DATABASE_URL: postgresql://docmost:$pg@docmost-db:5432/docmost?schema=public
      REDIS_URL: redis://docmost-redis:6379
    volumes: [docmost_storage:/app/data/storage]
    labels:
      - traefik.enable=true
      - traefik.http.routers.docmost.rule=Host(\`$domain\`)
      - traefik.http.routers.docmost.entrypoints=websecure
      - traefik.http.routers.docmost.tls.certresolver=le
      - traefik.http.services.docmost.loadbalancer.server.port=3000
    networks: [rollin]
volumes:
  docmost_db:
  docmost_storage:
networks:
  rollin:
    external: true
    name: $NET
COMPOSE
    drop_orphan_volume "docmost_docmost_db" "rollin-docmost-db"
    print_download_banner "Docmost + Postgres + Redis" "3 imagens · ~600 MB" "2 a 3 minutos"
    run_with_spinner "Subindo Docmost" 240 "cd /opt/rollin/docmost && docker compose up -d --quiet-pull"
    save_cred "Docmost" "url=https://$domain" "setup=Crie o workspace e o admin no 1º acesso."
    log "Docmost no ar: https://$domain"
}

# ---------- PocketBase (backend instantâneo) ----------
install_pocketbase() {
    hr "PocketBase (backend)"
    check_existing_install "PocketBase" "rollin-pocketbase" "/opt/rollin/pocketbase" || return 0
    local domain
    ask_domain "Subdomínio do PocketBase (ex: api.seudominio.com.br):" || { warn "Sem domínio — pulado"; return 0; }
    domain="$REPLY"; dns_warning "$domain" || return 0
    mkdir -p /opt/rollin/pocketbase
    cat > /opt/rollin/pocketbase/docker-compose.yml <<COMPOSE
services:
  pocketbase:
    image: ghcr.io/muchobien/pocketbase:latest
    container_name: rollin-pocketbase
    restart: unless-stopped
    volumes: [pocketbase_data:/pb_data]
    labels:
      - traefik.enable=true
      - traefik.http.routers.pocketbase.rule=Host(\`$domain\`)
      - traefik.http.routers.pocketbase.entrypoints=websecure
      - traefik.http.routers.pocketbase.tls.certresolver=le
      - traefik.http.services.pocketbase.loadbalancer.server.port=8090
    networks: [rollin]
volumes:
  pocketbase_data:
networks:
  rollin:
    external: true
    name: $NET
COMPOSE
    print_download_banner "PocketBase" "1 imagem · ~40 MB" "1 minuto"
    run_with_spinner "Subindo PocketBase" 120 "cd /opt/rollin/pocketbase && docker compose up -d --quiet-pull"
    save_cred "PocketBase" "url=https://$domain/_/" "setup=Crie o admin no 1º acesso ao painel (/_/). A API fica em https://$domain/api/."
    log "PocketBase no ar: https://$domain/_/"
}

# ---------- LocalAI (IA compatível com OpenAI) ----------
install_localai() {
    hr "LocalAI"
    check_existing_install "LocalAI" "rollin-localai" "/opt/rollin/localai" || return 0
    local domain
    ask_domain "Subdomínio do LocalAI (ex: ia.seudominio.com.br):" || { warn "Sem domínio — pulado"; return 0; }
    domain="$REPLY"; dns_warning "$domain" || return 0
    local key; key=$(rand_pwd 40)
    mkdir -p /opt/rollin/localai
    cat > /opt/rollin/localai/docker-compose.yml <<COMPOSE
services:
  localai:
    image: localai/localai:latest
    container_name: rollin-localai
    restart: unless-stopped
    environment:
      API_KEY: $key
    volumes: [localai_models:/build/models]
    labels:
      - traefik.enable=true
      - traefik.http.routers.localai.rule=Host(\`$domain\`)
      - traefik.http.routers.localai.entrypoints=websecure
      - traefik.http.routers.localai.tls.certresolver=le
      - traefik.http.services.localai.loadbalancer.server.port=8080
    networks: [rollin]
volumes:
  localai_models:
networks:
  rollin:
    external: true
    name: $NET
COMPOSE
    print_download_banner "LocalAI" "1 imagem · ~2 GB" "4 a 8 minutos"
    run_with_spinner "Subindo LocalAI" 480 "cd /opt/rollin/localai && docker compose up -d --quiet-pull"
    save_cred "LocalAI" "url=https://$domain" "api_key=$key" "setup=API compatível com OpenAI em https://$domain/v1 (header Authorization: Bearer <chave>). Baixe modelos pela API ou galeria."
    log "LocalAI no ar: https://$domain"
}

# ---------- Paperless-ngx (documentos) ----------
install_paperless() {
    hr "Paperless-ngx (documentos)"
    check_existing_install "Paperless-ngx" "rollin-paperless" "/opt/rollin/paperless" || return 0
    local domain
    ask_domain "Subdomínio do Paperless (ex: docs.seudominio.com.br):" || { warn "Sem domínio — pulado"; return 0; }
    domain="$REPLY"; dns_warning "$domain" || return 0
    local pg secret adm; pg=$(rand_pwd 24); secret=$(openssl rand -hex 32); adm=$(rand_pwd 16)
    mkdir -p /opt/rollin/paperless
    cat > /opt/rollin/paperless/docker-compose.yml <<COMPOSE
services:
  paperless-redis:
    image: redis:7
    container_name: rollin-paperless-redis
    restart: unless-stopped
    volumes: [paperless_redis:/data]
    networks: [rollin]

  paperless-db:
    image: postgres:16-alpine
    container_name: rollin-paperless-db
    restart: unless-stopped
    environment:
      POSTGRES_DB: paperless
      POSTGRES_USER: paperless
      POSTGRES_PASSWORD: $pg
    volumes: [paperless_db:/var/lib/postgresql/data]
    networks: [rollin]

  paperless:
    image: ghcr.io/paperless-ngx/paperless-ngx:latest
    container_name: rollin-paperless
    restart: unless-stopped
    depends_on: [paperless-db, paperless-redis]
    environment:
      PAPERLESS_REDIS: redis://paperless-redis:6379
      PAPERLESS_DBHOST: paperless-db
      PAPERLESS_DBNAME: paperless
      PAPERLESS_DBUSER: paperless
      PAPERLESS_DBPASS: $pg
      PAPERLESS_URL: https://$domain
      PAPERLESS_SECRET_KEY: $secret
      PAPERLESS_ADMIN_USER: admin
      PAPERLESS_ADMIN_PASSWORD: $adm
      PAPERLESS_TIME_ZONE: America/Sao_Paulo
      PAPERLESS_OCR_LANGUAGE: por
    volumes:
      - paperless_data:/usr/src/paperless/data
      - paperless_media:/usr/src/paperless/media
      - paperless_consume:/usr/src/paperless/consume
    labels:
      - traefik.enable=true
      - traefik.http.routers.paperless.rule=Host(\`$domain\`)
      - traefik.http.routers.paperless.entrypoints=websecure
      - traefik.http.routers.paperless.tls.certresolver=le
      - traefik.http.services.paperless.loadbalancer.server.port=8000
    networks: [rollin]
volumes:
  paperless_redis:
  paperless_db:
  paperless_data:
  paperless_media:
  paperless_consume:
networks:
  rollin:
    external: true
    name: $NET
COMPOSE
    drop_orphan_volume "paperless_paperless_db" "rollin-paperless-db"
    print_download_banner "Paperless-ngx" "3 imagens · ~1.2 GB" "3 a 5 minutos"
    run_with_spinner "Subindo Paperless-ngx" 360 "cd /opt/rollin/paperless && docker compose up -d --quiet-pull"
    save_cred "Paperless-ngx" "url=https://$domain" "user=admin" "password=$adm" "setup=Entre com admin e a senha acima. A inicialização pode levar 1-2 min."
    log "Paperless-ngx no ar: https://$domain"
}

# ---------- Teable (Airtable moderno) ----------
install_teable() {
    hr "Teable (planilha-banco)"
    check_existing_install "Teable" "rollin-teable" "/opt/rollin/teable" || return 0
    local domain
    ask_domain "Subdomínio do Teable (ex: base.seudominio.com.br):" || { warn "Sem domínio — pulado"; return 0; }
    domain="$REPLY"; dns_warning "$domain" || return 0
    local pg jwt sess; pg=$(rand_pwd 24); jwt=$(openssl rand -hex 32); sess=$(openssl rand -hex 32)
    mkdir -p /opt/rollin/teable
    cat > /opt/rollin/teable/docker-compose.yml <<COMPOSE
services:
  teable-db:
    image: postgres:16-alpine
    container_name: rollin-teable-db
    restart: unless-stopped
    environment:
      POSTGRES_DB: teable
      POSTGRES_USER: teable
      POSTGRES_PASSWORD: $pg
    volumes: [teable_db:/var/lib/postgresql/data]
    networks: [rollin]

  teable:
    image: ghcr.io/teableio/teable:latest
    container_name: rollin-teable
    restart: unless-stopped
    depends_on: [teable-db]
    environment:
      PUBLIC_ORIGIN: https://$domain
      PRISMA_DATABASE_URL: postgresql://teable:$pg@teable-db:5432/teable
      BACKEND_JWT_SECRET: $jwt
      BACKEND_SESSION_SECRET: $sess
      BACKEND_SESSION_EXPIRES_IN: "7d"
    volumes: [teable_data:/app/.assets]
    labels:
      - traefik.enable=true
      - traefik.http.routers.teable.rule=Host(\`$domain\`)
      - traefik.http.routers.teable.entrypoints=websecure
      - traefik.http.routers.teable.tls.certresolver=le
      - traefik.http.services.teable.loadbalancer.server.port=3000
    networks: [rollin]
volumes:
  teable_db:
  teable_data:
networks:
  rollin:
    external: true
    name: $NET
COMPOSE
    drop_orphan_volume "teable_teable_db" "rollin-teable-db"
    print_download_banner "Teable + Postgres" "2 imagens · ~800 MB" "2 a 3 minutos"
    run_with_spinner "Subindo Teable" 300 "cd /opt/rollin/teable && docker compose up -d --quiet-pull"
    save_cred "Teable" "url=https://$domain" "setup=Crie sua conta no 1º acesso."
    log "Teable no ar: https://$domain"
}

# ---------- Karakeep (favoritos com IA) ----------
install_karakeep() {
    hr "Karakeep (favoritos com IA)"
    check_existing_install "Karakeep" "rollin-karakeep" "/opt/rollin/karakeep" || return 0
    local domain
    ask_domain "Subdomínio do Karakeep (ex: links.seudominio.com.br):" || { warn "Sem domínio — pulado"; return 0; }
    domain="$REPLY"; dns_warning "$domain" || return 0
    local meili secret; meili=$(rand_pwd 40); secret=$(openssl rand -hex 32)
    mkdir -p /opt/rollin/karakeep
    cat > /opt/rollin/karakeep/docker-compose.yml <<COMPOSE
services:
  karakeep-meili:
    image: getmeili/meilisearch:v1.13.3
    container_name: rollin-karakeep-meili
    restart: unless-stopped
    environment:
      MEILI_NO_ANALYTICS: "true"
      MEILI_MASTER_KEY: $meili
    volumes: [karakeep_meili:/meili_data]
    networks: [rollin]

  karakeep-chrome:
    image: gcr.io/zenika-hub/alpine-chrome:123
    container_name: rollin-karakeep-chrome
    restart: unless-stopped
    command: ["--no-sandbox", "--disable-gpu", "--disable-dev-shm-usage", "--remote-debugging-address=0.0.0.0", "--remote-debugging-port=9222", "--hide-scrollbars"]
    networks: [rollin]

  karakeep:
    image: ghcr.io/karakeep-app/karakeep:release
    container_name: rollin-karakeep
    restart: unless-stopped
    depends_on: [karakeep-meili, karakeep-chrome]
    environment:
      MEILI_ADDR: http://karakeep-meili:7700
      MEILI_MASTER_KEY: $meili
      BROWSER_WEB_URL: http://karakeep-chrome:9222
      NEXTAUTH_SECRET: $secret
      NEXTAUTH_URL: https://$domain
      DATA_DIR: /data
      DISABLE_SIGNUPS: "false"
    volumes: [karakeep_data:/data]
    labels:
      - traefik.enable=true
      - traefik.http.routers.karakeep.rule=Host(\`$domain\`)
      - traefik.http.routers.karakeep.entrypoints=websecure
      - traefik.http.routers.karakeep.tls.certresolver=le
      - traefik.http.services.karakeep.loadbalancer.server.port=3000
    networks: [rollin]
volumes:
  karakeep_meili:
  karakeep_data:
networks:
  rollin:
    external: true
    name: $NET
COMPOSE
    print_download_banner "Karakeep + MeiliSearch + Chrome" "3 imagens · ~1.5 GB" "3 a 5 minutos"
    run_with_spinner "Subindo Karakeep" 360 "cd /opt/rollin/karakeep && docker compose up -d --quiet-pull"
    save_cred "Karakeep" "url=https://$domain" "setup=Crie sua conta no 1º acesso. Para o resumo/etiquetas por IA, ligue um provedor (ex.: seu Ollama) nas configurações."
    log "Karakeep no ar: https://$domain"
}

# ---------- Dockge (gerenciador de stacks) ----------
install_dockge() {
    hr "Dockge (gerenciador de stacks)"
    check_existing_install "Dockge" "rollin-dockge" "/opt/rollin/dockge" || return 0
    local domain
    ask_domain "Subdomínio do Dockge (ex: stacks.seudominio.com.br):" || { warn "Sem domínio — pulado"; return 0; }
    domain="$REPLY"; dns_warning "$domain" || return 0
    mkdir -p /opt/rollin/dockge
    cat > /opt/rollin/dockge/docker-compose.yml <<COMPOSE
services:
  dockge:
    image: louislam/dockge:1
    container_name: rollin-dockge
    restart: unless-stopped
    environment:
      DOCKGE_STACKS_DIR: /opt/rollin
    volumes:
      - /var/run/docker.sock:/var/run/docker.sock
      - dockge_data:/app/data
      - /opt/rollin:/opt/rollin
    labels:
      - traefik.enable=true
      - traefik.http.routers.dockge.rule=Host(\`$domain\`)
      - traefik.http.routers.dockge.entrypoints=websecure
      - traefik.http.routers.dockge.tls.certresolver=le
      - traefik.http.services.dockge.loadbalancer.server.port=5001
    networks: [rollin]
volumes:
  dockge_data:
networks:
  rollin:
    external: true
    name: $NET
COMPOSE
    print_download_banner "Dockge" "1 imagem · ~200 MB" "1 minuto"
    run_with_spinner "Subindo Dockge" 120 "cd /opt/rollin/dockge && docker compose up -d --quiet-pull"
    save_cred "Dockge" "url=https://$domain" "setup=Crie o admin no 1º acesso. Ele já enxerga suas stacks em /opt/rollin."
    log "Dockge no ar: https://$domain"
}

# ---------- 10 Open Claw ----------
# OpenClaw: hub de agentes IA (dashboard "Control UI" na porta 18789, que é o
# próprio gateway). Imagem pronta no ghcr. Sem banco. Token de acesso gerado.
# ATENÇÃO: a doc oficial não publica o compose — esta config é a melhor
# reconstrução a partir da doc; pode precisar de ajuste no 1º teste (bind
# address / command / onboarding). Ver [[project_rollin_setup_wizard]].
install_openclaw() {
    hr "10 · Open Claw (hub de agentes IA)"
    check_existing_install "Open Claw" "rollin-openclaw" "/opt/rollin/openclaw" || return 0
    local domain
    ask_domain "Subdomínio do Open Claw (ex: openclaw.seudominio.com.br):" \
        || { warn "Sem domínio — pulado"; return 0; }
    domain="$REPLY"
    dns_warning "$domain" || return 0

    # A Control UI do Open Claw SEMPRE exige device pairing (trava nativa, a doc
    # confirma — sem bypass). Estratégia que FUNCIONA (validada): sobe com
    # --allow-unconfigured (modo estável), protegido pelo TOKEN do gateway; um
    # serviço auto-aprovador aprova o pareamento do navegador em segundos. NÃO
    # usamos basic auth do Traefik — ela causa popup de senha EM LOOP por causa
    # do WebSocket da Control UI (re-pede credencial a cada conexão).
    local token; token=$(rand_pwd 40)
    mkdir -p /opt/rollin/openclaw
    cat > /opt/rollin/openclaw/docker-compose.yml <<COMPOSE
services:
  openclaw:
    image: ghcr.io/openclaw/openclaw:latest
    container_name: rollin-openclaw
    restart: unless-stopped
    command: ["node", "openclaw.mjs", "gateway", "--allow-unconfigured"]
    environment:
      OPENCLAW_GATEWAY_TOKEN: $token
      OPENCLAW_SKIP_ONBOARDING: "1"
      OPENCLAW_HOST: 0.0.0.0
      HOST: 0.0.0.0
    volumes:
      - openclaw_config:/home/node/.openclaw
      - openclaw_secrets:/home/node/.config/openclaw
    labels:
      - traefik.enable=true
      - traefik.http.routers.openclaw.rule=Host(\`$domain\`)
      - traefik.http.routers.openclaw.entrypoints=websecure
      - traefik.http.routers.openclaw.tls.certresolver=le
      - traefik.http.services.openclaw.loadbalancer.server.port=18789
    networks: [rollin]

volumes:
  openclaw_config:
  openclaw_secrets:

networks:
  rollin:
    external: true
    name: $NET
COMPOSE
    print_download_banner "Open Claw" "1 imagem · ~500 MB" "2 a 3 minutos"
    # CORS: libera a origem pública (senão "origem do navegador não permitida").
    local oc_origins
    oc_origins='[{"path":"gateway.controlUi.allowedOrigins","value":["http://localhost:18789","http://127.0.0.1:18789","https://'"$domain"'"]}]'
    run_with_spinner "Configurando origens do Open Claw (Control UI)" 120 \
        "cd /opt/rollin/openclaw && docker compose run --rm openclaw node openclaw.mjs config set --batch-json '$oc_origins'"
    run_with_spinner "Subindo Open Claw" 60 \
        "cd /opt/rollin/openclaw && docker compose up -d --quiet-pull"

    # Auto-aprovador de pareamento. A doc oficial do Open Claw é categórica: a
    # Control UI no navegador SEMPRE exige device pairing manual — não há bypass
    # por config (trusted-proxy/autoApprove só valem pra role:node). Este serviço
    # aprova os pareamentos pendentes em segundos, tornando o acesso automático.
    # A proteção real é a basic auth do Traefik (só quem tem a senha gera pairing).
    cat > /opt/rollin/openclaw/auto-approve.sh <<'APPROVER'
#!/bin/bash
# Aprova pareamentos pendentes da Control UI do Open Claw em loop.
while true; do
  for id in $(docker exec rollin-openclaw node openclaw.mjs devices list 2>/dev/null \
              | grep -oE '[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}'); do
    docker exec rollin-openclaw node openclaw.mjs devices approve "$id" >/dev/null 2>&1
  done
  sleep 3
done
APPROVER
    chmod +x /opt/rollin/openclaw/auto-approve.sh
    cat > /etc/systemd/system/rollin-openclaw-approver.service <<'UNIT'
[Unit]
Description=Rollin - auto-aprovador de pareamento do Open Claw
After=docker.service
Requires=docker.service

[Service]
ExecStart=/opt/rollin/openclaw/auto-approve.sh
Restart=always
RestartSec=5

[Install]
WantedBy=multi-user.target
UNIT
    systemctl daemon-reload >/dev/null 2>&1 || true
    systemctl enable --now rollin-openclaw-approver.service >/dev/null 2>&1 || true

    save_cred "Open Claw" "url=https://$domain" "api_key=$token" \
        "acesso=abra a URL, cole a api_key no campo 'Token do Gateway' e clique Conectar; o pareamento do navegador é aprovado automaticamente em ~3s (sem popup de senha)"
    log "Open Claw no ar: https://$domain (cole o token; pareamento auto-aprovado)"
}

# ---------- 11 Hermes ----------
# Hermes Agent (Nous Research): dashboard (9119) + gateway/API OpenAI-compatible
# (8642). Imagem pronta no Docker Hub. Sem banco — estado em /opt/data. Auth da
# API por API_SERVER_KEY (gerado). Expõe os dois em subdomínios separados.
install_hermes() {
    hr "11 · Hermes (agente IA — dashboard + gateway)"
    check_existing_install "Hermes" "rollin-hermes" "/opt/rollin/hermes" || return 0
    local dash_domain api_domain
    ask_domain "Subdomínio do DASHBOARD do Hermes (ex: hermes.seudominio.com.br):" \
        || { warn "Sem domínio — pulado"; return 0; }
    dash_domain="$REPLY"
    ask_domain "Subdomínio do GATEWAY/API do Hermes (ex: hermes-api.seudominio.com.br):" \
        || { warn "Sem domínio — pulado"; return 0; }
    api_domain="$REPLY"
    dns_warning "$dash_domain" "$api_domain" || return 0

    local api_key; api_key=$(rand_pwd 32)
    mkdir -p /opt/rollin/hermes
    cat > /opt/rollin/hermes/docker-compose.yml <<COMPOSE
services:
  hermes:
    image: nousresearch/hermes-agent:latest
    container_name: rollin-hermes
    restart: unless-stopped
    command: gateway run
    environment:
      HERMES_DASHBOARD: "1"
      HERMES_DASHBOARD_HOST: 0.0.0.0
      HERMES_DASHBOARD_PORT: "9119"
      API_SERVER_ENABLED: "true"
      API_SERVER_HOST: 0.0.0.0
      API_SERVER_KEY: $api_key
      # Fuso explícito: sem isso o agente erra datas/agendamentos (lembretes em UTC)
      TZ: $TZ_DEFAULT
    volumes:
      - hermes_data:/opt/data
    labels:
      - traefik.enable=true
      # Dashboard → 9119
      - traefik.http.routers.hermes-dash.rule=Host(\`$dash_domain\`)
      - traefik.http.routers.hermes-dash.entrypoints=websecure
      - traefik.http.routers.hermes-dash.tls.certresolver=le
      - traefik.http.routers.hermes-dash.service=hermes-dash
      - traefik.http.services.hermes-dash.loadbalancer.server.port=9119
      # Gateway/API → 8642
      - traefik.http.routers.hermes-api.rule=Host(\`$api_domain\`)
      - traefik.http.routers.hermes-api.entrypoints=websecure
      - traefik.http.routers.hermes-api.tls.certresolver=le
      - traefik.http.routers.hermes-api.service=hermes-api
      - traefik.http.services.hermes-api.loadbalancer.server.port=8642
    networks: [rollin]

volumes:
  hermes_data:

networks:
  rollin:
    external: true
    name: $NET
COMPOSE
    print_download_banner "Hermes Agent" "1 imagem · ~1.0 GB" "2 a 4 minutos"
    run_with_spinner "Subindo Hermes (dashboard + gateway)" 240 \
        "cd /opt/rollin/hermes && docker compose up -d --quiet-pull"
    # url=dashboard, viewer=api (o resumo mostra as 2 URLs + a API key)
    save_cred "Hermes Agent" "url=https://$dash_domain" "viewer=https://$api_domain" "api_key=$api_key"
    log "Hermes no ar: dashboard https://$dash_domain · gateway https://$api_domain"
}

# ============================================================================
# IA CLOUD — stacks self-hosted (Ollama, Open WebUI, pgvector, Qdrant, LiteLLM)
# Provisionadas via RH_STACK=ollama,openwebui,pgvector,qdrant,litellm
# Configurado pelos PIDs 133-137 (IA Cloud Start/Pro/Plus/Master/Enterprise)
# ============================================================================

# ---------- 12 Ollama (gerenciador de modelos open-source) ----------
install_ollama() {
    hr "12 · Ollama (gerenciador de modelos open-source · API porta 11434 rede interna)"
    check_existing_install "Ollama" "rollin-ollama" "/opt/rollin/ollama" || return 0
    # Sem domínio público — Ollama expõe API só pra rede interna (Open WebUI + LiteLLM
    # falam com ele em http://ollama:11434). Cliente acessa via Open WebUI (UI tipo ChatGPT).
    # Pra liberar API externa, edite docker-compose e adicione labels Traefik.

    mkdir -p /opt/rollin/ollama
    cat > /opt/rollin/ollama/docker-compose.yml <<COMPOSE
services:
  ollama:
    image: ollama/ollama:latest
    container_name: rollin-ollama
    restart: unless-stopped
    volumes:
      - ollama_models:/root/.ollama
    environment:
      OLLAMA_HOST: 0.0.0.0:11434
      OLLAMA_KEEP_ALIVE: 30m
      OLLAMA_MAX_LOADED_MODELS: 2
    networks: [rollin]
    # Sem labels Traefik — só acesso interno via http://ollama:11434

volumes:
  ollama_models:

networks:
  rollin:
    external: true
    name: $NET
COMPOSE
    print_download_banner "Ollama" "1 imagem · ~700 MB" "1 a 2 minutos"
    run_with_spinner "Subindo Ollama (porta interna 11434)" 180 \
        "cd /opt/rollin/ollama && docker compose up -d --quiet-pull"

    # Pre-pull modelo default leve pra cliente testar de imediato sem esperar download
    log "Baixando modelo default Llama 3.2 3B (~2 GB) em background — pode levar 5 min"
    run_with_spinner "Baixando Llama 3.2 3B" 600 \
        "docker exec rollin-ollama ollama pull llama3.2:3b" || warn "Pull falhou — cliente pode baixar manualmente"

    save_cred "Ollama" "url=interno (http://ollama:11434)" "setup=cliente acessa via Open WebUI" "modelo_default=llama3.2:3b"
    log "Ollama no ar: API interna http://ollama:11434 (acesse via Open WebUI)"
}

# ---------- 13 Open WebUI (interface tipo ChatGPT pra Ollama) ----------
install_openwebui() {
    hr "13 · Open WebUI (interface ChatGPT-like pro Ollama)"
    check_existing_install "Open WebUI" "rollin-openwebui" "/opt/rollin/openwebui" || return 0
    local domain
    ask_domain "Subdomínio do Open WebUI (ex: chat.seudominio.com.br):" \
        || { warn "Sem domínio — pulado"; return 0; }
    domain="$REPLY"
    dns_warning "$domain" || return 0

    # Open WebUI conecta no Ollama via OLLAMA_BASE_URL (rede interna rollin).
    # Cliente cria primeiro admin no 1º acesso (sign-up auto-promove ao primeiro user).
    local secret_key; secret_key=$(rand_pwd 32)
    mkdir -p /opt/rollin/openwebui
    cat > /opt/rollin/openwebui/docker-compose.yml <<COMPOSE
services:
  openwebui:
    image: ghcr.io/open-webui/open-webui:main
    container_name: rollin-openwebui
    restart: unless-stopped
    volumes:
      - openwebui_data:/app/backend/data
    environment:
      OLLAMA_BASE_URL: http://ollama:11434
      WEBUI_SECRET_KEY: $secret_key
      WEBUI_URL: https://$domain
      ENABLE_SIGNUP: "true"
      DEFAULT_USER_ROLE: pending
    labels:
      - traefik.enable=true
      - traefik.http.routers.openwebui.rule=Host(\`$domain\`)
      - traefik.http.routers.openwebui.entrypoints=websecure
      - traefik.http.routers.openwebui.tls.certresolver=le
      - traefik.http.services.openwebui.loadbalancer.server.port=8080
    networks: [rollin]

volumes:
  openwebui_data:

networks:
  rollin:
    external: true
    name: $NET
COMPOSE
    print_download_banner "Open WebUI" "1 imagem · ~1.5 GB" "2 a 4 minutos"
    run_with_spinner "Subindo Open WebUI" 240 \
        "cd /opt/rollin/openwebui && docker compose up -d --quiet-pull"
    save_cred "Open WebUI" "url=https://$domain" "setup=crie admin no 1º acesso (primeiro signup vira admin)"
    log "Open WebUI no ar: https://$domain — primeiro signup vira admin automaticamente"
}

# ---------- 14 pgvector (Postgres com extensão vector pra RAG) ----------
install_pgvector() {
    hr "14 · pgvector (Postgres 16 + extensão vector pra RAG)"
    check_existing_install "pgvector" "rollin-pgvector" "/opt/rollin/pgvector" || return 0
    # Sem domínio público — acesso só via rede interna rollin (porta 5432).
    # Open WebUI/LiteLLM/n8n usam pra busca semântica em documentos do cliente.

    local pg_pwd; pg_pwd=$(rand_pwd 24)
    mkdir -p /opt/rollin/pgvector/init
    cat > /opt/rollin/pgvector/init/01-extension.sql <<'SQL'
-- Habilita extensao pgvector no database default
CREATE EXTENSION IF NOT EXISTS vector;
SQL
    cat > /opt/rollin/pgvector/docker-compose.yml <<COMPOSE
services:
  pgvector:
    image: pgvector/pgvector:pg16
    container_name: rollin-pgvector
    restart: unless-stopped
    environment:
      POSTGRES_DB: rag
      POSTGRES_USER: rag
      POSTGRES_PASSWORD: $pg_pwd
    volumes:
      - pgvector_data:/var/lib/postgresql/data
      - ./init:/docker-entrypoint-initdb.d:ro
    networks: [rollin]
    # Sem labels Traefik — só rede interna (postgres://rag:***@pgvector:5432/rag)

volumes:
  pgvector_data:

networks:
  rollin:
    external: true
    name: $NET
COMPOSE
    print_download_banner "pgvector" "1 imagem · ~450 MB" "1 minuto"
    run_with_spinner "Subindo pgvector" 120 \
        "cd /opt/rollin/pgvector && docker compose up -d --quiet-pull"
    save_cred "pgvector" "url=interno (postgres://pgvector:5432/rag)" "user=rag" "password=$pg_pwd"
    log "pgvector no ar: rede interna postgres://rag:$pg_pwd@pgvector:5432/rag"
}

# ---------- 15 Qdrant (vector DB dedicado com dashboard) ----------
install_qdrant() {
    hr "15 · Qdrant (vector DB dedicado pra RAG corporativo)"
    check_existing_install "Qdrant" "rollin-qdrant" "/opt/rollin/qdrant" || return 0
    local domain
    ask_domain "Subdomínio do Qdrant dashboard (ex: vectors.seudominio.com.br):" \
        || { warn "Sem domínio — pulado"; return 0; }
    domain="$REPLY"
    dns_warning "$domain" || return 0

    # Qdrant expõe HTTP API (6333) + gRPC (6334). Dashboard web vem com a imagem.
    # API key obrigatória pra proteger acesso externo.
    local api_key; api_key=$(rand_pwd 40)
    mkdir -p /opt/rollin/qdrant
    cat > /opt/rollin/qdrant/docker-compose.yml <<COMPOSE
services:
  qdrant:
    image: qdrant/qdrant:latest
    container_name: rollin-qdrant
    restart: unless-stopped
    environment:
      QDRANT__SERVICE__API_KEY: $api_key
      QDRANT__SERVICE__ENABLE_TLS: "false"
    volumes:
      - qdrant_data:/qdrant/storage
    labels:
      - traefik.enable=true
      - traefik.http.routers.qdrant.rule=Host(\`$domain\`)
      - traefik.http.routers.qdrant.entrypoints=websecure
      - traefik.http.routers.qdrant.tls.certresolver=le
      - traefik.http.services.qdrant.loadbalancer.server.port=6333
    networks: [rollin]

volumes:
  qdrant_data:

networks:
  rollin:
    external: true
    name: $NET
COMPOSE
    print_download_banner "Qdrant" "1 imagem · ~120 MB" "30 segundos"
    run_with_spinner "Subindo Qdrant" 90 \
        "cd /opt/rollin/qdrant && docker compose up -d --quiet-pull"
    save_cred "Qdrant" "url=https://$domain/dashboard" "viewer=https://$domain" "api_key=$api_key"
    log "Qdrant no ar: dashboard https://$domain/dashboard · API https://$domain (header: api-key)"
}

# ---------- 16 LiteLLM (proxy multi-modelo, API compatível OpenAI) ----------
install_litellm() {
    hr "16 · LiteLLM (proxy multi-modelo · API REST compatível OpenAI)"
    check_existing_install "LiteLLM" "rollin-litellm" "/opt/rollin/litellm" || return 0
    local domain
    ask_domain "Subdomínio do LiteLLM (ex: llm.seudominio.com.br):" \
        || { warn "Sem domínio — pulado"; return 0; }
    domain="$REPLY"
    dns_warning "$domain" || return 0

    # LiteLLM: proxy unificado pra dezenas de providers (Ollama local, OpenAI, Claude,
    # Bedrock, etc) com 1 API só. Cliente aponta SDK pra esta URL e troca modelo via param.
    local master_key; master_key=$(rand_pwd 32)
    mkdir -p /opt/rollin/litellm
    cat > /opt/rollin/litellm/config.yaml <<YAML
model_list:
  - model_name: llama3.2
    litellm_params:
      model: ollama/llama3.2:3b
      api_base: http://ollama:11434
  - model_name: llama3
    litellm_params:
      model: ollama/llama3:8b
      api_base: http://ollama:11434
  - model_name: mistral
    litellm_params:
      model: ollama/mistral:7b
      api_base: http://ollama:11434

general_settings:
  master_key: $master_key

litellm_settings:
  drop_params: true
  set_verbose: false
YAML
    cat > /opt/rollin/litellm/docker-compose.yml <<COMPOSE
services:
  litellm:
    image: ghcr.io/berriai/litellm:main-stable
    container_name: rollin-litellm
    restart: unless-stopped
    command: ["--config", "/app/config.yaml", "--port", "4000"]
    volumes:
      - ./config.yaml:/app/config.yaml:ro
    environment:
      LITELLM_MASTER_KEY: $master_key
    labels:
      - traefik.enable=true
      - traefik.http.routers.litellm.rule=Host(\`$domain\`)
      - traefik.http.routers.litellm.entrypoints=websecure
      - traefik.http.routers.litellm.tls.certresolver=le
      - traefik.http.services.litellm.loadbalancer.server.port=4000
    networks: [rollin]

networks:
  rollin:
    external: true
    name: $NET
COMPOSE
    print_download_banner "LiteLLM" "1 imagem · ~400 MB" "1 minuto"
    run_with_spinner "Subindo LiteLLM" 120 \
        "cd /opt/rollin/litellm && docker compose up -d --quiet-pull"
    save_cred "LiteLLM" "url=https://$domain" "api_key=$master_key" "setup=aponte SDK OpenAI pra https://$domain/v1"
    log "LiteLLM no ar: https://$domain (API REST compatível OpenAI, header: Authorization: Bearer $master_key)"
}

# ============================================================================
# BANCOS DE DADOS (internos — sem porta pública; acesso pelos apps na rede rollin)
# Segurança: NUNCA expor banco na internet. Ficam só na rede docker 'rollin'.
# ============================================================================

# ---------- PostgreSQL ----------
install_postgres() {
    hr "PostgreSQL"
    check_existing_install "PostgreSQL" "rollin-postgres" "/opt/rollin/postgres" || return 0
    local pwd; pwd=$(rand_pwd 24)
    mkdir -p /opt/rollin/postgres
    cat > /opt/rollin/postgres/docker-compose.yml <<COMPOSE
services:
  postgres:
    image: postgres:16-alpine
    container_name: rollin-postgres
    restart: unless-stopped
    environment:
      POSTGRES_USER: rollin
      POSTGRES_PASSWORD: $pwd
      POSTGRES_DB: rollin
    volumes: [pg_data:/var/lib/postgresql/data]
    networks: [rollin]
volumes:
  pg_data:
networks:
  rollin:
    external: true
    name: $NET
COMPOSE
    drop_orphan_volume "postgres_pg_data" "rollin-postgres"
    print_download_banner "PostgreSQL" "1 imagem · ~250 MB" "1 minuto"
    run_with_spinner "Subindo PostgreSQL" 120 "cd /opt/rollin/postgres && docker compose up -d --quiet-pull"
    save_cred "PostgreSQL" "url=postgresql://rollin:$pwd@rollin-postgres:5432/rollin" "host=rollin-postgres" "port=5432" "user=rollin" "password=$pwd" "setup=conexão interna (rede da VPS). Use nos seus apps; NÃO exposto na internet."
    log "PostgreSQL no ar (interno): rollin-postgres:5432  user=rollin"
}

# ---------- MySQL ----------
install_mysql() {
    hr "MySQL"
    check_existing_install "MySQL" "rollin-mysql" "/opt/rollin/mysql" || return 0
    local pwd root_pwd; pwd=$(rand_pwd 24); root_pwd=$(rand_pwd 24)
    mkdir -p /opt/rollin/mysql
    cat > /opt/rollin/mysql/docker-compose.yml <<COMPOSE
services:
  mysql:
    image: mysql:8
    container_name: rollin-mysql
    restart: unless-stopped
    environment:
      MYSQL_ROOT_PASSWORD: $root_pwd
      MYSQL_DATABASE: rollin
      MYSQL_USER: rollin
      MYSQL_PASSWORD: $pwd
    volumes: [mysql_data:/var/lib/mysql]
    networks: [rollin]
volumes:
  mysql_data:
networks:
  rollin:
    external: true
    name: $NET
COMPOSE
    drop_orphan_volume "mysql_mysql_data" "rollin-mysql"
    print_download_banner "MySQL" "1 imagem · ~600 MB" "1 a 2 minutos"
    run_with_spinner "Subindo MySQL" 180 "cd /opt/rollin/mysql && docker compose up -d --quiet-pull"
    save_cred "MySQL" "url=mysql://rollin:$pwd@rollin-mysql:3306/rollin" "host=rollin-mysql" "port=3306" "user=rollin" "password=$pwd" "root_password=$root_pwd" "setup=conexão interna (rede da VPS). Use nos seus apps; NÃO exposto na internet."
    log "MySQL no ar (interno): rollin-mysql:3306  user=rollin  db=rollin"
}

# ---------- MongoDB ----------
install_mongodb() {
    hr "MongoDB"
    check_existing_install "MongoDB" "rollin-mongo" "/opt/rollin/mongodb" || return 0
    local pwd; pwd=$(rand_pwd 24)
    mkdir -p /opt/rollin/mongodb
    cat > /opt/rollin/mongodb/docker-compose.yml <<COMPOSE
services:
  mongo:
    image: mongo:7
    container_name: rollin-mongo
    restart: unless-stopped
    environment:
      MONGO_INITDB_ROOT_USERNAME: rollin
      MONGO_INITDB_ROOT_PASSWORD: $pwd
    volumes: [mongo_data:/data/db]
    networks: [rollin]
volumes:
  mongo_data:
networks:
  rollin:
    external: true
    name: $NET
COMPOSE
    drop_orphan_volume "mongodb_mongo_data" "rollin-mongo"
    print_download_banner "MongoDB" "1 imagem · ~280 MB" "1 minuto"
    run_with_spinner "Subindo MongoDB" 150 "cd /opt/rollin/mongodb && docker compose up -d --quiet-pull"
    save_cred "MongoDB" "url=mongodb://rollin:$pwd@rollin-mongo:27017/?authSource=admin" "host=rollin-mongo" "port=27017" "user=rollin" "password=$pwd" "setup=conexão interna (rede da VPS). Use nos seus apps; NÃO exposto na internet."
    log "MongoDB no ar (interno): rollin-mongo:27017  user=rollin"
}

# ---------- Redis ----------
install_redis() {
    hr "Redis"
    check_existing_install "Redis" "rollin-redis" "/opt/rollin/redis" || return 0
    local pwd; pwd=$(rand_pwd 24)
    mkdir -p /opt/rollin/redis
    cat > /opt/rollin/redis/docker-compose.yml <<COMPOSE
services:
  redis:
    image: redis:7-alpine
    container_name: rollin-redis
    restart: unless-stopped
    command: ["redis-server", "--requirepass", "$pwd", "--appendonly", "yes"]
    volumes: [redis_data:/data]
    networks: [rollin]
volumes:
  redis_data:
networks:
  rollin:
    external: true
    name: $NET
COMPOSE
    print_download_banner "Redis" "1 imagem · ~40 MB" "menos de 1 minuto"
    run_with_spinner "Subindo Redis" 90 "cd /opt/rollin/redis && docker compose up -d --quiet-pull"
    save_cred "Redis" "url=redis://default:$pwd@rollin-redis:6379" "host=rollin-redis" "port=6379" "password=$pwd" "setup=conexão interna (rede da VPS). Use nos seus apps; NÃO exposto na internet."
    log "Redis no ar (interno): rollin-redis:6379"
}

# ---------- Langflow (visual LLM flow builder) ----------
install_langflow() {
    hr "Langflow"
    check_existing_install "Langflow" "rollin-langflow" "/opt/rollin/langflow" || return 0
    local domain
    ask_domain "Subdomínio do Langflow (ex: langflow.seudominio.com.br):" \
        || { warn "Sem domínio — pulado"; return 0; }
    domain="$REPLY"
    dns_warning "$domain" || return 0
    local lf_user="admin"; local lf_pwd pg_pwd secret
    lf_pwd=$(rand_pwd 16); pg_pwd=$(rand_pwd 24); secret=$(rand_pwd 40)
    mkdir -p /opt/rollin/langflow
    cat > /opt/rollin/langflow/docker-compose.yml <<COMPOSE
services:
  langflow-db:
    image: postgres:16-alpine
    container_name: rollin-langflow-db
    restart: unless-stopped
    environment:
      POSTGRES_USER: langflow
      POSTGRES_PASSWORD: $pg_pwd
      POSTGRES_DB: langflow
    volumes: [langflow_pg:/var/lib/postgresql/data]
    networks: [rollin]
  langflow:
    image: langflowai/langflow:latest
    container_name: rollin-langflow
    restart: unless-stopped
    depends_on: [langflow-db]
    environment:
      LANGFLOW_DATABASE_URL: postgresql://langflow:$pg_pwd@rollin-langflow-db:5432/langflow
      LANGFLOW_CONFIG_DIR: /app/langflow
      LANGFLOW_AUTO_LOGIN: "false"
      LANGFLOW_SUPERUSER: $lf_user
      LANGFLOW_SUPERUSER_PASSWORD: $lf_pwd
      LANGFLOW_SECRET_KEY: $secret
    volumes: [langflow_data:/app/langflow]
    labels:
      - traefik.enable=true
      - traefik.http.routers.langflow.rule=Host(\`$domain\`)
      - traefik.http.routers.langflow.entrypoints=websecure
      - traefik.http.routers.langflow.tls.certresolver=le
      - traefik.http.services.langflow.loadbalancer.server.port=7860
    networks: [rollin]

volumes:
  langflow_data:
  langflow_pg:

networks:
  rollin:
    external: true
    name: $NET
COMPOSE
    drop_orphan_volume "langflow_langflow_pg" "rollin-langflow-db"
    print_download_banner "Langflow + Postgres" "2 imagens · ~1.5 GB" "3 a 5 minutos"
    run_with_spinner "Subindo Langflow" 300 "cd /opt/rollin/langflow && docker compose up -d --quiet-pull"
    save_cred "Langflow" "url=https://$domain" "user=$lf_user" "password=$lf_pwd"
    log "Langflow no ar: https://$domain  user=$lf_user  pwd=$lf_pwd"
}

# ---------- WAHA (WhatsApp HTTP API) ----------
install_waha() {
    hr "WAHA"
    check_existing_install "WAHA" "rollin-waha" "/opt/rollin/waha" || return 0
    local domain
    ask_domain "Subdomínio do WAHA (ex: waha.seudominio.com.br):" \
        || { warn "Sem domínio — pulado"; return 0; }
    domain="$REPLY"
    dns_warning "$domain" || return 0
    local wa_user="admin"; local wa_pwd api_key
    wa_pwd=$(rand_pwd 16); api_key=$(rand_pwd 32)
    mkdir -p /opt/rollin/waha
    cat > /opt/rollin/waha/docker-compose.yml <<COMPOSE
services:
  waha:
    image: devlikeapro/waha:latest
    container_name: rollin-waha
    restart: unless-stopped
    environment:
      WAHA_API_KEY: $api_key
      WHATSAPP_API_KEY: $api_key
      WAHA_DASHBOARD_USERNAME: $wa_user
      WAHA_DASHBOARD_PASSWORD: $wa_pwd
      WHATSAPP_SWAGGER_USERNAME: $wa_user
      WHATSAPP_SWAGGER_PASSWORD: $wa_pwd
      WHATSAPP_DEFAULT_ENGINE: WEBJS
      WAHA_BASE_URL: https://$domain
      WAHA_PRINT_QR: "False"
      WHATSAPP_RESTART_ALL_SESSIONS: "True"
    volumes: [waha_sessions:/app/.sessions]
    labels:
      - traefik.enable=true
      - traefik.http.routers.waha.rule=Host(\`$domain\`)
      - traefik.http.routers.waha.entrypoints=websecure
      - traefik.http.routers.waha.tls.certresolver=le
      - traefik.http.services.waha.loadbalancer.server.port=3000
    networks: [rollin]

volumes:
  waha_sessions:

networks:
  rollin:
    external: true
    name: $NET
COMPOSE
    print_download_banner "WAHA" "1 imagem · ~1 GB" "2 a 4 minutos"
    run_with_spinner "Subindo WAHA" 240 "cd /opt/rollin/waha && docker compose up -d --quiet-pull"
    save_cred "WAHA" "url=https://$domain/dashboard" "user=$wa_user" "password=$wa_pwd" "api_key=$api_key" "setup=Ao abrir: faca login com o usuario/senha acima. Depois va em Workers -> icone de editar (lapis) -> cole a API Key acima no campo 'Api Key' -> Salvar. O status vira Connected. Nas chamadas de API use o header X-Api-Key com essa mesma chave."
    log "WAHA no ar: https://$domain/dashboard  user=$wa_user"
}

# ============================================================================
# MENU
# ============================================================================

show_menu() {
    echo
    echo "${C_M}╔══════════════════════════════════════════════════════════════════════╗"
    echo "║  ${C_W}Catálogo Rollin Host${C_M} — escolha o que instalar:                    ║"
    echo "╚══════════════════════════════════════════════════════════════════════╝${C_N}"
    echo
    echo "  ${C_Y}[ 01 ]${C_N}  ${C_W}Traefik${C_N}         ${C_DIM}— reverse proxy + HTTPS automático ${C_G}(comece por ele)${C_N}"
    echo "  ${C_Y}[ 02 ]${C_N}  ${C_W}Portainer${C_N}       ${C_DIM}— painel web pra gerenciar Docker${C_N}"
    echo "  ${C_Y}[ 03 ]${C_N}  ${C_W}n8n${C_N}             ${C_DIM}— automação de fluxos + agentes IA${C_N}"
    echo "  ${C_Y}[ 04 ]${C_N}  ${C_W}Evolution API${C_N}   ${C_DIM}— integração com WhatsApp${C_N}"
    echo "  ${C_Y}[ 05 ]${C_N}  ${C_W}Chatwoot${C_N}        ${C_DIM}— atendimento omnichannel (chat, e-mail, redes)${C_N}"
    echo "  ${C_Y}[ 06 ]${C_N}  ${C_W}Typebot${C_N}         ${C_DIM}— builder visual de chatbots${C_N}"
    echo "  ${C_Y}[ 07 ]${C_N}  ${C_W}Flowise${C_N}         ${C_DIM}— orquestração de fluxos LLM (low-code)${C_N}"
    echo "  ${C_Y}[ 08 ]${C_N}  ${C_W}Uptime Kuma${C_N}     ${C_DIM}— monitoramento de uptime dos seus serviços${C_N}"
    echo "  ${C_Y}[ 09 ]${C_N}  ${C_W}NocoDB${C_N}          ${C_DIM}— banco no-code (estilo Airtable)${C_N}"
    echo "  ${C_Y}[ 10 ]${C_N}  ${C_W}Open Claw${C_N}       ${C_DIM}— hub de agentes IA (dashboard + gateway)${C_N}"
    echo "  ${C_Y}[ 11 ]${C_N}  ${C_W}Hermes${C_N}          ${C_DIM}— agente IA Nous Research (dashboard + API)${C_N}"
    echo
    echo "  ${C_M}── IA Cloud (self-hosted Llama/Mistral/Mixtral) ──${C_N}"
    echo "  ${C_Y}[ 12 ]${C_N}  ${C_W}Ollama${C_N}          ${C_DIM}— gerenciador de modelos open-source (Llama, Mistral, Mixtral)${C_N}"
    echo "  ${C_Y}[ 13 ]${C_N}  ${C_W}Open WebUI${C_N}      ${C_DIM}— interface tipo ChatGPT pro Ollama${C_N}"
    echo "  ${C_Y}[ 14 ]${C_N}  ${C_W}pgvector${C_N}        ${C_DIM}— Postgres + extensão vector pra RAG (busca semântica)${C_N}"
    echo "  ${C_Y}[ 15 ]${C_N}  ${C_W}Qdrant${C_N}          ${C_DIM}— vector DB dedicado com dashboard${C_N}"
    echo "  ${C_Y}[ 16 ]${C_N}  ${C_W}LiteLLM${C_N}         ${C_DIM}— proxy multi-modelo, API REST compatível OpenAI${C_N}"
    echo
    echo "  ${C_M}── Painel completo (vira o proxy da VPS — NÃO combina com as stacks acima) ──${C_N}"
    echo "  ${C_Y}[ 17 ]${C_N}  ${C_W}Easypanel${C_N}       ${C_DIM}— painel visual que gerencia a VPS (usa 80/443; apps sobem dentro dele)${C_N}"
    echo
    echo "  ${C_R}[ 99 ]${C_N}  Sair (mostra resumo das credenciais)"
    echo
}

# Imprime UMA aplicação no resumo: nome + status (healthcheck) + URL(s) + como
# acessar (login/senha gerado, API key, ou "crie no 1º acesso"). Chamada pelo
# parser do show_summary a cada bloco do arquivo de credenciais.
_summary_emit_app() {
    local name="$1" url="$2" url2="$3" user="$4" pass="$5" apikey="$6" ck="$7"
    [ -z "$name" ] && return 0
    local code status=""
    if [ -n "$url" ]; then
        code=$(curl -s $ck -o /dev/null -w "%{http_code}" --max-time 6 "$url" 2>/dev/null || true)
        [ -z "$code" ] && code="000"
        case "$code" in
            2*|3*|401) status="${C_G}● no ar${C_N}" ;;
            000)       status="${C_Y}● iniciando (1–2 min)${C_N}" ;;
            *)         status="${C_Y}● HTTP $code${C_N}" ;;
        esac
    fi
    printf "  ${C_W}%s${C_N}  %b\n" "$name" "$status"
    [ -n "$url" ]  && printf "    ${C_C}%s${C_N}\n" "$url"
    [ -n "$url2" ] && printf "    ${C_C}%s${C_N}\n" "$url2"
    if [ -n "$user" ] && [ -n "$pass" ]; then
        printf "    🔑 ${C_W}login:${C_N} %s   ${C_W}senha:${C_N} %s\n" "$user" "$pass"
    elif [ -n "$apikey" ]; then
        printf "    🔑 ${C_W}API key:${C_N} %s\n" "$apikey"
    else
        printf "    🔑 ${C_DIM}crie o login/admin no primeiro acesso${C_N}\n"
    fi
    echo
}

show_summary() {
    hr "Resumo da instalação"

    if [ ! -s "$CRED_FILE" ]; then
        warn "Nenhuma stack foi instalada nesta sessão."
        footer
        return
    fi

    echo
    echo "  ${C_W}🔗 Suas aplicações — acesso e credenciais:${C_N}"
    [ -n "$ACME_STAGING" ] && echo "  ${C_Y}(modo staging: cert de teste — navegador vai reclamar, é esperado)${C_N}"
    echo
    # Em staging o cert é "de mentira"; -k faz o healthcheck aceitá-lo.
    local ck=""; [ -n "$ACME_STAGING" ] && ck="-k"

    # DEDUP: se cliente rodou o wizard 2x ou reconfigurou uma stack, o CRED_FILE
    # tem múltiplas entradas pra mesma stack (ex: n8n em sslip + n8n em domínio
    # real). Mantém só a entrada MAIS RECENTE de cada stack no resumo.
    # Estratégia: 2 passes em awk. Pass 1 acumula blocos em array indexado pelo
    # nome (sobrescrevendo — última versão vence). Pass 2 imprime na ordem
    # cronológica original.
    local dedup_file
    dedup_file=$(mktemp /tmp/rh-cred-dedup-XXXXXX)
    awk '
        /^# / {
            # nome da stack = tudo após "# YYYY-MM-DD HH:MM:SS  "
            current = $0
            sub(/^#[[:space:]]*[0-9-]+[[:space:]]+[0-9:]+[[:space:]]+/, "", current)
            blocks[current] = $0 "\n"
            seq[current] = ++counter
            next
        }
        {
            if (current != "") blocks[current] = blocks[current] $0 "\n"
        }
        END {
            # ordena por seq (cronológico) e imprime
            n = 0
            for (name in blocks) { keys[++n] = name }
            # bubble sort simples pra preservar bash POSIX (não dá pra usar asort)
            for (i = 1; i <= n; i++) {
                for (j = i + 1; j <= n; j++) {
                    if (seq[keys[i]] > seq[keys[j]]) {
                        tmp = keys[i]; keys[i] = keys[j]; keys[j] = tmp
                    }
                }
            }
            for (i = 1; i <= n; i++) printf "%s", blocks[keys[i]]
        }
    ' "$CRED_FILE" > "$dedup_file"

    # Acumula os campos de cada bloco e imprime quando bate o próximo "#" (ou fim).
    local name="" url="" url2="" user="" pass="" apikey="" line
    while IFS= read -r line; do
        case "$line" in
            "#"*)
                _summary_emit_app "$name" "$url" "$url2" "$user" "$pass" "$apikey" "$ck"
                name=$(printf '%s' "$line" | sed -E 's/^#[[:space:]]*[0-9-]+[[:space:]]+[0-9:]+[[:space:]]+//')
                url=""; url2=""; user=""; pass=""; apikey=""
                ;;
            *url=https*)      url=$(printf  '%s' "$line" | sed -E 's/^[[:space:]]*url=//') ;;
            *builder=https*)  url=$(printf  '%s' "$line" | sed -E 's/^[[:space:]]*builder=//') ;;
            *viewer=https*)   url2=$(printf '%s' "$line" | sed -E 's/^[[:space:]]*viewer=//') ;;
            *user=*)          user=$(printf '%s' "$line" | sed -E 's/^[[:space:]]*user=//') ;;
            *password=*)      pass=$(printf '%s' "$line" | sed -E 's/^[[:space:]]*password=//') ;;
            *api_key=*)       apikey=$(printf '%s' "$line" | sed -E 's/^[[:space:]]*api_key=//') ;;
        esac
    done < "$dedup_file"
    _summary_emit_app "$name" "$url" "$url2" "$user" "$pass" "$apikey" "$ck"   # último bloco

    rm -f "$dedup_file" "${dedup_file}.keys"

    echo "  ${C_DIM}Tudo (incl. senhas internas de banco) salvo em $CRED_FILE — veja com: cat $CRED_FILE${C_N}"
    echo "  ${C_DIM}Apps com '● iniciando' sobem em segundos — recarregue no navegador.${C_N}"

    footer
}

# ============================================================================
# MAIN
# ============================================================================
main() {
    banner "Wizard de Setup VPS — instale stacks de produção com 1 clique"
    if [ -n "$ACME_STAGING" ]; then
        warn "MODO STAGING ativo — certificados de TESTE (Let's Encrypt staging)."
        warn "Use só pra validar a instalação. Em produção, rode SEM RH_ACME_STAGING."
    fi
    if [ "$RH_NONINTERACTIVE" = "1" ]; then
        warn "MODO HEADLESS — sem prompts. Stack: ${C_W}${RH_STACK}${C_N}"
        if [ -z "$RH_STACK" ]; then
            err "RH_NONINTERACTIVE=1 mas RH_STACK não foi informado. Abortando."
            exit 1
        fi
        if [ -z "$RH_ACME_EMAIL_ENV" ] && ! grep -q "RH_SAVED_ACME_EMAIL" /opt/rollin/.rollin-setup.env 2>/dev/null; then
            err "RH_NONINTERACTIVE=1 mas RH_ACME_EMAIL não foi informado (e não há e-mail salvo de setup anterior). Abortando."
            exit 1
        fi
    fi
    require_root
    detect_os
    install_deps
    setup_swap
    setup_firewall
    setup_motd       # hostname Rollin + MOTD em caixa (esconde provedor)
    install_docker

    # ===== Configuração do e-mail Let's Encrypt =====
    # Reusa o e-mail salvo de um setup anterior — NÃO re-pergunta numa VPS que
    # já foi configurada (só pede no 1o run). Persistido em /opt/rollin/.rollin-setup.env
    _saved_email=""
    [ -f /opt/rollin/.rollin-setup.env ] && _saved_email="$(. /opt/rollin/.rollin-setup.env 2>/dev/null; echo "${RH_SAVED_ACME_EMAIL:-}")"

    if [ "$RH_NONINTERACTIVE" = "1" ]; then
        # Headless: usa o env; se não veio, cai no e-mail salvo
        ACME_EMAIL="${RH_ACME_EMAIL_ENV:-$_saved_email}"
        log "E-mail Let's Encrypt (headless): ${C_W}$ACME_EMAIL${C_N}"
    elif [ -n "$_saved_email" ]; then
        # Já configurado antes: reusa sem re-perguntar
        ACME_EMAIL="$_saved_email"
        log "E-mail Let's Encrypt (reusando do setup anterior): ${C_W}$ACME_EMAIL${C_N}"
        echo "  ${C_DIM}(pra trocar: apague /opt/rollin/.rollin-setup.env ou rode com RH_ACME_EMAIL=...)${C_N}"
    else
        hr "Configuração inicial"
        echo
        echo "  Antes de escolher as stacks, vou te perguntar uma coisa só:"
        echo "    ${C_W}1.${C_N} ${C_W}Seu e-mail${C_N} ${C_DIM}— pra emissão de certificados HTTPS via Let's Encrypt${C_N}"
        echo
        echo "  ${C_DIM}Depois, pra cada stack escolhida, eu pergunto o subdomínio dela${C_N}"
        echo "  ${C_DIM}(ex: n8n.seudominio.com.br). Cancela com Ctrl+C a qualquer momento.${C_N}"

        while [ -z "$ACME_EMAIL" ]; do
            ask_input "Seu e-mail (será usado pra emitir HTTPS pelos seus domínios — não recebe spam)"
            ACME_EMAIL="$REPLY"
            if [ -z "$ACME_EMAIL" ]; then
                warn "E-mail é obrigatório. Tenta de novo."
            fi
        done
        log "E-mail registrado: ${C_W}$ACME_EMAIL${C_N}"
    fi

    # Persiste o e-mail pra próximas execuções não re-perguntarem
    mkdir -p /opt/rollin 2>/dev/null
    printf "RH_SAVED_ACME_EMAIL=%q\n" "$ACME_EMAIL" > /opt/rollin/.rollin-setup.env 2>/dev/null

    # Helper: chama install_$stack setando RH_CURRENT_STACK pro ask_domain pegar
    # (em modo headless, ask_domain monta {stack}.IP.sslip.io).
    run_install_stack() {
        local stack="$1"
        RH_CURRENT_STACK="$stack"
        set +e
        case "$stack" in
            traefik)        install_traefik ;;
            portainer)      install_portainer ;;
            n8n)            install_n8n ;;
            evolution|evolution-api)
                            install_evolution ;;
            chatwoot)       install_chatwoot ;;
            typebot)        install_typebot ;;
            flowise)        install_flowise ;;
            uptime|uptime-kuma)
                            install_uptime_kuma ;;
            nocodb)         install_nocodb ;;
            openclaw|open-claw)
                            install_openclaw ;;
            hermes|hermes-agent)
                            install_hermes ;;
            ollama)         install_ollama ;;
            openwebui|open-webui)
                            install_openwebui ;;
            pgvector)       install_pgvector ;;
            qdrant)         install_qdrant ;;
            litellm)        install_litellm ;;
            postgres|postgresql)
                            install_postgres ;;
            mysql)          install_mysql ;;
            mongodb|mongo)  install_mongodb ;;
            redis)          install_redis ;;
            langflow)       install_langflow ;;
            waha)           install_waha ;;
            supabase)       install_supabase ;;
            easypanel)      install_easypanel ;;
            codeserver|code-server|vscode)
                            install_codeserver ;;
            adminer)        install_adminer ;;
            minio)          install_minio ;;
            nextcloud)      install_nextcloud ;;
            vaultwarden)    install_vaultwarden ;;
            mariadb)        install_mariadb ;;
            phpmyadmin)     install_phpmyadmin ;;
            pgadmin)        install_pgadmin ;;
            mongoexpress|mongo-express)
                            install_mongoexpress ;;
            ghost)          install_ghost ;;
            directus)       install_directus ;;
            stirlingpdf|stirling-pdf)
                            install_stirlingpdf ;;
            baserow)        install_baserow ;;
            formbricks)     install_formbricks ;;
            gitea)          install_gitea ;;
            meilisearch|meili)
                            install_meilisearch ;;
            metabase)       install_metabase ;;
            umami)          install_umami ;;
            sftpgo)         install_sftpgo ;;
            wordpress|wp)   install_wordpress ;;
            wikijs|wiki)    install_wikijs ;;
            bookstack)      install_bookstack ;;
            espocrm)        install_espocrm ;;
            vikunja)        install_vikunja ;;
            focalboard)     install_focalboard ;;
            grafana)        install_grafana ;;
            typesense)      install_typesense ;;
            rabbitmq)       install_rabbitmq ;;
            matomo)         install_matomo ;;
            filebrowser|file-browser)
                            install_filebrowser ;;
            photoprism)     install_photoprism ;;
            node|nodejs)    install_node ;;
            java)           install_java ;;
            ruby)           install_ruby ;;
            tomcat)         install_tomcat ;;
            tomee)          install_tomee ;;
            nodered|node-red)
                            install_nodered ;;
            gotify)         install_gotify ;;
            ittools|it-tools)
                            install_ittools ;;
            jellyfin)       install_jellyfin ;;
            mssql|sqlserver)
                            install_mssql ;;
            firebird)       install_firebird ;;
            kanboard)       install_kanboard ;;
            planka)         install_planka ;;
            navidrome)      install_navidrome ;;
            firefly)        install_firefly ;;
            mattermost)     install_mattermost ;;
            memos)          install_memos ;;
            privatebin)     install_privatebin ;;
            dozzle)         install_dozzle ;;
            anythingllm)    install_anythingllm ;;
            miniflux)       install_miniflux ;;
            linkwarden)     install_linkwarden ;;
            wallabag)       install_wallabag ;;
            wuzapi)         install_wuzapi ;;
            wuzapiedge|wuzapi-edge)
                            install_wuzapiedge ;;
            trilium)        install_trilium ;;
            actualbudget)   install_actualbudget ;;
            mealie)         install_mealie ;;
            excalidraw)     install_excalidraw ;;
            drawio)         install_drawio ;;
            pairdrop)       install_pairdrop ;;
            homarr)         install_homarr ;;
            searxng)        install_searxng ;;
            cyberchef)      install_cyberchef ;;
            ntfy)           install_ntfy ;;
            netdata)        install_netdata ;;
            registry)       install_registry ;;
            beszel)         install_beszel ;;
            prestashop)     install_prestashop ;;
            immich)         install_immich ;;
            librechat)      install_librechat ;;
            activepieces)   install_activepieces ;;
            penpot)         install_penpot ;;
            docmost)        install_docmost ;;
            pocketbase)     install_pocketbase ;;
            localai)        install_localai ;;
            paperless)      install_paperless ;;
            teable)         install_teable ;;
            karakeep)       install_karakeep ;;
            dockge)         install_dockge ;;
            *) warn "Stack desconhecida ignorada: $stack" ;;
        esac
        local rc=$?
        # Garante o agente de métricas por container (idempotente; docker já existe aqui).
        ensure_cadvisor
        set -e
        RH_CURRENT_STACK=""
        return $rc
    }

    # ===== MODO HEADLESS: processa RH_STACK como CSV e sai =====
    if [ "$RH_NONINTERACTIVE" = "1" ]; then
        # Traefik vem PRIMEIRO sempre (HTTPS proxy é pré-req das outras stacks),
        # mesmo se o operador não declarou explicitamente em RH_STACK.
        local stacks_csv="$RH_STACK"
        # Easypanel e o proxy da VPS (nao usa o Traefik do rollin) — nesse caso NAO prepend Traefik
        if ! grep -qE '(^|,)(traefik|easypanel)(,|$)' <<<"$stacks_csv"; then
            stacks_csv="traefik,$stacks_csv"
        fi
        local IFS_OLD="$IFS"
        IFS=','
        local stack
        for stack in $stacks_csv; do
            stack=$(printf '%s' "$stack" | xargs)   # trim
            [ -z "$stack" ] && continue
            hr "Headless: instalando '$stack'"
            run_install_stack "$stack" || warn "Stack '$stack' retornou erro — seguindo."
        done
        IFS="$IFS_OLD"
        # Resumo é cosmético (faz healthchecks com curl). NUNCA pode derrubar o
        # processo (set -e) e marcar como "falhou" um install que deu certo.
        set +e
        show_summary
        exit 0
    fi

    # ===== MODO INTERATIVO: loop do menu =====
    while true; do
        show_menu
        ask_input "Digite o número da stack que quer instalar (ex: 03 = n8n) ou 99 pra sair"
        # set +e em volta do case: uma stack que falhe (DNS cancelado, erro de
        # rede, etc.) NUNCA pode matar o wizard inteiro — tem que voltar ao menu.
        # (sem isso, o `set -e` global aborta o script no primeiro erro de stack)
        set +e
        case "$REPLY" in
            01|1)  RH_CURRENT_STACK="traefik";   install_traefik ;;
            02|2)  RH_CURRENT_STACK="portainer"; install_portainer ;;
            03|3)  RH_CURRENT_STACK="n8n";       install_n8n ;;
            04|4)  RH_CURRENT_STACK="evolution"; install_evolution ;;
            05|5)  RH_CURRENT_STACK="chatwoot";  install_chatwoot ;;
            06|6)  RH_CURRENT_STACK="typebot";   install_typebot ;;
            07|7)  RH_CURRENT_STACK="flowise";   install_flowise ;;
            08|8)  RH_CURRENT_STACK="uptime";    install_uptime_kuma ;;
            09|9)  RH_CURRENT_STACK="nocodb";    install_nocodb ;;
            10)    RH_CURRENT_STACK="openclaw";  install_openclaw ;;
            11)    RH_CURRENT_STACK="hermes";    install_hermes ;;
            12)    RH_CURRENT_STACK="ollama";    install_ollama ;;
            13)    RH_CURRENT_STACK="openwebui"; install_openwebui ;;
            14)    RH_CURRENT_STACK="pgvector";  install_pgvector ;;
            15)    RH_CURRENT_STACK="qdrant";    install_qdrant ;;
            16)    RH_CURRENT_STACK="litellm";   install_litellm ;;
            17)    RH_CURRENT_STACK="easypanel"; install_easypanel ;;
            99|q|Q|sair|exit) show_summary; exit 0 ;;
            "") ;;
            *) warn "Opção inválida: $REPLY"; sleep 1; set -e; continue ;;
        esac
        RH_CURRENT_STACK=""
        set -e
        echo
        echo
        printf "  ${C_C}>${C_N} ${C_DIM}Pressione ENTER pra voltar ao menu...${C_N}"
        read -r _
    done
}

main "$@"
