#!/usr/bin/env bash # # Start the full Maestro dev stack on macOS/Linux: infra (Docker), backend # (FastAPI/uvicorn) and frontend (Next.js). Backend and frontend run together; # Ctrl-C stops both. # # Usage: # ./scripts/dev.sh # ./scripts/dev.sh --skip-infra # ./scripts/dev.sh --skip-seed # BACKEND_PORT=8001 FRONTEND_PORT=3001 ./scripts/dev.sh # set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" REPO_ROOT="$(dirname "$SCRIPT_DIR")" BACKEND="$REPO_ROOT/backend" FRONTEND="$REPO_ROOT/frontend" SKIP_INFRA=0 SKIP_SEED=0 for arg in "$@"; do case "$arg" in --skip-infra) SKIP_INFRA=1 ;; --skip-seed) SKIP_SEED=1 ;; esac done BACKEND_PORT="${BACKEND_PORT:-8000}" FRONTEND_PORT="${FRONTEND_PORT:-3000}" step() { printf '\n==> %s\n' "$1"; } port_holders() { lsof -ti "tcp:$1" -sTCP:LISTEN 2>/dev/null || true; } # Poll until nothing listens on $1, for up to $2 tenths of a second. Neither # `kill` nor `kill -9` is synchronous: the signal is delivered, but the listening # socket is only released once the process actually dies, and a uvicorn reloader # is a parent plus a child sharing that socket. The old fixed `sleep 0.5` was # therefore a race, and losing it surfaced as an opaque `[Errno 48] Address # already in use` from uvicorn with no WARN line above it. wait_port_free() { local port="$1" tries="$2" while [ "$tries" -gt 0 ]; do [ -z "$(port_holders "$port")" ] && return 0 sleep 0.1 tries=$((tries - 1)) done [ -z "$(port_holders "$port")" ] } free_port() { local port="$1" pids pids="$(port_holders "$port")" if [ -n "$pids" ]; then echo "WARN: port $port is in use (PID(s): $pids); killing." # shellcheck disable=SC2086 kill $pids 2>/dev/null || true if ! wait_port_free "$port" 20; then pids="$(port_holders "$port")" # shellcheck disable=SC2086 [ -n "$pids" ] && kill -9 $pids 2>/dev/null || true fi fi # Runs even when no holder was found: a process that is mid-bind or mid-exit # shows up in neither `lsof -sTCP:LISTEN` nor a successful bind. wait_port_free "$port" 30 && return 0 echo "WARN: port $port is still held by PID(s) $(port_holders "$port") after 5s;" echo " the service below will fail to bind. Free it, or set BACKEND_PORT/FRONTEND_PORT." } # --- 1. Infra (Docker) ---------------------------------------------------- if [ "$SKIP_INFRA" -eq 0 ]; then if command -v docker >/dev/null 2>&1; then step "Starting infra (Postgres, MongoDB, Qdrant) via docker-compose" (cd "$REPO_ROOT" && docker compose up -d) || echo "WARN: docker compose failed" else echo "WARN: Docker not found; skipping infra (pass --skip-infra to silence)." fi fi # --- 2. Backend ----------------------------------------------------------- step "Preparing backend" VENV="$BACKEND/.venv" # Backend requires Python >=3.11 (see CLAUDE.md). Pick the newest available # interpreter; a venv built on an older python silently fails to resolve pins # such as alembic>=1.17, which need >=3.10. PYTHON_BIN="" for candidate in python3.13 python3.12 python3.11; do if command -v "$candidate" >/dev/null 2>&1; then PYTHON_BIN="$candidate" break fi done if [ -z "$PYTHON_BIN" ]; then # Fall back to python3 only if it is >=3.11. if command -v python3 >/dev/null 2>&1 && \ python3 -c 'import sys; raise SystemExit(0 if sys.version_info >= (3, 11) else 1)'; then PYTHON_BIN="python3" else echo "ERROR: Python >=3.11 is required but was not found." >&2 echo " Install it (e.g. 'brew install python@3.11') and re-run." >&2 exit 1 fi fi # Recreate the venv if it is missing or was built on an interpreter <3.11. if [ -x "$VENV/bin/python" ] && \ ! "$VENV/bin/python" -c 'import sys; raise SystemExit(0 if sys.version_info >= (3, 11) else 1)'; then echo "Existing virtualenv uses an unsupported Python; recreating with $PYTHON_BIN..." rm -rf "$VENV" fi if [ ! -x "$VENV/bin/python" ]; then echo "Creating virtualenv with $PYTHON_BIN ($($PYTHON_BIN --version 2>&1))..." "$PYTHON_BIN" -m venv "$VENV" fi VENV_PY="$VENV/bin/python" if [ ! -f "$BACKEND/.env" ]; then cp "$REPO_ROOT/.env.example" "$BACKEND/.env" echo "WARN: created backend/.env from .env.example — set real secrets before production." fi echo "Installing backend dependencies..." "$VENV_PY" -m pip install --disable-pip-version-check -q -r "$BACKEND/requirements.txt" echo "Running database migrations (alembic upgrade head)..." ( cd "$BACKEND" && "$VENV_PY" -m alembic upgrade head ) \ || echo "WARN: alembic migration failed (is Postgres up?)" if [ "$SKIP_SEED" -eq 0 ]; then echo "Seeding featured marketplace teams (idempotent)..." # Seeding is cosmetic: a missing Mongo must not stop the dev stack coming up. ( cd "$BACKEND" && "$VENV_PY" -m app.scripts.seed_marketplace ) \ || echo "WARN: marketplace seed failed (is MongoDB up?)" fi # --- 3. Frontend ---------------------------------------------------------- step "Preparing frontend" if [ ! -d "$FRONTEND/node_modules" ]; then echo "Installing frontend dependencies..." (cd "$FRONTEND" && npm install) fi if [ ! -f "$FRONTEND/.env.local" ]; then cp "$FRONTEND/.env.local.example" "$FRONTEND/.env.local" fi # --- 4. Launch both, stop both on exit ------------------------------------ PIDS=() cleanup() { step "Shutting down..." for pid in "${PIDS[@]}"; do kill "$pid" 2>/dev/null || true; done } trap cleanup EXIT INT TERM free_port "$BACKEND_PORT" step "Launching backend on http://localhost:$BACKEND_PORT" # --reload-dir app restricts the watcher to the source package. Without it # uvicorn watches the whole CWD including .venv, and touched site-package files # (e.g. numpy) trigger an endless reload loop that drops in-flight requests. ( cd "$BACKEND" && exec "$VENV_PY" -m uvicorn app.main:app --reload --reload-dir app --port "$BACKEND_PORT" ) & PIDS+=("$!") free_port "$FRONTEND_PORT" step "Launching frontend on http://localhost:$FRONTEND_PORT" ( cd "$FRONTEND" && exec npm run dev -- --port "$FRONTEND_PORT" ) & PIDS+=("$!") printf '\nMaestro is running:\n' printf ' Backend : http://localhost:%s (docs: /docs)\n' "$BACKEND_PORT" printf ' Frontend: http://localhost:%s\n' "$FRONTEND_PORT" printf 'Press Ctrl-C to stop both.\n' wait