#!/usr/bin/env bash # # ctl.sh — Nexus dev server control script # # Usage: # ./ctl.sh start Start the dev server (background, logs to .paperclip/dev.log) # ./ctl.sh stop Gracefully stop the dev server and all child processes # ./ctl.sh restart Stop then start # ./ctl.sh status Show whether the server is running # ./ctl.sh logs Tail the dev server log # ./ctl.sh fg Start in foreground (interactive, Ctrl-C to stop) set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" PIDFILE="$SCRIPT_DIR/.paperclip/dev.pid" LOGFILE="$SCRIPT_DIR/.paperclip/dev.log" # Read PORT from .env (default 6100) so every command uses the same port. PORT=$(grep -s '^PORT=' "$SCRIPT_DIR/.env" | cut -d= -f2) PORT=${PORT:-6100} mkdir -p "$(dirname "$PIDFILE")" is_running() { [[ -f "$PIDFILE" ]] || return 1 local pid pid=$(<"$PIDFILE") kill -0 "$pid" 2>/dev/null } get_pid() { [[ -f "$PIDFILE" ]] && cat "$PIDFILE" || echo "" } do_start() { if is_running; then echo "Already running (pid $(get_pid)). Use '$0 restart' to restart." exit 0 fi echo "Starting Nexus dev server..." cd "$SCRIPT_DIR" # setsid creates a new process group so 'kill -- -PGID' reaches all children setsid pnpm dev >> "$LOGFILE" 2>&1 & local parent_pid=$! echo "$parent_pid" > "$PIDFILE" # Wait for health endpoint local ready=false for i in $(seq 1 30); do if curl -sf "http://localhost:$PORT/api/health" > /dev/null 2>&1; then ready=true break fi sleep 1 done if $ready; then echo "Server ready (pid $parent_pid) — http://localhost:$PORT" echo "Logs: $LOGFILE" else echo "Server started (pid $parent_pid) but health check not yet responding." echo "Check logs: tail -f $LOGFILE" fi } do_stop() { if ! is_running; then echo "Not running." # Clean up orphans anyway "$SCRIPT_DIR/scripts/kill-dev.sh" 2>/dev/null || true rm -f "$PIDFILE" return 0 fi local pid pid=$(get_pid) echo "Stopping Nexus dev server (pid $pid)..." # Kill the entire process group (negative PID = PGID). # setsid in do_start makes the parent the session/group leader, # so this reaches all children: pnpm, tsx, cross-env, node server, etc. kill -TERM -- -"$pid" 2>/dev/null || kill -TERM "$pid" 2>/dev/null || true # Wait up to 5s for graceful shutdown local waited=0 while kill -0 "$pid" 2>/dev/null && (( waited < 50 )); do sleep 0.1 ((waited += 1)) done # Force-kill any stragglers in the group if kill -0 "$pid" 2>/dev/null; then echo "Sending SIGKILL to remaining processes..." kill -KILL -- -"$pid" 2>/dev/null || true fi # Use kill-dev.sh to mop up embedded postgres "$SCRIPT_DIR/scripts/kill-dev.sh" 2>/dev/null || true rm -f "$PIDFILE" echo "Stopped." } do_status() { if ! is_running; then rm -f "$PIDFILE" echo "Nexus is not running." return fi local pid pid=$(get_pid) # Uptime from pidfile modification time (written at start) local uptime started_at if [[ -f "$PIDFILE" ]]; then local now pidfile_epoch now=$(date +%s) pidfile_epoch=$(stat -c %Y "$PIDFILE" 2>/dev/null || echo "$now") local diff=$(( now - pidfile_epoch )) local days=$(( diff / 86400 )) local hours=$(( (diff % 86400) / 3600 )) local mins=$(( (diff % 3600) / 60 )) if (( days > 0 )); then uptime="${days}d ${hours}h ${mins}m" elif (( hours > 0 )); then uptime="${hours}h ${mins}m" else uptime="${mins}m" fi started_at=$(date -d "@$pidfile_epoch" "+%Y-%m-%d %H:%M" 2>/dev/null || echo "?") else uptime="?" started_at="?" fi # Query health endpoint for deploy info local health health=$(curl -sf --max-time 2 "http://localhost:$PORT/api/health" 2>/dev/null || echo "") local mode exposure version mode=$(echo "$health" | grep -oP '"deploymentMode"\s*:\s*"\K[^"]+' || echo "?") exposure=$(echo "$health" | grep -oP '"deploymentExposure"\s*:\s*"\K[^"]+' || echo "?") version=$(echo "$health" | grep -oP '"version"\s*:\s*"\K[^"]+' || echo "?") # Detect listen host/port from .env or defaults local host port host=$(grep -s '^HOST=' "$SCRIPT_DIR/.env" | cut -d= -f2 || echo "127.0.0.1") host=${host:-127.0.0.1} port=$PORT # LAN IP (first non-loopback IPv4) local lan_ip lan_ip=$(ip -4 -o addr show scope global 2>/dev/null | awk '{print $4}' | cut -d/ -f1 | head -1 || echo "") # Detect embedded postgres local pg_pid pg_port pg_status pg_pid=$(ps --ppid "$pid" -o pid,args --no-headers -w 2>/dev/null | grep postgres | awk '{print $1}' | head -1 || echo "") if [[ -z "$pg_pid" ]]; then # postgres may be deeper in the tree — search all descendants pg_pid=$(ps -eo pid,args --no-headers 2>/dev/null | grep "[p]ostgres -D" | awk '{print $1}' | head -1 || echo "") fi pg_port=$(grep -s 'embeddedPostgresPort' "$HOME/.paperclip/instances/default/config.json" 2>/dev/null | grep -oP '\d+' || echo "54329") if [[ -n "$pg_pid" ]]; then pg_status="running (pid $pg_pid, port $pg_port)" else pg_status="not detected" fi # Vite HMR port local hmr_port=$(( port + 10000 )) # Process count in group local proc_count proc_count=$(ps -g "$pid" --no-headers 2>/dev/null | wc -l || echo "?") # Print status echo "" echo " Nexus Dev Server" echo " ──────────────────────────────────────────" echo " Status running since $started_at ($uptime)" echo " Version $version" echo " PID $pid ($proc_count processes)" echo " Mode $mode ($exposure)" echo "" echo " API http://localhost:$port/api" echo " UI http://localhost:$port" if [[ "$host" == "0.0.0.0" && -n "$lan_ip" ]]; then echo " LAN http://$lan_ip:$port" fi echo " HMR ws://localhost:$hmr_port" echo "" echo " Postgres $pg_status" echo " Log $LOGFILE" echo "" } do_logs() { if [[ -f "$LOGFILE" ]]; then tail -f "$LOGFILE" else echo "No log file at $LOGFILE" fi } do_fg() { if is_running; then echo "Already running in background (pid $(get_pid)). Stop it first with '$0 stop'." exit 1 fi cd "$SCRIPT_DIR" exec pnpm dev } case "${1:-}" in start) do_start ;; stop) do_stop ;; restart) do_stop; do_start ;; status) do_status ;; logs) do_logs ;; fg) do_fg ;; *) echo "Usage: $0 {start|stop|restart|status|logs|fg}" exit 1 ;; esac