#!/usr/bin/env bash set -euo pipefail ROOT="$(cd "$(dirname "$0")/.." && pwd)" HOST="${STUDIO_HOST:-10.10.0.2}" GRAY_WEB_PORT="${GRAY_WEB_PORT:-15174}" GRAY_API_PORT="${GRAY_API_PORT:-18085}" REMOTE_GRAY_ROOT="${REMOTE_GRAY_ROOT:-/Users/john/Project/gray/memind_adm}" CANDIDATE_DIR="" AUTO_YES=0 usage() { cat <<'EOF' Usage: bash scripts/deploy-gray-103.sh --candidate-dir DIR [--yes] Deploys a loopback-only, read-only memind_adm gray instance on 103. It does not replace /Users/john/Project/memind_adm and does not restart live ports 5174/8085. Defaults: gray web: 127.0.0.1:15174/ops/ gray API: 127.0.0.1:18085 The --yes flag is mandatory because this writes a separate gray release to 103. EOF } while [[ $# -gt 0 ]]; do case "$1" in --candidate-dir) shift [[ $# -gt 0 ]] || { echo "--candidate-dir requires a directory" >&2; exit 2; } CANDIDATE_DIR="$1" ;; --yes|-y) AUTO_YES=1 ;; -h|--help) usage exit 0 ;; *) echo "Unknown argument: $1" >&2 usage >&2 exit 2 ;; esac shift done [[ -n "${CANDIDATE_DIR}" ]] || { usage >&2; exit 2; } if [[ "${CANDIDATE_DIR}" != /* ]]; then CANDIDATE_DIR="${ROOT}/${CANDIDATE_DIR}" fi [[ -d "${CANDIDATE_DIR}" ]] || { echo "Candidate directory not found: ${CANDIDATE_DIR}" >&2; exit 1; } manifest="$(find "${CANDIDATE_DIR}" -maxdepth 1 -type f -name '*.manifest.txt' -print -quit)" sha_file="$(find "${CANDIDATE_DIR}" -maxdepth 1 -type f -name '*.sha256' -print -quit)" bundle="$(find "${CANDIDATE_DIR}" -maxdepth 1 -type f -name '*.tar.gz' -print -quit)" [[ -n "${manifest}" && -n "${sha_file}" && -n "${bundle}" ]] || { echo "Candidate bundle, manifest, or checksum file missing" >&2 exit 1 } release_id="$(sed -n 's/^release_id=//p' "${manifest}" | head -n 1)" [[ "${release_id}" =~ ^[A-Za-z0-9._-]+$ ]] || { echo "Invalid release_id in manifest: ${release_id}" >&2 exit 1 } bash "${ROOT}/scripts/check-gray-ready.sh" --candidate-dir "${CANDIDATE_DIR}" if [[ "${AUTO_YES}" -ne 1 ]]; then echo echo "Gray deployment is ready but was not started." echo "Target: ${HOST} ${REMOTE_GRAY_ROOT}/releases/${release_id}" echo "Ports: 127.0.0.1:${GRAY_WEB_PORT}, 127.0.0.1:${GRAY_API_PORT}" echo "Re-run with --yes after explicit approval." exit 2 fi remote_incoming="${REMOTE_GRAY_ROOT}/incoming/${release_id}" ssh -o BatchMode=yes -o ConnectTimeout=15 "${HOST}" "mkdir -p '${remote_incoming}'" scp -q "${bundle}" "${manifest}" "${sha_file}" "${HOST}:${remote_incoming}/" ssh -o BatchMode=yes "${HOST}" \ "RELEASE_ID='${release_id}' GRAY_WEB_PORT='${GRAY_WEB_PORT}' GRAY_API_PORT='${GRAY_API_PORT}' REMOTE_GRAY_ROOT='${REMOTE_GRAY_ROOT}' /bin/bash" <<'REMOTE' set -euo pipefail LIVE_DIR=/Users/john/Project/memind_adm INCOMING_DIR="${REMOTE_GRAY_ROOT}/incoming/${RELEASE_ID}" RELEASE_DIR="${REMOTE_GRAY_ROOT}/releases/${RELEASE_ID}" BUNDLE="${INCOMING_DIR}/memind-adm-${RELEASE_ID}.tar.gz" MANIFEST="${INCOMING_DIR}/memind-adm-${RELEASE_ID}.manifest.txt" SHA_FILE="${INCOMING_DIR}/memind-adm-${RELEASE_ID}.sha256" stop_gray_ports() { lsof -ti "TCP:${GRAY_WEB_PORT}" -sTCP:LISTEN 2>/dev/null | xargs kill 2>/dev/null || true lsof -ti "TCP:${GRAY_API_PORT}" -sTCP:LISTEN 2>/dev/null | xargs kill 2>/dev/null || true } rollback_gray() { stop_gray_ports echo "Gray startup failed; live memind_adm was not changed." >&2 } trap rollback_gray ERR live_api_before="$(curl -sS -o /dev/null -w '%{http_code}' http://127.0.0.1:8085/health || true)" live_web_before="$(curl -sS -o /dev/null -w '%{http_code}' http://127.0.0.1:5174/ops/ || true)" [[ "${live_api_before}" == "200" && "${live_web_before}" == "200" ]] || { echo "Live memind_adm is not healthy before gray deployment" >&2 exit 1 } for port in "${GRAY_WEB_PORT}" "${GRAY_API_PORT}"; do if lsof -nP -iTCP:"${port}" -sTCP:LISTEN -t | grep -q .; then echo "Gray port is already in use: ${port}" >&2 exit 1 fi done cd "${INCOMING_DIR}" shasum -a 256 -c "$(basename "${SHA_FILE}")" rm -rf "${RELEASE_DIR}" mkdir -p "${RELEASE_DIR}" tar -xzf "${BUNDLE}" -C "${RELEASE_DIR}" cp "${MANIFEST}" "${RELEASE_DIR}/.release-manifest.txt" [[ -f "${LIVE_DIR}/.env" ]] || { echo "Live .env is missing" >&2; exit 1; } cp "${LIVE_DIR}/.env" "${RELEASE_DIR}/.env" chmod 600 "${RELEASE_DIR}/.env" [[ -d "${RELEASE_DIR}/dist" ]] || { echo "Gray release missing dist/" >&2; exit 1; } [[ ! -d "${RELEASE_DIR}/src" ]] || { echo "Gray release contains src/" >&2; exit 1; } [[ -f "${RELEASE_DIR}/server/index.mjs" ]] || { echo "Gray release missing Admin API" >&2; exit 1; } export PATH="/opt/homebrew/bin:/opt/homebrew/opt/node@24/bin:/opt/homebrew/opt/node@22/bin:/usr/local/bin:/usr/bin:/bin:${PATH}" cd "${RELEASE_DIR}" if [[ -f package-lock.json ]]; then npm ci --no-audit --no-fund else npm install --no-audit --no-fund fi set -a # shellcheck disable=SC1091 source "${RELEASE_DIR}/.env" set +a export ADM_API_PORT="${GRAY_API_PORT}" export ADM_PORT="${GRAY_WEB_PORT}" export ADM_API_HOST=127.0.0.1 export ADM_WEB_HOST=127.0.0.1 export ADM_DEV_BACKEND="http://127.0.0.1:${GRAY_API_PORT}" export VITE_BASE_PATH=/ops export ADM_READ_ONLY_MODE=1 nohup node server/index.mjs >>"${RELEASE_DIR}/adm-gray-api.log" 2>&1 & echo $! >"${RELEASE_DIR}/.adm-gray-api.pid" api_ready=0 for _ in $(seq 1 30); do if curl -fsS "http://127.0.0.1:${GRAY_API_PORT}/health" >/dev/null; then api_ready=1 break fi sleep 1 done [[ "${api_ready}" == "1" ]] || { tail -n 80 "${RELEASE_DIR}/adm-gray-api.log" >&2 || true; exit 1; } nohup ./node_modules/.bin/vite preview --config scripts/vite-preview.config.mjs \ >>"${RELEASE_DIR}/adm-gray-web.log" 2>&1 & echo $! >"${RELEASE_DIR}/.adm-gray-web.pid" web_ready=0 for _ in $(seq 1 30); do if curl -fsS "http://127.0.0.1:${GRAY_WEB_PORT}/ops/" >/dev/null; then web_ready=1 break fi sleep 1 done [[ "${web_ready}" == "1" ]] || { tail -n 80 "${RELEASE_DIR}/adm-gray-web.log" >&2 || true; exit 1; } live_api_after="$(curl -sS -o /dev/null -w '%{http_code}' http://127.0.0.1:8085/health || true)" live_web_after="$(curl -sS -o /dev/null -w '%{http_code}' http://127.0.0.1:5174/ops/ || true)" [[ "${live_api_after}" == "200" && "${live_web_after}" == "200" ]] || { echo "Live memind_adm health changed during gray startup" >&2 exit 1 } cat >"${RELEASE_DIR}/GRAY_DEPLOYMENT.txt" <