Something went wrong. Try again.
Web frontend and supporting services for lance.blue
Something went wrong. Try again.
9.5 kB · 230 lines
Shell
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231#!/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