a / scripts /dev-worktree.sh
PNRxA
feat: add dev-worktree.sh to set up and run a worktree for local dev
cca484c
Raw History Blame Contribute Delete
9.83 kB
#!/usr/bin/env bash
#
# dev-worktree.sh β€” prepare and run an OpenPosterDB git worktree for local dev.
#
# A freshly created git worktree is missing all the gitignored bits that the
# app needs (api/.env, api/db, api/cache, web/node_modules). This script makes
# a new worktree runnable in one command:
#
# 1. Copies api/.env from the MAIN worktree if this worktree doesn't have one.
# 2. Installs web dependencies (npm) if web/node_modules is missing.
# 3. Runs the Rust API (api/) and the Vite web dev server (web/) together.
# Ctrl+C β€” or either process exiting on its own β€” stops both.
#
# Run it from inside the worktree you want to set up (any subdirectory is fine).
set -euo pipefail
usage() {
cat <<'EOF'
dev-worktree.sh β€” prepare and run an OpenPosterDB worktree for local dev.
Usage:
./scripts/dev-worktree.sh Set up (if needed) then run API + web.
./scripts/dev-worktree.sh --fresh Also delete this worktree's api/db and
api/cache first, for a clean slate.
./scripts/dev-worktree.sh --release Build/run the API in release mode.
./scripts/dev-worktree.sh --setup-only Copy env + install deps, but don't run.
./scripts/dev-worktree.sh --yes Skip the --fresh confirmation prompt
(needed for non-interactive use).
./scripts/dev-worktree.sh --help Show this help.
Note: --fresh in the MAIN worktree deletes your primary db/cache, so it asks
for confirmation first (or pass --yes). In a linked worktree it wipes without
asking.
What it does:
1. Copies api/.env from the main worktree if this one lacks it.
2. Installs web deps (npm) if web/node_modules is missing.
3. Runs the Rust API (api/) and the Vite dev server (web/) together;
Ctrl+C β€” or either process exiting β€” stops both.
Run it from inside the worktree you want to set up.
EOF
}
# --- Parse arguments --------------------------------------------------------
FRESH=0
SETUP_ONLY=0
ASSUME_YES=0
CARGO_RELEASE="" # "" = debug build, "--release" = release build
while [ $# -gt 0 ]; do
case "$1" in
--fresh|--clean) FRESH=1 ;;
--release) CARGO_RELEASE="--release" ;;
--setup-only) SETUP_ONLY=1 ;;
-y|--yes|--force) ASSUME_YES=1 ;;
-h|--help) usage; exit 0 ;;
*)
echo "Unknown option: $1" >&2
echo "Run '$0 --help' for usage." >&2
exit 2
;;
esac
shift
done
# --- Locate the current and main worktrees ----------------------------------
if ! WT="$(git rev-parse --show-toplevel 2>/dev/null)"; then
echo "Error: not inside a git repository. cd into your worktree first." >&2
exit 1
fi
# The main worktree is the parent directory of the shared git dir. For a linked
# worktree, --git-common-dir resolves to <main>/.git; for the main worktree it
# is the main repo's own .git. Either way, dirname gives the main worktree root.
# Capture into its own variable (rather than nesting inside dirname) so a git
# failure β€” e.g. git older than 2.31, which lacks --path-format β€” surfaces as a
# clear error instead of silently degrading MAIN_ROOT to ".".
if ! COMMON_GIT_DIR="$(git rev-parse --path-format=absolute --git-common-dir 2>/dev/null)" || [ -z "$COMMON_GIT_DIR" ]; then
echo "Error: could not determine the main worktree (git rev-parse failed; needs git >= 2.31)." >&2
exit 1
fi
MAIN_ROOT="$(dirname "$COMMON_GIT_DIR")"
if [ ! -d "$WT/api" ] || [ ! -d "$WT/web" ]; then
echo "Error: $WT does not look like the OpenPosterDB repo (missing api/ or web/)." >&2
exit 1
fi
echo ">> Worktree : $WT"
echo ">> Main repo: $MAIN_ROOT"
# --- Tooling check ----------------------------------------------------------
for tool in cargo npm; do
if ! command -v "$tool" >/dev/null 2>&1; then
echo "Error: '$tool' not found in PATH." >&2
exit 1
fi
done
# --- 1. Copy api/.env if this worktree doesn't have one ---------------------
SRC_ENV="$MAIN_ROOT/api/.env"
DST_ENV="$WT/api/.env"
if [ -f "$DST_ENV" ]; then
echo ">> api/.env already present β€” leaving it untouched."
elif [ "$WT" = "$MAIN_ROOT" ]; then
echo "Error: this is the main worktree and it has no api/.env." >&2
echo " Create it first: (cd \"$WT/api\" && cp .env.example .env) then fill in your keys." >&2
exit 1
elif [ ! -d "$MAIN_ROOT/api" ]; then
# MAIN_ROOT isn't a usable checkout (e.g. a bare-repo-centric layout where
# the common git dir's parent is not a worktree). Point at THIS worktree.
echo "Error: couldn't find a main worktree with api/ to copy .env from (looked in $MAIN_ROOT)." >&2
echo " Create api/.env directly in this worktree:" >&2
echo " (cd \"$WT/api\" && cp .env.example .env) then fill in your keys." >&2
exit 1
elif [ -f "$SRC_ENV" ]; then
cp "$SRC_ENV" "$DST_ENV"
echo ">> Copied api/.env from main worktree -> $DST_ENV"
else
echo "Error: the main worktree has no api/.env to copy ($SRC_ENV)." >&2
echo " Create it first: (cd \"$MAIN_ROOT/api\" && cp .env.example .env) then fill in your keys." >&2
exit 1
fi
# --- 2. Optional fresh start: wipe this worktree's local DB + cache ---------
if [ "$FRESH" -eq 1 ]; then
# In the main worktree, --fresh would delete your PRIMARY db/cache, so
# require confirmation there β€” an interactive prompt, or --yes for
# non-interactive use. In a linked worktree the data is throwaway, so wipe
# it without asking.
if [ "$WT" = "$MAIN_ROOT" ] && [ "$ASSUME_YES" -ne 1 ]; then
echo "Warning: --fresh on the main worktree deletes your PRIMARY local data:" >&2
echo " $WT/api/db" >&2
echo " $WT/api/cache" >&2
if [ -t 0 ]; then
printf "Delete them and start fresh? [y/N] " >&2
read -r reply || reply=""
case "$reply" in
[yY]|[yY][eE][sS]) ;;
*) echo "Aborted β€” your data was left untouched." >&2; exit 1 ;;
esac
else
echo "Refusing: not an interactive terminal. Re-run with --yes to confirm." >&2
exit 1
fi
fi
echo ">> --fresh: wiping this worktree's local data before starting."
for sub in db cache; do
target="$WT/api/$sub"
if [ -e "$target" ]; then
echo " removing $target"
rm -rf "$target"
else
echo " (nothing at $target)"
fi
done
fi
# --- 3. Install web dependencies if missing ---------------------------------
if [ ! -d "$WT/web/node_modules" ]; then
echo ">> Installing web dependencies (first run for this worktree)..."
if ! ( cd "$WT/web" && npm ci ); then
echo ">> 'npm ci' failed; falling back to 'npm install'..."
( cd "$WT/web" && npm install )
fi
fi
if [ "$SETUP_ONLY" -eq 1 ]; then
echo ">> Setup complete (--setup-only). Run again without it to start the servers."
exit 0
fi
# --- 4. Run the API and the web dev server together -------------------------
# Both servers run as background subshells. On shutdown we must tear down the
# whole tree (cargo + the API binary, npm + vite), not just the subshells.
#
# When this script leads its own process group β€” the normal case when it is run
# as a foreground command from an interactive shell β€” `kill 0` signals every
# process in that group, reaching all descendants cleanly. If instead it was
# launched without job control (from a wrapper script, a Makefile, an npm
# script, nohup, or any non-interactive parent) it shares the caller's process
# group, where `kill 0` would also signal unrelated processes. We detect that
# case and fall back to killing the two services (and their children) directly.
SCRIPT_PGID="$(ps -o pgid= -p "$$" 2>/dev/null | tr -d ' ')" || SCRIPT_PGID=""
API_PID=""
WEB_PID=""
# Terminate a process and all of its descendants, deepest first, so that a
# grandchild (e.g. vite under npm, or the API binary under cargo) doesn't get
# reparented and orphaned when its parent dies. Used only in the fallback path.
kill_tree() {
local pid="$1" child
for child in $(pgrep -P "$pid" 2>/dev/null); do
kill_tree "$child"
done
kill -TERM "$pid" 2>/dev/null || true
}
cleanup() {
trap - INT TERM EXIT
printf '\n>> Shutting down API + web dev server...\n'
if [ -n "$SCRIPT_PGID" ] && [ "$SCRIPT_PGID" = "$$" ]; then
# We lead our own group: one signal reaps both servers and their kids.
kill 0 2>/dev/null || true
else
# Not a group leader β€” signal only our own service subtrees, so we
# never hit the caller's process group.
for p in "$API_PID" "$WEB_PID"; do
[ -n "$p" ] || continue
kill_tree "$p"
done
fi
}
trap cleanup INT TERM EXIT
if [ -n "$CARGO_RELEASE" ]; then
echo ">> API backend: http://localhost:3000 (cargo run --release)"
else
echo ">> API backend: http://localhost:3000 (cargo run)"
fi
echo ">> Open the app: http://localhost:5173 (Vite dev server β€” uses the next free port if 5173 is taken)"
echo ">> Press Ctrl+C to stop both."
echo
# Rust API. The 'if' wrapper keeps a non-zero exit (e.g. a compile error) from
# tripping 'set -e' before we notify the parent, so a crash tears down both.
# $$ inside a subshell is the parent script's PID, which is what we want here.
(
if cd "$WT/api" && cargo run $CARGO_RELEASE; then ec=0; else ec=$?; fi
echo ">> [api] cargo run exited (code $ec)"
kill -TERM $$ 2>/dev/null || true
) &
API_PID=$!
# Web dev server.
(
if cd "$WT/web" && npm run dev; then ec=0; else ec=$?; fi
echo ">> [web] dev server exited (code $ec)"
kill -TERM $$ 2>/dev/null || true
) &
WEB_PID=$!
wait