#!/usr/bin/env bash # Manage the complete MercadoDeVida monolith on one development/LAN host. # Usage: ./scripts/monolith.sh set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" PROJECT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)" MODE="${1:-}" ACTION="${2:-}" RUNTIME_ROOT="${MDV_RUNTIME_DIR:-$PROJECT_DIR/.runtime}" RUNTIME_DIR="$RUNTIME_ROOT/$MODE" BACKEND_PORT="${BACKEND_PORT:-3000}" ADMIN_PORT="${ADMIN_PORT:-3001}" TPV_PORT="${TPV_PORT:-3002}" FRONTEND_PORT="${FRONTEND_PORT:-3003}" STOREFRONT_PORT="${STOREFRONT_PORT:-3004}" START_TIMEOUT="${START_TIMEOUT:-90}" WATCH_INTERVAL="${WATCH_INTERVAL:-5}" SERVICES=(backend admin tpv frontend storefront) usage() { cat <<'EOF' Usage: ./scripts/monolith.sh Commands: start Install/build when needed, migrate, and start every service restart Stop managed processes, then start every service status Show PID, process state, HTTP status, and URLs check Verify all services are responding (exit 0 if all OK) stop Gracefully stop every managed HTTP process logs Follow all service logs (Ctrl-C exits without stopping services) urls Print localhost and LAN URLs watch Auto-respawn any dead service every WATCH_INTERVAL seconds Environment overrides: LAN_IP, BACKEND_PORT, ADMIN_PORT, TPV_PORT, FRONTEND_PORT, STOREFRONT_PORT MDV_RUNTIME_DIR, START_TIMEOUT, WATCH_INTERVAL EOF } if [[ "$MODE" != "dev" && "$MODE" != "prod" ]]; then usage >&2 exit 2 fi case "$ACTION" in start|restart|status|check|stop|logs|urls|watch) ;; *) usage >&2; exit 2 ;; esac mkdir -p "$RUNTIME_DIR" lan_ip() { if [[ -n "${LAN_IP:-}" ]]; then printf '%s\n' "$LAN_IP" return fi local iface ip if command -v route >/dev/null 2>&1 && command -v ipconfig >/dev/null 2>&1; then iface="$(route -n get default 2>/dev/null | awk '/interface:/{print $2; exit}')" if [[ -n "$iface" ]]; then ip="$(ipconfig getifaddr "$iface" 2>/dev/null || true)" if [[ -n "$ip" ]]; then printf '%s\n' "$ip"; return; fi fi fi if command -v hostname >/dev/null 2>&1; then ip="$(hostname -I 2>/dev/null | awk '{print $1}' || true)" if [[ -n "$ip" ]]; then printf '%s\n' "$ip"; return; fi fi printf '127.0.0.1\n' } LAN_ADDRESS="$(lan_ip)" API_PUBLIC_URL="${API_PUBLIC_URL:-http://$LAN_ADDRESS:$BACKEND_PORT}" service_port() { case "$1" in backend) echo "$BACKEND_PORT" ;; admin) echo "$ADMIN_PORT" ;; tpv) echo "$TPV_PORT" ;; frontend) echo "$FRONTEND_PORT" ;; storefront) echo "$STOREFRONT_PORT" ;; esac } service_path() { case "$1" in backend) echo "/health" ;; *) echo "/" ;; esac } service_url() { local service="$1" host="${2:-127.0.0.1}" printf 'http://%s:%s%s\n' "$host" "$(service_port "$service")" "$(service_path "$service")" } service_domain() { # Returns the subdomain for Traefik/production access case "$1" in backend) echo "api-mv.rikrdo.com" ;; admin) echo "admin-mv.rikrdo.com" ;; tpv) echo "tpv-mv.rikrdo.com" ;; frontend) echo "shop-mv.rikrdo.com" ;; storefront) echo "seo-mv.rikrdo.com" ;; esac } pid_file() { printf '%s/%s.pid\n' "$RUNTIME_DIR" "$1"; } log_file() { printf '%s/%s.log\n' "$RUNTIME_DIR" "$1"; } read_pid() { local file file="$(pid_file "$1")" [[ -f "$file" ]] && tr -dc '0-9' < "$file" || true } is_running() { local pid pid="$(read_pid "$1")" [[ -n "$pid" ]] && kill -0 "$pid" 2>/dev/null } port_pid() { local port="$1" if command -v lsof >/dev/null 2>&1; then lsof -nP -tiTCP:"$port" -sTCP:LISTEN 2>/dev/null | head -1 || true fi } port_pids() { local port="$1" if command -v lsof >/dev/null 2>&1; then lsof -nP -tiTCP:"$port" -sTCP:LISTEN 2>/dev/null | sort -u || true fi } stop_pid() { local pid="$1" label="$2" deadline [[ -n "$pid" ]] || return 0 if ! kill -0 "$pid" 2>/dev/null; then return 0 fi echo "[INFO] Stopping $label (PID $pid)..." kill -TERM "$pid" 2>/dev/null || true deadline=$((SECONDS + 15)) while kill -0 "$pid" 2>/dev/null && (( SECONDS < deadline )); do sleep 1; done if kill -0 "$pid" 2>/dev/null; then echo "[WARN] $label did not stop gracefully; sending KILL" kill -KILL "$pid" 2>/dev/null || true fi } stop_service_port_listeners() { local service="$1" port pid found=0 port="$(service_port "$service")" while IFS= read -r pid; do [[ -n "$pid" ]] || continue found=1 stop_pid "$pid" "stale $service listener on port $port" done < <(port_pids "$port") if [[ "$found" == 1 ]]; then rm -f "$(pid_file "$service")" fi } stop_stale_project_processes() { local pid cmd while IFS= read -r line; do pid="${line%% *}" cmd="${line#* }" [[ -n "$pid" && "$pid" =~ ^[0-9]+$ ]] || continue [[ "$pid" == "$$" || "$pid" == "$BASHPID" ]] && continue case "$cmd" in *"$PROJECT_DIR"*"tsx"*"watch src/infrastructure/http/server.ts"*|\ *"$PROJECT_DIR"*"node_modules/tsx/dist/cli.mjs watch src/infrastructure/http/server.ts"*|\ *"$PROJECT_DIR"*"next start"*|\ *"$PROJECT_DIR"*"node_modules/next/dist/bin/next start"*) stop_pid "$pid" "stale project process" ;; esac done < <(ps -axo pid=,command= 2>/dev/null | sed 's/^ *//') } clear_stale_deployments() { echo '[INFO] Clearing old service deployments from managed ports...' stop_stale_project_processes local service for service in "${SERVICES[@]}"; do stop_service_port_listeners "$service" done } assert_port_available() { local service="$1" port existing managed port="$(service_port "$service")" existing="$(port_pid "$port")" managed="$(read_pid "$service")" if [[ -n "$existing" && "$existing" != "$managed" ]]; then echo "[FAIL] $service port $port is already used by unmanaged PID $existing" >&2 echo " Stop it explicitly before retrying; this script never broad-kills processes." >&2 return 1 fi } wait_http() { local service="$1" url deadline code url="$(service_url "$service")" deadline=$((SECONDS + START_TIMEOUT)) while (( SECONDS < deadline )); do if ! is_running "$service"; then echo "[FAIL] $service exited during startup. Last log lines:" >&2 tail -40 "$(log_file "$service")" >&2 || true return 1 fi code="$(curl --max-time 3 -sS -o /dev/null -w '%{http_code}' "$url" 2>/dev/null || true)" if [[ "$code" =~ ^[23] ]]; then echo "[OK] $service ready ($code) — $url" return 0 fi sleep 1 done echo "[FAIL] $service did not become healthy at $url in ${START_TIMEOUT}s" >&2 tail -40 "$(log_file "$service")" >&2 || true return 1 } ensure_env() { if [[ ! -f "$PROJECT_DIR/.env" ]]; then cat > "$PROJECT_DIR/.env" <<'EOF' DATABASE_URL=postgres://mdv:mdv_dev_only@localhost:5432/mercadodevida REDIS_URL=redis://localhost:6379 COOKIE_SECURE=false EOF echo "[INFO] Created project/.env for local development" fi } start_infrastructure() { command -v docker >/dev/null 2>&1 || { echo '[FAIL] Docker is required' >&2; exit 1; } echo '[INFO] Starting PostgreSQL and Redis...' # Always go through Compose instead of plain `docker start` so changes such # as Redis AOF or explicit volume names are reconciled without losing named # volumes. Compose recreates containers when needed but keeps # project_mdv_pg_data/project_mdv_redis_data attached. (cd "$PROJECT_DIR" && docker compose up -d postgres redis) local deadline=$((SECONDS + 60)) until docker exec mdv-dev-postgres pg_isready -U mdv -d mercadodevida >/dev/null 2>&1; do (( SECONDS < deadline )) || { echo '[FAIL] PostgreSQL did not become ready' >&2; exit 1; } sleep 1 done until docker exec mdv-dev-redis redis-cli ping >/dev/null 2>&1; do (( SECONDS < deadline )) || { echo '[FAIL] Redis did not become ready' >&2; exit 1; } sleep 1 done } install_dependencies() { local install_command=(npm install) [[ "$MODE" == "prod" ]] && install_command=(npm ci) for dir in "$PROJECT_DIR" "$PROJECT_DIR/apps/admin" "$PROJECT_DIR/apps/pos" "$PROJECT_DIR/frontend" "$PROJECT_DIR/storefront"; do echo "[INFO] ${install_command[*]} — ${dir#$PROJECT_DIR/}" (cd "$dir" && "${install_command[@]}") done } migrate() { echo '[INFO] Applying database migrations...' (cd "$PROJECT_DIR" && node --env-file-if-exists=.env node_modules/node-pg-migrate/bin/node-pg-migrate.js up --migrations-dir migrations) } build_prod() { echo '[INFO] Building backend...' (cd "$PROJECT_DIR" && npm run build) echo '[INFO] Building admin...' (cd "$PROJECT_DIR/apps/admin" && NEXT_PUBLIC_API_URL="$API_PUBLIC_URL" npm run build) echo '[INFO] Building customer frontend...' (cd "$PROJECT_DIR/frontend" && NEXT_PUBLIC_API_URL="$API_PUBLIC_URL" npm run build) echo '[INFO] Building SEO storefront...' (cd "$PROJECT_DIR/storefront" && API_BASE_URL="$API_PUBLIC_URL" NEXT_PUBLIC_API_URL="$API_PUBLIC_URL" npm run build) sync_uploads } sync_uploads() { # Mirror admin's public/uploads to frontend and storefront so that product # images are reachable from any of the three apps. Also mirrors every # generated thumbnail subdir (40/, 200/, ...) so the static cached files # reach all three apps in lockstep. local src="$PROJECT_DIR/apps/admin/public/uploads" local peers=( "$PROJECT_DIR/frontend/public/uploads" "$PROJECT_DIR/storefront/public/uploads" ) if [[ ! -d "$src" ]]; then return 0 fi for peer in "${peers[@]}"; do mkdir -p "$peer" find "$src" -maxdepth 2 -type f -print0 2>/dev/null | while IFS= read -r -d '' f; do local rel rel="${f#$src/}" if [[ ! -f "$peer/$rel" ]]; then mkdir -p "$(dirname "$peer/$rel")" cp "$f" "$peer/$rel" 2>/dev/null || true fi done done # Ensure thumbnails are up to date for the current image set. "$PROJECT_DIR/scripts/generate-thumbnails.sh" --quiet } spawn_service() { local service="$1" dir log pidfile port dir="$PROJECT_DIR" log="$(log_file "$service")" pidfile="$(pid_file "$service")" port="$(service_port "$service")" : > "$log" case "$MODE:$service" in dev:backend) (cd "$dir" && nohup env HOST=0.0.0.0 PORT="$port" NODE_ENV=development node --env-file=.env node_modules/tsx/dist/cli.mjs watch src/infrastructure/http/server.ts >"$log" 2>&1 < /dev/null & disown; echo $! >"$pidfile") ;; prod:backend) (cd "$dir" && nohup env HOST=0.0.0.0 PORT="$port" NODE_ENV=production node --env-file=.env dist/infrastructure/http/server.js >"$log" 2>&1 < /dev/null & disown; echo $! >"$pidfile") ;; dev:admin) (cd "$PROJECT_DIR/apps/admin" && nohup env NEXT_PUBLIC_API_URL="$API_PUBLIC_URL" node node_modules/next/dist/bin/next dev --hostname 0.0.0.0 --port "$port" >"$log" 2>&1 < /dev/null & disown; echo $! >"$pidfile") ;; prod:admin) (cd "$PROJECT_DIR/apps/admin" && nohup env NEXT_PUBLIC_API_URL="$API_PUBLIC_URL" node node_modules/next/dist/bin/next start --hostname 0.0.0.0 --port "$port" >"$log" 2>&1 < /dev/null & disown; echo $! >"$pidfile") ;; dev:tpv) (cd "$PROJECT_DIR/apps/pos" && nohup env NEXT_PUBLIC_API_URL="$API_PUBLIC_URL" node node_modules/next/dist/bin/next dev --hostname 0.0.0.0 --port "$port" >"$log" 2>&1 < /dev/null & disown; echo $! >"$pidfile") ;; prod:tpv) (cd "$PROJECT_DIR/apps/pos" && nohup env NEXT_PUBLIC_API_URL="$API_PUBLIC_URL" node node_modules/next/dist/bin/next start --hostname 0.0.0.0 --port "$port" >"$log" 2>&1 < /dev/null & disown; echo $! >"$pidfile") ;; dev:frontend) (cd "$PROJECT_DIR/frontend" && nohup env NEXT_PUBLIC_API_URL="$API_PUBLIC_URL" node node_modules/next/dist/bin/next dev --hostname 0.0.0.0 --port "$port" >"$log" 2>&1 < /dev/null & disown; echo $! >"$pidfile") ;; prod:frontend) (cd "$PROJECT_DIR/frontend" && nohup env NEXT_PUBLIC_API_URL="$API_PUBLIC_URL" node node_modules/next/dist/bin/next start --hostname 0.0.0.0 --port "$port" >"$log" 2>&1 < /dev/null & disown; echo $! >"$pidfile") ;; dev:storefront) (cd "$PROJECT_DIR/storefront" && nohup env API_BASE_URL="$API_PUBLIC_URL" NEXT_PUBLIC_API_URL="$API_PUBLIC_URL" node node_modules/next/dist/bin/next dev --hostname 0.0.0.0 --port "$port" >"$log" 2>&1 < /dev/null & disown; echo $! >"$pidfile") ;; prod:storefront) (cd "$PROJECT_DIR/storefront" && nohup env API_BASE_URL="$API_PUBLIC_URL" NEXT_PUBLIC_API_URL="$API_PUBLIC_URL" node node_modules/next/dist/bin/next start --hostname 0.0.0.0 --port "$port" >"$log" 2>&1 < /dev/null & disown; echo $! >"$pidfile") ;; esac sleep 1 wait_http "$service" local listener_pid listener_pid="$(port_pid "$port")" if [[ -n "$listener_pid" ]]; then echo "$listener_pid" > "$pidfile" fi } start_all() { ensure_env clear_stale_deployments for service in "${SERVICES[@]}"; do rm -f "$(pid_file "$service")" assert_port_available "$service" done start_infrastructure install_dependencies migrate [[ "$MODE" == "prod" ]] && build_prod sync_uploads for service in "${SERVICES[@]}"; do spawn_service "$service"; done echo print_urls } stop_service() { local service="$1" pid pid="$(read_pid "$service")" stop_pid "$pid" "$service" rm -f "$(pid_file "$service")" } stop_all() { local i for ((i=${#SERVICES[@]}-1; i>=0; i--)); do stop_service "${SERVICES[$i]}"; done } status_all() { ensure_alive silent local service pid state code url lan_url failed=0 printf '%-11s %-8s %-10s %-6s %s\n' SERVICE PID PROCESS HTTP URL for service in "${SERVICES[@]}"; do pid="$(read_pid "$service")" state='stopped' [[ -n "$pid" ]] || pid='-' if [[ "$pid" != '-' ]] && kill -0 "$pid" 2>/dev/null; then state='running'; fi url="$(service_url "$service")" code="$(curl --max-time 3 -sS -o /dev/null -w '%{http_code}' "$url" 2>/dev/null || true)" [[ -n "$code" && "$code" != '000' ]] || code='-' lan_url="$(service_url "$service" "$LAN_ADDRESS")" printf '%-11s %-8s %-10s %-6s %s\n' "$service" "$pid" "$state" "$code" "$lan_url" [[ "$state" == 'running' && "$code" =~ ^[23] ]] || failed=1 done echo (cd "$PROJECT_DIR" && docker compose ps) || true return "$failed" } # ensure_alive [silent|verbose] # Re-spawn any service whose tracked PID is not running. Used by `status` # so an operator who runs `monolith.sh status` always sees live processes # even after a backend crash with no error trace. ensure_alive() { local verbose="${1:-silent}" service pid [[ "$verbose" == "verbose" ]] || verbose='silent' for service in "${SERVICES[@]}"; do pid="$(read_pid "$service")" if [[ -n "$pid" ]] && kill -0 "$pid" 2>/dev/null; then continue fi if [[ "$verbose" == "verbose" ]]; then echo "[WATCHDOG] $service not running (last PID: ${pid:-none}); respawning..." fi rm -f "$(pid_file "$service")" # Only respawn if infrastructure is reachable; otherwise let `start` handle it. if (cd "$PROJECT_DIR" && docker ps --format '{{.Names}}' 2>/dev/null | grep -qE '^(mdv-dev-postgres|mdv-dev-redis)$'); then spawn_service "$service" || true fi done } watch_loop() { echo "[WATCHDOG] watching services every ${WATCH_INTERVAL}s (Ctrl+C to stop)" ensure_alive verbose while true; do sleep "$WATCH_INTERVAL" ensure_alive verbose done } check_services() { # Verify all services are responding with HTTP 2xx or 3xx # Exit 0 if all OK, exit 1 if any service is down local service url code failed=0 echo "Checking all services..." for service in "${SERVICES[@]}"; do url="$(service_url "$service")" code="$(curl --max-time 5 -sS -o /dev/null -w '%{http_code}' "$url" 2>/dev/null || true)" if [[ "$code" =~ ^[23] ]]; then echo "[OK] $service ($code) — $url" else echo "[FAIL] $service (HTTP $code) — $url" failed=1 fi done echo if [[ $failed -eq 0 ]]; then echo "All services are up!" return 0 else echo "Some services are down." return 1 fi } print_urls() { # Detect if Traefik/production domains are reachable local api_domain="$(service_domain backend)" local admin_domain="$(service_domain admin)" local tpv_domain="$(service_domain tpv)" local shop_domain="$(service_domain frontend)" local seo_domain="$(service_domain storefront)" cat <