#!/usr/bin/env bash set -euo pipefail ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" BACKEND_DIR="$ROOT_DIR/backend" FRONTEND_DIR="$ROOT_DIR/frontend" LOG_ROOT="$ROOT_DIR/logs" RUN_ID="$(date +"%Y%m%d-%H%M%S")" RUN_DIR="$LOG_ROOT/run-$RUN_ID" LATEST_LINK="$LOG_ROOT/latest" BACKEND_HOST="${BACKEND_HOST:-127.0.0.1}" BACKEND_PORT="${BACKEND_PORT:-8000}" FRONTEND_HOST="${FRONTEND_HOST:-127.0.0.1}" FRONTEND_PORT="${FRONTEND_PORT:-5173}" BACKEND_RELOAD="${BACKEND_RELOAD:-1}" AUTO_INSTALL="${AUTO_INSTALL:-0}" FOLLOW_LOGS="${FOLLOW_LOGS:-0}" CONDA_ENV="${CONDA_ENV:-pjctrl}" # Python path detection (prefer conda env) PYTHON_BIN="" detect_python() { # Check for conda environment local conda_python if command -v conda >/dev/null 2>&1; then conda_python="$(conda run -n "$CONDA_ENV" which python 2>/dev/null || true)" if [[ -n "$conda_python" && -x "$conda_python" ]]; then PYTHON_BIN="$conda_python" return fi fi # Fallback to system python3 if command -v python3 >/dev/null 2>&1; then PYTHON_BIN="python3" return fi log "ERROR: No Python interpreter found" exit 1 } ENV_LOG="" RUN_LOG="" HEALTH_LOG="" usage() { cat <<'EOF' Usage: ./projectctl.sh [start|stop|status|health] Commands: start Check environment, start backend/frontend, run health checks stop Stop backend/frontend based on latest pid files status Show current process status from latest pid files health Run health checks against running services Environment variables: BACKEND_HOST (default: 127.0.0.1) BACKEND_PORT (default: 8000) FRONTEND_HOST (default: 127.0.0.1) FRONTEND_PORT (default: 5173) BACKEND_RELOAD (default: 1) use 0 to disable --reload AUTO_INSTALL (default: 0) set 1 to auto npm install if missing FOLLOW_LOGS (default: 0) set 1 to tail logs after start CONDA_ENV (default: pjctrl) conda environment name for backend EOF } log() { local msg="$*" local ts ts="$(date +"%H:%M:%S")" echo "[$ts] $msg" | tee -a "$RUN_LOG" } env_log() { echo "$*" >> "$ENV_LOG" } health_log() { echo "$*" | tee -a "$HEALTH_LOG" } ensure_dirs() { mkdir -p "$RUN_DIR" mkdir -p "$LOG_ROOT" ln -sfn "$RUN_DIR" "$LATEST_LINK" ENV_LOG="$RUN_DIR/env.log" RUN_LOG="$RUN_DIR/run.log" HEALTH_LOG="$RUN_DIR/health.log" } require_cmds() { local missing=() for cmd in "$@"; do if ! command -v "$cmd" >/dev/null 2>&1; then missing+=("$cmd") fi done if [[ ${#missing[@]} -gt 0 ]]; then log "ERROR: Missing required commands: ${missing[*]}" exit 1 fi } get_env_value() { local key="$1" local file="$2" local line line="$(grep -E "^${key}=" "$file" | tail -n1 || true)" if [[ -z "$line" ]]; then echo "" return fi local val="${line#*=}" val="$(printf '%s' "$val" | sed -e 's/#.*$//' -e 's/^[[:space:]]*//' -e 's/[[:space:]]*$//')" echo "$val" } check_env_file() { local env_file="$BACKEND_DIR/.env" if [[ ! -f "$env_file" ]]; then log "WARN: backend/.env not found. Copy backend/.env.example and set values." return 1 fi local required_keys=( MYSQL_HOST MYSQL_PORT MYSQL_USER MYSQL_PASSWORD MYSQL_DATABASE REDIS_HOST REDIS_PORT JWT_SECRET_KEY AUTH_API_URL SYSTEM_ADMIN_EMAIL ) local optional_keys=( ENCRYPTION_MASTER_KEY ) env_log "Env file: $env_file" for key in "${required_keys[@]}"; do local val val="$(get_env_value "$key" "$env_file")" if [[ -z "$val" ]]; then log "ERROR: Missing or empty env key: $key" env_log "MISSING: $key" return 1 fi env_log "OK: $key" done for key in "${optional_keys[@]}"; do local val val="$(get_env_value "$key" "$env_file")" if [[ -z "$val" ]]; then log "WARN: Optional env key not set: $key" env_log "WARN: $key not set" else env_log "OK: $key" fi done return 0 } check_python_deps() { "$PYTHON_BIN" - <<'PY' import importlib import sys required = ["fastapi", "uvicorn", "sqlalchemy", "redis", "pydantic", "alembic"] missing = [] for mod in required: try: importlib.import_module(mod) except Exception: missing.append(mod) if missing: sys.stderr.write("Missing python packages: " + ", ".join(missing) + "\n") sys.exit(1) PY } check_redis() { local env_file="$BACKEND_DIR/.env" local redis_host redis_port redis_host="$(get_env_value "REDIS_HOST" "$env_file")" redis_port="$(get_env_value "REDIS_PORT" "$env_file")" redis_host="${redis_host:-localhost}" redis_port="${redis_port:-6379}" if command -v redis-cli >/dev/null 2>&1; then if ! redis-cli -h "$redis_host" -p "$redis_port" ping >/dev/null 2>&1; then log "WARN: Redis not reachable at $redis_host:$redis_port" return 1 fi env_log "Redis: OK ($redis_host:$redis_port)" else # Try nc as fallback if command -v nc >/dev/null 2>&1; then if ! nc -z "$redis_host" "$redis_port" >/dev/null 2>&1; then log "WARN: Redis port not open at $redis_host:$redis_port" return 1 fi env_log "Redis: Port reachable ($redis_host:$redis_port)" else log "WARN: Cannot verify Redis (no redis-cli or nc)" fi fi return 0 } check_mysql() { local env_file="$BACKEND_DIR/.env" local mysql_host mysql_port mysql_host="$(get_env_value "MYSQL_HOST" "$env_file")" mysql_port="$(get_env_value "MYSQL_PORT" "$env_file")" mysql_host="${mysql_host:-localhost}" mysql_port="${mysql_port:-3306}" # Just check if port is reachable (don't require mysql client) if command -v nc >/dev/null 2>&1; then if ! nc -z -w 3 "$mysql_host" "$mysql_port" >/dev/null 2>&1; then log "WARN: MySQL port not reachable at $mysql_host:$mysql_port" return 1 fi env_log "MySQL: Port reachable ($mysql_host:$mysql_port)" else log "WARN: Cannot verify MySQL connectivity (no nc command)" fi return 0 } check_node_deps() { if [[ ! -d "$FRONTEND_DIR/node_modules" ]]; then if [[ "$AUTO_INSTALL" == "1" ]]; then log "node_modules missing; running npm install..." (cd "$FRONTEND_DIR" && npm install) else log "ERROR: frontend/node_modules missing. Run npm install or set AUTO_INSTALL=1." exit 1 fi fi } port_in_use() { local port="$1" if command -v lsof >/dev/null 2>&1; then lsof -iTCP:"$port" -sTCP:LISTEN -n -P >/dev/null 2>&1 return $? fi if command -v nc >/dev/null 2>&1; then nc -z 127.0.0.1 "$port" >/dev/null 2>&1 return $? fi return 1 } check_ports() { if port_in_use "$BACKEND_PORT"; then log "ERROR: Port $BACKEND_PORT is already in use." exit 1 fi if port_in_use "$FRONTEND_PORT"; then log "ERROR: Port $FRONTEND_PORT is already in use." exit 1 fi } start_backend() { local reload_args=() if [[ "$BACKEND_RELOAD" == "1" ]]; then reload_args=("--reload") fi log "Starting backend on $BACKEND_HOST:$BACKEND_PORT (python: $PYTHON_BIN)..." ( cd "$BACKEND_DIR" nohup env PYTHONPATH="$BACKEND_DIR" \ "$PYTHON_BIN" -m uvicorn app.main:app \ --host "$BACKEND_HOST" \ --port "$BACKEND_PORT" \ "${reload_args[@]+"${reload_args[@]}"}" \ > "$RUN_DIR/backend.log" 2>&1 & echo $! > "$RUN_DIR/backend.pid" ) } start_frontend() { log "Starting frontend on $FRONTEND_HOST:$FRONTEND_PORT..." ( cd "$FRONTEND_DIR" nohup npm run dev -- --host "$FRONTEND_HOST" --port "$FRONTEND_PORT" \ > "$RUN_DIR/frontend.log" 2>&1 & echo $! > "$RUN_DIR/frontend.pid" ) } wait_for_url() { local url="$1" local name="$2" local retries="${3:-30}" local delay="${4:-1}" local i=0 while (( i < retries )); do if curl -fsS "$url" >/dev/null 2>&1; then health_log "$(date +"%Y-%m-%d %H:%M:%S") OK $name $url" return 0 fi sleep "$delay" i=$((i + 1)) done health_log "$(date +"%Y-%m-%d %H:%M:%S") FAIL $name $url" return 1 } run_health_checks() { log "Running health checks..." local backend_url="http://$BACKEND_HOST:$BACKEND_PORT/health" local frontend_url="http://$FRONTEND_HOST:$FRONTEND_PORT/" wait_for_url "$backend_url" "backend" 40 1 || log "WARN: Backend health check failed." wait_for_url "$frontend_url" "frontend" 40 1 || log "WARN: Frontend health check failed." } latest_run_dir() { if [[ -L "$LATEST_LINK" ]]; then readlink "$LATEST_LINK" return fi ls -dt "$LOG_ROOT"/run-* 2>/dev/null | head -n1 || true } stop_service() { local name="$1" local pidfile="$2" if [[ ! -f "$pidfile" ]]; then echo "$name: pid file not found." return fi local pid pid="$(cat "$pidfile")" if kill -0 "$pid" >/dev/null 2>&1; then echo "Stopping $name (pid $pid)..." # Kill child processes first (for uvicorn workers, vite children, etc.) if command -v pgrep >/dev/null 2>&1; then local children children="$(pgrep -P "$pid" 2>/dev/null || true)" if [[ -n "$children" ]]; then echo "$children" | xargs kill 2>/dev/null || true fi fi kill -TERM "$pid" 2>/dev/null || true # Wait briefly for graceful shutdown sleep 1 # Force kill if still running kill -0 "$pid" 2>/dev/null && kill -KILL "$pid" 2>/dev/null || true else echo "$name: process not running." fi rm -f "$pidfile" } status_service() { local name="$1" local pidfile="$2" if [[ ! -f "$pidfile" ]]; then echo "$name: pid file not found." return fi local pid pid="$(cat "$pidfile")" if kill -0 "$pid" >/dev/null 2>&1; then echo "$name: running (pid $pid)" else echo "$name: not running" fi } start() { ensure_dirs log "Run directory: $RUN_DIR" require_cmds node npm curl detect_python env_log "Timestamp: $(date -u +"%Y-%m-%dT%H:%M:%SZ")" env_log "Python: $("$PYTHON_BIN" --version 2>&1)" env_log "Python Path: $PYTHON_BIN" env_log "Node: $(node --version 2>&1)" env_log "NPM: $(npm --version 2>&1)" check_env_file check_python_deps check_node_deps check_redis || true # Warn but don't fail check_mysql || true # Warn but don't fail check_ports start_backend start_frontend run_health_checks log "Logs: $RUN_DIR" log "Backend log: $RUN_DIR/backend.log" log "Frontend log: $RUN_DIR/frontend.log" log "Health log: $RUN_DIR/health.log" if [[ "$FOLLOW_LOGS" == "1" ]]; then log "Tailing logs. Press Ctrl+C to stop tailing." tail -n 200 -f "$RUN_DIR/backend.log" "$RUN_DIR/frontend.log" fi } stop() { local dir dir="$(latest_run_dir)" if [[ -z "$dir" ]]; then echo "No run directory found." return fi stop_service "backend" "$dir/backend.pid" stop_service "frontend" "$dir/frontend.pid" } status() { local dir dir="$(latest_run_dir)" if [[ -z "$dir" ]]; then echo "No run directory found." return fi status_service "backend" "$dir/backend.pid" status_service "frontend" "$dir/frontend.pid" } health() { local dir dir="$(latest_run_dir)" if [[ -z "$dir" ]]; then echo "No run directory found." return fi RUN_DIR="$dir" RUN_LOG="$dir/run.log" HEALTH_LOG="$dir/health.log" run_health_checks } case "${1:-start}" in start) start ;; stop) stop ;; status) status ;; health) health ;; -h|--help) usage ;; *) usage; exit 1 ;; esac