#!/usr/bin/env bash # dev.sh — start/stop the frontend (Nuxt), backend (Express) and the Lean # editor server (lean4web via docker compose) together. # # Usage: # ./dev.sh Start everything in the foreground (Ctrl+C stops all) # ./dev.sh start Start everything in the background # ./dev.sh stop Stop everything # ./dev.sh restart Stop then start # ./dev.sh status Show what is running # ./dev.sh logs [service] Follow logs (all, backend, frontend, or lean) # # backend and frontend are npm dev servers; lean is the Lean editor server # from docker-compose.yml (see lean/). It is the heavy one — the image's # first build fetches Mathlib — so dev.sh only rides along with docker: when # docker is not available it says so and starts the other two regardless. set -euo pipefail ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" PID_DIR="$ROOT/.pids" LOG_DIR="$ROOT/.logs" COMPOSE="$ROOT/docker-compose.yml" SERVICES=("backend" "frontend") LEAN_LOG="$LOG_DIR/lean.log" compose() { docker compose -f "$COMPOSE" "$@"; } # The Lean server needs docker up, not just installed — probing the daemon # keeps a dead docker socket from failing backend + frontend. docker_ready() { command -v docker >/dev/null 2>&1 && docker info >/dev/null 2>&1 } lean_running() { docker_ready && [[ -n "$(compose ps --status running --services 2>/dev/null | grep -x lean)" ]] } pid_of() { local pid_file="$PID_DIR/$1.pid" [[ -f "$pid_file" ]] && cat "$pid_file" || true } is_running() { local pid pid="$(pid_of "$1")" [[ -n "$pid" ]] && kill -0 "$pid" 2>/dev/null } start_service() { local name="$1" if is_running "$name"; then echo "$name is already running (PID $(pid_of "$name"))" return fi mkdir -p "$PID_DIR" "$LOG_DIR" # setsid puts each service in its own process group so `stop` can kill # npm and all of its child processes (node --watch, nuxt, ...) cleanly. setsid bash -c 'cd "$1" && exec npm run dev' _ "$ROOT/$name" \ > "$LOG_DIR/$name.log" 2>&1 & local pid=$! echo "$pid" > "$PID_DIR/$name.pid" echo "started $name (PID $pid) — log: $LOG_DIR/$name.log" } # `up -d` also builds the image the first time it is asked for (heavy: # Mathlib), so this rides in the background with its output to lean.log # rather than holding `start` hostage to a cold build. start_lean() { if lean_running; then echo "lean is already running (docker compose)" return fi if ! docker_ready; then echo "lean: docker is not available — skipping the Lean server (see lean/README.md)" >&2 return fi mkdir -p "$LOG_DIR" setsid bash -c 'cd "$1" && exec docker compose -f "$2" up -d lean' _ "$ROOT" "$COMPOSE" \ > "$LEAN_LOG" 2>&1 & echo "starting lean (docker compose) — log: $LEAN_LOG" echo " (the first build fetches Mathlib and takes a while)" } stop_service() { local name="$1" local pid pid="$(pid_of "$name")" if ! is_running "$name"; then echo "$name is not running" rm -f "$PID_DIR/$name.pid" return fi # Negative PID kills the whole process group (npm + its children). kill -TERM -- "-$pid" 2>/dev/null || kill -TERM "$pid" 2>/dev/null || true # Wait up to 5s for a graceful shutdown, then force-kill. for _ in {1..50}; do kill -0 "$pid" 2>/dev/null || break sleep 0.1 done if kill -0 "$pid" 2>/dev/null; then kill -KILL -- "-$pid" 2>/dev/null || kill -KILL "$pid" 2>/dev/null || true fi rm -f "$PID_DIR/$name.pid" echo "stopped $name (PID $pid)" } # `stop`, not `down` — the container is kept (like a stopped dev server), # and `docker compose down` stays the maintainer's call. stop_lean() { if ! lean_running; then echo "lean is not running" return fi compose stop lean >/dev/null 2>&1 || true echo "stopped lean (docker compose)" } status() { for name in "${SERVICES[@]}"; do if is_running "$name"; then echo "$name: running (PID $(pid_of "$name"))" else echo "$name: stopped" fi done if lean_running; then echo "lean: running (docker compose)" elif docker_ready; then echo "lean: stopped" else echo "lean: stopped (docker is not available)" fi } FG_PIDS=() LEAN_PID="" SHUTTING=0 # Stop all foreground services (and their process groups). A second call # (e.g. pressing Ctrl+C again while shutting down) escalates to SIGKILL. fg_stop_all() { local name pid sig=TERM if [[ $SHUTTING -eq 1 ]]; then sig=KILL; else SHUTTING=1 echo echo "stopping backend + frontend + lean..." fi for pid in "${FG_PIDS[@]:-}"; do kill -"$sig" -- "-$pid" 2>/dev/null || kill -"$sig" "$pid" 2>/dev/null || true done if [[ -n "$LEAN_PID" ]]; then kill -"$sig" -- "-$LEAN_PID" 2>/dev/null || kill -"$sig" "$LEAN_PID" 2>/dev/null || true # belt and braces: if the compose client died before telling dockerd, # the container is still up — `stop` is quick and idempotent. docker_ready && compose stop lean >/dev/null 2>&1 || true fi wait 2>/dev/null || true for name in "${SERVICES[@]}"; do rm -f "$PID_DIR/$name.pid"; done exit 0 } foreground() { local name for name in "${SERVICES[@]}"; do if is_running "$name"; then echo "$name is already running (PID $(pid_of "$name")); run '$0 stop' first" >&2 exit 1 fi done mkdir -p "$PID_DIR" for name in "${SERVICES[@]}"; do # setsid puts each service in its own process group (PID == PGID) so the # output prefix pipeline survives the shutdown; sed -u tags every line. setsid bash -c 'cd "$1" && exec npm run dev' _ "$ROOT/$name" \ > >(sed -u "s/^/[$name] /") 2>&1 & local pid=$! FG_PIDS+=("$pid") echo "$pid" > "$PID_DIR/$name.pid" done # The Lean server rides along the same way — compose `up` streams its # build/run output, tagged. But the pair stays in charge: lean's absence # (no docker) or its failure (no image, daemon flake) never takes them # down, so `wait -n` watches only the npm services. if docker_ready; then setsid bash -c 'cd "$1" && exec docker compose -f "$2" up lean' _ "$ROOT" "$COMPOSE" \ > >(sed -u "s/^/[lean] /") 2>&1 & LEAN_PID=$! else echo "[lean] docker is not available — running without the Lean server (see lean/README.md)" >&2 fi trap 'fg_stop_all' INT TERM echo "backend + frontend running (lean if docker allows) — press Ctrl+C to stop all" # Exit as soon as either dev server dies, so they always run/stop together. wait -n "${FG_PIDS[@]}" echo "a dev server exited — stopping the others..." fg_stop_all } case "${1:-dev}" in dev) foreground ;; start) for name in "${SERVICES[@]}"; do start_service "$name"; done start_lean echo echo "backend: http://localhost:5000" echo "frontend: http://localhost:3000" echo "lean: http://localhost:8080 (docker compose) — the wiki frames it at /lean" ;; stop) for name in "${SERVICES[@]}"; do stop_service "$name"; done stop_lean ;; restart) for name in "${SERVICES[@]}"; do stop_service "$name"; done stop_lean for name in "${SERVICES[@]}"; do start_service "$name"; done start_lean ;; status) status ;; logs) case "${2:-all}" in # `all` mixes the file logs with the container's — lean lives in # docker, not in a dev.log, once it is up. all) if docker_ready; then compose logs -f lean & LEAN_LOG_PID=$! trap 'kill "$LEAN_LOG_PID" 2>/dev/null || true' INT TERM fi tail -f "$LOG_DIR"/*.log ;; backend|frontend) tail -f "$LOG_DIR/$2.log" ;; lean) if docker_ready; then compose logs -f lean; else echo "docker is not available" >&2; exit 1; fi ;; *) echo "usage: $0 logs [all|backend|frontend|lean]"; exit 1 ;; esac ;; *) echo "usage: $0 [dev|start|stop|restart|status|logs [all|backend|frontend|lean]] (default: dev)" exit 1 ;; esac