#!/usr/bin/env bash # # Converge — start / stop / status # # Safe to run on a host with other Next.js apps. It never matches processes by # name: it only ever stops a PID it has confirmed is a Next server whose working # directory is this project. Another app on port 3000 (or anywhere else) is # never touched, and if this app's port is already taken by something else the # script reports it and stops rather than clearing the way. # # ./start.sh start in the background, wait until it answers # ./start.sh stop stop only this project's server # ./start.sh restart stop, then start # ./start.sh status is it running, and on what PID # ./start.sh logs follow the log # ./start.sh fg run in the foreground (Ctrl-C to quit) # ./start.sh build production build, then serve it # # PORT=3600 ./start.sh use a different port set -euo pipefail ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" cd "$ROOT" PORT="${PORT:-3500}" RUN_DIR="$ROOT/.run" PID_FILE="$RUN_DIR/app-$PORT.pid" LOG_FILE="$RUN_DIR/app-$PORT.log" URL="http://localhost:$PORT" bold=$'\033[1m'; dim=$'\033[2m'; red=$'\033[31m'; green=$'\033[32m'; yellow=$'\033[33m'; off=$'\033[0m' say() { printf '%s\n' "$*"; } ok() { printf '%s✓%s %s\n' "$green" "$off" "$*"; } warn() { printf '%s!%s %s\n' "$yellow" "$off" "$*"; } die() { printf '%s✗%s %s\n' "$red" "$off" "$*" >&2; exit 1; } # --- identity checks ------------------------------------------------------- # Everything below refuses to act on a process it cannot prove is ours. # These helpers must succeed even when they find nothing: `lsof` and `ps` exit # non-zero on no-match, which under `set -euo pipefail` would abort the script # in the ordinary case of a free port. # Working directory of a PID, or empty. proc_cwd() { { lsof -a -p "$1" -d cwd -Fn 2>/dev/null || true; } | sed -n 's/^n//p' | head -1 return 0 } # Full command of a PID, or empty. proc_cmd() { ps -o command= -p "$1" 2>/dev/null || true return 0 } # True when the PID is a Next server running out of this project directory. is_our_app() { local pid="${1:-}" [ -n "$pid" ] || return 1 kill -0 "$pid" 2>/dev/null || return 1 case "$(proc_cmd "$pid")" in *next*) ;; *) return 1 ;; esac [ "$(proc_cwd "$pid")" = "$ROOT" ] } # PID listening on the port, or empty. port_pid() { { lsof -ti "tcp:$PORT" -sTCP:LISTEN 2>/dev/null || true; } | head -1 return 0 } # PID of our running server, adopting an already-running one if it is ours. running_pid() { local pid="" if [ -f "$PID_FILE" ]; then pid="$(cat "$PID_FILE" 2>/dev/null || true)"; fi if is_our_app "$pid"; then printf '%s' "$pid"; return 0; fi # No usable pid file — fall back to whoever holds the port, but only claim it # if it is demonstrably this project's server. pid="$(port_pid)" if is_our_app "$pid"; then printf '%s' "$pid"; return 0; fi return 1 } # --- actions --------------------------------------------------------------- ensure_deps() { command -v node >/dev/null || die "Node.js is not installed." command -v npm >/dev/null || die "npm is not installed." if [ ! -d node_modules ]; then say "${dim}Installing dependencies…${off}" npm install --no-audit --no-fund fi } # Refuse to start if the port belongs to something that is not this app. check_port_free() { local pid; pid="$(port_pid)" [ -n "$pid" ] || return 0 if is_our_app "$pid"; then return 0; fi printf '%s\n' "${red}✗${off} Port $PORT is in use by another program (PID $pid):" >&2 printf ' %s\n' "$(proc_cmd "$pid")" >&2 printf ' Leaving it alone. Start this app on a different port instead:\n' >&2 printf ' %sPORT=3600 ./start.sh%s\n' "$bold" "$off" >&2 exit 1 } wait_until_up() { local pid="$1" tries=90 while [ $tries -gt 0 ]; do if curl -sf -o /dev/null "$URL/api/catalog"; then return 0; fi kill -0 "$pid" 2>/dev/null || return 1 sleep 1 tries=$((tries - 1)) done return 1 } do_start() { if pid="$(running_pid)"; then ok "Already running on $URL ${dim}(PID $pid)${off}" return 0 fi ensure_deps check_port_free mkdir -p "$RUN_DIR" say "${dim}Starting on port ${PORT}…${off}" # setsid-less background start; the PID we record is the one we may later stop. PORT="$PORT" nohup npx next dev -p "$PORT" > "$LOG_FILE" 2>&1 & local pid=$! echo "$pid" > "$PID_FILE" if wait_until_up "$pid"; then ok "Converge is running" say " ${bold}$URL${off}" say " ${dim}logs: ./start.sh logs · stop: ./start.sh stop${off}" else rm -f "$PID_FILE" say "" tail -20 "$LOG_FILE" >&2 || true die "It did not come up. Full log: $LOG_FILE" fi } do_fg() { ensure_deps check_port_free say "${dim}Starting on port $PORT — Ctrl-C to stop.${off}" exec npx next dev -p "$PORT" } do_build() { ensure_deps check_port_free npx next build ok "Build complete — serving on $URL" exec npx next start -p "$PORT" } do_stop() { local pid if ! pid="$(running_pid)"; then rm -f "$PID_FILE" warn "Not running (nothing on port $PORT belonging to this project)." return 0 fi # Stop the tree we started, without ever matching on process name. kill "$pid" 2>/dev/null || true local tries=15 while [ $tries -gt 0 ] && kill -0 "$pid" 2>/dev/null; do sleep 0.4; tries=$((tries - 1)); done if kill -0 "$pid" 2>/dev/null; then warn "Did not exit cleanly; sending SIGKILL to PID $pid." kill -9 "$pid" 2>/dev/null || true fi rm -f "$PID_FILE" ok "Stopped." } do_status() { local pid if pid="$(running_pid)"; then ok "Running on $URL ${dim}(PID $pid)${off}" else local other; other="$(port_pid)" if [ -n "$other" ]; then warn "Not running. Port $PORT is held by another program (PID $other):" say " $(proc_cmd "$other")" else say "Not running. Port $PORT is free." fi fi } case "${1:-start}" in start) do_start ;; fg|dev) do_fg ;; build|prod) do_build ;; stop) do_stop ;; restart) do_stop; do_start ;; status) do_status ;; logs) [ -f "$LOG_FILE" ] || die "No log yet at $LOG_FILE"; tail -f "$LOG_FILE" ;; -h|--help|help) sed -n '2,25p' "${BASH_SOURCE[0]}" | sed 's/^# \{0,1\}//' ;; *) die "Unknown command: $1 (try: start, stop, restart, status, logs, fg, build)" ;; esac