Files
ss-tools/run.sh

491 lines
16 KiB
Bash
Executable File

#!/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}
AGENT_PORT=${AGENT_PORT:-7860}
SKIP_INSTALL=false
# Service-to-service shared secret between backend and the Gradio agent.
# Used for agent lifecycle persistence and the internal /api/agent/llm-config
# endpoint (which returns the decrypted LLM API key). When unset, a random
# per-run secret is generated so every service started by this run.sh shares it —
# the old hardcoded default ('agent-service-secret') was public in the repo and
# would let anyone forge service auth for the secrets-returning endpoint.
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 (all services share it)."
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" "$STORAGE_ROOT_PATH/chat_uploads"
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 + watchfiles agent watcher"
echo " BACKEND_PORT Port for the backend server (default: 8000)"
echo " FRONTEND_PORT Port for the frontend server (default: 5173)"
echo " AGENT_PORT Port for the Gradio agent (default: 7860)"
echo " AGENT_CONFIRM_TOOLS Enable HITL confirmation before tool calls (default: true)"
echo ""
echo " LLM providers are fetched from FastAPI /api/agent/llm-config at startup."
echo " Configure them in Admin -> LLM Settings."
}
# 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
# 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
# Install shared package
if ! python3 -c "import ss_tools.shared" 2>/dev/null; then
pip install -e ../shared 2>/dev/null || true
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
if [ -n "$AGENT_PID" ]; then
kill -TERM $AGENT_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
if [ -n "$AGENT_PID" ]; then
kill -KILL $AGENT_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 ${AGENT_PORT}/tcp 2>/dev/null || true
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}" \
GRADIO_URL="http://127.0.0.1:${AGENT_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 Gradio Agent
start_agent() {
echo -e "\033[0;35m[Agent]\033[0m Starting Gradio agent on port $AGENT_PORT..."
echo -e "\033[0;35m[Agent]\033[0m LLM config will be fetched from FastAPI /api/agent/llm-config at startup."
echo -e "\033[0;35m[Agent]\033[0m Configure LLM providers in Admin → LLM Settings."
cd agent
if [ -f ".venv/bin/activate" ]; then
source .venv/bin/activate
elif [ -f "../backend/.venv/bin/activate" ]; then
source "../backend/.venv/bin/activate"
fi
# Install both editable packages before using the module entry point. Tests may put
# agent/src on sys.path, but production must import the installed package graph.
if ! python3 -c "import ss_tools.agent, ss_tools.shared" 2>/dev/null; then
pip install -e ../shared 2>/dev/null && echo -e "\033[0;35m[Agent]\033[0m Installed ss-tools-shared"
pip install -e . 2>/dev/null && echo -e "\033[0;35m[Agent]\033[0m Installed ss-tools-agent"
fi
# backend/.env was safely loaded by load_dotenv before services start.
export SERVICE_JWT="$RUN_SERVICE_JWT"
export SERVICE_JWT
export FASTAPI_URL="http://127.0.0.1:${BACKEND_PORT}"
local _color=$'\033[0;35m[Agent]\033[0m '
export GRADIO_SERVER_PORT="$AGENT_PORT"
export GRADIO_ALLOW_PORT_FALLBACK="${GRADIO_ALLOW_PORT_FALLBACK:-false}"
export AGENT_CONFIRM_TOOLS="${AGENT_CONFIRM_TOOLS:-true}"
if [ "$DEV_MODE" = "true" ]; then
echo -e "\033[0;35m[Agent]\033[0m Hot-reload enabled via watchfiles (watching ss_tools/agent/)"
PYTHONUNBUFFERED=1 python3 -m watchfiles --filter python \
"python -m ss_tools.agent.run" src/ss_tools/agent \
> >(awk -v prefix="$_color " '{print prefix $0; fflush()}') 2>&1 &
else
PYTHONUNBUFFERED=1 python3 -m ss_tools.agent.run \
> >(awk -v prefix="$_color " '{print prefix $0; fflush()}') 2>&1 &
fi
AGENT_PID=$!
cd ..
}
start_backend
start_frontend
start_agent
echo "Services are running. Press Ctrl+C to stop."
wait
# #endregion Tooling.Run