lilith-platform.live/deployments/@domains/quinn.admin/deploy.sh
Natalie 02483204fd infra: repoint @lilith npm registry + Forgejo from dead black to DO cocotte-forge; serve /photos from local disk
black/apricot homelan died 2026-06-27. Point everything at the DO store tier:
- @lilith npm registry: forge.black.lan/npm.black.lan -> cocotte-forge Verdaccio
  (134.199.243.61:4873) across bunfig.toml scopes, all deploy.sh .npmrc writers,
  and package.json publishConfig.
- Forgejo URL (git/CI): forge.black.lan -> 134.199.243.61:3000 / :2222.
- quinn.www prod.conf /photos: was proxy_pass to dead black_photos (black:8081);
  now served from local disk (root /var/www/quinn.www/dist). Prevents a future
  deploy from re-breaking photos. (Phase G: repoint to DO Spaces/CDN later.)

Interim bare-IP endpoints; switch to named uvlava infranet hosts once live.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 08:09:33 -04:00

452 lines
22 KiB
Bash
Executable file

#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
REPO_ROOT="$(cd "$SCRIPT_DIR/../../../" && pwd)"
REMOTE="quinn-vps"
REMOTE_DIST="/var/www/quinn.admin/dist"
REMOTE_API="/opt/quinn-admin-api"
# Admin gallery photo store. The admin backend's PHOTOS_DIR points here (see
# scripts/quinn-admin-api.service). Holds the DESCRIPTIVE-named gallery set
# (matching the gallery_items DB) + the adversary/ visualization trees — a
# different set from the public quinn.www hash-named photos, served from a
# separate dir so the public vhost is untouched.
REMOTE_PHOTOS="/var/www/quinn.admin/photos"
REMOTE_ORIGINALS="/var/www/quinn.admin/originals"
ORIGINALS_SRC="${REPO_ROOT}/users/transquinnftw/originals"
PHOTOS_SRC="${REPO_ROOT}/deployments/@domains/quinn.www/root/public/photos"
REMOTE_BACKUPS_DIST="/var/www/quinn.admin/.deploy-backups/dist"
REMOTE_BACKUPS_API="/opt/quinn-admin-api.deploy-backups"
API_BUNDLE="dist/server.node.js"
TIMESTAMP="$(date '+%Y%m%d_%H%M%S')"
BACKUP_DIST="${REMOTE_BACKUPS_DIST}/${TIMESTAMP}"
BACKUP_API="${REMOTE_BACKUPS_API}/${TIMESTAMP}"
# ---------------------------------------------------------------------------
# --rollback flag: restore the most recent backup of both dist and API
# ---------------------------------------------------------------------------
if [[ "${1:-}" == "--rollback" ]]; then
echo "==> [ROLLBACK] Restoring previous admin frontend + API on ${REMOTE}..."
ssh "$REMOTE" bash -euo pipefail <<'ENDSSH'
REMOTE_BACKUPS_DIST="/var/www/quinn.admin/.deploy-backups/dist"
REMOTE_BACKUPS_API="/opt/quinn-admin-api.deploy-backups"
REMOTE_DIST="/var/www/quinn.admin/dist"
REMOTE_API="/opt/quinn-admin-api"
latest_dist="$(ls -1t "$REMOTE_BACKUPS_DIST" 2>/dev/null | head -1)"
latest_api="$(ls -1t "$REMOTE_BACKUPS_API" 2>/dev/null | head -1)"
if [[ -z "$latest_dist" && -z "$latest_api" ]]; then
echo "ERROR: no backups found for dist or API." >&2
exit 1
fi
if [[ -n "$latest_dist" ]]; then
echo " Restoring dist from $REMOTE_BACKUPS_DIST/$latest_dist ..."
rsync -a --delete "$REMOTE_BACKUPS_DIST/$latest_dist/" "$REMOTE_DIST/"
else
echo " WARNING: no dist backup found — skipping dist restore."
fi
if [[ -n "$latest_api" ]]; then
echo " Restoring API from $REMOTE_BACKUPS_API/$latest_api ..."
rsync -a --delete "$REMOTE_BACKUPS_API/$latest_api/" "$REMOTE_API/"
else
echo " WARNING: no API backup found — skipping API restore."
fi
ENDSSH
echo "==> Reloading nginx and restarting API service..."
ssh "$REMOTE" "nginx -t && systemctl reload nginx && systemctl restart quinn-admin-api"
echo ""
echo "Rollback completed at $(date '+%Y-%m-%d %H:%M:%S %Z')"
exit 0
fi
# ---------------------------------------------------------------------------
# --skip-build flag: artifacts pre-built in CI; skip local builds + tests
# ---------------------------------------------------------------------------
SKIP_BUILD=false
SKIP_E2E=false
for arg in "$@"; do
[[ "$arg" == "--skip-build" ]] && SKIP_BUILD=true
[[ "$arg" == "--skip-e2e" ]] && SKIP_E2E=true
done
# ---------------------------------------------------------------------------
# Rollback trap — fires on any error after backups are created
# ---------------------------------------------------------------------------
BACKUPS_CREATED=false
rollback_on_error() {
local exit_code=$?
echo ""
echo "✖ Deploy step failed (exit ${exit_code})."
if [[ "$BACKUPS_CREATED" == "true" ]]; then
echo "==> [AUTO-ROLLBACK] Restoring previous dist and API on ${REMOTE}..."
ssh "$REMOTE" bash -euo pipefail <<ENDSSH || echo " WARNING: rollback failed. Manual intervention required." >&2
set -euo pipefail
RESTORED=false
if [[ -d "${BACKUP_DIST}" ]]; then
rsync -a --delete "${BACKUP_DIST}/" "${REMOTE_DIST}/"
echo " dist restored from ${BACKUP_DIST}"
RESTORED=true
fi
if [[ -d "${BACKUP_API}" ]]; then
rsync -a --delete "${BACKUP_API}/" "${REMOTE_API}/"
echo " API restored from ${BACKUP_API}"
RESTORED=true
fi
if [[ "\$RESTORED" == "true" ]]; then
nginx -t && systemctl reload nginx
systemctl restart quinn-admin-api
echo " Services reloaded."
fi
ENDSSH
echo " Rollback complete — admin is on the previous release."
else
echo " No backups were created — nothing to roll back."
fi
exit "$exit_code"
}
trap rollback_on_error ERR
# ---------------------------------------------------------------------------
# [1/10] Build + [2.5/10] Tests
# Skipped when --skip-build: artifacts pre-built and tests already ran in CI
# ---------------------------------------------------------------------------
if [[ "$SKIP_BUILD" == false ]]; then
# Auto-bump patch version with deploy timestamp: 0.1.2-20260412_223433
_ver=$(cat "${REPO_ROOT}/VERSION.txt" | head -1)
_base=${_ver%%-*}
_major=${_base%%.*}; _rest=${_base#*.}; _minor=${_rest%%.*}; _patch=${_rest#*.}
echo "${_major}.${_minor}.$((_patch + 1))-${TIMESTAMP}" > "${REPO_ROOT}/VERSION.txt"
echo "==> [1/10] Building admin frontend..."
cd "$REPO_ROOT/codebase/@features/admin/frontend-public" && bun run build
echo "==> [1b/10] Building newsletter frontend..."
cd "$REPO_ROOT/codebase/@features/comm-newsletter/frontend-admin" && bun run build
echo "==> [2/10] Type-checking + bundling @features/api monolith..."
cd "$REPO_ROOT/codebase/@features/api"
bun run typecheck
# Build stamp injected into the bundle so GET /health reports exactly which
# build is live (version, monotonic build count, git sha, UTC build time).
_bi_ver="$(cat "${REPO_ROOT}/VERSION.txt" | head -1)"
_bi_bc="$(cat "${REPO_ROOT}/BUILD_COUNT" 2>/dev/null | head -1)"
_bi_sha="$(git -C "$REPO_ROOT" rev-parse --short HEAD 2>/dev/null || echo unknown)"
_bi_time="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
bun build --target=node --external better-sqlite3 --external sharp \
--define "__BUILD_INFO__={\"version\":\"${_bi_ver}\",\"buildCount\":\"${_bi_bc}\",\"sha\":\"${_bi_sha}\",\"builtAt\":\"${_bi_time}\"}" \
src/app/server.ts --outfile="${API_BUNDLE}" --sourcemap=none
cd "$SCRIPT_DIR"
echo "==> [2b/10] Type-checking + bundling newsletter API..."
cd "$REPO_ROOT/codebase/@features/comm-newsletter/backend-api" && bun run typecheck && bun run build:prod
cd "$SCRIPT_DIR"
echo "==> [2.5/10] Running @features/api unit tests..."
cd "$REPO_ROOT/codebase/@features/api"
if ! bun run test 2>&1; then
echo "ERROR: @features/api unit tests FAILED — deploy aborted." >&2
exit 1
fi
echo " Unit tests passed."
cd "$SCRIPT_DIR"
if [[ "$SKIP_E2E" == "true" ]]; then
echo "==> [2.6/10] Skipping admin E2E smoke tests (--skip-e2e)."
else
echo "==> [2.6/10] Running admin E2E smoke tests..."
echo " (ephemeral DB + dev test env — not VPS secrets from step [9/10]; use --skip-e2e to bypass)"
cd "$SCRIPT_DIR"
if ! bun run test:e2e 2>&1; then
echo "ERROR: Admin E2E smoke tests FAILED — deploy aborted." >&2
exit 1
fi
echo " Smoke tests passed."
cd "$SCRIPT_DIR"
fi
fi
# ---------------------------------------------------------------------------
# [3/10] Backup current dist and API on remote before deploying
# ---------------------------------------------------------------------------
echo "==> [3/10] Backing up current dist and API on ${REMOTE}..."
ssh "$REMOTE" bash -euo pipefail <<ENDSSH
set -euo pipefail
mkdir -p "${REMOTE_BACKUPS_DIST}" "${REMOTE_BACKUPS_API}"
if [[ -d "${REMOTE_DIST}" && -n "\$(ls -A '${REMOTE_DIST}' 2>/dev/null)" ]]; then
rsync -a "${REMOTE_DIST}/" "${BACKUP_DIST}/"
echo " dist backup: ${BACKUP_DIST}"
find "${REMOTE_BACKUPS_DIST}" -maxdepth 1 -mindepth 1 -type d -mtime +7 -exec rm -rf {} + 2>/dev/null || true
else
echo " No existing dist — first deploy."
fi
if [[ -d "${REMOTE_API}" && -n "\$(ls -A '${REMOTE_API}' 2>/dev/null)" ]]; then
rsync -a --exclude='node_modules' "${REMOTE_API}/" "${BACKUP_API}/"
echo " API backup: ${BACKUP_API}"
find "${REMOTE_BACKUPS_API}" -maxdepth 1 -mindepth 1 -type d -mtime +7 -exec rm -rf {} + 2>/dev/null || true
else
echo " No existing API — first deploy."
fi
ENDSSH
BACKUPS_CREATED=true
# ---------------------------------------------------------------------------
# [4/10] Deploy frontend dist
# ---------------------------------------------------------------------------
echo "==> [4/10] Deploying admin frontend dist/ to ${REMOTE}..."
rsync -avz --delete "$REPO_ROOT/codebase/@features/admin/frontend-public/dist/" "${REMOTE}:${REMOTE_DIST}/"
echo "==> [4b/10] Deploying newsletter frontend dist/ to ${REMOTE}..."
ssh "$REMOTE" "mkdir -p /var/www/quinn.admin/newsletter/dist"
rsync -avz --delete "$REPO_ROOT/codebase/@features/comm-newsletter/frontend-admin/dist/" "${REMOTE}:/var/www/quinn.admin/newsletter/dist/"
# ---------------------------------------------------------------------------
# [4c/10] Deploy admin gallery photo store
# The admin backend serves /admin/photos/files/* from REMOTE_PHOTOS (its
# PHOTOS_DIR); nginx rewrites /photos/* → /admin/photos/files/*. This set is
# the DESCRIPTIVE-named gallery photos (matching the gallery_items DB) plus the
# adversary/ visualization trees — distinct from the public quinn.www hash-named
# set, so it lives in its own dir and the public vhost is unaffected.
# Additive (NO --delete): repo photos are authoring source-of-truth; never prune
# the serving copy out from under live admin traffic.
# ---------------------------------------------------------------------------
if [[ -d "$PHOTOS_SRC" ]]; then
echo "==> [4c/10] Deploying admin gallery photos to ${REMOTE}:${REMOTE_PHOTOS}..."
ssh "$REMOTE" "install -d -o www-data -g www-data ${REMOTE_PHOTOS}"
rsync -avz "${PHOTOS_SRC}/" "${REMOTE}:${REMOTE_PHOTOS}/"
ssh "$REMOTE" "chown -R www-data:www-data ${REMOTE_PHOTOS}"
else
echo "==> [4c/10] WARNING: ${PHOTOS_SRC} not found on this host — skipping admin gallery photo sync." >&2
echo " Admin gallery thumbnails + variant lenses will 404 until the photo set is present." >&2
fi
# ---------------------------------------------------------------------------
# [4d/10] Deploy clean masters (originals) for ?master=1 downloads
# PROTECT_PHOTOS_DIR=/var/www/quinn.admin/originals in the service unit.
# CRITICAL security properties:
# - NOT reachable via any nginx static root/location (only SSO-gated backend reads it)
# - www-data readable (service runs as www-data)
# - Additive (NO --delete): never prune originals out from under live traffic
# ---------------------------------------------------------------------------
if [[ -d "$ORIGINALS_SRC" ]]; then
echo "==> [4d/10] Deploying clean masters to ${REMOTE}:${REMOTE_ORIGINALS}..."
ssh "$REMOTE" "install -d -o www-data -g www-data -m 750 ${REMOTE_ORIGINALS}"
rsync -avz "${ORIGINALS_SRC}/" "${REMOTE}:${REMOTE_ORIGINALS}/"
ssh "$REMOTE" "chown -R www-data:www-data ${REMOTE_ORIGINALS} && chmod -R 640 ${REMOTE_ORIGINALS}/*"
echo " Clean masters synced. Verify nginx has NO static location for ${REMOTE_ORIGINALS}."
else
echo "==> [4d/10] WARNING: ${ORIGINALS_SRC} not found on this host — skipping originals sync." >&2
echo " ?master=1 downloads will fall back to PHOTOS_DIR (colorswapped versions)." >&2
fi
# ---------------------------------------------------------------------------
# [5/10] Deploy @features/api monolith bundle
# ---------------------------------------------------------------------------
echo "==> [5/10] Deploying @features/api monolith bundle to ${REMOTE}:${REMOTE_API}..."
# Only ship the compiled bundle — no sources, no node_modules (synced below).
ssh "$REMOTE" "mkdir -p ${REMOTE_API}/dist"
rsync -avz \
"$REPO_ROOT/codebase/@features/api/${API_BUNDLE}" "${REMOTE}:${REMOTE_API}/${API_BUNDLE}"
# ---------------------------------------------------------------------------
# [5b/10] Deploy newsletter API bundle
# ---------------------------------------------------------------------------
echo "==> [5b/10] Deploying newsletter API bundle to ${REMOTE}..."
ssh "$REMOTE" "mkdir -p /opt/quinn-newsletter-api"
rsync -avz --delete \
--exclude='src' \
--exclude='tsconfig.json' \
--exclude='node_modules' \
--exclude='.env' \
--exclude='data' \
"$REPO_ROOT/codebase/@features/comm-newsletter/backend-api/" "${REMOTE}:/opt/quinn-newsletter-api/"
# ---------------------------------------------------------------------------
# [6/10] Install production dependencies on remote (native modules like sharp)
# ---------------------------------------------------------------------------
echo "==> [6/10] Installing @features/api deps locally + syncing node_modules to ${REMOTE}..."
# VPS has no public route to Forgejo/Verdaccio, so resolve @lilith deps here
# using the local .npmrc, then rsync the built node_modules over. Same
# pattern as quinn.api/deploy.sh — keeps the VPS registry-agnostic.
ADMIN_DEPS_DIR="$(mktemp -d)"
# Strip workspace:* deps — they are inlined by `bun build` into server.node.js,
# so node_modules at runtime never needs them. npm install refuses workspace:
# protocol outside a real workspace context, hence the strip.
node -e '
const fs = require("fs");
const pkg = JSON.parse(fs.readFileSync(process.argv[1], "utf8"));
for (const section of ["dependencies", "devDependencies", "peerDependencies", "optionalDependencies"]) {
if (!pkg[section]) continue;
for (const [name, ver] of Object.entries(pkg[section])) {
if (typeof ver === "string" && ver.startsWith("workspace:")) delete pkg[section][name];
}
}
fs.writeFileSync(process.argv[2], JSON.stringify(pkg, null, 2));
' "$REPO_ROOT/codebase/@features/api/package.json" "$ADMIN_DEPS_DIR/package.json"
echo '@lilith:registry=http://134.199.243.61:4873/' > "$ADMIN_DEPS_DIR/.npmrc"
(cd "$ADMIN_DEPS_DIR" && npm install --omit=dev --legacy-peer-deps 2>&1 | tail -3)
rsync -avz --delete "$ADMIN_DEPS_DIR/node_modules/" "${REMOTE}:${REMOTE_API}/node_modules/"
rm -rf "${ADMIN_DEPS_DIR:-/nonexistent}"
echo "==> [6b/10] Installing newsletter API dependencies locally + syncing to ${REMOTE}..."
NL_DEPS_DIR="$(mktemp -d)"
node -e '
const fs = require("fs");
const pkg = JSON.parse(fs.readFileSync(process.argv[1], "utf8"));
for (const section of ["dependencies", "devDependencies", "peerDependencies", "optionalDependencies"]) {
if (!pkg[section]) continue;
for (const [name, ver] of Object.entries(pkg[section])) {
if (typeof ver === "string" && ver.startsWith("workspace:")) delete pkg[section][name];
}
}
fs.writeFileSync(process.argv[2], JSON.stringify(pkg, null, 2));
' "$REPO_ROOT/codebase/@features/comm-newsletter/backend-api/package.json" "$NL_DEPS_DIR/package.json"
echo '@lilith:registry=http://134.199.243.61:4873/' > "$NL_DEPS_DIR/.npmrc"
(cd "$NL_DEPS_DIR" && npm install --omit=dev --legacy-peer-deps 2>&1 | tail -3)
rsync -avz --delete "$NL_DEPS_DIR/node_modules/" "${REMOTE}:/opt/quinn-newsletter-api/node_modules/"
rm -rf "${NL_DEPS_DIR:-/nonexistent}"
# ---------------------------------------------------------------------------
# [7/10] nginx: prod.conf
# ---------------------------------------------------------------------------
echo "==> [7/10] Syncing nginx prod.conf (admin.transquinnftw.com)..."
scp "$SCRIPT_DIR/nginx/prod.conf" "${REMOTE}:/etc/nginx/sites-available/admin.transquinnftw.com"
ssh "$REMOTE" "ln -sf /etc/nginx/sites-available/admin.transquinnftw.com /etc/nginx/sites-enabled/admin.transquinnftw.com 2>/dev/null || true"
# ---------------------------------------------------------------------------
# [8/10] nginx test + reload
# ---------------------------------------------------------------------------
echo "==> [8/10] Testing and reloading nginx..."
ssh "$REMOTE" "nginx -t && systemctl reload nginx"
# ---------------------------------------------------------------------------
# [9/10] Sync systemd service files + restart services
# ---------------------------------------------------------------------------
echo "==> [9/10] Syncing systemd service files and restarting services..."
scp "$SCRIPT_DIR/scripts/quinn-admin-api.service" "${REMOTE}:/etc/systemd/system/quinn-admin-api.service"
scp "$SCRIPT_DIR/scripts/quinn-newsletter-api.service" "${REMOTE}:/etc/systemd/system/quinn-newsletter-api.service"
# Provision secrets file for newsletter if it doesn't exist yet
ssh "$REMOTE" bash -euo pipefail <<'ENDSSH'
if [[ ! -f /etc/quinn-newsletter-api/secrets.env ]]; then
mkdir -p /etc/quinn-newsletter-api
# Copy JWT_SECRET from admin secrets so the shared cookie works
JWT_SECRET="$(grep '^JWT_SECRET=' /etc/quinn-admin-api/secrets.env | cut -d= -f2-)"
printf 'JWT_SECRET=%s\nSMTP_HOST=localhost\nSMTP_PORT=587\nSMTP_REQUIRE_TLS=false\n' "$JWT_SECRET" \
> /etc/quinn-newsletter-api/secrets.env
chmod 600 /etc/quinn-newsletter-api/secrets.env
chown root:root /etc/quinn-newsletter-api/secrets.env
echo " newsletter secrets.env provisioned."
else
echo " newsletter secrets.env already exists — skipping."
fi
ENDSSH
# Provision mail-admin SSH access: www-data key → root@127.0.0.1 → docker
ssh "$REMOTE" bash -euo pipefail <<'ENDSSH'
# root can always run docker — no group add needed
# Create www-data SSH directory
install -d -m 700 -o www-data -g www-data /var/www/.ssh
# Generate ed25519 key for www-data if not present
if [[ ! -f /var/www/.ssh/id_ed25519 ]]; then
ssh-keygen -t ed25519 -N '' -C 'www-data@quinn-vps-mailcmd' \
-f /var/www/.ssh/id_ed25519
chown www-data:www-data /var/www/.ssh/id_ed25519 /var/www/.ssh/id_ed25519.pub
echo " www-data SSH key generated."
else
echo " www-data SSH key already exists — skipping keygen."
fi
# Add www-data pubkey to root's authorized_keys (idempotent)
PUBKEY="$(cat /var/www/.ssh/id_ed25519.pub)"
install -d -m 700 -o root -g root /root/.ssh
touch /root/.ssh/authorized_keys
chmod 600 /root/.ssh/authorized_keys
chown root:root /root/.ssh/authorized_keys
if ! grep -qF "$PUBKEY" /root/.ssh/authorized_keys; then
# Ensure the file ends with a newline before appending to avoid key concatenation
[[ -s /root/.ssh/authorized_keys ]] && tail -c1 /root/.ssh/authorized_keys | grep -qP '[^\n]' && echo "" >> /root/.ssh/authorized_keys
echo "$PUBKEY" >> /root/.ssh/authorized_keys
echo " www-data pubkey added to root authorized_keys."
else
echo " www-data pubkey already in root authorized_keys — skipping."
fi
# Pre-populate www-data known_hosts so BatchMode=yes doesn't reject localhost
ssh-keyscan -H 127.0.0.1 2>/dev/null > /var/www/.ssh/known_hosts
chown www-data:www-data /var/www/.ssh/known_hosts
chmod 600 /var/www/.ssh/known_hosts
# QUINN_MY_SERVICE_TOKEN — sourced from SSO secrets (the vps distribution point for
# the plum-canonical token), NOT generated here. Admin self-generation was a
# split-brain source: it could mint a value that disagreed with my-api. Re-synced
# every deploy so a plum rotation (applied via quinn.sso deploy) propagates here too.
SECRETS=/etc/quinn-admin-api/secrets.env
SSO_SECRETS=/etc/quinn-sso-api/secrets.env
MY_SVC_TOKEN="$(grep '^QUINN_MY_SERVICE_TOKEN=' "$SSO_SECRETS" 2>/dev/null | cut -d= -f2- || true)"
if [[ -z "$MY_SVC_TOKEN" ]]; then
echo "ERROR: QUINN_MY_SERVICE_TOKEN missing from $SSO_SECRETS — deploy quinn.sso first." >&2
exit 1
fi
if grep -q '^QUINN_MY_SERVICE_TOKEN=' "$SECRETS"; then
sed -i "s|^QUINN_MY_SERVICE_TOKEN=.*|QUINN_MY_SERVICE_TOKEN=${MY_SVC_TOKEN}|" "$SECRETS"
echo " QUINN_MY_SERVICE_TOKEN re-synced from SSO secrets."
else
echo "QUINN_MY_SERVICE_TOKEN=${MY_SVC_TOKEN}" >> "$SECRETS"
echo " QUINN_MY_SERVICE_TOKEN added from SSO secrets."
fi
chmod 600 "$SECRETS"
# SERVICE_TOKEN is required for service-to-service auth (@features/api monolith).
# It must be provisioned manually in /etc/quinn-admin-api/secrets.env — this
# deploy script will not generate it automatically to avoid silent token churn.
if ! grep -q '^SERVICE_TOKEN=' "$SECRETS"; then
echo "ERROR: SERVICE_TOKEN is missing from ${SECRETS} on $(hostname)." >&2
echo " Add it manually: echo 'SERVICE_TOKEN=<token>' >> ${SECRETS}" >&2
echo " Generate a token with: openssl rand -hex 32" >&2
exit 1
fi
echo " SERVICE_TOKEN present in secrets.env."
# QUINN_DB_URL is required for the @features/api monolith (Postgres).
if ! grep -q '^QUINN_DB_URL=' "$SECRETS"; then
echo "ERROR: QUINN_DB_URL is missing from ${SECRETS} on $(hostname)." >&2
echo " Add it manually: echo 'QUINN_DB_URL=postgres://...' >> ${SECRETS}" >&2
exit 1
fi
echo " QUINN_DB_URL present in secrets.env."
# QUINN_MACSYNC_DB_URL is required for the @features/api monolith.
if ! grep -q '^QUINN_MACSYNC_DB_URL=' "$SECRETS"; then
echo "ERROR: QUINN_MACSYNC_DB_URL is missing from ${SECRETS} on $(hostname)." >&2
echo " Add it manually: echo 'QUINN_MACSYNC_DB_URL=postgres://...' >> ${SECRETS}" >&2
exit 1
fi
echo " QUINN_MACSYNC_DB_URL present in secrets.env."
ENDSSH
ssh "$REMOTE" "systemctl daemon-reload && systemctl enable quinn-admin-api quinn-newsletter-api && systemctl restart quinn-admin-api quinn-newsletter-api"
# ---------------------------------------------------------------------------
# [10/10] Health check — abort on failure (does not echo WARN)
# ---------------------------------------------------------------------------
echo "==> [10/10] Verifying admin site is live..."
sleep 5
if ! curl -sf -A 'Mozilla/5.0' https://admin.transquinnftw.com/health > /dev/null; then
echo "ERROR: health check failed — https://admin.transquinnftw.com/health did not respond." >&2
exit 1
fi
echo " Health check passed."
echo ""
echo "Deployed quinn.admin successfully at $(date '+%Y-%m-%d %H:%M:%S %Z')"
echo "To roll back: bash $SCRIPT_DIR/deploy.sh --rollback"