#!/usr/bin/env bash # Runs a disposable headquarters-api against a fresh sqlite database on # whichever port the OS hands out - safe for several Claude sessions, # subagents or worktrees to each run their own at once, since nothing here # picks a port or a file path a second invocation could collide with. # # scripts/dev-instance.sh start [--web] [--state-dir DIR] # scripts/dev-instance.sh stop STATE_DIR # scripts/dev-instance.sh status STATE_DIR # scripts/dev-instance.sh list # # `start` prints STATE_DIR on its own line last; capture it to talk to this # instance again (scripts/dev-session.sh takes the same STATE_DIR). Under it: # api.log, api.pid, api.port, db.sqlite, and with --web, web.log, web.pid, # web.port. Nothing here deletes the state dir - inspect it after a failed # run, or `rm -rf` it yourself once you are done. # # The api runs as the compiled binary directly, not `cargo run`, so api.pid # names the real server process rather than a wrapper `stop` might not # reach. # # --web starts the Astro dev server on a random port in the 5180-5199 range # CLAUDE.md's own "Driving the browser" section already documents, and wires # the two servers to each other: the api needs the web origin for CORS # (WEB_ORIGIN), and the web app needs the api's origin to send it anything # (VITE_API_ORIGIN, baked in at the dev server's own startup - web/src/api.ts # otherwise falls back to :3000, which is never where this script puts the # api). Each only learns the other's real port after that other one has # actually bound, which is a real mutual dependency, not just an ordering # nuisance: the api starts first, against a *candidate* web port picked # before either server runs; if the web server then fails to bind that exact # port (a real collision, not the common case, but --strictPort makes it # fail loudly rather than silently drift), the api that was already started # against the failed candidate is stopped and the whole pair retries against # a new one. # # A version of this script shipped once without wiring the two together at # all - api.ts's own :3000 fallback happened to be silently wrong for every # --web instance, since this script never puts the api there. Found by # actually loading a page against it, not by reading the code again. # # Getting the process-tracking half of --web right also took running it and # reading what happened, not just reading CLAUDE.md's existing notes: `npm # run dev -- --strictPort` returns quickly either way (it hands the real # server off and exits, matching CLAUDE.md's "runs it in the background when # it detects an agent"), so its own exit code says nothing about success - # and `astro dev status`/`stop` reported "no dev server running" for a # server that curl could still reach the whole time, in this environment, # for a server started this same way. What is reliable: astro's own startup # line prints its real pid ("Dev server running at ... (pid NNNN)"), and # that pid answers `kill -0` correctly - confirmed directly. web.pid is that # real pid, not npm's, and `stop` kills it directly rather than trusting # astro's own status command. One caveat that stays true regardless: a # second `astro dev` in a worktree that already has one running attaches to # it and ignores the port it is given rather than failing, so two --web # instances sharing one worktree will not truly isolate - use a separate # worktree per concurrent --web instance. # # SESSION_SECRET defaults to this codebase's own fixed test-fixture secret # (session.rs's test module) so scripts/dev-session.sh can mint a cookie # against it with no coordination; override if that ever matters to you. set -euo pipefail cd "$(dirname "$0")/.." default_state_root="${TMPDIR:-/tmp}" dev_secret="0123456789abcdef0123456789abcdef" wait_for_log_line() { # Polls $1 (a log file) for the first line matching extended regex $2, # printing the matched line once found. Bails if $3 (a pid) stops # existing first - a process that exited before logging the line it was # started to prove is a failure, not something to wait out. local log="$1" pattern="$2" pid="$3" line="" for _ in $(seq 1 1200); do if line=$(grep -m1 -oE "$pattern" "$log" 2>/dev/null); then printf '%s\n' "$line" return 0 fi kill -0 "$pid" 2>/dev/null || return 1 sleep 0.5 done return 1 } # Starts the api against $1 (state dir), $2 (web origin for CORS), waiting # for it to actually bind. Writes api.pid/api.port and prints the port, or # returns non-zero leaving nothing running - a caller does not have to # guess whether it needs to clean up a half-started process. start_api() { local state_dir="$1" web_origin="$2" local db="$state_dir/db.sqlite" local secret="${SESSION_SECRET:-$dev_secret}" DB_PATH="$db" SESSION_SECRET="$secret" WEB_ORIGIN="$web_origin" BIND_ADDR=127.0.0.1:0 \ "$CARGO_TARGET_DIR/debug/headquarters-api" \ >"$state_dir/api.log" 2>&1 & local api_pid=$! local listening if ! listening=$(wait_for_log_line "$state_dir/api.log" \ "listening on 127\\.0\\.0\\.1:[0-9]+" "$api_pid"); then echo "dev-instance: api never came up; see $state_dir/api.log" >&2 kill "$api_pid" 2>/dev/null || true return 1 fi echo "$api_pid" >"$state_dir/api.pid" local api_port="${listening##*:}" echo "$api_port" >"$state_dir/api.port" printf '%s\n' "$api_port" } # Starts the web dev server on $1 (state dir) at exactly $2 (port), pointed # at $3 (api origin). Writes web.pid/web.port on success; a non-zero return # means this exact port did not bind (most likely a collision) and nothing # of this attempt is left running. start_web_on() { local state_dir="$1" port="$2" api_origin="$3" VITE_API_ORIGIN="$api_origin" npm --prefix web run dev -- --port "$port" --strictPort \ >"$state_dir/web.log" 2>&1 || true local found found=$(grep -m1 -oE 'pid [0-9]+' "$state_dir/web.log" 2>/dev/null) || return 1 echo "${found##* }" >"$state_dir/web.pid" echo "$port" >"$state_dir/web.port" } cmd_start() { local want_web=0 state_dir="" while [ $# -gt 0 ]; do case "$1" in --web) want_web=1; shift ;; --state-dir) state_dir="$2"; shift 2 ;; *) echo "dev-instance: start: unknown argument $1" >&2; exit 1 ;; esac done if [ "$want_web" = 1 ] && [ ! -d web/node_modules ]; then # The one prerequisite worth a named error - everything downstream of # it fails as "lex: not found" or similar, which points nowhere near # the real cause. echo "dev-instance: web/node_modules is missing; run 'npm install --prefix web' first" >&2 exit 1 fi state_dir="${state_dir:-$(mktemp -d "$default_state_root/headquarters-dev.XXXXXX")}" mkdir -p "$state_dir" export CARGO_TARGET_DIR="${CARGO_TARGET_DIR:-$HOME/.cache/lance-blue-target}" cargo build -p headquarters-api -j 2 local api_port="" web_port="" if [ "$want_web" = 1 ]; then local tries=0 while [ -z "$web_port" ] && [ "$tries" -lt 8 ]; do tries=$((tries + 1)) local candidate=$((5180 + RANDOM % 20)) if ! api_port=$(start_api "$state_dir" "http://127.0.0.1:$candidate"); then continue fi if start_web_on "$state_dir" "$candidate" "http://127.0.0.1:$api_port"; then web_port="$candidate" else kill "$(cat "$state_dir/api.pid")" 2>/dev/null || true api_port="" fi done if [ -z "$web_port" ]; then echo "dev-instance: web dev server never came up after $tries tries; see $state_dir/web.log" >&2 exit 1 fi else # A placeholder: the api only needs a syntactically valid origin to # start, and CORS is moot until something actually calls it cross-origin. api_port=$(start_api "$state_dir" "${WEB_ORIGIN:-http://127.0.0.1:1}") || exit 1 fi echo "dev-instance: api on 127.0.0.1:$api_port (pid $(cat "$state_dir/api.pid")), db $state_dir/db.sqlite" >&2 [ "$want_web" = 1 ] && echo "dev-instance: web on http://127.0.0.1:$web_port" >&2 echo "dev-instance: seed a session with: scripts/dev-session.sh $state_dir" >&2 printf '%s\n' "$state_dir" } pid_alive() { [ -f "$1" ] && kill -0 "$(cat "$1")" 2>/dev/null } cmd_stop() { local state_dir="${1:?usage: $0 stop STATE_DIR}" local stopped=0 for pidfile in "$state_dir/api.pid" "$state_dir/web.pid"; do if pid_alive "$pidfile"; then kill "$(cat "$pidfile")" 2>/dev/null || true stopped=$((stopped + 1)) fi done echo "dev-instance: stopped $stopped process(es) for $state_dir" >&2 } cmd_status() { local state_dir="${1:?usage: $0 status STATE_DIR}" for name in api web; do local pidfile="$state_dir/$name.pid" portfile="$state_dir/$name.port" [ -f "$pidfile" ] || continue if pid_alive "$pidfile"; then echo "$name: running, pid $(cat "$pidfile"), port $(cat "$portfile" 2>/dev/null || echo '?')" else echo "$name: not running (pid file present, process gone)" fi done } cmd_list() { local found=0 for dir in "$default_state_root"/headquarters-dev.*; do [ -d "$dir" ] || continue found=1 echo "$dir" cmd_status "$dir" | sed 's/^/ /' done [ "$found" = 1 ] || echo "dev-instance: no instances under $default_state_root/headquarters-dev.*" >&2 } case "${1:-}" in start) shift; cmd_start "$@" ;; stop) shift; cmd_stop "$@" ;; status) shift; cmd_status "$@" ;; list) cmd_list ;; *) echo "usage: $0 start [--web] [--state-dir DIR] | stop STATE_DIR | status STATE_DIR | list" >&2 exit 1 ;; esac