Ctrl K
ComunidadGuías…

dev.sh

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
#!/usr/bin/env bash
set -euo pipefail

# Ensure Go is on PATH when invoked via SSH non-login shell
export PATH="$PATH:/usr/local/go/bin:$HOME/go/bin"

PROJECT_DIR="$(cd -P "$(dirname "$0")" && pwd -P)"

# --- Multi-instance detection ---
# crewship_1 -> instance 1, crewship_2 -> instance 2, etc.
INSTANCE_NUM=$(basename "$PROJECT_DIR" | sed -n 's/^crewship_\([0-9]*\)$/\1/p')
INSTANCE_NUM=${INSTANCE_NUM:-0}

if [[ "$INSTANCE_NUM" -eq 0 ]]; then
  GO_PORT=8080; NEXT_PORT=3001; S=""
else
  GO_PORT=$((8080 + INSTANCE_NUM)); NEXT_PORT=$((3010 + INSTANCE_NUM)); S="-${INSTANCE_NUM}"
fi

# .env.local remains a trusted shell file. Preserve every incoming exported
# value, including an explicitly empty value, after evaluating its defaults.
load_env_local() {
  local env_file="$1" name i
  local -a incoming_names=() incoming_values=()
  while IFS= read -r name; do
    incoming_names+=("$name")
    incoming_values+=("${!name}")
  done < <(compgen -e)
  if [[ -f "$env_file" ]]; then
    set -a
    . "$env_file"
    set +a
  fi
  for ((i=0; i<${#incoming_names[@]}; i++)); do
    name="${incoming_names[$i]}"
    if [[ "${!name-}" != "${incoming_values[$i]}" ]]; then
      printf -v "$name" '%s' "${incoming_values[$i]}"
    fi
    export "$name"
  done
}

resolve_dev_paths() {
  local installation_id identity_home identity_parent identity_tail=""
  local checkout_id selected_home="${CREWSHIP_HOME:-}" selected_data="${CREWSHIP_DATA_DIR:-}" socket_bytes
  while [[ "$selected_home" != / && "$selected_home" == */ ]]; do selected_home="${selected_home%/}"; done
  while [[ "$selected_data" != / && "$selected_data" == */ ]]; do selected_data="${selected_data%/}"; done
  checkout_id=$(printf '%s' "$PROJECT_DIR" | cksum | awk '{print $1}')
  if [[ -n "$selected_home" && -n "$selected_data" && "$selected_home" != "$selected_data" ]]; then
    echo 'CREWSHIP_HOME and CREWSHIP_DATA_DIR must select the same installation' >&2
    return 1
  fi
  DEV_HOME="${selected_home:-${selected_data:-${XDG_DATA_HOME:-$HOME/.local/share}/crewship/dev/$checkout_id}}"
  if [[ "$DEV_HOME" != /* ]]; then
    echo 'Development installation home must be absolute' >&2
    return 1
  fi
  while [[ "$DEV_HOME" != / && "$DEV_HOME" == */ ]]; do DEV_HOME="${DEV_HOME%/}"; done
  export CREWSHIP_HOME="$DEV_HOME" CREWSHIP_DATA_DIR="$DEV_HOME"
  # Resolve the existing ancestor physically without creating the home.
  # This makes symlink aliases select the same Docker resource namespace.
  identity_parent="$DEV_HOME"
  while [[ ! -d "$identity_parent" && "$identity_parent" != / ]]; do
    identity_tail="/$(basename "$identity_parent")$identity_tail"
    identity_parent=$(dirname "$identity_parent")
  done
  identity_home="$(cd -P "$identity_parent" && pwd -P)"
  identity_home="${identity_home%/}$identity_tail"
  identity_home="${identity_home:-/}"
  installation_id=$(printf '%s' "$identity_home" | cksum | awk '{print $1}')
  RUNTIME_DIR="$DEV_HOME/run"
  if [[ -n "${XDG_RUNTIME_DIR:-}" ]]; then
    if [[ "$XDG_RUNTIME_DIR" != /* ]]; then
      echo 'XDG_RUNTIME_DIR must be absolute' >&2
      return 1
    fi
    RUNTIME_DIR="$XDG_RUNTIME_DIR/crewship-dev-$installation_id"
  fi
  NEXT_PID_FILE="$RUNTIME_DIR/next.pid"
  GO_PID_FILE="$RUNTIME_DIR/go.pid"
  NEXT_LOG="$DEV_HOME/logs/next.log"
  GO_LOG="$DEV_HOME/logs/go.log"
  DEV_BINARY="$DEV_HOME/run/bin/crewship"
  DATA_DIR="${CREWSHIP_STORAGE_BASE_PATH:-$DEV_HOME/data}"
  LOG_PATH="${CREWSHIP_LOG_PATH:-$DEV_HOME/logs}"
  BOLT_PATH="${CREWSHIP_BOLT_PATH:-$DEV_HOME/state/state.db}"
  PAGE_PROJECTS_DIR="${CREWSHIP_PAGE_PROJECTS_PATH:-$DEV_HOME/page-projects}"
  SOCKET_PATH="${CREWSHIP_SOCKET_PATH:-$RUNTIME_DIR/crewship.sock}"
  # The server has historically run from PROJECT_DIR. Resolve every relative
  # override once so stop/status/nuke use the same files from any caller cwd.
  local path_name
  for path_name in DATA_DIR LOG_PATH BOLT_PATH PAGE_PROJECTS_DIR SOCKET_PATH; do
    if [[ "${!path_name}" != /* ]]; then
      printf -v "$path_name" '%s' "$PROJECT_DIR/${!path_name}"
    fi
  done
  if [[ -n "${CREWSHIP_STORAGE_MEMORY_ROOT:-}" && "$CREWSHIP_STORAGE_MEMORY_ROOT" != /* ]]; then
    export CREWSHIP_STORAGE_MEMORY_ROOT="$PROJECT_DIR/$CREWSHIP_STORAGE_MEMORY_ROOT"
  fi
  STATE_DIR=$(dirname "$BOLT_PATH")
  # Darwin sockaddr_un is shorter than Linux; do not create an unusable IPC
  # path or silently fall back to a global /tmp socket.
  socket_bytes=$(printf '%s' "$SOCKET_PATH" | wc -c)
  if (( socket_bytes > 103 )); then
    echo 'Development socket path is too long; set a shorter XDG_RUNTIME_DIR or CREWSHIP_SOCKET_PATH' >&2
    return 1
  fi
  export CREWSHIP_STORAGE_BASE_PATH="$DATA_DIR" CREWSHIP_LOG_PATH="$LOG_PATH"
  export CREWSHIP_BOLT_PATH="$BOLT_PATH" CREWSHIP_SOCKET_PATH="$SOCKET_PATH"
  export CREWSHIP_PAGE_PROJECTS_PATH="$PAGE_PROJECTS_DIR"
  export DATABASE_URL="${DATABASE_URL:-file:$DEV_HOME/db/crewship.db}"
  GO_PORT="${CREWSHIP_PORT:-$GO_PORT}"
  export CREWSHIP_CONTAINER_PREFIX="${CREWSHIP_CONTAINER_PREFIX-crewship-dev-$installation_id}"
  CONTAINER_NETWORK="${CREWSHIP_CONTAINER_NETWORK-crewship-dev-$installation_id-agents}"
  export CREWSHIP_CONTAINER_NETWORK="$CONTAINER_NETWORK"
  if [[ ! "$CREWSHIP_CONTAINER_PREFIX" =~ ^[a-zA-Z0-9][a-zA-Z0-9_.-]*$ || -z "$CONTAINER_NETWORK" ]]; then
    echo 'Development container prefix and network must be nonempty valid names' >&2
    return 1
  fi
}

# Do not silently abandon surviving data from the former /tmp layout.
check_legacy_dev_paths() {
  local old new i legacy_root="${1:-/tmp}"
  local -a old_paths=("$legacy_root/crewship${S}-data" "$legacy_root/crewship${S}-state" "$legacy_root/crewship${S}-page-projects")
  local -a new_paths=("$DATA_DIR" "$STATE_DIR" "$PAGE_PROJECTS_DIR")
  for ((i=0; i<${#old_paths[@]}; i++)); do
    old="${old_paths[$i]}"; new="${new_paths[$i]}"
    if [[ "$old" != "$new" && -d "$old" &&
          -n "$(find "$old" -mindepth 1 -maxdepth 1 -print -quit 2>/dev/null)" ]]; then
      echo "Refusing to abandon legacy dev data in $old; explicitly select that path or migrate it to $new" >&2
      return 1
    fi
  done
}

# A new runtime directory must not hide an old server still using its data.
# A recycled PID is insufficient evidence: verify the executable and cwd.
legacy_dev_server_running() {
  local proc_root="${1:-/proc}" pid_file="${2:-/tmp/crewship${S}-go.pid}" pid exe cwd command
  [[ -f "$pid_file" ]] || return 1
  pid=$(cat "$pid_file")
  [[ "$pid" =~ ^[0-9]+$ ]] || return 1
  kill -0 "$pid" 2>/dev/null || return 1
  if [[ -d "$proc_root/$pid" ]]; then
    exe=$(readlink "$proc_root/$pid/exe" 2>/dev/null) || return 1
    cwd=$(readlink "$proc_root/$pid/cwd" 2>/dev/null) || return 1
    [[ "${exe% (deleted)}" == "/tmp/crewship${S}-dev" && "$cwd" == "$PROJECT_DIR" ]]
  else
    # macOS does not expose /proc. Match the old absolute binary, never just
    # the word crewship or the presence of a process with that PID.
    command=$(ps -p "$pid" -o command= 2>/dev/null) || return 1
    [[ "$command" == "/tmp/crewship${S}-dev start"* ]]
  fi
}

check_legacy_dev_process() {
  if legacy_dev_server_running; then
    echo "Old dev server is still running (PID file /tmp/crewship${S}-go.pid). Stop it with its supervisor or original launcher before changing the layout." >&2
    return 1
  fi
}

load_env_local "$PROJECT_DIR/.env.local"
resolve_dev_paths

# Defaults that used to reach services only by re-sourcing the rewritten file.
export CREWSHIP_PORT="$GO_PORT"
export CREWSHIP_CONTAINER_NETWORK="$CONTAINER_NETWORK"
export NEXT_PUBLIC_GO_PORT="${NEXT_PUBLIC_GO_PORT-$GO_PORT}"
export NEXTAUTH_URL="${NEXTAUTH_URL-http://localhost:$NEXT_PORT}"
export CREWSHIP_NEXTJS_URL="${CREWSHIP_NEXTJS_URL-http://localhost:$NEXT_PORT}"
export CREWSHIP_ALLOWED_ORIGINS="${CREWSHIP_ALLOWED_ORIGINS-http://localhost:$NEXT_PORT,http://127.0.0.1:$NEXT_PORT}"

RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[0;33m'
CYAN='\033[0;36m'
BOLD='\033[1m'
NC='\033[0m'

log()  { echo -e "${CYAN}[crewship${S}]${NC} $*"; }
ok()   { echo -e "${GREEN}[crewship${S}]${NC} $*"; }
warn() { echo -e "${YELLOW}[crewship${S}]${NC} $*"; }
err()  { echo -e "${RED}[crewship${S}]${NC} $*" >&2; }

# guard_log_size caps a service log so a crash-loop can't fill the disk.
# Origin: a wedged journal writer on one dev slot spammed a non-retriable
# error 10x/s and grew /tmp/crewship-N-go.log to 69 GB, taking the whole
# host to 100% disk. Backgrounds a watcher (bounded by the owning process's
# life) that truncates the log in place once it passes the cap.
#
# Truncate in place (`: > file`) — NOT rename — so the writer's open fd stays
# valid and the disk space is actually reclaimed; a mv would leak the space
# to the held-open inode. The warning is deliberately LOUD: a log that keeps
# hitting the cap is a crash-loop symptom we want surfaced, not silently
# rotated into oblivion (that just hides the underlying bug).
#
# The watcher's stdio is redirected into the log it guards rather than
# inherited. Inherited, it holds the write end of whatever pipe dev.sh was
# invoked through for as long as the service runs — so `ssh host ./dev.sh
# start` never returns, because ssh waits for an EOF that a 30-second sleep
# loop will not deliver until the server dies. That is what made
# scripts/deploy-dev.sh sit for 37 minutes after printing "Deploy complete"
# on 2026-07-20, and what left a pair of long-lived writers on the slot
# reconciler's own log after every restart. Writing the warning into the
# fresh log, immediately after the truncation, keeps it loud exactly where
# someone diagnosing the crash-loop is already looking — and makes the new
# log self-documenting about why it starts mid-story.
guard_log_size() {
  local f="$1" owner="$2"
  local cap=$((50 * 1024 * 1024)) # 50 MB
  (
    while kill -0 "$owner" 2>/dev/null; do
      local sz
      sz=$(wc -c < "$f" 2>/dev/null || echo 0)
      if (( sz > cap )); then
        : > "$f"
        warn "log $f passed $((cap / 1024 / 1024))MB — truncated. Likely a crash-loop; check for repeated ERRORs below" >> "$f"
      fi
      sleep 30
    done
  ) </dev/null >/dev/null 2>&1 &
  disown 2>/dev/null || true
}

preserve_crash_log() {
  # A truncating `> "$log"` redirect on every start/restart destroys the
  # very evidence needed to diagnose why the previous run died — hit this
  # 2026-07-15 when the reconciler auto-restarted a crashed slot and the
  # crash reason was gone by the time anyone looked. Single bounded
  # rotation (mv, not copy — instant regardless of size) keeps exactly
  # one prior run's output around, no unbounded accumulation.
  #
  # Must be an `if`, not `[[ ... ]] && mv`: as the function's last
  # statement that list returns 1 whenever the log is absent or empty,
  # the function returns 1, and dev.sh's `set -e` aborts start_go at the
  # call site — before the log is ever recreated, so the slot can never
  # start again. Self-inflicted and permanent: the first rotation is what
  # removes the file that the next start then trips over. This wedged the
  # dev1 slot from 2026-07-16 until 2026-07-20, and with it the slot
  # reconciler, which `set -e`s out of its own loop when a slot's dev.sh
  # exits non-zero — so dev2/dev3 silently stopped tracking main too.
  local f="$1"
  if [[ -s "$f" ]]; then
    mv -f "$f" "${f}.prev"
  fi
}

# Persistent PID files can survive a reboot and refer to a reused PID. Verify
# service identity before reporting it as running or sending any signal.
process_is_owned() {
  local pid="$1" pid_file="$2" proc_root="${3:-/proc}" exe cwd command expected=""
  [[ "$pid" =~ ^[0-9]+$ ]] || return 1
  kill -0 "$pid" 2>/dev/null || return 1
  if [[ "$pid_file" == "$GO_PID_FILE" ]]; then
    expected="$DEV_BINARY"
  elif [[ "$pid_file" == "/tmp/crewship${S}-go.pid" ]]; then
    expected="/tmp/crewship${S}-dev"
  elif [[ "$pid_file" != "$NEXT_PID_FILE" ]]; then
    return 1
  fi
  if [[ -d "$proc_root/$pid" ]]; then
    exe=$(readlink "$proc_root/$pid/exe" 2>/dev/null) || return 1
    cwd=$(readlink "$proc_root/$pid/cwd" 2>/dev/null) || return 1
    [[ "$cwd" == "$PROJECT_DIR" ]] || return 1
    if [[ -n "$expected" ]]; then
      [[ "${exe% (deleted)}" == "$expected" ]]
    else
      [[ "${exe##*/}" == node ]] || return 1
      tr '\0' '\n' < "$proc_root/$pid/cmdline" | grep -Fxq -e dev-server.mjs -e "$PROJECT_DIR/dev-server.mjs"
    fi
  else
    command=$(ps -p "$pid" -o command= 2>/dev/null) || return 1
    if [[ -n "$expected" ]]; then
      [[ "$command" == "$expected start"* ]]
    else
      [[ "$command" == "node dev-server.mjs $NEXT_PORT"* || "$command" == *"/node dev-server.mjs $NEXT_PORT"* ]]
    fi
  fi
}

is_running() {
  local pid_file="$1"
  if [[ -f "$pid_file" ]]; then
    local pid
    pid=$(cat "$pid_file")
    if process_is_owned "$pid" "$pid_file"; then
      echo "$pid"
      return 0
    fi
    rm -f "$pid_file"
  fi
  return 1
}

# Portable timeout: macOS ships without GNU timeout; Homebrew provides gtimeout.
TIMEOUT_CMD="$(command -v timeout 2>/dev/null || command -v gtimeout 2>/dev/null || true)"
if [[ -z "$TIMEOUT_CMD" ]]; then
  warn "timeout/gtimeout not found; install GNU coreutils for Docker detection timeouts"
fi

port_in_use() {
  # Use /dev/tcp probe instead of lsof — lsof scans all FDs system-wide
  # and is extremely slow on external SSD volumes (10-30s vs <0.1s).
  (echo >/dev/tcp/127.0.0.1/"$1") 2>/dev/null || nc -z 127.0.0.1 "$1" 2>/dev/null
}

detect_db_mode() {
  if [[ "${DATABASE_URL:-}" == postgresql://* || "${DATABASE_URL:-}" == postgres://* ]]; then
    echo "postgresql"
    return
  fi
  echo "sqlite"
}

check_prerequisites() {
  local missing=0

  # .env.local auto-bootstrap. Pre-fix `dev.sh start` would hard-fail
  # on a missing .env.local with "copy from .env.example and fill in
  # values", which contradicts the project's zero-friction install
  # principle: the binary itself auto-generates ENCRYPTION_KEY +
  # NEXTAUTH_SECRET into $HOME/.crewship/secrets.env on first start.
  # .env.local exists only to carry dev-tool overrides (port numbers,
  # paths, optional debugging flags), so a missing file should
  # bootstrap from .env.example silently and let the operator move on.
  # If the example file is also missing, that's a real prereq
  # failure — surface it.
  if [[ ! -f "$PROJECT_DIR/.env.local" ]]; then
    if [[ -f "$PROJECT_DIR/.env.example" ]]; then
      log ".env.local missing — bootstrapping from .env.example"
      if cp "$PROJECT_DIR/.env.example" "$PROJECT_DIR/.env.local"; then
        chmod 600 "$PROJECT_DIR/.env.local" 2>/dev/null || true
        load_env_local "$PROJECT_DIR/.env.local"
        ok ".env.local created from .env.example"
      else
        err "could not copy .env.example -> .env.local (check filesystem permissions)"
        missing=1
      fi
    else
      err ".env.local missing and no .env.example to bootstrap from"
      missing=1
    fi
  fi

  for cmd in node pnpm go; do
    if ! command -v "$cmd" &>/dev/null; then
      err "$cmd is not installed"
      missing=1
    fi
  done

  # Docker is only required if agents will be run
  if ! command -v docker &>/dev/null; then
    warn "docker is not installed -- agent containers will not work"
  fi

  if [[ $missing -ne 0 ]]; then
    err "Prerequisites check failed"
    exit 1
  fi
}

# Generate/update .env.local with instance-specific ports and paths.
# Preserves existing secrets (NEXTAUTH_SECRET, ENCRYPTION_KEY, DATABASE_URL, etc.)
generate_env_local() {
  local env_file="$PROJECT_DIR/.env.local"

  if [[ ! -f "$env_file" ]]; then
    return
  fi

  # Keys managed by this function (will be replaced/added).
  # NEXT_PUBLIC_WS_URL is listed so it gets stripped from old .env.local
  # files but is NOT re-emitted — the dev server proxy makes it unnecessary.
  local managed_keys=(
    NEXT_PUBLIC_GO_PORT
    NEXT_PUBLIC_WS_URL  # removed from output but still stripped to clean up old values
    NEXTAUTH_URL
    CREWSHIP_PORT
    CREWSHIP_SOCKET_PATH
    CREWSHIP_CONTAINER_NETWORK
    CREWSHIP_CONTAINER_PREFIX
    CREWSHIP_STORAGE_BASE_PATH
    CREWSHIP_LOG_PATH
    CREWSHIP_BOLT_PATH
    CREWSHIP_NEXTJS_URL
    # Emitted below since the origin allowlist landed, but never listed here,
    # so every start appended one more copy: clone 3 carried 100+ identical
    # CREWSHIP_ALLOWED_ORIGINS lines by 2026-09-12. Managed means stripped.
    CREWSHIP_ALLOWED_ORIGINS
  )

  # Read existing file, stripping managed keys and the banner this function
  # writes (it was accumulating for the same reason as the origins line).
  local tmp_file
  tmp_file=$(mktemp "$PROJECT_DIR/.env.local.XXXXXX")
  local key
  while IFS= read -r line || [[ -n "$line" ]]; do
    local skip=false
    if [[ "$line" == "# --- Auto-generated by dev.sh"* ]]; then
      skip=true
    fi
    for key in "${managed_keys[@]}"; do
      if [[ "$line" == "${key}="* ]]; then
        skip=true
        break
      fi
    done
    if [[ "$skip" == false ]]; then
      echo "$line" >> "$tmp_file"
    fi
  done < "$env_file"

  # Remove trailing blank lines, then add one
  sed -i '' -e :a -e '/^\n*$/{$d;N;ba' -e '}' "$tmp_file" 2>/dev/null || true
  echo "" >> "$tmp_file"

  # Append managed keys
  {
    echo "# --- Auto-generated by dev.sh (instance${S:-0}) ---"
    echo "NEXT_PUBLIC_GO_PORT=${GO_PORT}"
    # NEXT_PUBLIC_WS_URL intentionally omitted — frontend auto-detects
    # from window.location.hostname, which works for both local and remote access.
    echo "NEXTAUTH_URL=http://localhost:${NEXT_PORT}"
    echo "CREWSHIP_PORT=${GO_PORT}"
    echo "CREWSHIP_SOCKET_PATH=${SOCKET_PATH}"
    echo "CREWSHIP_CONTAINER_NETWORK=${CONTAINER_NETWORK}"
    echo "CREWSHIP_CONTAINER_PREFIX=${CREWSHIP_CONTAINER_PREFIX:-crewship${S}}"
    echo "CREWSHIP_STORAGE_BASE_PATH=${DATA_DIR}"
    echo "CREWSHIP_LOG_PATH=${LOG_PATH}"
    echo "CREWSHIP_BOLT_PATH=${BOLT_PATH:-${STATE_DIR}/state.db}"
    echo "CREWSHIP_NEXTJS_URL=http://localhost:${NEXT_PORT}"
    # Origin allowlist for state-changing requests. The default same-
    # origin gate rejects POST/PUT/DELETE when Origin (Next.js port)
    # differs from the daemon's host:port (Go port). Listing both
    # local hostnames here keeps E2E tooling (Playwright, curl with
    # CORS preflights) green without ad-hoc echoes into .env.local
    # that disappear on the next nuke. Production deployments set
    # CREWSHIP_ALLOWED_ORIGINS explicitly to their public origin.
    echo "CREWSHIP_ALLOWED_ORIGINS=http://localhost:${NEXT_PORT},http://127.0.0.1:${NEXT_PORT}"
  } >> "$tmp_file"

  mv "$tmp_file" "$env_file"
}

start_postgres() {
  local db_mode
  db_mode=$(detect_db_mode)

  if [[ "$db_mode" != "postgresql" ]]; then
    ok "Using SQLite (no PostgreSQL needed)"
    return
  fi

  local pg_port=$((5432 + INSTANCE_NUM))
  log "Starting PostgreSQL on :$pg_port (DATABASE_URL points to PostgreSQL)..."
  if COMPOSE_PROJECT_NAME="crewship${S}" CREWSHIP_PG_PORT="$pg_port" \
      docker compose -f "$PROJECT_DIR/docker/docker-compose.yml" ps --format '{{.Status}}' 2>/dev/null | grep -qi "up"; then
    ok "PostgreSQL already running"
  else
    COMPOSE_PROJECT_NAME="crewship${S}" CREWSHIP_PG_PORT="$pg_port" \
      docker compose -f "$PROJECT_DIR/docker/docker-compose.yml" up -d
    ok "PostgreSQL started on :$pg_port"
  fi
}

# The Go binary embeds web/out/ via //go:embed. Running `go build` without
# a fresh `pnpm build && scripts/embed-web-out.sh sync` silently bakes a stale
# UI into the new binary -- exactly the "make build piecemeal" trap documented in
# CLAUDE.md. Detect frontend source changes against a marker file and run
# the Next.js pipeline only when needed (idempotent; warm runs are ~0.1 s).
ensure_web_build_fresh() {
  local marker="$PROJECT_DIR/.web-build-marker"

  # The marker records the git HEAD the last frontend build was made from.
  # mtime-only freshness ("find -newer marker") is unreliable right after
  # `git fetch && git reset --hard`: files whose content didn't change keep
  # their old mtimes, and a marker touched by a later restart can postdate
  # a whole deploy — which is exactly how a stale UI shipped to dev2 on
  # 2026-07-12 despite a frontend-heavy merge day. HEAD comparison makes
  # deploys deterministic; the mtime scan below still catches uncommitted
  # local edits between commits.
  local head_now marker_head=""
  head_now=$(cd "$PROJECT_DIR" && git rev-parse HEAD 2>/dev/null || echo unknown)

  if [[ -f "$marker" ]] && [[ -d "$PROJECT_DIR/web/out" ]] && [[ -s "$PROJECT_DIR/web/out/index.html" ]]; then
    marker_head=$(head -n 1 "$marker" 2>/dev/null || true)
    if [[ "$marker_head" != "$head_now" ]]; then
      log "Frontend built from $marker_head, repo is at $head_now -- rebuilding web/out/..."
    else
    local newer
    newer=$(cd "$PROJECT_DIR" && find \
      app components hooks lib types \
      package.json pnpm-lock.yaml tsconfig.json \
      -type f -newer "$marker" 2>/dev/null | head -1)
    if [[ -z "$newer" ]]; then
      newer=$(cd "$PROJECT_DIR" && find . -maxdepth 1 -type f \
        \( -name 'next.config.*' -o -name 'tailwind.config.*' -o -name 'postcss.config.*' \) \
        -newer "$marker" 2>/dev/null | head -1)
    fi
    if [[ -z "$newer" ]]; then
      log "web/out/ is up-to-date (skipping pnpm build)"
      return 0
    fi
    log "Frontend source changed ($newer) -- rebuilding web/out/..."
    fi
  else
    log "web/out/ missing or stale -- running initial pnpm build..."
  fi

  if ! (cd "$PROJECT_DIR" && pnpm build); then
    err "pnpm build failed -- web/out/ left untouched"
    return 1
  fi
  # Preserves the tracked web/out/.placeholder.html and verifies a real
  # export landed — see scripts/embed-web-out.sh (#1567).
  if ! "$PROJECT_DIR/scripts/embed-web-out.sh" sync; then
    err "failed to stage the static export into web/out/"
    return 1
  fi
  printf '%s\n' "$head_now" > "$marker"
  ok "Frontend build refreshed -> web/out/ (HEAD $head_now)"
}

start_go() {
  if pid=$(is_running "$GO_PID_FILE"); then
    ok "crewship already running (pid $pid)"
    return
  fi

  if port_in_use "$GO_PORT"; then
    err "Port $GO_PORT already in use -- stop the process first"
    return 1
  fi

  log "Starting crewship on :$GO_PORT..."
  mkdir -p "$DATA_DIR" "$LOG_PATH" "$STATE_DIR" "$DEV_HOME/db" "$DEV_HOME/logs" "$RUNTIME_DIR" "$(dirname "$DEV_BINARY")"
  preserve_crash_log "$GO_LOG"

  ensure_web_build_fresh || return 1

  # Build first (cached builds are <1s, cold ~9s on external SSD).
  # "go run" compiles on every start and the compilation time eats into
  # the health-check budget, causing false "timed out" warnings.
  local binary="$DEV_BINARY"
  log "Building crewship..."
  # Stamp the build with the identity of the tree we are ACTUALLY building
  # (#1686). A dev slot carries no release ldflags, so `commit` on
  # GET /api/v1/system/version — and therefore `crewship version --remote` —
  # would otherwise come entirely from the Go toolchain's vcs.* stamps, and
  # those describe the enclosing clone when PROJECT_DIR is a git worktree
  # nested inside one (.claude/worktrees/<name>/). The result was a server
  # confidently reporting a commit it was not running. build-stamp.sh asks git,
  # which honours the worktree's `.git` file; see internal/buildinfo.
  local stamp_ldflags=""
  stamp_ldflags="$("$PROJECT_DIR/scripts/build-stamp.sh" ldflags "$PROJECT_DIR" 2>/dev/null || true)"
  if ! (cd "$PROJECT_DIR" && go build -ldflags "$stamp_ldflags" -o "$binary" ./cmd/crewship) 2>&1; then
    err "Go build failed -- check errors above"
    return 1
  fi

  # Build the standalone sidecar binary + stage entrypoint.sh alongside the
  # crewship binary so the docker provider's autodetect can pick them up and
  # bind-mount into crew containers (CRE-123 Phase 1). Place them next to the
  # dev binary so os.Executable()-based detection resolves correctly.
  local bin_dir
  bin_dir="$(dirname "$binary")"
  log "Building crewship-sidecar..."
  # The sidecar is bind-mounted into the crew's LINUX container and exec'd
  # there (sanity-checked with `--version`), so it must be a linux binary for
  # the container's arch — NOT a host-native one. On a macOS host a native
  # build produces a Mach-O binary that fails to exec in the container with
  # exit 255, which the engine misreports as a glibc/musl mismatch. Always
  # cross-compile for linux; match GOARCH to the Docker container platform
  # (which follows the host arch).
  local sidecar_goarch
  case "$(uname -m)" in
    arm64 | aarch64) sidecar_goarch="arm64" ;;
    x86_64 | amd64)  sidecar_goarch="amd64" ;;
    *)               sidecar_goarch="$(go env GOARCH)" ;;
  esac
  if ! (cd "$PROJECT_DIR" && CGO_ENABLED=0 GOOS=linux GOARCH="$sidecar_goarch" go build -ldflags="-s -w" -o "${bin_dir}/crewship-sidecar" ./cmd/crewship-sidecar) 2>&1; then
    err "Sidecar build failed -- check errors above"
    return 1
  fi
  cp "$PROJECT_DIR/scripts/entrypoint.sh" "${bin_dir}/entrypoint.sh"
  chmod +x "${bin_dir}/entrypoint.sh"

  # A dev slot must ALWAYS run the sidecar it just built, co-located with the
  # freshly-built server binary. Otherwise a hardcoded CREWSHIP_SIDECAR_PATH in
  # .env.local (e.g. a hand-pinned /opt/crewship_N/... path left over from an
  # earlier deploy) wins via the `. ./.env.local` sourcing below and silently
  # bind-mounts a STALE sidecar into every crew container — that is #1390, which
  # shipped dev slots a sidecar missing the CRE-153 memory-auth chokepoint
  # (unauthenticated cross-agent memory read/write) and the #1387 token_fp
  # binding. The server binary is rebuilt on every start; the sidecar now tracks
  # it. Freshness wins on a slot: we force the just-built paths AFTER sourcing
  # .env.local (see the subshell), so a stale pin cannot take effect. Warn
  # loudly here — on the deploy console, not buried in the go log — when we are
  # overriding an explicit pin, so a deliberate override is never lost silently.
  local fresh_sidecar="${bin_dir}/crewship-sidecar"
  local fresh_entrypoint="${bin_dir}/entrypoint.sh"
  local pinned_sidecar
  pinned_sidecar=$(grep -E '^CREWSHIP_SIDECAR_PATH=' "$PROJECT_DIR/.env.local" 2>/dev/null \
    | head -1 | cut -d'=' -f2- | tr -d '"' || true)
  if [[ -n "$pinned_sidecar" && "$pinned_sidecar" != "$fresh_sidecar" ]]; then
    warn "ignoring CREWSHIP_SIDECAR_PATH=$pinned_sidecar from .env.local — a dev slot always runs the sidecar it just built ($fresh_sidecar). Remove the pin from .env.local to silence this. (#1390)"
  fi

  (
    cd "$PROJECT_DIR"
    # Environment was loaded once before resolving installation paths.
    # Custom Page source must live outside crew storage. Give each dev slot
    # its own protected store without rewriting the operator's .env.local.
    export CREWSHIP_PAGE_PROJECTS_PATH="${CREWSHIP_PAGE_PROJECTS_PATH:-$PAGE_PROJECTS_DIR}"
    # Force the freshly-built, co-located sidecar + entrypoint (#1390). Exported
    # AFTER sourcing .env.local so a stale hardcoded pin there cannot win — the
    # server's applyEnvOverrides honours CREWSHIP_SIDECAR_PATH, so last export
    # wins. With no pin this is identical to the config autodetect result (same
    # path), so the no-.env.local case is unchanged.
    export CREWSHIP_SIDECAR_PATH="$fresh_sidecar"
    export CREWSHIP_ENTRYPOINT_PATH="$fresh_entrypoint"
    export CREWSHIP_LOG_LEVEL="${CREWSHIP_LOG_LEVEL-debug}"
    # Auto-detect container runtime; fall back to --no-docker if none found
    # Supports Docker, Podman, and Apple Containers (macOS 26+)
    export CREWSHIP_CONTAINER_PROVIDER="${CREWSHIP_CONTAINER_PROVIDER:-auto}"
    local has_runtime=false
    if [[ -n "$TIMEOUT_CMD" ]] && { "$TIMEOUT_CMD" 3 docker info &>/dev/null || "$TIMEOUT_CMD" 3 podman info &>/dev/null; }; then
      has_runtime=true
    elif [[ -z "$TIMEOUT_CMD" ]] && { docker info &>/dev/null 2>&1 || podman info &>/dev/null 2>&1; }; then
      has_runtime=true
    fi
    # Check Apple Containers CLI (macOS 26+)
    if ! $has_runtime && command -v container &>/dev/null && container system status &>/dev/null 2>&1; then
      has_runtime=true
    fi
    if $has_runtime; then
      exec "$binary" start
    else
      exec "$binary" start --no-docker
    fi
  ) > "$GO_LOG" 2>&1 &

  echo $! > "$GO_PID_FILE"
  guard_log_size "$GO_LOG" "$(cat "$GO_PID_FILE")"

  local attempts=0
  while [[ $attempts -lt 15 ]]; do
    if curl -sf http://localhost:$GO_PORT/api/health >/dev/null 2>&1; then
      ok "crewship started (pid $(cat "$GO_PID_FILE"))"
      return
    fi
    sleep 1
    attempts=$((attempts + 1))
  done

  warn "crewship started but health check timed out -- check $GO_LOG"
}

start_next() {
  if pid=$(is_running "$NEXT_PID_FILE"); then
    ok "Next.js already running (pid $pid)"
    return
  fi

  if port_in_use "$NEXT_PORT"; then
    err "Port $NEXT_PORT already in use -- stop the process first"
    return 1
  fi

  log "Starting Next.js on :$NEXT_PORT..."
  preserve_crash_log "$NEXT_LOG"

  (
    cd "$PROJECT_DIR"
    # Environment was loaded once before resolving installation paths.
    exec node dev-server.mjs "$NEXT_PORT"
  ) > "$NEXT_LOG" 2>&1 &

  echo $! > "$NEXT_PID_FILE"
  guard_log_size "$NEXT_LOG" "$(cat "$NEXT_PID_FILE")"

  local attempts=0
  while [[ $attempts -lt 20 ]]; do
    if curl -sf -o /dev/null http://localhost:$NEXT_PORT 2>/dev/null; then
      ok "Next.js started (pid $(cat "$NEXT_PID_FILE"))"
      return
    fi
    sleep 1
    attempts=$((attempts + 1))
  done

  warn "Next.js started but readiness check timed out -- check $NEXT_LOG"
}

kill_tree() {
  local pid="$1"
  local children
  children=$(pgrep -P "$pid" 2>/dev/null || true)
  for child in $children; do
    kill_tree "$child"
  done
  kill "$pid" 2>/dev/null || true
}

stop_service() {
  local name="$1" pid_file="$2" port="$3"

  if pid=$(is_running "$pid_file"); then
    log "Stopping $name (pid $pid)..."
    kill_tree "$pid"
    local attempts=0
    while kill -0 "$pid" 2>/dev/null && [[ $attempts -lt 10 ]]; do
      sleep 0.5
      attempts=$((attempts + 1))
    done
    if process_is_owned "$pid" "$pid_file"; then
      kill -9 "$pid" 2>/dev/null || true
    fi
    rm -f "$pid_file"
  fi

  if port_in_use "$port"; then
    # Use fuser (fast) or fall back to lsof with timeout to avoid SSD hangs
    local orphan_pids
    orphan_pids=$(fuser "$port/tcp" 2>/dev/null | tr -s ' ' || true)
    if [[ -z "$orphan_pids" && -n "$TIMEOUT_CMD" ]]; then
      orphan_pids=$("$TIMEOUT_CMD" 5 lsof -ti:"$port" 2>/dev/null || true)
    elif [[ -z "$orphan_pids" ]]; then
      orphan_pids=$(lsof -ti:"$port" 2>/dev/null || true)
    fi
    for orphan_pid in $orphan_pids; do
      if process_is_owned "$orphan_pid" "$pid_file"; then
        kill "$orphan_pid" 2>/dev/null || true
      fi
    done
    sleep 1
    if port_in_use "$port"; then
      orphan_pids=$(fuser "$port/tcp" 2>/dev/null | tr -s ' ' || true)
      if [[ -z "$orphan_pids" && -n "$TIMEOUT_CMD" ]]; then
        orphan_pids=$("$TIMEOUT_CMD" 5 lsof -ti:"$port" 2>/dev/null || true)
      elif [[ -z "$orphan_pids" ]]; then
        orphan_pids=$(lsof -ti:"$port" 2>/dev/null || true)
      fi
      for orphan_pid in $orphan_pids; do
        if process_is_owned "$orphan_pid" "$pid_file"; then
          kill -9 "$orphan_pid" 2>/dev/null || true
        fi
      done
    fi
  fi

  if port_in_use "$port"; then
    warn "$name port $port remains occupied; refusing to kill an unverified process. Stop it with its supervisor."
    return 1
  fi
  ok "$name stopped"
}

cmd_start() {
  check_legacy_dev_process
  check_legacy_dev_paths
  check_prerequisites

  # Install git pre-commit hook (idempotent, auto-skips if already installed)
  # See scripts/install-hooks.sh — runs gitleaks + golangci-lint on changed files.
  if [[ -x "$PROJECT_DIR/scripts/install-hooks.sh" ]]; then
    "$PROJECT_DIR/scripts/install-hooks.sh" >/dev/null 2>&1 || true
  fi

  echo -e "${BOLD}Crewship Dev Environment${NC}"
  if [[ "$INSTANCE_NUM" -gt 0 ]]; then
    echo -e "${BOLD}Instance ${INSTANCE_NUM}${NC} (Go :${GO_PORT}, Next.js :${NEXT_PORT})"
  fi
  echo "========================"
  echo ""

  generate_env_local

  local db_mode
  db_mode=$(detect_db_mode)
  echo -e "  Database:  ${CYAN}$db_mode${NC}"
  echo ""
  start_postgres
  start_go
  start_next
  echo ""
  ok "All services started"
  echo -e "  Frontend:  ${CYAN}http://localhost:$NEXT_PORT${NC}"
  echo -e "  Backend:   ${CYAN}http://localhost:$GO_PORT${NC}"
  echo -e "  WebSocket: ${CYAN}ws://localhost:$GO_PORT/ws${NC}"
  echo -e "  Logs:      ${CYAN}./dev.sh logs${NC}"
}

cmd_stop() {
  echo -e "${BOLD}Stopping Crewship${S}...${NC}"
  stop_service "Next.js" "$NEXT_PID_FILE" "$NEXT_PORT"
  if legacy_dev_server_running; then
    stop_service "crewship (legacy runtime)" "/tmp/crewship${S}-go.pid" "$GO_PORT"
  else
    stop_service "crewship" "$GO_PID_FILE" "$GO_PORT"
  fi
  local db_mode
  db_mode=$(detect_db_mode)
  if [[ "$db_mode" == "postgresql" ]]; then
    ok "Stopped (PostgreSQL left running)"
  else
    ok "Stopped"
  fi
}

cmd_restart() {
  cmd_stop
  echo ""
  cmd_start
}

# deploy_staleness PROJECT_DIR BINARY — one sentence when what this slot
# serves is not what it has checked out, empty when it is.
#
# Two ways a slot lies about its code, both seen on crewship-dev on
# 2026-09-12: the frontend marker records a HEAD the repo has since moved
# past (a commit landed and nobody reloaded — dev2 sat 102 frontend files
# behind for a day), or the running binary is older than the commit it is
# supposed to be running (the build was skipped or failed and the previous
# binary kept serving). `status` is where a person looks before saying
# "it's deployed", so this is where the sentence belongs. Never fails the
# caller: a missing marker or binary is reported by the lines around it.
deploy_staleness() {
  local project="$1" binary="$2"
  local head marker_head="" binary_epoch commit_epoch
  head=$(git -C "$project" rev-parse HEAD 2>/dev/null) || return 0
  if [[ -f "$project/.web-build-marker" ]]; then
    marker_head=$(head -n 1 "$project/.web-build-marker" 2>/dev/null || true)
    if [[ -n "$marker_head" && "$marker_head" != "$head" ]]; then
      echo "web/out/ was built from ${marker_head:0:8}, repo is at ${head:0:8}"
      return 0
    fi
  fi
  # `binary` may be a /proc/<pid>/exe link. Follow it with stat -L so the
  # mtime is the executing inode's, and read the link's text: once the file
  # on disk has been replaced the kernel appends " (deleted)", which means the
  # process is running code that no longer exists on disk — stale by
  # definition, whatever the timestamps say.
  # -L before -e: a /proc exe link to a replaced file is a dangling symlink,
  # and -e alone would step over exactly the case that matters.
  if [[ -L "$binary" || -e "$binary" ]]; then
    local target
    target=$(readlink "$binary" 2>/dev/null || echo "$binary")
    if [[ "$target" == *" (deleted)" ]]; then
      echo "the running binary was replaced on disk after it started (${target% (deleted)})"
      return 0
    fi
    # GNU stat first, BSD stat second: on macOS `stat -c` is an error, and
    # falling to 0 there reported every binary as stale. `date -d @…` is
    # GNU too; BSD spells it `date -r`.
    binary_epoch=$(stat -L -c %Y "$binary" 2>/dev/null || stat -L -f %m "$binary" 2>/dev/null || echo 0)
    commit_epoch=$(git -C "$project" log -1 --format=%ct 2>/dev/null || echo 0)
    if (( commit_epoch > binary_epoch )); then
      local when
      when=$(date -d "@$commit_epoch" '+%Y-%m-%d %H:%M' 2>/dev/null || date -r "$commit_epoch" '+%Y-%m-%d %H:%M' 2>/dev/null || echo "$commit_epoch")
      echo "the running binary was built before HEAD was committed ($when)"
      return 0
    fi
  fi
  return 0
}

cmd_status() {
  echo -e "${BOLD}Crewship Dev Environment${NC}"
  if [[ "$INSTANCE_NUM" -gt 0 ]]; then
    echo -e "${BOLD}Instance ${INSTANCE_NUM}${NC} (Go :${GO_PORT}, Next.js :${NEXT_PORT})"
  fi
  echo "========================"

  if git -C "$PROJECT_DIR" rev-parse --git-dir >/dev/null 2>&1; then
    local branch sha dirty
    branch=$(git -C "$PROJECT_DIR" rev-parse --abbrev-ref HEAD 2>/dev/null || echo "?")
    sha=$(git -C "$PROJECT_DIR" rev-parse --short HEAD 2>/dev/null || echo "?")
    if git -C "$PROJECT_DIR" diff --quiet 2>/dev/null \
        && git -C "$PROJECT_DIR" diff --cached --quiet 2>/dev/null \
        && [[ -z "$(git -C "$PROJECT_DIR" ls-files --others --exclude-standard 2>/dev/null)" ]]; then
      dirty=""
    else
      dirty=" ${YELLOW}(dirty)${NC}"
    fi
    echo -e "  Branch:      ${CYAN}${branch}${NC} @ ${sha}${dirty}"
    if [[ -f "$PROJECT_DIR/.web-build-marker" ]]; then
      echo -e "  web/out/:    built $(date -r "$PROJECT_DIR/.web-build-marker" '+%Y-%m-%d %H:%M' 2>/dev/null || stat -c '%y' "$PROJECT_DIR/.web-build-marker" 2>/dev/null | cut -d. -f1) from $(cut -c1-8 "$PROJECT_DIR/.web-build-marker")"
    else
      echo -e "  web/out/:    ${YELLOW}no marker (will rebuild on next start)${NC}"
    fi
    # The binary to judge is whatever is actually listening on the Go port,
    # not the one this script would build: a slot whose unit was overridden
    # to run another launcher (dev3's pages-demo.conf) still answers here.
    # Handed as the /proc link itself, not its readlink: once the file on
    # disk has been replaced the process keeps running the OLD inode and
    # readlink answers "<path> (deleted)", which no `-f` test will find — the
    # exact case a STALE line exists for. stat -L on the link reaches the
    # inode that is actually executing (validation 2026-09-13).
    local stale running_bin="$DEV_BINARY" listening_pid=""
    listening_pid=$(ss -ltnpH "sport = :$GO_PORT" 2>/dev/null | grep -oE 'pid=[0-9]+' | head -1 | cut -d= -f2 || true)
    # -L, not -e: once the binary on disk has been replaced the exe link is
    # dangling to any test that follows it, and -e would fall back to the
    # path — the one case the STALE line exists for (CodeRabbit on #2518).
    if [[ -n "$listening_pid" ]] && [[ -L "/proc/$listening_pid/exe" ]]; then
      running_bin="/proc/$listening_pid/exe"
    fi
    if stale=$(deploy_staleness "$PROJECT_DIR" "$running_bin"); then :; fi
    if [[ -n "$stale" ]]; then
      echo -e "  ${YELLOW}STALE:${NC}       $stale"
      echo -e "               run ${CYAN}sudo systemctl reload crewship-ws@${INSTANCE_NUM}${NC} (or ./dev.sh restart) to serve HEAD"
    fi
  fi

  local db_mode
  db_mode=$(detect_db_mode)

  if [[ "$db_mode" == "postgresql" ]]; then
    local pg_port=$((5432 + INSTANCE_NUM))
    if COMPOSE_PROJECT_NAME="crewship${S}" CREWSHIP_PG_PORT="$pg_port" \
        docker compose -f "$PROJECT_DIR/docker/docker-compose.yml" ps --format '{{.Status}}' 2>/dev/null | grep -qi "up"; then
      echo -e "  PostgreSQL:  ${GREEN}running${NC} (port $pg_port)"
    else
      echo -e "  PostgreSQL:  ${RED}stopped${NC}"
    fi
  else
    echo -e "  SQLite:      ${GREEN}${DATABASE_URL}${NC}"
  fi

  if pid=$(is_running "$GO_PID_FILE"); then
    if curl -sf http://localhost:$GO_PORT/api/health >/dev/null 2>&1; then
      echo -e "  crewship:    ${GREEN}running${NC} (pid $pid)"
    else
      echo -e "  crewship:    ${YELLOW}starting${NC} (pid $pid)"
    fi
  elif port_in_use "$GO_PORT"; then
    echo -e "  crewship:    ${YELLOW}running (orphan on :$GO_PORT)${NC}"
  else
    echo -e "  crewship:    ${RED}stopped${NC}"
  fi

  if pid=$(is_running "$NEXT_PID_FILE"); then
    echo -e "  Next.js:     ${GREEN}running${NC} (pid $pid, port $NEXT_PORT)"
  elif port_in_use "$NEXT_PORT"; then
    echo -e "  Next.js:     ${YELLOW}running (orphan on :$NEXT_PORT)${NC}"
  else
    echo -e "  Next.js:     ${RED}stopped${NC}"
  fi

  echo ""
  echo -e "  Frontend:    http://localhost:$NEXT_PORT"
  echo -e "  Backend:     http://localhost:$GO_PORT"
  echo -e "  WebSocket:   ws://localhost:$GO_PORT/ws"
  echo -e "  IPC Socket:  $SOCKET_PATH"
}

cmd_logs() {
  tail -f "$GO_LOG" "$NEXT_LOG" 2>/dev/null
}

cmd_logs_go() {
  tail -f "$GO_LOG" 2>/dev/null
}

cmd_logs_next() {
  tail -f "$NEXT_LOG" 2>/dev/null
}

# Refuse destructive directory cleanup anywhere in the source tree or its
# parents, regardless of confirmation. Follow symlinks for existing paths.
validate_dev_cleanup_dir() {
  local dir="$1" resolved checkout
  [[ -d "$dir" ]] || return 0
  resolved=$(cd -P "$dir" && pwd -P) || return 1
  checkout=$(cd -P "$PROJECT_DIR" && pwd -P) || return 1
  if [[ "$resolved" == / || "$resolved" == "$checkout" ||
        "$checkout" == "$resolved"/* || "$resolved" == "$checkout"/* ]]; then
    echo "Refusing to remove source tree or parent: $dir" >&2
    return 1
  fi
}

cmd_nuke() {
  check_legacy_dev_process || return 1
  # All commands use the paths resolved once from the same effective
  # environment, including explicit overrides from .env.local.
  local page_projects_dir="$PAGE_PROJECTS_DIR"

  # start_go runs the server with the checkout as its working directory (its
  # subshell cd's to PROJECT_DIR before sourcing .env.local and exec'ing), so
  # the server resolves a relative CREWSHIP_PAGE_PROJECTS_PATH against the
  # checkout. Anchor the same way here: otherwise the cd -P below resolves a
  # relative pin against the operator's cwd, and the guard cannot vouch for a
  # directory start never used.
  if [[ -n "$page_projects_dir" && "$page_projects_dir" != /* ]]; then
    page_projects_dir="$PROJECT_DIR/$page_projects_dir"
  fi

  # A pinned CREWSHIP_PAGE_PROJECTS_PATH that resolves to the checkout — or to
  # any of its parents, `/` included — would feed remove_data_dir an `rm -rf`
  # aimed at the source tree or worse. The confirmation below only *displays*
  # the path and --yes skips it, so validate the resolved directory here,
  # before anything destructive, regardless of --yes. pwd -P (not realpath,
  # which macOS lacks) matches how PROJECT_DIR itself is resolved.
  local resolved_projects resolved_checkout
  resolved_checkout="$(cd "$PROJECT_DIR" 2>/dev/null && pwd -P)"
  if [[ -z "$page_projects_dir" ]]; then
    err "Refusing to nuke: resolved page projects path is empty."
    exit 1
  fi
  if [[ -e "$page_projects_dir" && ! -d "$page_projects_dir" ]]; then
    # An existing non-directory would sail through the cd below and straight
    # into rm -rf — refuse it rather than guess what the pin meant.
    err "Refusing to nuke: page projects path '$page_projects_dir' exists but is not a directory."
    exit 1
  fi
  # cd -P: physical traversal, so a `link/..` path cannot validate against one
  # directory while rm -rf follows the symlink into another. The resolved path
  # is what the confirmation shows and what remove_data_dir deletes.
  resolved_projects="$(cd -P "$page_projects_dir" 2>/dev/null && pwd -P || true)"
  if [[ -z "$resolved_projects" ]]; then
    # Nothing exists at the pinned path; remove_data_dir no-ops it, so there is
    # nothing dangerous to delete. Say so instead of failing the whole reset.
    warn "Page projects path '$page_projects_dir' does not exist — nothing to remove for it"
  elif [[ "$resolved_projects" == "/" ||
          "$resolved_projects" == "$resolved_checkout" ||
          "$resolved_checkout" == "$resolved_projects"/* ]]; then
    err "Refusing to nuke: page projects path '$page_projects_dir' resolves to '$resolved_projects',"
    err "which is the checkout or one of its parents. Point CREWSHIP_PAGE_PROJECTS_PATH"
    err "at a scratch directory outside the repo and retry."
    exit 1
  else
    page_projects_dir="$resolved_projects"
  fi

  local cleanup_dir
  for cleanup_dir in "$DATA_DIR" "$LOG_PATH" "$page_projects_dir"; do
    validate_dev_cleanup_dir "$cleanup_dir" || return 1
  done

  echo -e "${BOLD}${RED}Factory Reset — Crewship${S}${NC}"
  echo "This will destroy ALL local data:"
  echo "  - SQLite database (${DATABASE_URL})"
  echo "  - Agent output, workspace, crew data ($DATA_DIR)"
  echo "  - Bolt state ($STATE_DIR)"
  if [[ "$page_projects_dir" == "$PAGE_PROJECTS_DIR" ]]; then
    echo "  - Page project source ($page_projects_dir)"
  else
    echo "  - Page project source ($page_projects_dir — custom CREWSHIP_PAGE_PROJECTS_PATH)"
  fi
  echo "  - Conversations ($DATA_DIR/conversations)"
  echo "  - Log files ($LOG_PATH)"
  echo "  - Docker containers (${CREWSHIP_CONTAINER_PREFIX}-*  — team + sidecars + init)"
  echo "  - Docker volumes (${CREWSHIP_CONTAINER_PREFIX}-*    — home, tools, sidecar volumes)"
  echo "  - Docker network ($CONTAINER_NETWORK)"
  echo ""

  if [[ "${2:-}" != "--yes" ]]; then
    read -rp "Type 'nuke' to confirm: " confirm
    if [[ "$confirm" != "nuke" ]]; then
      err "Aborted."
      exit 1
    fi
  fi

  echo ""
  # 1. Stop services
  log "Stopping services..."
  cmd_stop 2>/dev/null || true
  if port_in_use "$GO_PORT" || legacy_dev_server_running; then
    err "Refusing to nuke: server is still running; stop it with its supervisor first."
    return 1
  fi

  # 2. Remove Docker containers + volumes for this instance.
  #
  # The filter is the effective installation prefix (no `-team-` suffix) so it catches
  # sidecar containers too: crewship-N-svc-<crew>-<service> from the
  # SPEC-4 sugar path stayed running across nukes before this fix,
  # which left a "factory reset complete" message lying — the next
  # `dev.sh start` would join the old postgres/redis still holding
  # their old passwords. Volumes (home / tools / svc-*-vol) needed
  # the same widening: nuke that leaves volumes behind isn't a
  # nuke, the next agent picks up a populated /home/agent and acts
  # like nothing was reset.
  log "Removing Docker containers..."
  local docker_cmd
  docker_cmd=$(command -v docker 2>/dev/null || echo "$HOME/.docker/bin/docker")
  if [[ -x "$docker_cmd" ]] || command -v docker &>/dev/null; then
    local prefix="${CREWSHIP_CONTAINER_PREFIX}-" prefix_regex
    # Docker names contain a leading slash; anchor rather than match a
    # substring belonging to another installation. Prefix validation above
    # restricts regex metacharacters to dots, which must be escaped.
    prefix_regex="${prefix//./\\.}"
    local containers
    containers=$("$docker_cmd" ps -a --filter "name=^/?${prefix_regex}" --format '{{.ID}}' 2>/dev/null || true)
    if [[ -n "$containers" ]]; then
      echo "$containers" | xargs "$docker_cmd" rm -f 2>/dev/null || true
      ok "Containers removed"
    else
      ok "No containers to remove"
    fi

    # Volumes: same prefix filter. xargs -r so an empty list is a noop
    # instead of a no-args `docker volume rm` (which errors loudly).
    local volumes
    volumes=$("$docker_cmd" volume ls --format '{{.Name}}' 2>/dev/null | while IFS= read -r volume; do
      if [[ "$volume" == "$prefix"* ]]; then printf '%s\n' "$volume"; fi
    done || true)
    if [[ -n "$volumes" ]]; then
      echo "$volumes" | xargs "$docker_cmd" volume rm 2>/dev/null || true
      ok "Volumes removed"
    else
      ok "No volumes to remove"
    fi

    # Remove network
    if $docker_cmd network inspect "$CONTAINER_NETWORK" &>/dev/null; then
      $docker_cmd network rm "$CONTAINER_NETWORK" 2>/dev/null || true
      ok "Network $CONTAINER_NETWORK removed"
    fi
  else
    warn "Docker not available — skipping container cleanup"
  fi

  # 3. Remove data directories.
  # Files written by agent containers are owned by container UIDs (1001/1002),
  # so a host-side `rm -rf` hits EACCES. Fall back to a one-shot Alpine
  # container that is root inside and can clean its own droppings. The image
  # tag is pinned (not `alpine:3`) so an offline box surfaces a pull failure
  # loudly instead of silently no-op'ing.
  log "Removing data..."
  local cleanup_image="alpine:3.19"
  remove_data_dir() {
    local dir="$1"
    [[ -e "$dir" ]] || return 0
    rm -rf "$dir" 2>/dev/null && return 0
    if [[ -x "$docker_cmd" ]] || command -v docker &>/dev/null; then
      local parent base
      parent=$(dirname "$dir")
      base=$(basename "$dir")
      if ! "$docker_cmd" image inspect "$cleanup_image" &>/dev/null; then
        if ! "$docker_cmd" pull "$cleanup_image" &>/dev/null; then
          warn "could not fetch $cleanup_image — skipping container-assisted rm"
          return
        fi
      fi
      "$docker_cmd" run --rm -v "$parent:/target" "$cleanup_image" \
        sh -c 'rm -rf "/target/$1"' -- "$base" 2>/dev/null || true
    fi
    # set -e is on; use an explicit if so the [[ ]] test returning false
    # (i.e. dir is finally gone) doesn't abort cmd_nuke before later steps.
    if [[ -e "$dir" ]]; then
      warn "$dir not fully removed — try: sudo rm -rf \"$dir\""
    fi
  }
  remove_data_dir "$DATA_DIR"
  rm -f "$BOLT_PATH"
  remove_data_dir "$page_projects_dir"
  remove_data_dir "$LOG_PATH"
  rm -f "$SOCKET_PATH"
  ok "Data directories removed"

  # 4. Remove SQLite database + every sidecar file the engine writes
  #    next to it (WAL journal, shared-memory file, schema-backup
  #    snapshots from migrate.go's pre-migrate-vN-to-vM-<ts>.bak path).
  #    Without the glob, a `nuke` left stale `.bak` files behind that
  #    looked like "fresh start" containers had old data, and on a
  #    later `restore` the migrate engine occasionally re-opened the
  #    backup believing it was current. Wipe everything matching the
  #    DB stem so the next `start` builds from scratch every time.
  local db_removed=false
  local db_path=""
  if [[ "$DATABASE_URL" == file:* ]]; then
    db_path="${DATABASE_URL#file:}"
    db_path="${db_path%%\?*}"
    if [[ "$db_path" != /* ]]; then db_path="$PROJECT_DIR/$db_path"; fi
  fi
  if [[ -n "$db_path" ]]; then
    for path in "$db_path" "$db_path"-shm "$db_path"-wal "$db_path"-journal "$db_path".pre-migrate-*; do
      if [[ -e "$path" ]]; then
        rm -f "$path"
        db_removed=true
      fi
    done
  fi
  if [[ "$db_removed" == "true" ]]; then
    ok "SQLite database + sidecars removed"
  fi

  # 5. Remove log files
  rm -f "$GO_LOG" "$NEXT_LOG" "${GO_LOG}.prev" "${NEXT_LOG}.prev"
  rm -f "$GO_PID_FILE" "$NEXT_PID_FILE"
  ok "Logs and PID files cleaned"

  echo ""
  ok "Factory reset complete. Run ${CYAN}./dev.sh start${NC} then ${CYAN}./dev.sh seed${NC} for a fresh environment."
}

cmd_seed() {
  log "Seeding database via CLI..."

  # Health check — ensure the server is running before attempting to seed
  if ! curl -sf "http://localhost:${GO_PORT}/api/health" >/dev/null 2>&1; then
    err "crewship is not running on :${GO_PORT} -- run ./dev.sh start first"
    exit 1
  fi

  # Verify .env.local exists (needed for SEED_* env vars)
  if [[ ! -f "$PROJECT_DIR/.env.local" ]]; then
    err ".env.local not found -- copy from .env.example and fill in values"
    exit 1
  fi

  # Build CLI binary
  log "Building crewship CLI..."
  mkdir -p "$(dirname "$DEV_BINARY")"
  (cd "$PROJECT_DIR" && go build -o "$DEV_BINARY" ./cmd/crewship/)

  # Source env vars and run seed via CLI (requires running server)
  (
    cd "$PROJECT_DIR"
    # Environment was loaded once before resolving installation paths.
    "$DEV_BINARY" seed --server "http://localhost:${GO_PORT:-8080}" --with-team-chat "$@"
  )

  ok "Seed complete."
}

# Switch the working tree to a different ref (branch / PR / tag / SHA) and
# restart all services. Always stashes WIP first so nothing is lost. Pairs
# with ensure_web_build_fresh() so Next.js + Go are rebuilt on demand.
#
# Usage:
#   ./dev.sh deploy main              -- track origin/main
#   ./dev.sh deploy feat/my-thing     -- track origin/feat/my-thing
#   ./dev.sh deploy pr/220            -- check out GitHub PR #220
#   ./dev.sh deploy v1.4.0            -- detached HEAD on tag
#   ./dev.sh deploy 404650b7          -- detached HEAD on SHA
cmd_deploy() {
  local ref="${1:-}"
  if [[ -z "$ref" ]]; then
    err "usage: ./dev.sh deploy <branch|tag|sha|pr/<NUM>>"
    err ""
    err "  ./dev.sh deploy main"
    err "  ./dev.sh deploy feat/my-feature"
    err "  ./dev.sh deploy pr/220"
    err "  ./dev.sh deploy 404650b7"
    return 1
  fi

  if ! git -C "$PROJECT_DIR" rev-parse --git-dir >/dev/null 2>&1; then
    err "not a git repository: $PROJECT_DIR"
    return 1
  fi

  echo -e "${BOLD}Deploying ${CYAN}${ref}${NC}${BOLD} to crewship${S} dev environment${NC}"
  echo "========================"
  echo ""

  # Stash WIP (working tree + index + untracked) so a redeploy never destroys
  # in-progress work. Recover via: git stash list   then git stash apply <ref>.
  local has_wip=false
  if ! git -C "$PROJECT_DIR" diff --quiet 2>/dev/null \
     || ! git -C "$PROJECT_DIR" diff --cached --quiet 2>/dev/null \
     || [[ -n "$(git -C "$PROJECT_DIR" ls-files --others --exclude-standard 2>/dev/null)" ]]; then
    has_wip=true
  fi

  if $has_wip; then
    local current_branch stash_msg
    current_branch=$(git -C "$PROJECT_DIR" rev-parse --abbrev-ref HEAD 2>/dev/null || echo "HEAD")
    stash_msg="dev.sh deploy auto-stash @ ${current_branch} ($(date -u +%FT%TZ))"
    log "Stashing local WIP -> ${stash_msg}"
    git -C "$PROJECT_DIR" stash push --include-untracked -m "${stash_msg}" >/dev/null
    warn "WIP stashed -- recover with: git stash list && git stash apply <ref>"
  fi

  cmd_stop

  log "Fetching origin..."
  git -C "$PROJECT_DIR" fetch origin --prune --tags

  # Resolve ref:
  #   pr/<NUM>   -> fetch refs/pull/<NUM>/head -> local branch pr-<NUM>
  #   <branch>   -> create-or-reset local branch to origin/<branch>
  #   <sha|tag>  -> detached HEAD
  if [[ "$ref" =~ ^pr/([0-9]+)$ ]]; then
    local pr_num="${BASH_REMATCH[1]}"
    local local_ref="pr-$pr_num"
    log "Resolving GitHub PR #${pr_num} -> branch ${local_ref}"
    if ! git -C "$PROJECT_DIR" fetch origin "pull/${pr_num}/head:${local_ref}" --force; then
      err "Could not fetch PR #${pr_num} -- does it exist on origin?"
      return 1
    fi
    git -C "$PROJECT_DIR" checkout "$local_ref"
  elif git -C "$PROJECT_DIR" show-ref --verify --quiet "refs/remotes/origin/$ref"; then
    log "Resolving branch ${ref} -> origin/${ref}"
    # Warn about unpushed local commits before force-resetting the branch.
    # (Commits stay reachable via reflog; just want to surface the fact.)
    if git -C "$PROJECT_DIR" show-ref --verify --quiet "refs/heads/$ref"; then
      local local_sha remote_sha
      local_sha=$(git -C "$PROJECT_DIR" rev-parse "$ref")
      remote_sha=$(git -C "$PROJECT_DIR" rev-parse "origin/$ref")
      if [[ "$local_sha" != "$remote_sha" ]] \
          && ! git -C "$PROJECT_DIR" merge-base --is-ancestor "$local_sha" "$remote_sha" 2>/dev/null; then
        warn "Local '${ref}' diverged from origin/${ref}; resetting to remote"
        warn "  recovery: git reflog show ${ref}"
      fi
    fi
    git -C "$PROJECT_DIR" checkout -B "$ref" "origin/$ref"
  elif git -C "$PROJECT_DIR" rev-parse --verify --quiet "${ref}^{commit}" >/dev/null; then
    log "Resolving ${ref} -> detached HEAD"
    git -C "$PROJECT_DIR" checkout --detach "$ref"
  else
    err "Could not resolve ref: ${ref}"
    err "Tried: origin/${ref}, pr/<NUM>, local sha/tag"
    return 1
  fi

  echo ""
  log "Now on:"
  git -C "$PROJECT_DIR" log -1 --oneline
  echo ""

  # cmd_start runs ensure_web_build_fresh -> pnpm build (only if frontend
  # changed) -> go build -> start services. Idempotent on warm runs.
  cmd_start
}

case "${1:-help}" in
  start)     cmd_start ;;
  stop)      cmd_stop ;;
  restart)   cmd_restart ;;
  status)    cmd_status ;;
  deploy)    cmd_deploy "${2:-}" ;;
  nuke)      cmd_nuke "$@" ;;
  seed)      shift; cmd_seed "$@" ;;
  logs)      cmd_logs ;;
  logs:go)   cmd_logs_go ;;
  logs:next) cmd_logs_next ;;
  *)
    echo "Usage: ./dev.sh {start|stop|restart|status|deploy|seed|nuke|logs|logs:go|logs:next}"
    echo ""
    echo "  start             Start crewship + Next.js (+ PostgreSQL if configured)"
    echo "  stop              Stop crewship + Next.js"
    echo "  restart           Stop then start all services"
    echo "  status            Show branch / build / service status"
    echo "  deploy <ref>      Switch to <ref> and restart (stashes WIP)"
    echo "                    ref = branch | pr/<NUM> | tag | <sha>"
    echo "  seed [flags]      Seed demo data and team colleagues (requires running server)"
    echo "  nuke              Factory reset (destroy all data, containers, start fresh)"
    echo "  logs              Tail combined logs"
    echo "  logs:go           Tail crewship logs only"
    echo "  logs:next         Tail Next.js logs only"
    if [[ "$INSTANCE_NUM" -gt 0 ]]; then
      echo ""
      echo "  Instance:  ${INSTANCE_NUM} (Go :${GO_PORT}, Next.js :${NEXT_PORT})"
    fi
    exit 1
    ;;
esac