#!/usr/bin/env bash
# deploy.sh — idempotent deployment script for ac-tool
# Usage: sudo bash /opt/ac-tool/deploy.sh
set -euo pipefail
# ── Config ────────────────────────────────────────────────────────────────────
APP_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
WEB_DIR="/var/www/html/ac-helper"
WEB_USER="www-data"
ENV_FILE="$APP_DIR/.env"
ENV_EXAMPLE="$APP_DIR/.env.example"
CONTAINER_NAME="ac-tool"
DB_CONTAINER_NAME="ac-tool-db"
FE_BUILD_TAG="ac-tool-fe-extract"
# ── Colours ───────────────────────────────────────────────────────────────────
log() { echo -e "\033[1;34m▶\033[0m $*"; }
ok() { echo -e "\033[1;32m✔\033[0m $*"; }
warn() { echo -e "\033[1;33m⚠\033[0m $*"; }
die() { echo -e "\033[1;31m✖\033[0m $*" >&2; exit 1; }
hr() { echo "────────────────────────────────────────────────────────────"; }
hr
echo " AC Tool — deployment"
echo " $(date '+%Y-%m-%d %H:%M:%S') | dir: $APP_DIR"
hr
# ── 1. Root check ─────────────────────────────────────────────────────────────
if [[ $EUID -ne 0 ]]; then
die "Run as root: sudo bash $0"
fi
# ── 2. Prerequisites ──────────────────────────────────────────────────────────
log "Checking prerequisites..."
command -v docker >/dev/null 2>&1 || die "Docker not installed"
command -v git >/dev/null 2>&1 || die "Git not installed"
docker compose version >/dev/null 2>&1 || die "Docker Compose plugin not found (need 'docker compose', not 'docker-compose')"
ok "Prerequisites OK"
# ── 3. .env setup ─────────────────────────────────────────────────────────────
log "Checking .env..."
if [[ ! -f "$ENV_FILE" ]]; then
[[ ! -f "$ENV_EXAMPLE" ]] && die ".env.example not found in $APP_DIR"
cp "$ENV_EXAMPLE" "$ENV_FILE"
warn ".env created from .env.example"
warn "Fill in the required values and re-run:"
warn " nano $ENV_FILE"
exit 0
fi
# Load .env into the current shell so we can read APP_PORT etc.
set -o allexport
# shellcheck disable=SC1090
source "$ENV_FILE"
set +o allexport
APP_PORT="${APP_PORT:-8100}"
POSTGRES_PASSWORD="${POSTGRES_PASSWORD:-}"
# Warn about blank required keys
MISSING=""
for KEY in GEMINI_API_KEY ADMIN_EMAIL SESSION_SECRET POSTGRES_PASSWORD; do
VAL="${!KEY:-}"
[[ -z "$VAL" ]] && MISSING="$MISSING $KEY"
done
[[ -n "$MISSING" ]] && warn "These keys are empty in .env:$MISSING"
ok ".env loaded (APP_PORT=$APP_PORT)"
# ── 4. Port check ─────────────────────────────────────────────────────────────
log "Checking port $APP_PORT..."
if ss -tlnp | grep -q ":${APP_PORT} "; then
if docker ps --format '{{.Names}} {{.Ports}}' | grep -q "${CONTAINER_NAME}.*${APP_PORT}"; then
warn "Port $APP_PORT already used by our container (will be restarted)"
else
die "Port $APP_PORT is occupied by another process. Change APP_PORT in .env or free the port."
fi
else
ok "Port $APP_PORT is free"
fi
# ── 5. Git pull ───────────────────────────────────────────────────────────────
# Run git as the invoking user (not root) so SSH keys work
GIT_USER="${SUDO_USER:-$(whoami)}"
log "Pulling latest code (as $GIT_USER)..."
cd "$APP_DIR"
sudo -u "$GIT_USER" git fetch origin
LOCAL=$(git rev-parse HEAD)
REMOTE=$(git rev-parse '@{u}')
if [[ "$LOCAL" == "$REMOTE" ]]; then
ok "Already up to date ($(git rev-parse --short HEAD))"
else
sudo -u "$GIT_USER" git pull --ff-only || die "git pull failed — local changes detected. Stash or reset them first."
ok "Updated to $(git rev-parse --short HEAD)"
fi
# ── 6. Docker build ───────────────────────────────────────────────────────────
log "Building Docker image..."
docker compose build --pull
ok "Docker image built"
# ── 7. Extract frontend from Docker build stage ───────────────────────────────
log "Building and extracting frontend..."
TMPDIR=$(mktemp -d)
trap 'rm -rf "$TMPDIR"; docker rmi "$FE_BUILD_TAG" >/dev/null 2>&1 || true' EXIT
docker build --target frontend-builder --tag "$FE_BUILD_TAG" "$APP_DIR"
EXTRACT_CONTAINER=$(docker create "$FE_BUILD_TAG")
docker cp "$EXTRACT_CONTAINER:/app/frontend/dist/." "$TMPDIR/"
docker rm "$EXTRACT_CONTAINER" >/dev/null
FILE_COUNT=$(find "$TMPDIR" -type f | wc -l)
ok "Frontend built: $FILE_COUNT files"
# ── 8. Deploy frontend static files ───────────────────────────────────────────
log "Deploying frontend to $WEB_DIR..."
mkdir -p "$WEB_DIR"
find "${WEB_DIR}" -mindepth 1 -delete
cp -r "$TMPDIR/." "$WEB_DIR/"
chown -R "$WEB_USER:$WEB_USER" "$WEB_DIR"
chmod -R 755 "$WEB_DIR"
ok "Frontend deployed to $WEB_DIR"
# ── 9. Restart containers ─────────────────────────────────────────────────────
log "Restarting containers (app + postgres)..."
docker compose down --remove-orphans 2>/dev/null || true
docker compose up -d
ok "Containers started"
# ── 10. Wait for PostgreSQL ────────────────────────────────────────────────────
log "Waiting for PostgreSQL to be ready..."
for i in $(seq 1 30); do
if docker exec "$DB_CONTAINER_NAME" pg_isready -U achelper -d achelper >/dev/null 2>&1; then
ok "PostgreSQL is ready"
break
fi
if [[ $i -eq 30 ]]; then
die "PostgreSQL did not become ready after 60s. Check logs: docker logs $DB_CONTAINER_NAME"
fi
sleep 2
done
# ── 11. Health check ──────────────────────────────────────────────────────────
log "Waiting for application to be healthy..."
HEALTH_URL="http://localhost:${APP_PORT}/health"
for i in $(seq 1 30); do
if curl -sf "$HEALTH_URL" >/dev/null 2>&1; then
ok "Application is healthy"
break
fi
if [[ $i -eq 30 ]]; then
die "Health check failed after 60s. Check logs: docker logs $CONTAINER_NAME"
fi
sleep 2
done
# ── 12. Data directory ────────────────────────────────────────────────────────
DATA_DIR="$APP_DIR/data"
mkdir -p "$DATA_DIR/uploads" "$DATA_DIR/outputs"
ok "Data directories ready at $DATA_DIR"
# ── 13. JSON → PostgreSQL migration (first deploy after adding Postgres) ───────
#
# If old JSON data files exist, offer to run the one-time migration script
# that imports users, clients, dropdowns, sheets, and export templates into DB.
#
JSON_MIGRATION_MARKER="$DATA_DIR/.pg_migrated"
if [[ ! -f "$JSON_MIGRATION_MARKER" ]]; then
HAS_JSON=false
for f in "$DATA_DIR/users.json" "$DATA_DIR/sheets_metadata.json" "$DATA_DIR/clients.json"; do
[[ -f "$f" ]] && HAS_JSON=true && break
done
if [[ "$HAS_JSON" == "true" ]]; then
echo ""
warn "Old JSON data files detected. Run the one-time migration to import them into PostgreSQL?"
warn " yes — migrate now (recommended)"
warn " no — skip (data already in DB or you'll migrate manually)"
read -r -p " Migrate now? [yes/no]: " MIGRATE_ANSWER /dev/null; then
echo ""
hr
echo " Apache config not detected — add this inside your VirtualHost block:"
hr
cat <
ProxyPass http://localhost:${APP_PORT}/api/
ProxyPassReverse http://localhost:${APP_PORT}/api/
# Proxy WebSocket
ProxyPass /ac-helper/ws ws://localhost:${APP_PORT}/ws
ProxyPassReverse /ac-helper/ws ws://localhost:${APP_PORT}/ws
# Serve frontend static files
Alias /ac-helper/ /var/www/html/ac-helper/
Options -Indexes
AllowOverride None
Require all granted
FallbackResource /ac-helper/index.html
APACHE
hr
fi
# ── Summary ───────────────────────────────────────────────────────────────────
echo ""
ok "Deployment complete!"
echo " App container: docker logs -f $CONTAINER_NAME"
echo " DB container: docker logs -f $DB_CONTAINER_NAME"
echo " Health: $HEALTH_URL"
echo " Frontend: $WEB_DIR ($FILE_COUNT files)"
echo " Data: $DATA_DIR"
echo " Commit: $(git -C "$APP_DIR" rev-parse --short HEAD)"
echo ""