Scripts Standardization
Unified script conventions for managing multi-service applications
Reference implementations: OCapistaine, Vaettir, VS Code workspaces
Table of Contents
- Script Hierarchy
- Standard Scripts
- Environment Handling
- Process Management
- Best Practices
- Templates
- 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.shfor 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"
Full-Featured start.sh
#!/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
- Bash Best Practices: https://mywiki.wooledge.org/BashGuide
- Process Management: https://ss64.com/bash/
- Signal Handling: https://en.wikipedia.org/wiki/Signal_(IPC)
Last Updated: 2026-02-22 Branch: valkyria Status: Cross-repo standard