Skip to main content

Scripts Standardization

Unified script conventions for managing multi-service applications

Reference implementations: OCapistaine, Vaettir, VS Code workspaces


Table of Contents

  1. Script Hierarchy
  2. Standard Scripts
  3. Environment Handling
  4. Process Management
  5. Best Practices
  6. Templates
  7. VS Code Integration

Script Hierarchy

Directory Structure

project/
├── scripts/ # All automation scripts
│ ├── README.md # Documentation (list all scripts)
│ ├── utils.sh # Shared bash functions
│ ├── start.sh # Main entry point
│ ├── start_with_deps.sh # Full stack startup
│ ├── stop.sh # Graceful shutdown
│ ├── healthcheck.sh # Service verification
│ ├── restart.sh # Combined stop + start
│ └── [service-name]/ # Service-specific scripts (optional)
│ ├── setup.sh
│ └── deploy.sh
├── .env # Environment variables (git-ignored)
├── .env.example # Example configuration
└── Makefile # Optional: high-level commands

Execution Flow

User Input (start, stop, restart)


start.sh / stop.sh

┌────┴────┐
▼ ▼
scripts/ utils.sh (shared functions)
[specific] ├─ load_env()
├─ check_port()
├─ kill_port()
├─ status()
└─ log()

Standard Scripts

1. start.sh - Main Entry Point

Purpose: Start the primary service with dependencies

Responsibilities:

  • Load environment variables
  • Check/wait for dependencies (Redis, DB, etc.)
  • Kill stale processes
  • Start service
  • Report status

OCapistaine Example:

#!/bin/bash
# scripts/start.sh - Start OCapistaine Uvicorn API

set -e

SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_ROOT="$(dirname "$SCRIPT_DIR")"

# Source shared utilities
source "$SCRIPT_DIR/utils.sh"

# Load environment
log "info" "Loading environment..."
load_env "$PROJECT_ROOT/.env"

# Configuration
PORT="${UVICORN_PORT:-8050}"
SERVICE_NAME="OCapistaine API"

log "info" "Starting $SERVICE_NAME on port $PORT..."

# Kill stale processes
log "info" "Checking for stale processes..."
if lsof -ti:$PORT > /dev/null 2>&1; then
log "warn" "Process running on port $PORT, stopping..."
kill_port "$PORT"
sleep 2
fi

# Verify dependencies
log "info" "Verifying dependencies..."
check_redis "localhost" "6379"

# Start service
log "info" "Starting service..."
cd "$PROJECT_ROOT"
poetry run uvicorn app.main:app \
--reload \
--host 0.0.0.0 \
--port "$PORT" &

SERVICE_PID=$!
log "info" "Service started with PID $SERVICE_PID"

# Wait for service to be ready
sleep 3
if curl -s http://localhost:$PORT/health > /dev/null; then
log "ok" "$SERVICE_NAME is running on http://localhost:$PORT"
else
log "error" "Service failed to start"
exit 1
fi

# Save PID for later cleanup
echo "$SERVICE_PID" > "$PROJECT_ROOT/.service.pid"

2. start_with_deps.sh - Full Stack

Purpose: Start all required services (Redis, API, UI, scheduler)

Responsibilities:

  • Start infrastructure (Redis, database)
  • Wait for each service to be healthy
  • Start application services
  • Start UI (if applicable)
  • Report all URLs

Template:

#!/bin/bash
# scripts/start_with_deps.sh - Start complete stack

set -e

SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_ROOT="$(dirname "$SCRIPT_DIR")"
source "$SCRIPT_DIR/utils.sh"

load_env "$PROJECT_ROOT/.env"

log "header" "Starting Complete Stack"
echo ""

# 1. Redis
log "step" "[1/4] Starting Redis..."
if ! command -v redis-server > /dev/null; then
log "error" "Redis not installed. Install with: brew install redis"
exit 1
fi

redis-server --daemonize yes --port 6379 > /dev/null 2>&1
sleep 1
if check_redis "localhost" "6379"; then
log "ok" "Redis running on :6379"
else
log "error" "Redis startup failed"
exit 1
fi

# 2. API Service
log "step" "[2/4] Starting API..."
"$SCRIPT_DIR/start.sh" &
API_PID=$!
wait_for_url "http://localhost:8050/health"
log "ok" "API running on :8050"

# 3. Scheduler (if embedded, already started with API)
log "step" "[3/4] Scheduler..."
log "ok" "Scheduler started with API"

# 4. Streamlit UI (optional)
log "step" "[4/4] Starting UI..."
if [ -f "$PROJECT_ROOT/app/front.py" ]; then
cd "$PROJECT_ROOT"
poetry run streamlit run app/front.py --server.port 8502 &
UI_PID=$!
sleep 5
log "ok" "UI running on :8502"
fi

echo ""
log "header" "✓ Stack Ready"
echo ""
echo " API: http://localhost:8050"
echo " API Docs: http://localhost:8050/docs"
echo " UI: http://localhost:8502"
echo " Redis: localhost:6379"
echo ""
log "info" "To stop: scripts/stop.sh"

3. stop.sh - Graceful Shutdown

Purpose: Stop services cleanly, waiting for tasks to complete

Responsibilities:

  • Signal services to stop (SIGTERM)
  • Wait for graceful shutdown (30s timeout)
  • Force kill if necessary (SIGKILL)
  • Cleanup resources

Template:

#!/bin/bash
# scripts/stop.sh - Stop all services

SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_ROOT="$(dirname "$SCRIPT_DIR")"
source "$SCRIPT_DIR/utils.sh"

load_env "$PROJECT_ROOT/.env"

log "info" "Stopping services..."

# Kill by port (graceful first)
PORTS=("8050" "8502" "6379")
for PORT in "${PORTS[@]}"; do
if lsof -ti:$PORT > /dev/null 2>&1; then
log "info" "Stopping service on port $PORT..."
kill_port "$PORT" 30 # 30s timeout
fi
done

# Kill by PID file if exists
if [ -f "$PROJECT_ROOT/.service.pid" ]; then
PID=$(cat "$PROJECT_ROOT/.service.pid")
if ps -p "$PID" > /dev/null 2>&1; then
log "info" "Force killing PID $PID..."
kill -9 "$PID" 2>/dev/null || true
fi
rm "$PROJECT_ROOT/.service.pid"
fi

log "ok" "All services stopped"

4. healthcheck.sh - Service Verification

Purpose: Check if all services are running and healthy

Template:

#!/bin/bash
# scripts/healthcheck.sh - Verify service health

SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_ROOT="$(dirname "$SCRIPT_DIR")"
source "$SCRIPT_DIR/utils.sh"

load_env "$PROJECT_ROOT/.env"

log "header" "Health Check"
echo ""

# API
if curl -s http://localhost:8050/health | grep -q "healthy"; then
log "ok" "API (:8050) - Healthy"
else
log "fail" "API (:8050) - Not responding"
fi

# Redis
if check_redis "localhost" "6379"; then
log "ok" "Redis (:6379) - Connected"
else
log "fail" "Redis (:6379) - Disconnected"
fi

# UI (optional)
if curl -s http://localhost:8502 > /dev/null 2>&1; then
log "ok" "UI (:8502) - Running"
else
log "warn" "UI (:8502) - Not running (optional)"
fi

echo ""

5. restart.sh - Combined Operation

Purpose: Stop and start services

#!/bin/bash
# scripts/restart.sh

SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"

"$SCRIPT_DIR/stop.sh"
sleep 2
"$SCRIPT_DIR/start.sh"

Environment Handling

.env File

Location: project/.env (git-ignored)

# OCapistaine Configuration

# API Configuration
UVICORN_PORT=8050
ENVIRONMENT=development

# Redis
REDIS_HOST=localhost
REDIS_PORT=6379

# LLM Providers
OLLAMA_BASE_URL=http://localhost:11434
OPENAI_API_KEY=sk-...
GEMINI_API_KEY=...
CLAUDE_API_KEY=sk-ant-...

# Logging
LOG_LEVEL=info

# Opik Tracing (optional)
OPIK_API_KEY=...
OPIK_WORKSPACE=ocapistaine-dev

.env.example

Location: project/.env.example (checked in)

# Copy this file to .env and fill in your values
# git: checked in
# .env: git-ignored (local secrets)

# API Configuration
UVICORN_PORT=8050
ENVIRONMENT=development

# Redis
REDIS_HOST=localhost
REDIS_PORT=6379

# LLM Providers (at least one required)
OLLAMA_BASE_URL=http://localhost:11434
OPENAI_API_KEY=your_key_here
GEMINI_API_KEY=your_key_here
CLAUDE_API_KEY=your_key_here

# Logging
LOG_LEVEL=info

# Opik (optional)
OPIK_API_KEY=your_key_here
OPIK_WORKSPACE=ocapistaine-dev

Loading .env

Shared function (scripts/utils.sh):

load_env() {
local env_file="${1:-.env}"

if [ ! -f "$env_file" ]; then
log "warn" "No $env_file found"
return
fi

# Source .env file (skip comments and empty lines)
while IFS= read -r line || [ -n "$line" ]; do
if [[ ! "$line" =~ ^# ]] && [[ -n "$line" ]]; then
export "$line"
fi
done < "$env_file"

log "ok" "Environment loaded from $env_file"
}

Process Management

Killing Stale Processes

Function (scripts/utils.sh):

kill_port() {
local port="$1"
local timeout="${2:-10}" # Default 10s timeout

# Get PID on port
local pids=$(lsof -t -i ":$port" 2>/dev/null || true)

if [ -z "$pids" ]; then
log "info" "No process on port $port"
return 0
fi

# Send SIGTERM (graceful shutdown)
log "info" "Stopping process(es) on port $port..."
echo "$pids" | xargs kill -15 2>/dev/null || true

# Wait for graceful shutdown
local elapsed=0
while lsof -ti:$port > /dev/null 2>&1 && [ $elapsed -lt $timeout ]; do
sleep 1
((elapsed++))
done

# Force kill if still running
if lsof -ti:$port > /dev/null 2>&1; then
log "warn" "Process didn't stop, force killing..."
echo "$pids" | xargs kill -9 2>/dev/null || true
sleep 1
fi

if ! lsof -ti:$port > /dev/null 2>&1; then
log "ok" "Port $port is now free"
else
log "error" "Failed to free port $port"
return 1
fi
}

Checking Service Health

Functions (scripts/utils.sh):

wait_for_url() {
local url="$1"
local timeout="${2:-30}"

local elapsed=0
while ! curl -s "$url" > /dev/null 2>&1 && [ $elapsed -lt $timeout ]; do
sleep 1
((elapsed++))
done

if curl -s "$url" > /dev/null 2>&1; then
return 0
else
log "error" "Timeout waiting for $url"
return 1
fi
}

check_redis() {
local host="${1:-localhost}"
local port="${2:-6379}"

if redis-cli -h "$host" -p "$port" ping > /dev/null 2>&1; then
return 0
else
return 1
fi
}

Best Practices

✅ DO

  • Idempotent: Running script twice should be safe
  • Meaningful output: Use structured logging
  • Error handling: Exit on errors (set -e)
  • Port checking: Always verify ports are free
  • Timeouts: Always use timeouts for waits
  • Documentation: Header comment explaining purpose
  • Shared functions: Use utils.sh for common operations
  • Environment variables: Load from .env

❌ DON'T

  • Hardcoded values: Use environment variables
  • Silent failures: Always report errors
  • Long-running loops: Always include timeouts
  • Assuming tools exist: Check with command -v
  • Force kill immediately: Try graceful shutdown first
  • Inline everything: Extract to functions for reuse

Templates

Minimal start.sh

#!/bin/bash
set -e

SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_ROOT="$(dirname "$SCRIPT_DIR")"
source "$SCRIPT_DIR/utils.sh"

load_env "$PROJECT_ROOT/.env"
PORT="${PORT:-8000}"

kill_port "$PORT" || true
cd "$PROJECT_ROOT"

echo "Starting service on port $PORT..."
poetry run uvicorn app.main:app --port "$PORT"
#!/bin/bash
set -e

SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_ROOT="$(dirname "$SCRIPT_DIR")"
source "$SCRIPT_DIR/utils.sh"

# Parse arguments
BACKGROUND=false
for arg in "$@"; do
case $arg in
--bg|--background) BACKGROUND=true ;;
esac
done

# Load environment
load_env "$PROJECT_ROOT/.env"
PORT="${UVICORN_PORT:-8000}"

# Header
log "header" "Starting OCapistaine API"
log "info" "Port: $PORT"
log "info" "Environment: ${ENVIRONMENT:-development}"
echo ""

# Check dependencies
log "step" "Checking dependencies..."
check_dependency "python"
check_dependency "poetry"
check_dependency "redis-server"
check_redis "localhost" "6379"
log "ok" "Dependencies OK"
echo ""

# Kill stale processes
log "step" "Cleaning up..."
kill_port "$PORT" || true
log "ok" "Port $PORT is free"
echo ""

# Start service
log "step" "Starting service..."
cd "$PROJECT_ROOT"

if [ "$BACKGROUND" = true ]; then
nohup poetry run uvicorn app.main:app \
--reload \
--host 0.0.0.0 \
--port "$PORT" > logs/uvicorn.log 2>&1 &
SERVICE_PID=$!
echo "$SERVICE_PID" > "$PROJECT_ROOT/.service.pid"
log "ok" "Service started in background (PID: $SERVICE_PID)"
else
poetry run uvicorn app.main:app \
--reload \
--host 0.0.0.0 \
--port "$PORT"
fi

# Health check
sleep 3
if wait_for_url "http://localhost:$PORT/health" 5; then
log "ok" "Service is healthy"
echo ""
log "header" "✓ Ready"
echo ""
echo " URL: http://localhost:$PORT"
echo " Docs: http://localhost:$PORT/docs"
echo ""
fi

VS Code Integration

Makefile

# Makefile - High-level commands

.PHONY: help start start-bg stop restart status health

help:
@echo "OCapistaine Management Commands"
@echo ""
@echo " make start - Start API (foreground)"
@echo " make start-bg - Start API (background)"
@echo " make stop - Stop all services"
@echo " make restart - Restart services"
@echo " make status - Check service status"
@echo " make health - Health check"
@echo ""

start:
./scripts/start.sh

start-bg:
./scripts/start.sh --background

stop:
./scripts/stop.sh

restart: stop start

status:
lsof -i :8050 || echo "API not running"
lsof -i :8502 || echo "UI not running"

health:
./scripts/healthcheck.sh

VS Code Tasks

File: .vscode/tasks.json

{
"version": "2.0.0",
"tasks": [
{
"label": "Start OCapistaine",
"type": "shell",
"command": "./scripts/start.sh",
"problemMatcher": [],
"group": {
"kind": "build",
"isDefault": true
}
},
{
"label": "Stop OCapistaine",
"type": "shell",
"command": "./scripts/stop.sh",
"problemMatcher": []
},
{
"label": "Health Check",
"type": "shell",
"command": "./scripts/healthcheck.sh",
"problemMatcher": []
}
]
}

Launch Configuration

File: .vscode/launch.json

{
"version": "0.2.0",
"configurations": [
{
"name": "OCapistaine API",
"type": "python",
"request": "launch",
"module": "uvicorn",
"args": ["app.main:app", "--reload", "--port", "8050"],
"jinja": true,
"justMyCode": true,
"env": {
"PYTHONPATH": "${workspaceFolder}"
}
}
]
}

Shared Utils Template

File: scripts/utils.sh

#!/bin/bash
# scripts/utils.sh - Shared shell functions

# Colors for output
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
NC='\033[0m' # No Color

# Logging functions
log() {
local level="$1"
local message="$2"

case "$level" in
header)
echo -e "\n${BLUE}════════════════════════════════════${NC}"
echo -e "${BLUE}$message${NC}"
echo -e "${BLUE}════════════════════════════════════${NC}\n"
;;
step)
echo -e "\n${BLUE}$message${NC}"
;;
info)
echo -e "${BLUE}ℹ️ $message${NC}"
;;
ok)
echo -e "${GREEN}$message${NC}"
;;
warn)
echo -e "${YELLOW}⚠️ $message${NC}"
;;
fail)
echo -e "${RED}$message${NC}"
;;
error)
echo -e "${RED}❌ ERROR: $message${NC}"
;;
*)
echo "$message"
;;
esac
}

# Load environment variables from .env file
load_env() {
local env_file="${1:-.env}"
if [ -f "$env_file" ]; then
while IFS= read -r line || [ -n "$line" ]; do
if [[ ! "$line" =~ ^# ]] && [[ -n "$line" ]]; then
export "$line"
fi
done < "$env_file"
log "ok" "Environment loaded"
else
log "warn" "No $env_file found"
fi
}

# Check if command exists
check_dependency() {
if command -v "$1" > /dev/null; then
log "ok" "$1 found"
else
log "error" "$1 not found. Install it first."
exit 1
fi
}

# Kill process on port with graceful shutdown
kill_port() {
local port="$1"
local timeout="${2:-10}"

local pids=$(lsof -t -i ":$port" 2>/dev/null || echo "")
[ -z "$pids" ] && return 0

echo "$pids" | xargs kill -15 2>/dev/null || true
local elapsed=0
while [ $elapsed -lt $timeout ] && lsof -ti:$port > /dev/null 2>&1; do
sleep 1
((elapsed++))
done

if lsof -ti:$port > /dev/null 2>&1; then
echo "$pids" | xargs kill -9 2>/dev/null || true
fi
}

# Wait for URL to respond
wait_for_url() {
local url="$1"
local timeout="${2:-30}"
local elapsed=0

while [ $elapsed -lt $timeout ]; do
if curl -s "$url" > /dev/null 2>&1; then
return 0
fi
sleep 1
((elapsed++))
done

return 1
}

# Check Redis connection
check_redis() {
redis-cli -h "${1:-localhost}" -p "${2:-6379}" ping > /dev/null 2>&1
}

Cross-Repository Application

OCapistaine

  • start.sh: scripts/start.sh (uvicorn)
  • start_with_deps.sh: scripts/start_with_deps.sh (Redis + Uvicorn + Streamlit)
  • stop.sh: scripts/stop.sh
  • healthcheck.sh: scripts/healthcheck.sh

Vaettir

  • start.sh: scripts/docker-compose-up.sh
  • stop.sh: scripts/docker-compose-down.sh
  • healthcheck.sh: Check n8n + services

New Services

  • Copy templates/scripts/ from this directory
  • Customize for your service
  • Ensure all 4 standard scripts exist

References


Last Updated: 2026-02-22 Branch: valkyria Status: Cross-repo standard