#!/usr/bin/env bash # ============================================================================= # quinn.ai — Deploy companion-api + PWA frontend to vps-0 # ============================================================================= # Deploys: # companion-api → /var/www/quinn.ai/api/ (NestJS :3850, systemd) # web PWA → /var/www/quinn.ai/dist/ (static Vite SPA, nginx) # # Prerequisites on vps-0: # - /etc/quinn-ai/companion-api.env (copy env/vps-0.env.example, fill values) # - /etc/quinn-ai/htpasswd (htpasswd -c /etc/quinn-ai/htpasswd quinn) # - Docker Compose running companion-postgres + companion-redis # - wg-quick@wg1 enabled + active (wg1 → apricot 10.9.0.2) # - nginx sites-enabled/ symlink (created by this script on first run) # - Node.js 20+ # # Usage: # ./deploy.sh # full deploy # ./deploy.sh --rollback # restore previous api backup # ./deploy.sh --skip-build # skip local build (CI: artifacts pre-built) # ============================================================================= set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" # deployments/quinn.ai/ → @companion/ is 2 levels up REPO_ROOT="$(cd "$SCRIPT_DIR/../../" && pwd)" API_DIR="${REPO_ROOT}/@applications/api" WEB_DIR="${REPO_ROOT}/@applications/web" REMOTE="quinn-vps" REMOTE_API="/var/www/quinn.ai/api" REMOTE_WEB="/var/www/quinn.ai/dist" REMOTE_BACKUPS="/opt/quinn-ai.deploy-backups" TIMESTAMP="$(date '+%Y%m%d_%H%M%S')" BACKUP_PATH="${REMOTE_BACKUPS}/${TIMESTAMP}" # --------------------------------------------------------------------------- # --rollback: restore the most recent api backup # --------------------------------------------------------------------------- if [[ "${1:-}" == "--rollback" ]]; then echo "==> [ROLLBACK] Restoring previous companion-api on ${REMOTE}..." ssh "$REMOTE" bash -euo pipefail <<'ENDSSH' REMOTE_BACKUPS="/opt/quinn-ai.deploy-backups" REMOTE_API="/var/www/quinn.ai/api" latest="$(ls -1t "$REMOTE_BACKUPS" 2>/dev/null | head -1)" if [[ -z "$latest" ]]; then echo "ERROR: no backups found in $REMOTE_BACKUPS" >&2 exit 1 fi echo " Restoring from $REMOTE_BACKUPS/$latest ..." rsync -a --delete "$REMOTE_BACKUPS/$latest/" "$REMOTE_API/" echo " Restored successfully." ENDSSH echo "==> Restarting service and reloading nginx..." ssh "$REMOTE" "sudo systemctl restart quinn-ai-companion-api && sudo nginx -t && sudo systemctl reload nginx" echo "" echo "Rollback completed at $(date '+%Y-%m-%d %H:%M:%S %Z')" exit 0 fi # --------------------------------------------------------------------------- # --skip-build # --------------------------------------------------------------------------- SKIP_BUILD=false for arg in "$@"; do [[ "$arg" == "--skip-build" ]] && SKIP_BUILD=true; done # --------------------------------------------------------------------------- # Rollback trap # --------------------------------------------------------------------------- BACKUP_CREATED=false rollback_on_error() { local exit_code=$? echo "" echo "Deploy step failed (exit ${exit_code})." if [[ "$BACKUP_CREATED" == "true" ]]; then echo "==> [AUTO-ROLLBACK] Restoring ${BACKUP_PATH} → ${REMOTE_API} ..." ssh "$REMOTE" bash -euo pipefail <&2 set -euo pipefail if [[ -d "${BACKUP_PATH}" ]]; then rsync -a --delete "${BACKUP_PATH}/" "${REMOTE_API}/" echo " companion-api restored from ${BACKUP_PATH}" sudo systemctl restart quinn-ai-companion-api echo " Service restarted." fi ENDSSH echo " Rollback complete." else echo " No backup was created — nothing to roll back." fi exit "$exit_code" } trap rollback_on_error ERR # --------------------------------------------------------------------------- # [0/8] Pre-flight checks # --------------------------------------------------------------------------- echo "==> [0/8] Pre-flight checks..." # Verify source dirs exist for dir in "$API_DIR" "$WEB_DIR"; do if [[ ! -d "$dir" ]]; then echo "ERROR: source directory not found: $dir" >&2 exit 1 fi done echo " Source directories OK." # Verify apricot reachable over WireGuard echo " Checking apricot (10.9.0.2) over wg1..." if ! ssh "$REMOTE" "ping -c 1 -W 3 10.9.0.2 > /dev/null 2>&1"; then echo "ERROR: vps-0 cannot reach apricot at 10.9.0.2 — is wg-quick@wg1 active on vps-0?" >&2 echo " Run: ssh $REMOTE 'systemctl is-active wg-quick@wg1'" >&2 exit 1 fi echo " Apricot reachable over wg1." # Quick model-boss sanity check (claude:haiku is the cheapest/fastest) echo " Checking model-boss (10.9.0.2:8210) via vps-0..." if ! ssh "$REMOTE" "curl -sS -m 10 -o /dev/null -w '%{http_code}' http://10.9.0.2:8210/v1/models" | grep -q "200"; then echo "ERROR: model-boss not responding at http://10.9.0.2:8210/v1/models" >&2 exit 1 fi echo " model-boss OK." # Verify env file exists on vps-0 if ! ssh "$REMOTE" "test -f /etc/quinn-ai/companion-api.env" 2>/dev/null; then echo "ERROR: /etc/quinn-ai/companion-api.env not found on ${REMOTE}." >&2 echo " Copy env/vps-0.env.example → /etc/quinn-ai/companion-api.env and fill in values." >&2 exit 1 fi echo " companion-api.env present." # Verify htpasswd exists if ! ssh "$REMOTE" "test -f /etc/quinn-ai/htpasswd" 2>/dev/null; then echo "ERROR: /etc/quinn-ai/htpasswd not found on ${REMOTE}." >&2 echo " Run: ssh $REMOTE 'mkdir -p /etc/quinn-ai && htpasswd -c /etc/quinn-ai/htpasswd quinn'" >&2 exit 1 fi echo " htpasswd present." # --------------------------------------------------------------------------- # [1/8] Build # --------------------------------------------------------------------------- if [[ "$SKIP_BUILD" == false ]]; then echo "==> [1/8] Building companion-api (NestJS → dist/)..." cd "$API_DIR" && pnpm build cd "$SCRIPT_DIR" echo "==> [1/8] Building companion-web PWA (Vite → dist/)..." cd "$WEB_DIR" && pnpm build cd "$SCRIPT_DIR" else echo "==> [1/8] Build skipped (--skip-build)." fi # Build standalone node_modules for VPS (no registry access there) echo "==> [1b/8] Resolving companion-api production deps for VPS..." API_DEPS_DIR="$(mktemp -d)" cp "$API_DIR/package.json" "$API_DEPS_DIR/" # If the project has a .npmrc pointing at Verdaccio, copy it [[ -f "$API_DIR/.npmrc" ]] && cp "$API_DIR/.npmrc" "$API_DEPS_DIR/" (cd "$API_DEPS_DIR" && npm install --omit=dev --legacy-peer-deps 2>&1 | tail -3) # --------------------------------------------------------------------------- # [2/8] Backup current api on remote # --------------------------------------------------------------------------- echo "==> [2/8] Backing up current companion-api on ${REMOTE}..." ssh "$REMOTE" bash -euo pipefail </dev/null)" ]]; then rsync -a --exclude='node_modules' "${REMOTE_API}/" "${BACKUP_PATH}/" echo " Backup: ${BACKUP_PATH}" find "${REMOTE_BACKUPS}" -maxdepth 1 -mindepth 1 -type d -mtime +7 -exec rm -rf {} + 2>/dev/null || true else echo " No existing companion-api — first deploy." fi ENDSSH BACKUP_CREATED=true # --------------------------------------------------------------------------- # [3/8] Deploy companion-api # --------------------------------------------------------------------------- echo "==> [3/8] Deploying companion-api to ${REMOTE}:${REMOTE_API}..." ssh "$REMOTE" "mkdir -p ${REMOTE_API}" rsync -avz --delete \ --exclude='src' \ --exclude='test' \ --exclude='tsconfig*.json' \ --exclude='node_modules' \ --exclude='.env*' \ --exclude='*.spec.js' \ --exclude='*.test.js' \ "$API_DIR/" "${REMOTE}:${REMOTE_API}/" rsync -avz --delete "${API_DEPS_DIR}/node_modules/" "${REMOTE}:${REMOTE_API}/node_modules/" rm -rf "${API_DEPS_DIR:-/nonexistent}" # --------------------------------------------------------------------------- # [4/8] Deploy web PWA # --------------------------------------------------------------------------- echo "==> [4/8] Deploying web PWA to ${REMOTE}:${REMOTE_WEB}..." ssh "$REMOTE" "mkdir -p ${REMOTE_WEB}" rsync -avz --delete "$WEB_DIR/dist/" "${REMOTE}:${REMOTE_WEB}/" # --------------------------------------------------------------------------- # [5/8] Install systemd unit # --------------------------------------------------------------------------- echo "==> [5/8] Syncing systemd unit..." scp "$SCRIPT_DIR/systemd/quinn-ai-companion-api.service" "${REMOTE}:/etc/systemd/system/quinn-ai-companion-api.service" ssh "$REMOTE" "sudo systemctl daemon-reload && sudo systemctl enable quinn-ai-companion-api" # --------------------------------------------------------------------------- # [6/8] nginx: sync prod.conf + enable site + rate-limit maps # --------------------------------------------------------------------------- echo "==> [6/8] Syncing nginx prod.conf..." scp "$SCRIPT_DIR/nginx/prod.conf" "${REMOTE}:/etc/nginx/sites-available/quinn.ai" ssh "$REMOTE" "sudo ln -sf /etc/nginx/sites-available/quinn.ai /etc/nginx/sites-enabled/quinn.ai 2>/dev/null || true" ssh "$REMOTE" bash -euo pipefail <<'ENDSSH' MAP_CONF=/etc/nginx/conf.d/quinn-ai-maps.conf sudo tee "$MAP_CONF" > /dev/null <<'EOF' # Managed by quinn.ai deploy.sh limit_req_zone $binary_remote_addr zone=quinn_ai_req:10m rate=60r/m; limit_conn_zone $binary_remote_addr zone=quinn_ai_conn:10m; EOF echo " Wrote quinn-ai-maps.conf." ENDSSH # --------------------------------------------------------------------------- # [7/8] nginx test + reload # --------------------------------------------------------------------------- echo "==> [7/8] Testing and reloading nginx..." ssh "$REMOTE" "sudo nginx -t && sudo systemctl reload nginx" # --------------------------------------------------------------------------- # [8/8] Restart service + health checks # --------------------------------------------------------------------------- echo "==> [8/8] Restarting companion-api..." ssh "$REMOTE" "sudo systemctl restart quinn-ai-companion-api" sleep 4 echo "==> [8/8] Health checks..." if ! ssh "$REMOTE" "curl -sf http://127.0.0.1:3850/health > /dev/null"; then echo "ERROR: companion-api health check failed (http://127.0.0.1:3850/health)" >&2 echo " Check logs: ssh $REMOTE 'journalctl -u quinn-ai-companion-api -n 50'" >&2 exit 1 fi echo " companion-api healthy." # External HTTPS health check (requires htpasswd credentials) HTPASSWD_USER="$(ssh "$REMOTE" "head -1 /etc/quinn-ai/htpasswd | cut -d: -f1" 2>/dev/null)" if [[ -n "$HTPASSWD_USER" ]]; then echo " External HTTPS check: provide password when prompted (or skip with Ctrl-C)..." if curl -sf --max-time 10 -u "${HTPASSWD_USER}" \ https://ai.transquinnftw.com/health > /dev/null 2>&1; then echo " External HTTPS health check passed." else echo " WARN: external health check skipped (basic-auth credentials required)." echo " Verify manually: curl -u quinn https://ai.transquinnftw.com/health" fi fi echo "" echo "Deployed quinn.ai successfully at $(date '+%Y-%m-%d %H:%M:%S %Z')" echo "" echo " companion-api: http://127.0.0.1:3850" echo " model-boss: http://10.9.0.2:8210 (over wg1)" echo " chatterbox-tts: http://10.9.0.2:8000 (over wg1)" echo " web: https://ai.transquinnftw.com" echo "" echo "To roll back: bash $SCRIPT_DIR/deploy.sh --rollback"