#!/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 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//). 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//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 " (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-- 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-.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 >" 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 . 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 " fi cmd_stop log "Fetching origin..." git -C "$PROJECT_DIR" fetch origin --prune --tags # Resolve ref: # pr/ -> fetch refs/pull//head -> local branch pr- # -> create-or-reset local branch to origin/ # -> 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/, 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 Switch to and restart (stashes WIP)" echo " ref = branch | pr/ | tag | " 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