diff --git a/scripts/dev-instance.sh b/scripts/dev-instance.sh new file mode 100755 index 0000000..7bd14be --- /dev/null +++ b/scripts/dev-instance.sh @@ -0,0 +1,194 @@ +#!/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. Getting +# this right took actually running it and reading what happened, twice: `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 +} + +start_web() { + # web/node_modules missing is 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. + if [ ! -d web/node_modules ]; then + echo "dev-instance: web/node_modules is missing; run 'npm install --prefix web' first" >&2 + exit 1 + fi + + local state_dir="$1" web_port="" web_pid="" web_tries=0 + while [ -z "$web_port" ] && [ "$web_tries" -lt 8 ]; do + web_tries=$((web_tries + 1)) + local candidate=$((5180 + RANDOM % 20)) + # Returns quickly whether or not the port bound - see the header note. + npm --prefix web run dev -- --port "$candidate" --strictPort \ + >"$state_dir/web.log" 2>&1 || true + local found + if found=$(grep -m1 -oE 'pid [0-9]+' "$state_dir/web.log" 2>/dev/null); then + web_port="$candidate" + web_pid="${found##* }" + fi + done + if [ -z "$web_port" ]; then + echo "dev-instance: web dev server never came up after $web_tries tries; see $state_dir/web.log" >&2 + exit 1 + fi + echo "$web_pid" >"$state_dir/web.pid" + echo "$web_port" >"$state_dir/web.port" + printf '%s\n' "$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 + 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}" + local db="$state_dir/db.sqlite" + local secret="${SESSION_SECRET:-$dev_secret}" + # A placeholder until --web (or the caller) sets a real one; the api only + # needs a syntactically valid origin to start, and CORS is moot until + # something actually calls it cross-origin. + local web_origin="${WEB_ORIGIN:-http://127.0.0.1:1}" + + if [ "$want_web" = 1 ]; then + web_origin="http://127.0.0.1:$(start_web "$state_dir")" + fi + + cargo build -p headquarters-api -j 2 + + 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=$! + echo "$api_pid" >"$state_dir/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 + exit 1 + fi + local api_port="${listening##*:}" + echo "$api_port" >"$state_dir/api.port" + + echo "dev-instance: api on 127.0.0.1:$api_port (pid $api_pid), db $db" >&2 + [ "$want_web" = 1 ] && echo "dev-instance: web on $web_origin" >&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 diff --git a/scripts/dev-session.sh b/scripts/dev-session.sh new file mode 100755 index 0000000..e347c67 --- /dev/null +++ b/scripts/dev-session.sh @@ -0,0 +1,39 @@ +#!/usr/bin/env bash +# Seeds a signed-in session into a scripts/dev-instance.sh instance and +# prints the cookie value - the piece devauth.ts's VITE_LOCAL_AUTH=1 does +# not cover, since it fakes only the frontend's sign-in and never touches +# headquarters-api, so a screen that calls the API as a signed-in player +# still sees no session under it. +# +# scripts/dev-session.sh STATE_DIR [DID] [HANDLE] +# +# STATE_DIR is what `dev-instance.sh start` printed. DID defaults to a fixed +# obviously-fake one; HANDLE is optional and, if given, also seeds an +# `account` row so the UI has a real-looking handle to show instead of the +# raw DID. +# +# Prints exactly one line to stdout: the cookie value. Use it as a `Cookie: +# headquarters_session=` header, or as a raw `document.cookie` +# equivalent injected at the network layer - it is HttpOnly, so page +# JavaScript cannot set it itself. +set -euo pipefail + +cd "$(dirname "$0")/.." + +state_dir="${1:?usage: $0 STATE_DIR [DID] [HANDLE]}" +did="${2:-}" +handle="${3:-}" + +[ -f "$state_dir/db.sqlite" ] || { + echo "dev-session: $state_dir/db.sqlite does not exist - did dev-instance.sh start run first?" >&2 + exit 1 +} + +export CARGO_TARGET_DIR="${CARGO_TARGET_DIR:-$HOME/.cache/lance-blue-target}" +export DEV_SESSION_DB="$state_dir/db.sqlite" +export SESSION_SECRET="${SESSION_SECRET:-0123456789abcdef0123456789abcdef}" +[ -n "$did" ] && export DEV_SESSION_DID="$did" +[ -n "$handle" ] && export DEV_SESSION_HANDLE="$handle" + +cargo build -p headquarters-api --bin dev_session -j 2 >&2 +"$CARGO_TARGET_DIR/debug/dev_session"