#!/usr/bin/env bash # ๐Ÿ”ง Development Environment Manager # Clean, portable helper for local dev services & apps set -Eeuo pipefail IFS=$'\n\t' ######################################## # Config (override via env if you like) ######################################## SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" PROJECT_ROOT="${PROJECT_ROOT:-"$(cd "$SCRIPT_DIR/../.." && pwd)"}" # Compose file selection # Priority: explicit COMPOSE_FILE > DEV_STACK (full|services|auto) > default (full if present) DEFAULT_COMPOSE_SERVICES="$PROJECT_ROOT/docker/dev/docker-compose.yml" DEFAULT_COMPOSE_FULL="$PROJECT_ROOT/docker/dev/docker-compose.full.yml" if [ -z "${COMPOSE_FILE:-}" ]; then case "${DEV_STACK:-services}" in full) COMPOSE_FILE="$DEFAULT_COMPOSE_FULL" ;; services) COMPOSE_FILE="$DEFAULT_COMPOSE_SERVICES" ;; auto) if [ -f "$DEFAULT_COMPOSE_FULL" ]; then COMPOSE_FILE="$DEFAULT_COMPOSE_FULL" else COMPOSE_FILE="$DEFAULT_COMPOSE_SERVICES" fi ;; *) COMPOSE_FILE="$DEFAULT_COMPOSE_FULL" ;; esac fi ENV_FILE="${ENV_FILE:-"$PROJECT_ROOT/.env"}" ENV_EXAMPLE_FILE="${ENV_EXAMPLE_FILE:-"$PROJECT_ROOT/.env.example"}" PROJECT_NAME="${PROJECT_NAME:-portal-dev}" DB_USER_DEFAULT="dev" DB_NAME_DEFAULT="portal_dev" DB_WAIT_SECS="${DB_WAIT_SECS:-30}" NEXT_PORT_DEFAULT=3000 BFF_PORT_DEFAULT=4000 ######################################## # Colors (fallback if tput missing) ######################################## if command -v tput >/dev/null 2>&1 && [ -t 1 ]; then GREEN="$(tput setaf 2)" YELLOW="$(tput setaf 3)" RED="$(tput setaf 1)" NC="$(tput sgr0)" else GREEN='\033[0;32m' YELLOW='\033[1;33m' RED='\033[0;31m' NC='\033[0m' fi log() { echo -e "${GREEN}[DEV] $*${NC}"; } warn() { echo -e "${YELLOW}[DEV] $*${NC}"; } fail() { echo -e "${RED}[DEV] ERROR: $*${NC}"; exit 1; } trap 'fail "Command failed (exit $?) at line $LINENO. See logs above."' ERR ######################################## # Docker Compose wrapper (v2 & v1) ######################################## detect_compose() { if docker compose version >/dev/null 2>&1; then echo "docker compose" elif command -v docker-compose >/dev/null 2>&1; then echo "docker-compose" else fail "Docker Compose not found. Install Docker Desktop or docker-compose." fi } COMPOSE_BIN="$(detect_compose)" compose() { # shellcheck disable=SC2086 eval $COMPOSE_BIN -f "$COMPOSE_FILE" -p "$PROJECT_NAME" "$@" } ######################################## # Preflight checks ######################################## preflight() { command -v docker >/dev/null 2>&1 || fail "Docker is required." [ -f "$COMPOSE_FILE" ] || fail "Compose file not found: $COMPOSE_FILE (set DEV_STACK=services or COMPOSE_FILE to override)" # Suggest Docker running if ps fails if ! docker info >/dev/null 2>&1; then fail "Docker daemon not reachable. Is Docker running?" fi # pnpm required for app tasks if [[ "${1:-}" == "apps" || "${1:-}" == "migrate" ]]; then command -v pnpm >/dev/null 2>&1 || fail "pnpm is required for app commands." fi } ######################################## # Env handling ######################################## ensure_env() { if [ ! -f "$ENV_FILE" ]; then warn "Environment file not found at $ENV_FILE" if [ -f "$ENV_EXAMPLE_FILE" ]; then log "Creating .env from example..." cp "$ENV_EXAMPLE_FILE" "$ENV_FILE" warn "Please edit $ENV_FILE with your actual values." else warn "No .env.example found at $ENV_EXAMPLE_FILE. Creating empty .env..." : > "$ENV_FILE" fi fi } load_env_exported() { # Export so child processes see env (compose, pnpm etc.) set +u set -a [ -f "$ENV_FILE" ] && . "$ENV_FILE" || true set +a set -u } ######################################## # Helpers ######################################## services_running() { compose ps | grep -q "Up" } wait_for_postgres() { local user="${POSTGRES_USER:-$DB_USER_DEFAULT}" local db="${POSTGRES_DB:-$DB_NAME_DEFAULT}" local timeout="$DB_WAIT_SECS" log "โณ Waiting for database ($db) to be ready (timeout: ${timeout}s)..." local elapsed=0 local step=2 until compose exec -T postgres pg_isready -U "$user" -d "$db" >/dev/null 2>&1; do sleep "$step" elapsed=$((elapsed + step)) if (( elapsed >= timeout )); then fail "Database failed to become ready within ${timeout}s" fi done log "โœ… Database is ready!" } kill_by_port() { local port="$1" # Prefer lsof on macOS; fall back to fuser on Linux if command -v lsof >/dev/null 2>&1; then if lsof -tiTCP:"$port" -sTCP:LISTEN >/dev/null 2>&1; then log " Killing process on port $port..." lsof -tiTCP:"$port" -sTCP:LISTEN | xargs -r kill -9 2>/dev/null || true fi elif command -v fuser >/dev/null 2>&1; then if fuser -n tcp "$port" >/dev/null 2>&1; then log " Killing process on port $port..." fuser -k -n tcp "$port" 2>/dev/null || true fi else warn "Neither lsof nor fuser found; skipping port cleanup for $port." fi } # Check if a port is free using Node (portable) is_port_free() { local port="$1" if ! command -v node >/dev/null 2>&1; then return 0 # assume free if node unavailable fi node -e "const net=require('net');const p=parseInt(process.argv[1],10);const s=net.createServer();s.once('error',()=>process.exit(1));s.once('listening',()=>s.close(()=>process.exit(0)));s.listen({port:p,host:'127.0.0.1'});" "$port" } # Find a free port starting from base, up to +50 find_free_port() { local base="$1" local limit=$((base+50)) local p="$base" while [ "$p" -le "$limit" ]; do if is_port_free "$p"; then echo "$p" return 0 fi p=$((p+1)) done echo "$base" } ######################################## # Commands ######################################## start_services() { preflight "start" cd "$PROJECT_ROOT" ensure_env load_env_exported log "๐Ÿš€ Starting development services..." log "๐Ÿงฉ Using compose: $COMPOSE_FILE" compose up -d postgres redis wait_for_postgres local next="${NEXT_PORT:-$NEXT_PORT_DEFAULT}" local bff="${BFF_PORT:-$BFF_PORT_DEFAULT}" # Ensure desired ports are free; kill any listeners kill_by_port "$next" kill_by_port "$bff" # If still busy, either auto-shift (if allowed) or fail if ! is_port_free "$next"; then if [ "${ALLOW_PORT_SHIFT:-0}" = "1" ]; then local next_free next_free="$(find_free_port "$next")" warn "Port $next in use; assigning NEXT_PORT=$next_free" export NEXT_PORT="$next_free" next="$next_free" else fail "Port $next is in use. Stop the process or run '$0 cleanup'. Set ALLOW_PORT_SHIFT=1 to auto-assign another port." fi fi if ! is_port_free "$bff"; then if [ "${ALLOW_PORT_SHIFT:-0}" = "1" ]; then local bff_free bff_free="$(find_free_port "$bff")" warn "Port $bff in use; assigning BFF_PORT=$bff_free" export BFF_PORT="$bff_free" bff="$bff_free" else fail "Port $bff is in use. Stop the process or run '$0 cleanup'. Set ALLOW_PORT_SHIFT=1 to auto-assign another port." fi fi log "โœ… Development services are running!" log "๐Ÿ”— Database: postgresql://${POSTGRES_USER:-$DB_USER_DEFAULT}:${POSTGRES_PASSWORD:-${POSTGRES_PASSWORD:-dev}}@localhost:5432/${POSTGRES_DB:-$DB_NAME_DEFAULT}" log "๐Ÿ”— Redis: redis://localhost:6379" log "๐Ÿ”— BFF API (expected): http://localhost:${bff}/api" log "๐Ÿ”— Frontend (expected): http://localhost:${next}" } start_with_tools() { preflight "tools" cd "$PROJECT_ROOT" ensure_env load_env_exported log "๐Ÿ› ๏ธ Starting development services with admin tools..." log "๐Ÿงฉ Using compose: $COMPOSE_FILE" # Explicitly start only services + admin tools to avoid building app containers in the full compose compose --profile tools up -d postgres redis adminer redis-commander mailhog wait_for_postgres log "๐Ÿ”— Database Admin: http://localhost:8080" log "๐Ÿ”— Redis Commander: http://localhost:8081" } stop_services() { preflight "stop" cd "$PROJECT_ROOT" log "โน๏ธ Stopping development services..." compose down --remove-orphans log "โœ… Services stopped" } show_status() { preflight "status" cd "$PROJECT_ROOT" log "๐Ÿ“Š Development Services Status:" log "๐Ÿงฉ Using compose: $COMPOSE_FILE" compose ps } show_logs() { preflight "logs" cd "$PROJECT_ROOT" # Pass-through any service names after "logs" # e.g. ./dev.sh logs postgres redis log "๐Ÿงฉ Using compose: $COMPOSE_FILE" compose logs -f --tail=100 "${@:2}" } cleanup_dev() { log "๐Ÿงน Cleaning up all development processes and ports..." # Pull ports from env if present; include common defaults local ports=() ports+=("${NEXT_PORT:-$NEXT_PORT_DEFAULT}") ports+=("${BFF_PORT:-$BFF_PORT_DEFAULT}") ports+=(5555) # Prisma Studio default for p in "${ports[@]}"; do kill_by_port "$p" done # Kill common dev processes by name pkill -f "next dev" 2>/dev/null && log " Stopped Next.js dev server" || true pkill -f "nest start --watch" 2>/dev/null && log " Stopped NestJS watch server" || true pkill -f "next-server" 2>/dev/null && log " Stopped Next.js server process" || true pkill -f "pnpm.*--parallel.*dev" 2>/dev/null && log " Stopped parallel dev processes" || true pkill -f "prisma studio" 2>/dev/null && log " Stopped Prisma Studio" || true pkill -f "apps/bff/scripts/dev-watch.sh" 2>/dev/null && log " Stopped BFF dev-watch script" || true pkill -f "tsc -p tsconfig.build.json --watch" 2>/dev/null && log " Stopped BFF TypeScript watcher" || true pkill -f "tsc-alias.*tsconfig.build.json.*-w" 2>/dev/null && log " Stopped BFF tsc-alias watcher" || true pkill -f "node --watch dist/main.js" 2>/dev/null && log " Stopped BFF Node watcher" || true sleep 1 log "โœ… Development cleanup completed" } start_apps() { preflight "apps" cd "$PROJECT_ROOT" cleanup_dev if ! services_running; then start_services fi load_env_exported # Build shared package first log "๐Ÿ”จ Building shared package..." pnpm --filter @customer-portal/domain build pnpm --filter @customer-portal/validation build # Build BFF before watch (ensures dist exists). Use Nest build for correct emit. log "๐Ÿ”จ Building BFF for initial setup (ts emit)..." ( cd "$PROJECT_ROOT/apps/bff" \ && pnpm clean \ && rm -f tsconfig.build.tsbuildinfo \ && pnpm build || pnpm exec tsc -b --force tsconfig.build.json ) if [ ! -d "$PROJECT_ROOT/apps/bff/dist" ]; then warn "BFF dist not found after build; forcing TypeScript emit..." (cd "$PROJECT_ROOT/apps/bff" && pnpm exec tsc -b --force tsconfig.build.json) fi if [ ! -f "$PROJECT_ROOT/apps/bff/dist/main.js" ] && [ ! -f "$PROJECT_ROOT/apps/bff/dist/main.cjs" ]; then warn "BFF main output not found; will rely on watch to produce it." fi local next="${NEXT_PORT:-$NEXT_PORT_DEFAULT}" local bff="${BFF_PORT:-$BFF_PORT_DEFAULT}" log "๐ŸŽฏ Starting development applications..." log "๐Ÿ”— BFF API: http://localhost:${bff}/api" log "๐Ÿ”— Frontend: http://localhost:${next}" log "๐Ÿ”— Database: postgresql://${POSTGRES_USER:-$DB_USER_DEFAULT}:${POSTGRES_PASSWORD:-${POSTGRES_PASSWORD:-dev}}@localhost:5432/${POSTGRES_DB:-$DB_NAME_DEFAULT}" log "๐Ÿ”— Redis: redis://localhost:6379" log "๐Ÿ“š API Docs: http://localhost:${bff}/api/docs" log "Starting apps with hot-reload..." # Prisma Studio can be started manually with: pnpm db:studio # Run portal + bff in parallel with hot reload pnpm --parallel \ --filter @customer-portal/domain \ --filter @customer-portal/validation \ --filter @customer-portal/portal \ --filter @customer-portal/bff run dev } reset_env() { preflight "reset" cd "$PROJECT_ROOT" log "๐Ÿ”„ Resetting development environment..." compose down -v --remove-orphans docker system prune -f log "โœ… Development environment reset" } migrate_db() { preflight "migrate" cd "$PROJECT_ROOT" if ! compose ps postgres | grep -q "Up"; then fail "Database service not running. Run '$0 start' or '$0 apps' first." fi load_env_exported log "๐Ÿ—„๏ธ Running database migrations..." pnpm db:migrate log "โœ… Database migrations completed" } studio_db() { preflight "studio" cd "$PROJECT_ROOT" if ! compose ps postgres | grep -q "Up"; then fail "Database service not running. Run '$0 start' or '$0 apps' first." fi load_env_exported log "๐Ÿ” Starting Prisma Studio..." pnpm --filter @customer-portal/bff run db:studio } usage() { cat <