#!/usr/bin/env bash # ── Timmy Time — Development Environment Launcher ──────────────────────────── # # Starts the full dev stack via docker compose with: # • Auto port selection (finds a free port starting at 8000) # • Clickable dashboard + service links # • Status line (version, git commit, backend inference) # • Smoke test on startup # • Drops into a dev shell with `timmy` CLI wired to this instance # # Usage: # tox -e dev # # or directly: bash scripts/dev_server.sh set -euo pipefail REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)" cd "$REPO_ROOT" # ── Colours ────────────────────────────────────────────────────────────────── GREEN='\033[0;32m' AMBER='\033[0;33m' RED='\033[0;31m' CYAN='\033[0;36m' BOLD='\033[1m' DIM='\033[2m' RESET='\033[0m' # ── Find a free port ───────────────────────────────────────────────────────── find_free_port() { local port="${1:-8000}" local max_port=$((port + 20)) while [ "$port" -le "$max_port" ]; do if ! lsof -iTCP:"$port" -sTCP:LISTEN >/dev/null 2>&1; then echo "$port" return 0 fi echo -e "${DIM} port $port in use, trying next...${RESET}" >&2 port=$((port + 1)) done echo -e "${RED}ERROR: No free port found in range 8000-$max_port${RESET}" >&2 return 1 } PORT=$(find_free_port 8000) # ── Gather version info ───────────────────────────────────────────────────── VERSION=$(python3 -c " try: import tomllib except ImportError: import tomli as tomllib with open('pyproject.toml', 'rb') as f: print(tomllib.load(f)['tool']['poetry']['version']) " 2>/dev/null || echo "unknown") GIT_COMMIT=$(git rev-parse --short HEAD 2>/dev/null || echo "unknown") GIT_TIMESTAMP=$(git log -1 --format='%ci' 2>/dev/null || echo "unknown") GIT_BRANCH=$(git branch --show-current 2>/dev/null || echo "unknown") # ── Detect backend inference source ────────────────────────────────────────── OLLAMA_URL="${OLLAMA_URL:-http://localhost:11434}" INFERENCE_SOURCE="Ollama @ ${OLLAMA_URL}" if curl -sf "${OLLAMA_URL}/api/tags" >/dev/null 2>&1; then OLLAMA_STATUS="${GREEN}reachable${RESET}" else OLLAMA_STATUS="${AMBER}unreachable${RESET}" fi # ── Banner ─────────────────────────────────────────────────────────────────── echo "" echo -e "${BOLD}${CYAN}╔══════════════════════════════════════════════════════════════╗${RESET}" echo -e "${BOLD}${CYAN}║ TIMMY TIME — Development Environment ║${RESET}" echo -e "${BOLD}${CYAN}╚══════════════════════════════════════════════════════════════╝${RESET}" echo "" # ── Status line ────────────────────────────────────────────────────────────── echo -e "${BOLD}Status${RESET}" echo -e " Version: ${GREEN}v${VERSION}${RESET} ${DIM}(${GIT_BRANCH} @ ${GIT_COMMIT})${RESET}" echo -e " Commit: ${DIM}${GIT_TIMESTAMP}${RESET}" echo -e " Inference: ${INFERENCE_SOURCE} [${OLLAMA_STATUS}]" echo "" # ── Start docker compose ──────────────────────────────────────────────────── echo -e "${BOLD}Starting services...${RESET}" export TIMMY_DEV_PORT="$PORT" # Ensure data directory exists for volume mount mkdir -p "$REPO_ROOT/data" docker compose \ -f docker-compose.yml \ -f docker-compose.dev.yml \ -p "timmy-dev" \ up -d --build \ --remove-orphans 2>&1 | tail -5 # Override the published port via env docker compose \ -f docker-compose.yml \ -f docker-compose.dev.yml \ -p "timmy-dev" \ port dashboard 8000 >/dev/null 2>&1 || true # If the port override didn't work via compose, stop and re-create with the right port # We use a dev-specific override to set the port dynamically if [ "$PORT" != "8000" ]; then echo -e "${DIM} Rebinding to port $PORT...${RESET}" docker compose -p "timmy-dev" down --remove-orphans >/dev/null 2>&1 || true # Create a temporary override for the port OVERRIDE_FILE=$(mktemp /tmp/timmy-dev-port.XXXXXX.yml) cat > "$OVERRIDE_FILE" <&1 | tail -5 rm -f "$OVERRIDE_FILE" fi # ── Wait for healthy ──────────────────────────────────────────────────────── echo -ne "${DIM} Waiting for dashboard to become healthy...${RESET}" HEALTHY=false for i in $(seq 1 30); do if curl -sf "http://localhost:${PORT}/health" >/dev/null 2>&1; then HEALTHY=true break fi echo -n "." sleep 1 done echo "" # ── Smoke test ─────────────────────────────────────────────────────────────── echo "" echo -e "${BOLD}Smoke Tests${RESET}" if [ "$HEALTHY" = true ]; then echo -e " Dashboard health: ${GREEN}PASS${RESET}" else echo -e " Dashboard health: ${RED}FAIL${RESET} ${DIM}(may still be starting)${RESET}" fi # Check Ollama connectivity from inside the container if curl -sf "${OLLAMA_URL}/api/tags" >/dev/null 2>&1; then echo -e " Ollama reachable: ${GREEN}PASS${RESET}" else echo -e " Ollama reachable: ${AMBER}SKIP${RESET} ${DIM}(optional service)${RESET}" fi # ── Clickable links ───────────────────────────────────────────────────────── echo "" echo -e "${BOLD}Services${RESET}" echo -e " Dashboard: ${CYAN}http://localhost:${PORT}${RESET}" echo -e " Health: ${CYAN}http://localhost:${PORT}/health${RESET}" echo -e " API docs: ${CYAN}http://localhost:${PORT}/docs${RESET}" echo -e " Ollama: ${CYAN}${OLLAMA_URL}${RESET}" echo "" # ── Dev shell ──────────────────────────────────────────────────────────────── echo -e "${BOLD}Dev Shell${RESET}" echo -e " ${DIM}The \`timmy\` CLI is available and points to this dev instance.${RESET}" echo -e " ${DIM}Type 'exit' or Ctrl-D to stop the dev environment.${RESET}" echo "" cleanup() { echo "" echo -e "${AMBER}Shutting down dev environment...${RESET}" docker compose -p "timmy-dev" down --remove-orphans 2>/dev/null || true echo -e "${GREEN}Dev environment stopped.${RESET}" } trap cleanup EXIT # Drop into a subshell with the dev environment on PATH export PYTHONPATH="$REPO_ROOT/src:${PYTHONPATH:-}" export PATH="$REPO_ROOT/.venv/bin:$PATH" export TIMMY_DEV=1 export OLLAMA_URL export PS1="(timmy-dev) \w \$ " exec bash --norc --noprofile -c " export PS1='${GREEN}(timmy-dev)${RESET} \w \$ ' export PYTHONPATH='$PYTHONPATH' export PATH='$PATH' export TIMMY_DEV=1 export OLLAMA_URL='$OLLAMA_URL' echo 'Ready. Try: timmy status' exec bash --norc -i "