#!/bin/bash # #region Tooling.Run [C:2] [TYPE Module] # @PURPOSE: Utility script for run # @COMPLEXITY: 1 # Project Launch Script # Automates setup and concurrent execution of backend and frontend servers. set -e SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" PROJECT_ROOT="$SCRIPT_DIR" # Parse KEY=VALUE dotenv files without executing their contents. Existing process # variables win so shell/CI configuration cannot be silently replaced by a file. load_dotenv() { local env_file="$1" [ -f "$env_file" ] || return 0 while IFS= read -r -d '' key && IFS= read -r -d '' value; do if [[ ! -v "$key" ]]; then export "$key=$value" fi done < <(python3 - "$env_file" <<'PY' import sys from pathlib import Path for raw in Path(sys.argv[1]).read_text(encoding="utf-8").splitlines(): line = raw.strip() if not line or line.startswith("#"): continue if line.startswith("export "): line = line[7:].lstrip() if "=" not in line: continue key, value = line.split("=", 1) key = key.strip() if not key.replace("_", "a").isalnum() or key[:1].isdigit(): continue value = value.strip() if len(value) >= 2 and value[0] == value[-1] and value[0] in {"'", '"'}: value = value[1:-1] sys.stdout.buffer.write(key.encode() + b"\0" + value.encode() + b"\0") PY ) } load_dotenv "$PROJECT_ROOT/backend/.env" # Default configuration DEV_MODE="${DEV_MODE:-false}" BACKEND_PORT=${BACKEND_PORT:-8000} FRONTEND_PORT=${FRONTEND_PORT:-5173} SKIP_INSTALL=false # Service-to-service shared secret validated by the backend for service # principals (MCP service tokens, internal integrations). When unset, a random # per-run secret is generated for every service started by this run.sh — the # old hardcoded default ('agent-service-secret') was public in the repo and # would let anyone forge service auth. if [ -z "${SERVICE_JWT:-}" ]; then export SERVICE_JWT="$(openssl rand -hex 24 2>/dev/null || echo "svc-$(date +%s%N)-$$")" echo -e "\033[0;33m[SERVICE_JWT]\033[0m Not set — generated a random service secret for this run." echo -e "\033[0;33m[SERVICE_JWT]\033[0m Set SERVICE_JWT explicitly when running services in separate terminals." fi RUN_SERVICE_JWT="$SERVICE_JWT" # Local paths must be writable outside the container image. The application # defaults to /app/storage for Docker, while run.sh executes from the host. if [ -z "${STORAGE_ROOT_PATH:-}" ]; then export STORAGE_ROOT_PATH="$(dirname "$PROJECT_ROOT")/ss-tools-storage" fi STORAGE_ROOT_PATH="$(realpath -m "$STORAGE_ROOT_PATH")" DEFAULT_STORAGE_ROOT="$(realpath -m "$(dirname "$PROJECT_ROOT")/ss-tools-storage")" if [[ "$STORAGE_ROOT_PATH" == "/" || "$STORAGE_ROOT_PATH" != "$DEFAULT_STORAGE_ROOT" && "$STORAGE_ROOT_PATH" != "$PROJECT_ROOT"/* ]]; then echo "Error: STORAGE_ROOT_PATH must be inside this project or its sibling ss-tools-storage directory." >&2 exit 1 fi mkdir -p "$STORAGE_ROOT_PATH/repositories" "$STORAGE_ROOT_PATH/backups" "$STORAGE_ROOT_PATH/drafts" export STORAGE_ROOT_PATH # Help message show_help() { echo "Usage: ./run.sh [options]" echo "" echo "Options:" echo " --help Show this help message" echo " --skip-install Skip dependency checks and installation" echo "" echo "Environment Variables:" echo " DEV_MODE Hot-reload mode (true/false). Default: false" echo " Enables uvicorn --reload" echo " BACKEND_PORT Port for the backend server (default: 8000)" echo " FRONTEND_PORT Port for the frontend server (default: 5173)" } # Parse arguments while [[ "$#" -gt 0 ]]; do case $1 in --help) show_help; exit 0 ;; --skip-install) SKIP_INSTALL=true ;; *) echo "Unknown parameter passed: $1"; show_help; exit 1 ;; esac shift done echo "Starting Project Launch Script..." # Environment validation validate_env() { echo "Validating environment..." if ! command -v python3 &> /dev/null; then echo "Error: python3 is not installed." exit 1 fi if ! python3 -c 'import sys; exit(0) if sys.version_info >= (3, 9) else exit(1)'; then PYTHON_VERSION=$(python3 -c 'import sys; print(".".join(map(str, sys.version_info[:2])))') echo "Error: python3 version 3.9 or higher is required. Found $PYTHON_VERSION" exit 1 fi if ! command -v npm &> /dev/null; then echo "Error: npm is not installed." exit 1 fi PYTHON_VERSION=$(python3 -c 'import sys; print(".".join(map(str, sys.version_info[:2])))') echo "Environment validation passed (Python $PYTHON_VERSION, npm $(npm -v))" } validate_env # ── backend/.env was safely loaded before configuration defaults ── if [ -f "backend/.env" ]; then echo "Loaded backend/.env for database config." fi # Database connectivity preflight check_database() { # DATABASE_URL is the sole application database setting. local DB_URL="${DATABASE_URL:-postgresql+psycopg2://postgres:postgres@localhost:5432/ss_tools}" # SQLite does not require external service. if [[ "$DB_URL" == sqlite* ]]; then echo "Database preflight: sqlite detected, skipping PostgreSQL connectivity check." return fi local DB_HOST DB_PORT read -r DB_HOST DB_PORT < <( python3 - "$DB_URL" <<'PY' import sys from urllib.parse import urlparse url = sys.argv[1] if "://" not in url: print("localhost 5432") raise SystemExit(0) # Support SQLAlchemy schemes like postgresql+psycopg2://... scheme, rest = url.split("://", 1) parsed = urlparse(f"{scheme.split('+', 1)[0]}://{rest}") host = parsed.hostname or "localhost" port = parsed.port or 5432 print(f"{host} {port}") PY ) local check_cmd check_cmd='import socket,sys; socket.create_connection((sys.argv[1], int(sys.argv[2])), timeout=1).close()' if python3 -c "$check_cmd" "$DB_HOST" "$DB_PORT" >/dev/null 2>&1; then echo "Database preflight: reachable at ${DB_HOST}:${DB_PORT}." return fi echo "Database preflight: cannot connect to ${DB_HOST}:${DB_PORT}." # For local development defaults, attempt to auto-start bundled PostgreSQL. if [ "$DB_HOST" = "localhost" ] && [ "$DB_PORT" = "5432" ] && command -v docker >/dev/null 2>&1; then if [ -f "docker-compose.yml" ]; then echo "Attempting to start local PostgreSQL via docker compose (service: db)..." docker compose up -d db || true fi fi for _ in {1..20}; do if python3 -c "$check_cmd" "$DB_HOST" "$DB_PORT" >/dev/null 2>&1; then echo "Database preflight: reachable at ${DB_HOST}:${DB_PORT}." return fi sleep 1 done echo "Error: PostgreSQL is unavailable at ${DB_HOST}:${DB_PORT}." echo "Run: docker compose up -d db" echo "Or set DATABASE_URL to a reachable database." exit 1 } check_database # Fernet encryption key preflight (generates if missing/invalid) ensure_encryption_key() { local ENV_FILE="backend/.env" local key="" if [ ! -f "$ENV_FILE" ]; then mkdir -p "$(dirname "$ENV_FILE")" fi # Extract existing key from .env if [ -f "$ENV_FILE" ]; then # shellcheck disable=SC2013 for line in $(grep "^ENCRYPTION_KEY=" "$ENV_FILE" | head -1); do key="${line#ENCRYPTION_KEY=}" done fi # Validate existing key if [ -n "$key" ] && echo "$key" | python3 -c " import base64, sys v = sys.stdin.read().strip() try: d = base64.urlsafe_b64decode(v.encode()) if len(d) == 32: sys.exit(0) except Exception: pass sys.exit(1) " 2>/dev/null; then echo "Encryption key preflight: existing ENCRYPTION_KEY reused from $ENV_FILE" return fi # Generate new key local new_key new_key=$(python3 -c "import base64, os; print(base64.urlsafe_b64encode(os.urandom(32)).decode())") if [ -f "$ENV_FILE" ] && grep -q "^ENCRYPTION_KEY=" "$ENV_FILE"; then # Replace invalid key if [[ "$OSTYPE" == "darwin"* ]]; then sed -i '' "s/^ENCRYPTION_KEY=.*/ENCRYPTION_KEY=$new_key/" "$ENV_FILE" else sed -i "s/^ENCRYPTION_KEY=.*/ENCRYPTION_KEY=$new_key/" "$ENV_FILE" fi else echo "ENCRYPTION_KEY=$new_key" >> "$ENV_FILE" fi echo "Encryption key preflight: ENCRYPTION_KEY generated and saved to $ENV_FILE" } ensure_encryption_key # JWT secret preflight (generates if missing/invalid) ensure_jwt_secret() { local ENV_FILE="backend/.env" local key="" if [ ! -f "$ENV_FILE" ]; then mkdir -p "$(dirname "$ENV_FILE")" fi # Extract existing key from .env if [ -f "$ENV_FILE" ]; then # shellcheck disable=SC2013 for line in $(grep "^AUTH_SECRET_KEY=" "$ENV_FILE" | head -1); do key="${line#AUTH_SECRET_KEY=}" done fi # Validate existing key (must be non-empty and reasonably long) if [ -n "$key" ] && [ ${#key} -ge 16 ]; then echo "JWT secret preflight: existing AUTH_SECRET_KEY reused from $ENV_FILE" return fi # Generate new key local new_key new_key=$(python3 -c "import secrets; print(secrets.token_urlsafe(32))") if [ -f "$ENV_FILE" ] && grep -q "^AUTH_SECRET_KEY=" "$ENV_FILE"; then # Replace invalid key if [[ "$OSTYPE" == "darwin"* ]]; then sed -i '' "s/^AUTH_SECRET_KEY=.*/AUTH_SECRET_KEY=$new_key/" "$ENV_FILE" else sed -i "s/^AUTH_SECRET_KEY=.*/AUTH_SECRET_KEY=$new_key/" "$ENV_FILE" fi else echo "AUTH_SECRET_KEY=$new_key" >> "$ENV_FILE" fi echo "JWT secret preflight: AUTH_SECRET_KEY generated and saved to $ENV_FILE" } ensure_jwt_secret # MCP OAuth uses an asymmetric signing key so resource servers can verify tokens through JWKS. # This development launcher persists a generated key locally; production compose profiles must # continue to provide MCP_JWT_PRIVATE_KEY explicitly from their secret manager. ensure_mcp_oauth_key() { local ENV_FILE="backend/.env" # load_dotenv (line 47) already exported the full value using `IFS= read -r -d ''`, so prefer # the environment over a `for line in $(grep ...)` extraction: the unquoted grep splits the PEM # header on its spaces, truncates the key, and forced a regeneration on EVERY start (2026-09-11). local key="${MCP_JWT_PRIVATE_KEY:-}" if [ -n "$key" ] && MCP_JWT_PRIVATE_KEY="$key" python3 -c ' import os from cryptography.hazmat.primitives import serialization from cryptography.hazmat.primitives.asymmetric.rsa import RSAPrivateKey key = serialization.load_pem_private_key(os.environ["MCP_JWT_PRIVATE_KEY"].replace("\\n", "\n").encode(), password=None) raise SystemExit(0 if isinstance(key, RSAPrivateKey) and key.key_size >= 2048 else 1) ' 2>/dev/null; then export MCP_JWT_PRIVATE_KEY="$key" echo "MCP OAuth key preflight: existing MCP_JWT_PRIVATE_KEY reused from $ENV_FILE" return fi if [ -n "$key" ] && MCP_JWT_PRIVATE_KEY="$key" "$PROJECT_ROOT/backend/.venv/bin/python" -c ' import os from cryptography.hazmat.primitives import serialization from cryptography.hazmat.primitives.asymmetric.rsa import RSAPrivateKey key = serialization.load_pem_private_key(os.environ["MCP_JWT_PRIVATE_KEY"].replace("\\n", "\n").encode(), password=None) raise SystemExit(0 if isinstance(key, RSAPrivateKey) and key.key_size >= 2048 else 1) ' 2>/dev/null; then # Defensive fallback for hosts whose system python3 lacks `cryptography`; the backend venv # always has it. (The 2026-09-11 regeneration storm was not a cryptography gap but the # word-split extraction above, now replaced by the exported environment value.) export MCP_JWT_PRIVATE_KEY="$key" echo "MCP OAuth key preflight: existing MCP_JWT_PRIVATE_KEY reused from $ENV_FILE (venv python)" return fi if ! command -v openssl >/dev/null 2>&1; then echo "Error: openssl is required to generate MCP_JWT_PRIVATE_KEY for local development." >&2 exit 1 fi local new_key new_key=$(openssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:2048 2>/dev/null | sed ':a;N;$!ba;s/\n/\\n/g') if [ -z "$new_key" ]; then echo "Error: unable to generate MCP_JWT_PRIVATE_KEY." >&2 exit 1 fi # Earlier development versions wrote a multiline PEM with echo, which leaves invalid # continuation lines in dotenv files. Replace the whole old block atomically with one # escaped line; Python's dotenv reader and our loader both then receive the same value. python3 - "$ENV_FILE" "$new_key" <<'PY' import sys from pathlib import Path path = Path(sys.argv[1]) encoded_key = sys.argv[2] source = path.read_text(encoding="utf-8") if path.exists() else "" result: list[str] = [] replaced = False skipping_pem = False for line in source.splitlines(): if line.startswith("MCP_JWT_PRIVATE_KEY="): if "-----END PRIVATE KEY-----" in line: # Single-line escaped assignment: replace the FIRST one in place and drop duplicates; # never append a second copy (unbounded .env growth, 2026-09-11). if not replaced: result.append(f"MCP_JWT_PRIVATE_KEY={encoded_key}") replaced = True continue # Legacy multiline PEM: skip the whole block, append the escaped key once at the end. skipping_pem = True continue if skipping_pem: if line.strip() == "-----END PRIVATE KEY-----": skipping_pem = False continue result.append(line) if not replaced: result.append(f"MCP_JWT_PRIVATE_KEY={encoded_key}") path.write_text("\n".join(result) + "\n", encoding="utf-8") PY export MCP_JWT_PRIVATE_KEY="$new_key" echo "MCP OAuth key preflight: MCP_JWT_PRIVATE_KEY generated and saved to $ENV_FILE" } ensure_mcp_oauth_key # Backend dependency management setup_backend() { if [ "$SKIP_INSTALL" = true ]; then echo "Skipping backend installation..." return fi echo "Setting up backend..." cd backend if [ ! -d ".venv" ]; then echo "Creating virtual environment..." python3 -m venv .venv fi source .venv/bin/activate if [ -f "requirements.txt" ]; then echo "Installing backend dependencies..." pip install -r requirements.txt fi cd .. } # Frontend dependency management setup_frontend() { if [ "$SKIP_INSTALL" = true ]; then echo "Skipping frontend installation..." return fi echo "Setting up frontend..." cd frontend if [ ! -d "node_modules" ]; then echo "Installing frontend dependencies..." npm install else echo "frontend/node_modules already exists. Skipping npm install." fi cd .. } setup_backend setup_frontend # Cleanup function for graceful shutdown cleanup() { echo "" echo "Stopping services..." # Phase 1: kill by tracked PID (process substitution ensures correct PID capture) if [ -n "$BACKEND_PID" ]; then kill -TERM $BACKEND_PID 2>/dev/null || true fi if [ -n "$FRONTEND_PID" ]; then kill -TERM $FRONTEND_PID 2>/dev/null || true fi # Phase 2: wait for graceful exit (up to 2s), then force kill sleep 1 if [ -n "$BACKEND_PID" ]; then kill -KILL $BACKEND_PID 2>/dev/null || true fi if [ -n "$FRONTEND_PID" ]; then kill -KILL $FRONTEND_PID 2>/dev/null || true fi # Phase 3: fuser fallback — kill anything still on our ports (belt-and-suspenders) if command -v fuser &>/dev/null; then fuser -k ${BACKEND_PORT}/tcp 2>/dev/null || true fuser -k ${FRONTEND_PORT}/tcp 2>/dev/null || true fi echo "Services stopped." exit 0 } # Trap SIGINT (Ctrl+C), SIGTERM (kill), and SIGHUP (terminal close) trap cleanup SIGINT SIGTERM SIGHUP # Start Backend start_backend() { echo -e "\033[0;34m[Backend]\033[0m Starting on port $BACKEND_PORT..." cd backend local -a uvicorn_env_args=() if [ -f ".venv/bin/activate" ]; then source .venv/bin/activate else echo -e "\033[0;31m[Backend]\033[0m Warning: .venv/bin/activate not found. Attempting to run without venv." fi export SERVICE_JWT="$RUN_SERVICE_JWT" export SERVICE_JWT STORAGE_ROOT_PATH # PYTHONUNBUFFERED=1 prevents stderr buffering when piped through awk. # awk with fflush() handles child-process stderr correctly (unlike sed), # making --reload safe in DEV_MODE. local -a reload_args=() if [ "$DEV_MODE" = "true" ]; then reload_args=(--reload --reload-dir src) echo -e "\033[0;34m[Backend]\033[0m Hot-reload enabled (watching src/)" fi # Alembic reset/upgrade runs under one database lock. if [ -f "alembic.ini" ]; then echo -e "\033[0;34m[Backend]\033[0m Applying database reset/upgrade under migration lock..." if ! python3 -m src.scripts.prepare_database; then echo -e "\033[0;31m[Backend]\033[0m Database reset/upgrade failed — check DATABASE_URL and migration history." return 1 fi echo -e "\033[0;32m[Backend]\033[0m Database reset/upgrade applied." fi local _color=$'\033[0;34m[Backend]\033[0m ' PYTHONUNBUFFERED=1 python3 -m uvicorn src.app:app --host 0.0.0.0 --port "$BACKEND_PORT" \ "${uvicorn_env_args[@]}" "${reload_args[@]}" \ > >(awk -v prefix="$_color " '{print prefix $0; fflush()}') 2>&1 & BACKEND_PID=$! cd .. } # Start Frontend start_frontend() { echo -e "\033[0;32m[Frontend]\033[0m Starting on port $FRONTEND_PORT..." cd frontend local _color=$'\033[0;32m[Frontend]\033[0m ' BACKEND_URL="http://127.0.0.1:${BACKEND_PORT}" \ npm run dev -- --host 0.0.0.0 --port "$FRONTEND_PORT" \ > >(awk -v prefix="$_color " '{print prefix $0; fflush()}') 2>&1 & FRONTEND_PID=$! cd .. } start_backend start_frontend echo "Services are running. Press Ctrl+C to stop." wait # #endregion Tooling.Run