dev.sh
Rama main · réplica de lectura del repositorio original.
#!/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