mirror of
https://github.com/jjbrunton/Sauci.git
synced 2026-10-31 18:19:43 +00:00
Merge local stack single-command fix into staging
This commit is contained in:
commit
8d9a3f4f1c
2 files changed
+471
-56
No files matched your search
@@ -16,6 +16,22 @@ described by their environment examples.
|
||||
| MCP | Hono | 3002 |
|
||||
| Expo Metro | Expo | 8081 |
|
||||
|
||||
If another local project owns one of the application ports, add a different,
|
||||
unique value to the ignored root `.env.local` before running `up`:
|
||||
|
||||
```text
|
||||
SAUCI_WEB_PORT=3100
|
||||
SAUCI_ADMIN_PORT=3101
|
||||
SAUCI_MCP_PORT=3102
|
||||
SAUCI_METRO_PORT=8181
|
||||
```
|
||||
|
||||
`SAUCI_POSTGRES_PORT` and `SAUCI_API_PORT` remain available for their existing
|
||||
local overrides. Every configured port must be a unique integer from 1 through
|
||||
65535. Before launching managed services, the launcher checks both IPv4 and IPv6
|
||||
listeners and fails closed with the owning PID, command, and working directory;
|
||||
it never stops another project's process.
|
||||
|
||||
PostgreSQL is bound to `127.0.0.1`, is healthchecked before application startup,
|
||||
and stores data in a named Docker volume. The local connection string is:
|
||||
|
||||
@@ -26,28 +42,56 @@ postgresql://sauci:sauci_local_only@127.0.0.1:54320/sauci
|
||||
Those credentials are for local development only. Deployed environments must
|
||||
inject unique secrets; they must never consume `infra/backend/.env.example`.
|
||||
|
||||
## First run
|
||||
## Start the stack
|
||||
|
||||
1. Use Node from `.nvmrc` and run `npm ci`.
|
||||
2. Install Docker Desktop or Rancher Desktop and tmux.
|
||||
3. Configure the hosted Supabase Auth public URL/key for the mobile application
|
||||
and the hosted Auth issuer/JWKS settings for the API. Do not configure any
|
||||
local command with production credentials. Authenticated mobile E2E requires
|
||||
the designated hosted non-production Auth project and a disposable,
|
||||
onboarding-complete user whose subject has a matching local profile. Pass
|
||||
its credentials as `E2E_EMAIL` and `E2E_PASSWORD` only to the Maestro process;
|
||||
do not store them in an Expo environment file.
|
||||
To exercise MCP tools, also set a local `ADMIN_API_SERVICE_TOKEN`, its
|
||||
fixture `ADMIN_API_SERVICE_USER_ID`, and a separate `SAUCI_MCP_API_KEY`.
|
||||
`scripts/dev-local.sh` passes the service token to MCP as
|
||||
`SAUCI_ADMIN_API_TOKEN`; MCP never receives a database or Supabase service
|
||||
One command starts the local database, applies migrations and fixtures, and
|
||||
launches the application services:
|
||||
|
||||
```bash
|
||||
scripts/dev-local.sh up
|
||||
```
|
||||
|
||||
Before the first use, install Docker Desktop or Rancher Desktop, tmux, and the
|
||||
repository dependencies with `npm ci`. The launcher reads `.nvmrc` itself. When
|
||||
the active Node version differs, it loads an installed Homebrew `nvm` and selects
|
||||
the exact version automatically. If that version has not been installed yet, run
|
||||
the command the launcher prints once, then rerun `up`.
|
||||
|
||||
Create the ignored root `.env.local` with the public Auth settings for the
|
||||
designated non-production Supabase project (`itbzhrvlgvdmzbnhzhyx`). Set
|
||||
`SUPABASE_AUTH_URL` (or `EXPO_PUBLIC_SUPABASE_URL`) and
|
||||
`EXPO_PUBLIC_SUPABASE_ANON_KEY`; the launcher uses the URL for the API and both
|
||||
values for Expo. It accepts only this designated hosted project or loopback Auth,
|
||||
and explicitly rejects the production project and every other hosted project.
|
||||
Environment variables exported in the shell take precedence over `.env.local`.
|
||||
The launcher treats `.env.local` as data, never shell code: use unquoted
|
||||
`KEY=VALUE` lines for only the documented launcher keys. Never place production
|
||||
credentials or Auth issuer/JWKS, Auth service-role, RevenueCat, or other provider
|
||||
secrets in `.env.local`; the launcher removes those inherited values from its
|
||||
local API and worker processes. Each `up` replaces its managed tmux windows so
|
||||
stale application processes cannot retain earlier configuration, while leaving
|
||||
unrelated windows intact. Credentials are passed only to the local process that
|
||||
needs them: MCP receives its local API token/key but never `DATABASE_URL`.
|
||||
Before replacing a managed window, the launcher verifies and stops only its
|
||||
current descendant processes. If those descendants do not exit promptly, it
|
||||
leaves that window in place and fails rather than broadening process cleanup.
|
||||
This includes a previously captured descendant that has left the pane lineage:
|
||||
the launcher will not signal it again or replace the window until that captured
|
||||
process identity is gone.
|
||||
|
||||
Authenticated mobile E2E requires a disposable, onboarding-complete user whose
|
||||
subject has a matching local profile. Pass its credentials as `E2E_EMAIL` and
|
||||
`E2E_PASSWORD` only to the Maestro process; do not store them in an Expo
|
||||
environment file. To exercise MCP tools, also set a local
|
||||
`ADMIN_API_SERVICE_TOKEN`, its fixture `ADMIN_API_SERVICE_USER_ID`, and a
|
||||
separate `SAUCI_MCP_API_KEY`. `scripts/dev-local.sh` passes the service token to
|
||||
MCP as `SAUCI_ADMIN_API_TOKEN`; MCP never receives a database or Supabase service
|
||||
credential.
|
||||
4. Run `scripts/dev-local.sh reset` to recreate only the dedicated local Docker
|
||||
volume, apply API migrations, and create configured fixtures.
|
||||
5. Run `scripts/dev-local.sh up`.
|
||||
|
||||
`up` is idempotent. `down` stops tmux application processes and leaves the
|
||||
database available. `down --all` also stops PostgreSQL but retains its volume.
|
||||
`up` is idempotent. `down` stops only the launcher's managed tmux application
|
||||
windows and leaves unrelated windows and the database available. `down --all`
|
||||
also stops PostgreSQL but retains its volume, and runs only after managed
|
||||
application cleanup succeeds.
|
||||
Use `status`, `logs <service>`, `restart <service>`, and `attach` for diagnosis.
|
||||
`logs postgres` reads Compose logs; other names read their tmux panes.
|
||||
The `worker` pane owns scheduled jobs, notifications, moderation classification,
|
||||
|
||||
+408
-37
@@ -5,8 +5,98 @@ SESSION="sauci-dev"
|
||||
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
COMPOSE_FILE="$ROOT/infra/backend/compose.yaml"
|
||||
COMPOSE_PROJECT="sauci-local"
|
||||
DESCENDANT_SHUTDOWN_ATTEMPTS=30
|
||||
DESCENDANT_SHUTDOWN_DELAY_SECONDS=0.1
|
||||
|
||||
load_env_local() {
|
||||
local env_file="$ROOT/.env.local" line line_number=0 key value seen_keys=' '
|
||||
local safe_value_pattern='^[A-Za-z0-9._:/?&=+%@,#-]+$'
|
||||
[ -f "$env_file" ] || return 0
|
||||
|
||||
# This is deliberately a data parser, not `source`: .env.local is ignored
|
||||
# developer input and must never execute in the launcher's shell.
|
||||
while IFS= read -r line || [ -n "$line" ]; do
|
||||
line_number=$((line_number + 1))
|
||||
[[ -z "$line" || "$line" == \#* ]] && continue
|
||||
if [[ ! "$line" =~ ^([A-Z0-9_]+)=(.*)$ ]]; then
|
||||
printf '✗ %s:%s must be KEY=VALUE with no quoting or shell syntax.\n' "$env_file" "$line_number" >&2
|
||||
exit 1
|
||||
fi
|
||||
key="${BASH_REMATCH[1]}"
|
||||
value="${BASH_REMATCH[2]}"
|
||||
case " $key " in
|
||||
" SAUCI_POSTGRES_PORT "|" SAUCI_API_PORT "|" SAUCI_WEB_PORT "|" SAUCI_ADMIN_PORT "|\
|
||||
" SAUCI_MCP_PORT "|" SAUCI_METRO_PORT "|" SAUCI_MOBILE_API_URL "|\
|
||||
" SUPABASE_AUTH_URL "|" EXPO_PUBLIC_SUPABASE_URL "|" EXPO_PUBLIC_SUPABASE_ANON_KEY "|\
|
||||
" ADMIN_API_SERVICE_USER_ID "|" ADMIN_API_SERVICE_TOKEN "|" SAUCI_MCP_API_KEY ") ;;
|
||||
*)
|
||||
printf '✗ %s:%s contains unsupported launcher key %s.\n' "$env_file" "$line_number" "$key" >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
case "$seen_keys" in
|
||||
*" $key "*)
|
||||
printf '✗ %s:%s duplicates launcher key %s.\n' "$env_file" "$line_number" "$key" >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
if [[ -z "$value" || ! "$value" =~ $safe_value_pattern ]]; then
|
||||
printf '✗ %s:%s contains an unsafe or empty value.\n' "$env_file" "$line_number" >&2
|
||||
exit 1
|
||||
fi
|
||||
# Explicit caller exports are authoritative over ignored local defaults.
|
||||
printenv "$key" >/dev/null 2>&1 || export "$key=$value"
|
||||
seen_keys+="$key "
|
||||
done <"$env_file"
|
||||
}
|
||||
|
||||
load_nvm() {
|
||||
command -v nvm >/dev/null 2>&1 && return
|
||||
|
||||
local nvm_dir="${NVM_DIR:-$HOME/.nvm}" brew_nvm_prefix
|
||||
local -a candidates=("$nvm_dir/nvm.sh")
|
||||
if command -v brew >/dev/null 2>&1; then
|
||||
brew_nvm_prefix="$(brew --prefix nvm 2>/dev/null || true)"
|
||||
[ -n "$brew_nvm_prefix" ] && candidates+=("$brew_nvm_prefix/nvm.sh")
|
||||
fi
|
||||
|
||||
local candidate
|
||||
for candidate in "${candidates[@]}"; do
|
||||
if [ -s "$candidate" ]; then
|
||||
# shellcheck disable=SC1090
|
||||
. "$candidate"
|
||||
command -v nvm >/dev/null 2>&1 && return
|
||||
fi
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
activate_required_node() {
|
||||
local required_node current_node
|
||||
required_node="$(tr -d '[:space:]' <"$ROOT/.nvmrc")"
|
||||
[ -n "$required_node" ] || die ".nvmrc must specify a Node version."
|
||||
|
||||
if command -v node >/dev/null 2>&1; then
|
||||
current_node="$(node -p 'process.versions.node')"
|
||||
[ "$current_node" = "$required_node" ] && return
|
||||
fi
|
||||
|
||||
load_nvm || die "Node $required_node is required. Install Homebrew nvm, then run: nvm install $required_node"
|
||||
nvm use --silent "$required_node" >/dev/null 2>&1 || die "Node $required_node is required. Install it once with: nvm install $required_node"
|
||||
current_node="$(node -p 'process.versions.node')"
|
||||
[ "$current_node" = "$required_node" ] || die "Node $required_node is required; nvm selected $current_node instead."
|
||||
}
|
||||
|
||||
# Read the ignored developer configuration before computing launch settings.
|
||||
# Explicit environment variables take precedence over .env.local values.
|
||||
load_env_local
|
||||
|
||||
POSTGRES_PORT="${SAUCI_POSTGRES_PORT:-54320}"
|
||||
API_PORT="${SAUCI_API_PORT:-3003}"
|
||||
WEB_PORT="${SAUCI_WEB_PORT:-3000}"
|
||||
ADMIN_PORT="${SAUCI_ADMIN_PORT:-3001}"
|
||||
MCP_PORT="${SAUCI_MCP_PORT:-3002}"
|
||||
METRO_PORT="${SAUCI_METRO_PORT:-8081}"
|
||||
LOCAL_DATABASE_URL="postgresql://sauci:sauci_local_only@127.0.0.1:${POSTGRES_PORT}/sauci"
|
||||
LOCAL_API_URL="http://127.0.0.1:${API_PORT}"
|
||||
MOBILE_API_URL="${SAUCI_MOBILE_API_URL:-$LOCAL_API_URL}"
|
||||
@@ -17,21 +107,21 @@ LOCAL_ADMIN_SERVICE_TOKEN="${ADMIN_API_SERVICE_TOKEN:-local-sauci-admin-service-
|
||||
LOCAL_MCP_API_KEY="${SAUCI_MCP_API_KEY:-local-sauci-mcp-only}"
|
||||
|
||||
SERVERS=(
|
||||
"api|env PORT=$API_PORT DATABASE_URL=$LOCAL_DATABASE_URL SUPABASE_AUTH_URL=$AUTH_URL ADMIN_API_SERVICE_USER_ID=$LOCAL_ADMIN_SERVICE_USER_ID ADMIN_API_SERVICE_TOKEN=$LOCAL_ADMIN_SERVICE_TOKEN MEDIA_ROOT=$ROOT/.local/media MEDIA_SIGNING_SECRET=local-media-signing-secret-change-me-123456 MEDIA_PUBLIC_BASE_URL=http://127.0.0.1:$API_PORT npm run dev -w @sauci/api"
|
||||
"worker|env DATABASE_URL=$LOCAL_DATABASE_URL CLASSIFIER_ENABLED=false MEDIA_ROOT=$ROOT/.local/media MEDIA_SIGNING_SECRET=local-media-signing-secret-change-me-123456 MEDIA_PUBLIC_BASE_URL=http://127.0.0.1:$API_PORT npm run dev:worker -w @sauci/api"
|
||||
"web|env NEXT_PUBLIC_API_URL=$LOCAL_API_URL npm run dev -w @sauci/web -- --port 3000"
|
||||
"admin|env VITE_API_URL=$LOCAL_API_URL npm run dev -w @sauci/admin -- --host 127.0.0.1 --port 3001"
|
||||
"mcp|env PORT=3002 SAUCI_ADMIN_API_URL=$LOCAL_API_URL SAUCI_ADMIN_API_TOKEN=$LOCAL_ADMIN_SERVICE_TOKEN SAUCI_MCP_API_KEY=$LOCAL_MCP_API_KEY npm run dev -w sauci-mcp-server"
|
||||
"mobile|env EXPO_PUBLIC_API_URL=$MOBILE_API_URL EXPO_PUBLIC_SUPABASE_URL=$AUTH_URL EXPO_PUBLIC_SUPABASE_ANON_KEY=$AUTH_ANON_KEY npm run dev -w @sauci/mobile -- --port 8081 --localhost"
|
||||
"api|PORT=$API_PORT DATABASE_URL=$LOCAL_DATABASE_URL MEDIA_ROOT=$ROOT/.local/media MEDIA_SIGNING_SECRET=local-media-signing-secret-change-me-123456 MEDIA_PUBLIC_BASE_URL=http://127.0.0.1:$API_PORT npm run dev -w @sauci/api"
|
||||
"worker|DATABASE_URL=$LOCAL_DATABASE_URL CLASSIFIER_ENABLED=false MEDIA_ROOT=$ROOT/.local/media MEDIA_SIGNING_SECRET=local-media-signing-secret-change-me-123456 MEDIA_PUBLIC_BASE_URL=http://127.0.0.1:$API_PORT npm run dev:worker -w @sauci/api"
|
||||
"web|env NEXT_PUBLIC_API_URL=$LOCAL_API_URL npm run dev -w @sauci/web -- --port $WEB_PORT"
|
||||
"admin|env VITE_API_URL=$LOCAL_API_URL npm run dev -w @sauci/admin -- --host 127.0.0.1 --port $ADMIN_PORT"
|
||||
"mcp|env PORT=$MCP_PORT npm run dev -w sauci-mcp-server"
|
||||
"mobile|npm run dev -w @sauci/mobile -- --port $METRO_PORT --localhost"
|
||||
)
|
||||
|
||||
PORTS=(
|
||||
"postgres:$POSTGRES_PORT"
|
||||
"api:$API_PORT"
|
||||
"web:3000"
|
||||
"admin:3001"
|
||||
"mcp:3002"
|
||||
"metro:8081"
|
||||
"web:$WEB_PORT"
|
||||
"admin:$ADMIN_PORT"
|
||||
"mcp:$MCP_PORT"
|
||||
"metro:$METRO_PORT"
|
||||
)
|
||||
|
||||
c_reset=$'\033[0m'; c_dim=$'\033[2m'; c_grn=$'\033[32m'; c_ylw=$'\033[33m'; c_red=$'\033[31m'; c_cyn=$'\033[36m'
|
||||
@@ -46,15 +136,21 @@ compose() {
|
||||
}
|
||||
|
||||
preflight() {
|
||||
activate_required_node
|
||||
if ! node - "$POSTGRES_PORT" "$API_PORT" "$WEB_PORT" "$ADMIN_PORT" "$MCP_PORT" "$METRO_PORT" <<'NODE'
|
||||
const ports = process.argv.slice(2);
|
||||
const valid = ports.every((port) => /^\d+$/.test(port) && Number(port) >= 1 && Number(port) <= 65535);
|
||||
if (!valid || new Set(ports.map(Number)).size !== ports.length) process.exitCode = 1;
|
||||
NODE
|
||||
then
|
||||
die "Configured ports must be unique integers from 1 to 65535."
|
||||
fi
|
||||
command -v tmux >/dev/null 2>&1 || die "tmux not found. Install: brew install tmux"
|
||||
command -v npm >/dev/null 2>&1 || die "npm not found. Install Node $(cat "$ROOT/.nvmrc")."
|
||||
command -v npm >/dev/null 2>&1 || die "npm not found after activating Node $(cat "$ROOT/.nvmrc")."
|
||||
command -v docker >/dev/null 2>&1 || die "Docker-compatible CLI not found."
|
||||
docker info >/dev/null 2>&1 || die "Container runtime is not running."
|
||||
docker compose version >/dev/null 2>&1 || die "Docker Compose v2 is required."
|
||||
[ -d "$ROOT/node_modules" ] || die "Dependencies missing. Run: npm ci"
|
||||
local node_major
|
||||
node_major="$(node -p 'process.versions.node.split(`.`)[0]')"
|
||||
[ "$node_major" = "20" ] || die "Node 20 is required. Activate .nvmrc before starting the stack."
|
||||
}
|
||||
|
||||
assert_local_database() {
|
||||
@@ -65,10 +161,212 @@ assert_local_database() {
|
||||
[ "$POSTGRES_PORT" = "54320" ] || die "Destructive reset requires the dedicated local PostgreSQL port 54320."
|
||||
}
|
||||
|
||||
assert_local_auth() {
|
||||
case "$AUTH_URL" in
|
||||
http://127.0.0.1:*|http://localhost:*|http://\[::1\]:*) ;;
|
||||
*) die "Refusing local development against non-local Supabase Auth: $AUTH_URL" ;;
|
||||
assert_allowed_auth() {
|
||||
local verdict
|
||||
verdict="$(node - "$AUTH_URL" <<'NODE'
|
||||
const raw = process.argv[2];
|
||||
try {
|
||||
const url = new URL(raw);
|
||||
if (url.username || url.password || url.search || url.hash || !['', '/'].includes(url.pathname)) throw new Error('shape');
|
||||
const isCanonicalRoot = raw === url.origin || raw === `${url.origin}/`;
|
||||
const isLoopback = isCanonicalRoot && url.protocol === 'http:' && ['127.0.0.1', 'localhost', '[::1]'].includes(url.hostname);
|
||||
const isNonProduction = isCanonicalRoot && url.protocol === 'https:' && url.origin === 'https://itbzhrvlgvdmzbnhzhyx.supabase.co';
|
||||
if (isLoopback || isNonProduction) process.stdout.write('allowed');
|
||||
else if (url.hostname === 'ckjcrkjpmhqhiucifukx.supabase.co') process.stdout.write('production');
|
||||
else process.stdout.write('unapproved');
|
||||
} catch {
|
||||
process.stdout.write('invalid');
|
||||
}
|
||||
NODE
|
||||
)"
|
||||
case "$verdict" in
|
||||
allowed) ;;
|
||||
production) die "Refusing local development against the production Supabase Auth project." ;;
|
||||
invalid) die "SUPABASE_AUTH_URL must be a valid root Auth URL without credentials, a path, query, or fragment." ;;
|
||||
*) die "Refusing local development against an unapproved Supabase Auth URL." ;;
|
||||
esac
|
||||
}
|
||||
|
||||
prepare_session() {
|
||||
BOOTSTRAP_CREATED=false
|
||||
if ! tmux has-session -t "$SESSION" 2>/dev/null; then
|
||||
tmux new-session -d -s "$SESSION" -n _bootstrap -c "$ROOT" 'sleep 300'
|
||||
BOOTSTRAP_CREATED=true
|
||||
elif ! tmux list-windows -t "$SESSION" -F '#{window_name}' | grep -qx '_bootstrap'; then
|
||||
tmux new-window -d -t "$SESSION" -n _bootstrap -c "$ROOT" 'sleep 300'
|
||||
BOOTSTRAP_CREATED=true
|
||||
fi
|
||||
}
|
||||
|
||||
restart_managed_windows() {
|
||||
local name failed=false
|
||||
for name in api worker web admin mcp mobile; do
|
||||
stop_managed_window "$name" || failed=true
|
||||
done
|
||||
[ "$failed" = false ]
|
||||
}
|
||||
|
||||
is_numeric_pid() {
|
||||
[[ "$1" =~ ^[1-9][0-9]*$ ]]
|
||||
}
|
||||
|
||||
pane_pid_for_window() {
|
||||
local name="$1" pane_pid
|
||||
pane_pid="$(tmux display-message -p -t "$SESSION:$name" '#{pane_pid}' 2>/dev/null || true)"
|
||||
is_numeric_pid "$pane_pid" && printf '%s\n' "$pane_pid"
|
||||
}
|
||||
|
||||
process_start_time() {
|
||||
local pid="$1" start_time
|
||||
is_numeric_pid "$pid" || return 1
|
||||
start_time="$(ps -p "$pid" -o lstart= 2>/dev/null | sed 's/^[[:space:]]*//;s/[[:space:]]*$//')"
|
||||
[ -n "$start_time" ] && printf '%s\n' "$start_time"
|
||||
}
|
||||
|
||||
has_pane_ancestry() {
|
||||
local pid="$1" pane_pid="$2" parent_pid hops=0
|
||||
is_numeric_pid "$pid" && is_numeric_pid "$pane_pid" || return 1
|
||||
while [ "$hops" -lt 64 ]; do
|
||||
parent_pid="$(ps -p "$pid" -o ppid= 2>/dev/null | tr -d '[:space:]')"
|
||||
is_numeric_pid "$parent_pid" || return 1
|
||||
[ "$parent_pid" = "$pane_pid" ] && return 0
|
||||
[ "$parent_pid" -gt 1 ] || return 1
|
||||
pid="$parent_pid"
|
||||
hops=$((hops + 1))
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
capture_descendants() {
|
||||
local parent_pid="$1" child_pid
|
||||
is_numeric_pid "$parent_pid" || return 0
|
||||
while IFS= read -r child_pid; do
|
||||
is_numeric_pid "$child_pid" || continue
|
||||
capture_descendants "$child_pid"
|
||||
local start_time
|
||||
start_time="$(process_start_time "$child_pid" || true)"
|
||||
[ -n "$start_time" ] || continue
|
||||
MANAGED_DESCENDANT_PIDS+=("$child_pid")
|
||||
MANAGED_DESCENDANT_STARTS+=("$start_time")
|
||||
record_captured_identity "$child_pid" "$start_time"
|
||||
done < <(pgrep -P "$parent_pid" 2>/dev/null || true)
|
||||
}
|
||||
|
||||
record_captured_identity() {
|
||||
local pid="$1" start_time="$2" index
|
||||
for index in "${!CAPTURED_DESCENDANT_PIDS[@]}"; do
|
||||
[ "${CAPTURED_DESCENDANT_PIDS[$index]}" = "$pid" ] &&
|
||||
[ "${CAPTURED_DESCENDANT_STARTS[$index]}" = "$start_time" ] && return 0
|
||||
done
|
||||
CAPTURED_DESCENDANT_PIDS+=("$pid")
|
||||
CAPTURED_DESCENDANT_STARTS+=("$start_time")
|
||||
}
|
||||
|
||||
captured_identity_remains() {
|
||||
local index pid start_time
|
||||
for index in "${!CAPTURED_DESCENDANT_PIDS[@]}"; do
|
||||
pid="${CAPTURED_DESCENDANT_PIDS[$index]}"
|
||||
start_time="${CAPTURED_DESCENDANT_STARTS[$index]}"
|
||||
is_numeric_pid "$pid" || continue
|
||||
[ "$(process_start_time "$pid" || true)" = "$start_time" ] && return 0
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
signal_verified_descendants() {
|
||||
local name="$1" expected_pane_pid="$2" index pid start_time current_pane_pid
|
||||
for index in "${!MANAGED_DESCENDANT_PIDS[@]}"; do
|
||||
pid="${MANAGED_DESCENDANT_PIDS[$index]}"
|
||||
start_time="${MANAGED_DESCENDANT_STARTS[$index]}"
|
||||
is_numeric_pid "$pid" || continue
|
||||
current_pane_pid="$(pane_pid_for_window "$name" || true)"
|
||||
[ "$current_pane_pid" = "$expected_pane_pid" ] || continue
|
||||
[ "$(process_start_time "$pid" || true)" = "$start_time" ] || continue
|
||||
has_pane_ancestry "$pid" "$expected_pane_pid" || continue
|
||||
kill -TERM "$pid" 2>/dev/null || true
|
||||
done
|
||||
}
|
||||
|
||||
descendants_remain() {
|
||||
local pane_pid="$1" child_pid
|
||||
is_numeric_pid "$pane_pid" || return 1
|
||||
child_pid="$(pgrep -P "$pane_pid" 2>/dev/null | head -1 || true)"
|
||||
[ -n "$child_pid" ]
|
||||
}
|
||||
|
||||
stop_managed_window() {
|
||||
local name="$1" pane_pid attempt=1
|
||||
case "$name" in api|worker|web|admin|mcp|mobile) ;; *) die "unknown managed window '$name'" ;; esac
|
||||
tmux list-windows -t "$SESSION" -F '#{window_name}' 2>/dev/null | grep -qx "$name" || return 0
|
||||
|
||||
CAPTURED_DESCENDANT_PIDS=("")
|
||||
CAPTURED_DESCENDANT_STARTS=("")
|
||||
while [ "$attempt" -le "$DESCENDANT_SHUTDOWN_ATTEMPTS" ]; do
|
||||
pane_pid="$(pane_pid_for_window "$name" || true)"
|
||||
if ! is_numeric_pid "$pane_pid"; then
|
||||
captured_identity_remains && return 1
|
||||
return 0
|
||||
fi
|
||||
if ! descendants_remain "$pane_pid"; then
|
||||
captured_identity_remains && {
|
||||
warn "window '$name' has a captured descendant outside its pane lineage; leaving it running"
|
||||
return 1
|
||||
}
|
||||
tmux kill-window -t "$SESSION:$name" 2>/dev/null || true
|
||||
return 0
|
||||
fi
|
||||
|
||||
# Bash 3.2 plus nounset requires non-empty sentinels for these arrays.
|
||||
MANAGED_DESCENDANT_PIDS=("")
|
||||
MANAGED_DESCENDANT_STARTS=("")
|
||||
capture_descendants "$pane_pid"
|
||||
signal_verified_descendants "$name" "$pane_pid"
|
||||
sleep "$DESCENDANT_SHUTDOWN_DELAY_SECONDS"
|
||||
attempt=$((attempt + 1))
|
||||
done
|
||||
pane_pid="$(pane_pid_for_window "$name" || true)"
|
||||
if captured_identity_remains; then
|
||||
warn "window '$name' still has captured descendants; leaving it running"
|
||||
return 1
|
||||
fi
|
||||
if is_numeric_pid "$pane_pid" && descendants_remain "$pane_pid"; then
|
||||
warn "window '$name' still has managed descendants; leaving it running"
|
||||
return 1
|
||||
fi
|
||||
tmux kill-window -t "$SESSION:$name" 2>/dev/null || true
|
||||
}
|
||||
|
||||
window_environment() {
|
||||
local name="$1"
|
||||
# Bash 3.2 with nounset treats an empty array expansion as unbound. Keep a
|
||||
# sentinel and skip it when building tmux's optional -e arguments.
|
||||
WINDOW_ENV=("")
|
||||
WINDOW_FORWARD=''
|
||||
case "$name" in
|
||||
api)
|
||||
WINDOW_ENV=(
|
||||
"SUPABASE_AUTH_URL=$AUTH_URL"
|
||||
"ADMIN_API_SERVICE_USER_ID=$LOCAL_ADMIN_SERVICE_USER_ID"
|
||||
"ADMIN_API_SERVICE_TOKEN=$LOCAL_ADMIN_SERVICE_TOKEN"
|
||||
)
|
||||
WINDOW_FORWARD='SUPABASE_AUTH_URL="$SUPABASE_AUTH_URL" ADMIN_API_SERVICE_USER_ID="$ADMIN_API_SERVICE_USER_ID" ADMIN_API_SERVICE_TOKEN="$ADMIN_API_SERVICE_TOKEN"'
|
||||
;;
|
||||
mcp)
|
||||
WINDOW_ENV=(
|
||||
"SAUCI_ADMIN_API_URL=$LOCAL_API_URL"
|
||||
"SAUCI_ADMIN_API_TOKEN=$LOCAL_ADMIN_SERVICE_TOKEN"
|
||||
"SAUCI_MCP_API_KEY=$LOCAL_MCP_API_KEY"
|
||||
)
|
||||
WINDOW_FORWARD='SAUCI_ADMIN_API_URL="$SAUCI_ADMIN_API_URL" SAUCI_ADMIN_API_TOKEN="$SAUCI_ADMIN_API_TOKEN" SAUCI_MCP_API_KEY="$SAUCI_MCP_API_KEY"'
|
||||
;;
|
||||
mobile)
|
||||
WINDOW_ENV=(
|
||||
"EXPO_PUBLIC_API_URL=$MOBILE_API_URL"
|
||||
"EXPO_PUBLIC_SUPABASE_URL=$AUTH_URL"
|
||||
"EXPO_PUBLIC_SUPABASE_ANON_KEY=$AUTH_ANON_KEY"
|
||||
)
|
||||
WINDOW_FORWARD='EXPO_PUBLIC_API_URL="$EXPO_PUBLIC_API_URL" EXPO_PUBLIC_SUPABASE_URL="$EXPO_PUBLIC_SUPABASE_URL" EXPO_PUBLIC_SUPABASE_ANON_KEY="$EXPO_PUBLIC_SUPABASE_ANON_KEY"'
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
@@ -85,7 +383,8 @@ api_workspace_exists() {
|
||||
run_api_script() {
|
||||
local script="$1"
|
||||
api_workspace_exists || die "apps/api is not present yet; cannot run db:$script"
|
||||
(cd "$ROOT" && env DATABASE_URL="$LOCAL_DATABASE_URL" npm run "db:$script" -w @sauci/api)
|
||||
(cd "$ROOT" && env -i PATH="$PATH" HOME="$HOME" TMPDIR="${TMPDIR:-/tmp}" \
|
||||
DATABASE_URL="$LOCAL_DATABASE_URL" npm run "db:$script" -w @sauci/api)
|
||||
}
|
||||
|
||||
start_window() {
|
||||
@@ -98,8 +397,17 @@ start_window() {
|
||||
warn "apps/api is not present yet; skipping API window"
|
||||
return
|
||||
fi
|
||||
tmux new-window -t "$SESSION" -n "$name" -c "$ROOT"
|
||||
tmux send-keys -t "$SESSION:$name" "$cmd" C-m
|
||||
window_environment "$name"
|
||||
local entry escaped_path escaped_home escaped_tmpdir
|
||||
printf -v escaped_path '%q' "$PATH"
|
||||
printf -v escaped_home '%q' "$HOME"
|
||||
printf -v escaped_tmpdir '%q' "${TMPDIR:-/tmp}"
|
||||
set -- tmux new-window -d -t "$SESSION" -n "$name" -c "$ROOT"
|
||||
for entry in "${WINDOW_ENV[@]}"; do
|
||||
[ -n "$entry" ] && set -- "$@" -e "$entry"
|
||||
done
|
||||
set -- "$@" "env -i PATH=$escaped_path HOME=$escaped_home TMPDIR=$escaped_tmpdir $WINDOW_FORWARD $cmd"
|
||||
"$@"
|
||||
}
|
||||
|
||||
port_check() {
|
||||
@@ -115,20 +423,57 @@ port_check() {
|
||||
fi
|
||||
}
|
||||
|
||||
report_port_owner() {
|
||||
local port="$1" pid command cwd raw_command
|
||||
while IFS= read -r pid; do
|
||||
[ -n "$pid" ] || continue
|
||||
raw_command="$(ps -p "$pid" -o command= 2>/dev/null)"
|
||||
command="$(printf '%s' "$raw_command" | LC_ALL=C tr -d '[:cntrl:]')"
|
||||
command="${command#"${command%%[![:space:]]*}"}"
|
||||
command="${command%%[[:space:]]*}"
|
||||
[ -n "$command" ] && command="$(basename "$command")"
|
||||
cwd="$(lsof -a -p "$pid" -d cwd -Fn 2>/dev/null | sed -n 's/^n//p' | head -1)"
|
||||
cwd="$(printf '%s' "$cwd" | LC_ALL=C tr -d '[:cntrl:]')"
|
||||
printf ' PID %s\n command: %s\n cwd: %s\n' "$pid" "${command:-unknown}" "${cwd:-unknown}" >&2
|
||||
done < <(lsof -nP -tiTCP:"$port" -sTCP:LISTEN 2>/dev/null | sort -u)
|
||||
}
|
||||
|
||||
assert_app_ports_available() {
|
||||
local entry name port occupied=false
|
||||
local -a app_ports=("api:$API_PORT" "web:$WEB_PORT" "admin:$ADMIN_PORT" "mcp:$MCP_PORT" "metro:$METRO_PORT")
|
||||
for entry in "${app_ports[@]}"; do
|
||||
name="${entry%%:*}"; port="${entry##*:}"
|
||||
if port_up "$port"; then
|
||||
printf '✗ Port %s for %s is already listening. Refusing to replace its owner.\n' "$port" "$name" >&2
|
||||
report_port_owner "$port"
|
||||
occupied=true
|
||||
fi
|
||||
done
|
||||
[ "$occupied" = false ] || return 1
|
||||
}
|
||||
|
||||
cmd_up() {
|
||||
preflight
|
||||
[ -n "$AUTH_URL" ] || die "SUPABASE_AUTH_URL (or EXPO_PUBLIC_SUPABASE_URL) is required for hosted Auth verification."
|
||||
assert_local_auth
|
||||
assert_allowed_auth
|
||||
prepare_session
|
||||
if ! restart_managed_windows; then
|
||||
[ "$BOOTSTRAP_CREATED" = true ] && tmux kill-window -t "$SESSION:_bootstrap" 2>/dev/null || true
|
||||
die "Refusing to restart while a managed window still has descendants."
|
||||
fi
|
||||
if ! assert_app_ports_available; then
|
||||
[ "$BOOTSTRAP_CREATED" = true ] && tmux kill-window -t "$SESSION:_bootstrap" 2>/dev/null || true
|
||||
die "Refusing to start while a configured application port is occupied."
|
||||
fi
|
||||
ensure_infra
|
||||
run_api_script migrate
|
||||
run_api_script seed
|
||||
tmux has-session -t "$SESSION" 2>/dev/null || tmux new-session -d -s "$SESSION" -n _bootstrap -c "$ROOT"
|
||||
local service
|
||||
for service in "${SERVERS[@]}"; do start_window "${service%%|*}" "${service#*|}"; done
|
||||
tmux kill-window -t "$SESSION:_bootstrap" 2>/dev/null || true
|
||||
[ "$BOOTSTRAP_CREATED" = true ] && tmux kill-window -t "$SESSION:_bootstrap" 2>/dev/null || true
|
||||
ok "Stack starting in tmux session '$SESSION'"
|
||||
port_check
|
||||
printf "\n API: %s/health/live\n Web: http://127.0.0.1:3000\n Admin: http://127.0.0.1:3001\n MCP: http://127.0.0.1:3002/health\n" "$LOCAL_API_URL"
|
||||
printf "\n API: %s/health/live\n Web: http://127.0.0.1:%s\n Admin: http://127.0.0.1:%s\n MCP: http://127.0.0.1:%s/health\n" "$LOCAL_API_URL" "$WEB_PORT" "$ADMIN_PORT" "$MCP_PORT"
|
||||
}
|
||||
|
||||
cmd_status() {
|
||||
@@ -152,26 +497,52 @@ cmd_logs() {
|
||||
}
|
||||
|
||||
cmd_restart() {
|
||||
local name="${1:?usage: restart <service>}" service
|
||||
local name="${1:?usage: restart <service>}" service="" candidate
|
||||
if [ "$name" = "postgres" ]; then
|
||||
compose restart postgres
|
||||
ok "restarted postgres"
|
||||
return
|
||||
fi
|
||||
tmux has-session -t "$SESSION" 2>/dev/null || die "session not running"
|
||||
tmux kill-window -t "$SESSION:$name" 2>/dev/null || true
|
||||
for service in "${SERVERS[@]}"; do
|
||||
if [ "${service%%|*}" = "$name" ]; then
|
||||
start_window "$name" "${service#*|}"
|
||||
ok "restarted $name"
|
||||
return
|
||||
for candidate in "${SERVERS[@]}"; do
|
||||
if [ "${candidate%%|*}" = "$name" ]; then
|
||||
service="$candidate"
|
||||
break
|
||||
fi
|
||||
done
|
||||
die "unknown service '$name'"
|
||||
[ -n "$service" ] || die "unknown service '$name'"
|
||||
|
||||
preflight
|
||||
case "$name" in
|
||||
api|mobile)
|
||||
[ -n "$AUTH_URL" ] || die "SUPABASE_AUTH_URL (or EXPO_PUBLIC_SUPABASE_URL) is required."
|
||||
assert_allowed_auth
|
||||
;;
|
||||
esac
|
||||
[ "$name" != "mobile" ] || [ -n "$AUTH_ANON_KEY" ] || die "EXPO_PUBLIC_SUPABASE_ANON_KEY is required for the mobile Auth client."
|
||||
|
||||
tmux has-session -t "$SESSION" 2>/dev/null || die "session not running"
|
||||
prepare_session
|
||||
if ! stop_managed_window "$name"; then
|
||||
[ "$BOOTSTRAP_CREATED" = true ] && tmux kill-window -t "$SESSION:_bootstrap" 2>/dev/null || true
|
||||
die "Refusing to restart '$name' while its managed descendants remain."
|
||||
fi
|
||||
start_window "$name" "${service#*|}"
|
||||
[ "$BOOTSTRAP_CREATED" = true ] && tmux kill-window -t "$SESSION:_bootstrap" 2>/dev/null || true
|
||||
ok "restarted $name"
|
||||
}
|
||||
|
||||
cmd_down() {
|
||||
tmux kill-session -t "$SESSION" 2>/dev/null && ok "application services stopped" || warn "no session '$SESSION'"
|
||||
if tmux has-session -t "$SESSION" 2>/dev/null; then
|
||||
prepare_session
|
||||
if ! restart_managed_windows; then
|
||||
[ "$BOOTSTRAP_CREATED" = true ] && tmux kill-window -t "$SESSION:_bootstrap" 2>/dev/null || true
|
||||
die "Refusing to stop infrastructure while a managed window still has descendants."
|
||||
fi
|
||||
[ "$BOOTSTRAP_CREATED" = true ] && tmux kill-window -t "$SESSION:_bootstrap" 2>/dev/null || true
|
||||
ok "managed application services stopped"
|
||||
else
|
||||
warn "no session '$SESSION'"
|
||||
fi
|
||||
if [ "${1:-}" = "--all" ]; then
|
||||
compose down
|
||||
ok "standalone backend infrastructure stopped (data retained)"
|
||||
@@ -203,9 +574,9 @@ cmd_native() {
|
||||
preflight
|
||||
[ -n "$AUTH_URL" ] || die "SUPABASE_AUTH_URL (or EXPO_PUBLIC_SUPABASE_URL) is required."
|
||||
[ -n "$AUTH_ANON_KEY" ] || die "EXPO_PUBLIC_SUPABASE_ANON_KEY is required for the mobile Auth client."
|
||||
assert_local_auth
|
||||
assert_allowed_auth
|
||||
ensure_infra
|
||||
(cd "$ROOT" && env EXPO_PUBLIC_API_URL="$MOBILE_API_URL" \
|
||||
(cd "$ROOT" && env -i PATH="$PATH" HOME="$HOME" TMPDIR="${TMPDIR:-/tmp}" EXPO_PUBLIC_API_URL="$MOBILE_API_URL" \
|
||||
EXPO_PUBLIC_SUPABASE_URL="$AUTH_URL" EXPO_PUBLIC_SUPABASE_ANON_KEY="$AUTH_ANON_KEY" \
|
||||
npm run "$platform" -w @sauci/mobile)
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user