204 lines
6.3 KiB
Bash
Executable File
204 lines
6.3 KiB
Bash
Executable File
#!/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:web` 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:web$' 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:web` 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:web
|