#!/usr/bin/env bash # # Local development launcher for hermes-ui. # # Starts the Koa API server and the Vite client together in the foreground, # so you can watch the logs and Ctrl-C to stop everything. # # ./start.sh # # Then open http://localhost:8649/ and sign in with admin / 123456. # # The API server listens on 8647 and the Vite dev server proxies /api and # /socket.io to it. Both ports are overridable: # # API_PORT=9000 CLIENT_PORT=9001 ./start.sh # # Ports left over from a previous run are cleaned up automatically. # set -uo pipefail cd "$(dirname "$0")" API_PORT="${API_PORT:-8647}" CLIENT_PORT="${CLIENT_PORT:-8649}" bold() { printf '\033[1m%s\033[0m\n' "$1"; } info() { printf '\033[36m›\033[0m %s\n' "$1"; } warn() { printf '\033[33m!\033[0m %s\n' "$1"; } die() { printf '\033[31m✗\033[0m %s\n' "$1" >&2; exit 1; } # --- Preflight ----------------------------------------------------------- command -v node >/dev/null 2>&1 || die "node not found. Install Node.js >= 23." node_version="$(node -v)" node_major="${node_version#v}" node_major="${node_major%%.*}" if [ "$node_major" -lt 23 ]; then die "Node.js >= 23 required, found $node_version." fi info "node $node_version" if [ ! -d node_modules ]; then warn "node_modules missing — installing (this takes a few minutes)." npm ci --ignore-scripts || die "npm ci failed." # node-pty's install script is what places the loadable native module. # `npm ci --ignore-scripts` skips it, which silently disables the terminal. info "rebuilding node-pty" npm rebuild node-pty || warn "node-pty rebuild failed — the terminal may not work." elif ! node -e "require('node-pty')" >/dev/null 2>&1; then warn "node-pty native module missing — rebuilding (needed for the terminal)." npm rebuild node-pty || warn "node-pty rebuild failed — the terminal may not work." fi # --- Port cleanup -------------------------------------------------------- # # A previous run can leave vite, the ts-node server, and their supervisor # behind. The Vite client runs with --strictPort, so it refuses to fall back # to another port and dies instead. Clean all of it before starting. # PIDs listening on a TCP port, one per line. Always exits 0 — `lsof` returns # non-zero when nothing matches, which under `set -e` would abort the script. pids_on_port() { lsof -nP -iTCP:"$1" -sTCP:LISTEN -t 2>/dev/null | sort -u || true } port_in_use() { local pids pids="$(pids_on_port "$1")" [ -n "$pids" ] } # Whether a PID belongs to this project. Guards against killing an unrelated # process that merely happens to hold the port. pid_is_ours() { local pid="$1" cmd cwd cmd="$(ps -o command= -p "$pid" 2>/dev/null || true)" case "$cmd" in *hermes-ui*|*"packages/server/src/index.ts"*|*node_modules/.bin/vite*) return 0 ;; esac # A plain `node` process running from inside the project directory is a # leftover dev server too — e.g. one started by hand from this repo. case "$cmd" in node*) ;; *) return 1 ;; esac cwd="$(lsof -a -p "$pid" -d cwd -Fn 2>/dev/null | sed -n 's/^n//p' | head -n 1 || true)" case "$cwd" in "$PWD"/*|"$PWD") return 0 ;; esac return 1 } # Walk up from a PID to the topmost ancestor that still belongs to this # project. Killing only the listener leaves its npm/nodemon parent alive to # respawn it, which is how stale servers come back. top_of_tree() { local top="$1" parent parent_cmd while true; do parent="$(ps -o ppid= -p "$top" 2>/dev/null | tr -d ' ' || true)" [ -n "$parent" ] || break [ "$parent" = "1" ] && break [ "$parent" = "$$" ] && break parent_cmd="$(ps -o command= -p "$parent" 2>/dev/null || true)" case "$parent_cmd" in *hermes-ui*|*concurrently*|*cross-env*|*nodemon*|*"npm run dev"*) top="$parent" ;; *) break ;; esac done echo "$top" } # Kill a PID once. The supervisor is reachable from several ports, so the # same PID can be offered more than once — remember what we already killed. # A plain string, not an associative array: macOS ships bash 3.2, which has # no `declare -A`. KILLED_PIDS=" " kill_pid() { local pid="$1" signal="${2:-TERM}" cmd cmd="$(ps -o command= -p "$pid" 2>/dev/null || true)" [ -n "$cmd" ] || return 0 case "$KILLED_PIDS" in *" $pid "*) return 0 ;; esac KILLED_PIDS="$KILLED_PIDS$pid " warn "killing pid $pid ($cmd)" kill "-$signal" "$pid" 2>/dev/null || true } free_port() { local port="$1" pids pid top waited pids="$(pids_on_port "$port")" [ -n "$pids" ] || return 0 for pid in $pids; do if ! pid_is_ours "$pid"; then die "port $port is held by a process that is not this project (pid $pid). Stop it yourself, or set $port to a free port." fi done for pid in $pids; do top="$(top_of_tree "$pid")" kill_pid "$top" done # Give them a moment to release the socket. waited=0 while [ "$waited" -lt 30 ]; do port_in_use "$port" || return 0 sleep 0.2 waited=$((waited + 1)) done # Still held — SIGKILL the stragglers. for pid in $(pids_on_port "$port"); do kill_pid "$pid" KILL done sleep 0.5 } # Kill a leftover `npm run dev` supervisor before taking the ports. If it # stays alive it will respawn the children we just killed. kill_stale_supervisor() { local pid for pid in $(pgrep -f 'npm run dev:server' 2>/dev/null || true); do [ "$pid" = "$$" ] && continue pid_is_ours "$pid" || continue kill_pid "$pid" done for pid in $(pgrep -f 'npm run dev:client' 2>/dev/null || true); do [ "$pid" = "$$" ] && continue pid_is_ours "$pid" || continue kill_pid "$pid" done for pid in $(pgrep -f 'npm run dev$' 2>/dev/null || true); do [ "$pid" = "$$" ] && continue pid_is_ours "$pid" || continue kill_pid "$pid" done sleep 0.5 } kill_stale_supervisor free_port "$API_PORT" free_port "$CLIENT_PORT" # --- Launch -------------------------------------------------------------- bold "hermes-ui — starting" info "api http://localhost:$API_PORT" info "client http://localhost:$CLIENT_PORT (login: admin / 123456)" echo # `npm run dev` runs both halves under concurrently: the server through # nodemon (hot restart on server edits) and the client through Vite. # HERMES_WEB_UI_BACKEND_PORT tells Vite which port to proxy /api and # /socket.io to; PORT tells the Koa server where to listen. export PORT="$API_PORT" export HERMES_WEB_UI_BACKEND_PORT="$API_PORT" npm run dev