diff --git a/dev.sh b/dev.sh index 97bfb99b71..093a6ab0d4 100755 --- a/dev.sh +++ b/dev.sh @@ -2,6 +2,8 @@ # Development environment management script # Usage: ./dev.sh [up|down|restart|logs|build] +# ./dev.sh up --ephemeral - native backend + preview frontend on this worktree's slot (scripts/dev-ephemeral.sh) +# ./dev.sh gc - reap ephemeral slots past their 24 h TTL # ./dev.sh test - unit tests (in-memory, no services needed) # ./dev.sh test:integration - integration tests (requires ./dev.sh up) # ./dev.sh clawdbot [up|down|logs] @@ -135,6 +137,10 @@ print_local_dev_notes() { case "$1" in up) + if [ "$2" = "--ephemeral" ]; then + # Per-worktree native stack on its own ports and database (D177). See scripts/dev-ephemeral.sh. + exec bash "$(dirname "$0")/scripts/dev-ephemeral.sh" up + fi ensure_local_env_file echo "๐Ÿš€ Starting development environment..." # Set higher timeout for frontend npm install @@ -150,6 +156,10 @@ case "$1" in print_local_dev_notes ;; + gc) + exec bash "$(dirname "$0")/scripts/dev-ephemeral.sh" gc + ;; + down) echo "๐Ÿ›‘ Stopping development environment..." docker compose -f $COMPOSE_FILE down diff --git a/scripts/dev-ephemeral.sh b/scripts/dev-ephemeral.sh new file mode 100755 index 0000000000..15330264d0 --- /dev/null +++ b/scripts/dev-ephemeral.sh @@ -0,0 +1,365 @@ +#!/bin/bash + +# ./dev.sh up --ephemeral and ./dev.sh gc (D177, TASK-619). +# One native backend and one vite-preview frontend per worktree, on that +# worktree's slot ports. Each slot also gets its own Mongo and Postgres +# containers on their own volumes, so no slot touches the shared compose +# services and gc can remove a slot's data by removing its containers. +# +# scripts/dev-ephemeral.sh up - claim (or reuse) this worktree's slot and start it +# scripts/dev-ephemeral.sh gc - stop and remove slots past their 24 h TTL + +set -euo pipefail + +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +DEV_DIR="${COMMONLY_DEV_DIR:-$HOME/.commonly/dev}" +LOCK_DIR="$DEV_DIR/registry.lock" +SLOTS_MJS="$ROOT/scripts/dev-slots.mjs" +# Same images as docker-compose.dev.yml, so a slot runs the versions CI runs. +PG_IMAGE="postgres:17" +MONGO_IMAGE="mongo:8.2" +# Local only: each slot's Postgres and Mongo listen on 127.0.0.1. +PG_APP_USER="commonly" +PG_APP_PASSWORD="${COMMONLY_SLOT_PG_PASSWORD:-commonly_dev}" + +LSOF="" + +# Node 20 matches CI. Use the Homebrew keg when it exists; otherwise fall back to PATH. +node20_bin() { + local prefix + prefix="$(brew --prefix node@20 2>/dev/null || true)" + if [ -n "$prefix" ] && [ -x "$prefix/bin/node" ]; then + echo "$prefix/bin" + fi +} + +lock_acquire() { + mkdir -p "$DEV_DIR" + local tries=0 + until mkdir "$LOCK_DIR" 2>/dev/null; do + tries=$((tries + 1)) + if [ "$tries" -gt 150 ]; then + echo "registry lock is held: $LOCK_DIR (remove it only if no ./dev.sh is running)" >&2 + exit 1 + fi + sleep 0.2 + done + trap 'rmdir "$LOCK_DIR" 2>/dev/null || true' EXIT +} + +lock_release() { + rmdir "$LOCK_DIR" 2>/dev/null || true + trap - EXIT +} + +require_docker() { + if ! docker info >/dev/null 2>&1; then + echo "Docker is not running. The ephemeral slot needs its own Mongo and Postgres containers." >&2 + exit 1 + fi +} + +# lsof is found through PATH. macOS installs it in /usr/sbin, which some shells leave off PATH. +require_lsof() { + PATH="$PATH:/usr/sbin:/sbin" + LSOF="$(command -v lsof || true)" + if [ -z "$LSOF" ]; then + echo "lsof is not on PATH. The ephemeral slot needs it to record the PID that listens on each port." >&2 + exit 1 + fi +} + +wait_port() { + local port="$1" label="$2" tries=0 + until nc -z 127.0.0.1 "$port" 2>/dev/null; do + tries=$((tries + 1)) + if [ "$tries" -gt 150 ]; then + echo "$label did not open port $port within 30s" >&2 + exit 1 + fi + sleep 0.2 + done +} + +# Fails when a port is still taken. Used after the recorded processes are stopped. +wait_port_free() { + local port="$1" tries=0 + while nc -z 127.0.0.1 "$port" 2>/dev/null; do + tries=$((tries + 1)) + if [ "$tries" -gt 50 ]; then + echo "port $port is still in use; stop that process or run ./dev.sh gc" >&2 + exit 1 + fi + sleep 0.2 + done +} + +wait_http() { + local url="$1" label="$2" log="$3" tries=0 + until curl -fsS "$url" >/dev/null 2>&1; do + tries=$((tries + 1)) + if [ "$tries" -gt 300 ]; then + echo "$label did not answer $url within 60s. Last log lines:" >&2 + tail -n 20 "$log" >&2 || true + exit 1 + fi + sleep 0.2 + done +} + +# The PID that actually listens on a port. A background subshell's $! is not reliable as the server PID. +listening_pid() { + local pid + pid="$("$LSOF" -nP -iTCP:"$1" -sTCP:LISTEN -t 2>/dev/null | head -n 1 || true)" + if [ -z "$pid" ]; then + echo "nothing is listening on port $1 after the health check passed" >&2 + exit 1 + fi + echo "$pid" +} + +# Stops a process this slot recorded, but only if the PID still belongs to that server. +# A PID the OS has since reused is left alone. Waits until the process is gone. +stop_pid() { + local pid="$1" pattern="$2" tries=0 + [ -n "$pid" ] && [ "$pid" != "null" ] || return 0 + if kill -0 "$pid" 2>/dev/null && ps -o command= -p "$pid" | grep -q "$pattern"; then + kill "$pid" 2>/dev/null || true + while kill -0 "$pid" 2>/dev/null; do + tries=$((tries + 1)) + if [ "$tries" -gt 50 ]; then + kill -9 "$pid" 2>/dev/null || true + break + fi + sleep 0.2 + done + fi +} + +# Per-slot secrets: created once, kept in the slot directory, never shared between slots. +ensure_slot_secrets() { + local file="$SLOT_DIR/secrets.env" + if [ ! -f "$file" ]; then + (umask 077 && { + echo "JWT_SECRET=$(openssl rand -hex 32)" + echo "LOCAL_DEV_LOGIN_PASSWORD=$(openssl rand -hex 16)" + } >"$file") + fi + # shellcheck disable=SC1090 + . "$file" +} + +# Starts a named container, or restarts it when it exists but is stopped. +ensure_container() { + local name="$1" + shift + if docker container inspect "$name" >/dev/null 2>&1; then + if [ "$(docker inspect -f '{{.State.Running}}' "$name")" != "true" ]; then + docker start "$name" >/dev/null + fi + else + docker run -d --name "$name" "$@" >/dev/null + fi +} + +# Removes a slot's containers and volumes. Returns non-zero if any of them still exists. +remove_slot_containers() { + local n="$1" name + for name in "commonly-slot-$n-pg" "commonly-slot-$n-mongo"; do + if docker container inspect "$name" >/dev/null 2>&1; then + docker rm -f "$name" >/dev/null || return 1 + fi + done + for name in "commonly-slot-$n-pgdata" "commonly-slot-$n-mongodata"; do + if docker volume inspect "$name" >/dev/null 2>&1; then + docker volume rm "$name" >/dev/null || return 1 + fi + done +} + +# Records a launched PID in the registry right away, so a later failure in this run leaves +# something the next run or gc can stop. +record_pid() { + local which="$1" pid="$2" + lock_acquire + if [ "$which" = "backend" ]; then + node "$SLOTS_MJS" set-pids "$SLOT_N" "$pid" "" + else + node "$SLOTS_MJS" set-pids "$SLOT_N" "" "$pid" + fi + lock_release +} + +up_ephemeral() { + require_docker + require_lsof + local node_bin + node_bin="$(node20_bin)" + if [ -n "$node_bin" ]; then + export PATH="$node_bin:$PATH" + else + echo "โš ๏ธ node@20 not found under Homebrew; using $(node -v). CI runs Node 20." >&2 + fi + + lock_acquire + local slug assignments + slug="$(basename "$ROOT" | tr -cs 'a-zA-Z0-9' '_' | tr 'A-Z' 'a-z' | sed -E 's/^_+//; s/_+$//' | cut -c1-24)" + assignments="$(node "$SLOTS_MJS" claim "$ROOT" "$slug")" + eval "$assignments" + lock_release + + local pg_port mongo_port + pg_port=$((5102 + 10 * SLOT_N)) + mongo_port=$((5103 + 10 * SLOT_N)) + echo "๐Ÿงฉ Slot $SLOT_N: backend :$BACKEND_PORT, frontend :$FRONTEND_PORT, postgres :$pg_port, mongo :$mongo_port, database $DB_NAME" + mkdir -p "$SLOT_DIR" + ensure_slot_secrets + + # A slot that is already running is replaced, not reused. Its recorded servers are stopped + # first, and a port still taken after that stops the run, so nothing runs that this run did not start. + stop_pid "${BACKEND_PID:-}" "dist/server.js" + stop_pid "${FRONTEND_PID:-}" "vite" + wait_port_free "$BACKEND_PORT" + wait_port_free "$FRONTEND_PORT" + + docker volume create "commonly-slot-$SLOT_N-pgdata" >/dev/null + docker volume create "commonly-slot-$SLOT_N-mongodata" >/dev/null + ensure_container "commonly-slot-$SLOT_N-pg" \ + -e POSTGRES_USER="$PG_APP_USER" -e POSTGRES_PASSWORD="$PG_APP_PASSWORD" -e POSTGRES_DB="$DB_NAME" \ + -p "127.0.0.1:$pg_port:5432" -v "commonly-slot-$SLOT_N-pgdata:/var/lib/postgresql" "$PG_IMAGE" + ensure_container "commonly-slot-$SLOT_N-mongo" \ + -p "127.0.0.1:$mongo_port:27017" -v "commonly-slot-$SLOT_N-mongodata:/data/db" "$MONGO_IMAGE" + wait_port "$mongo_port" "slot mongo" + local tries=0 + until docker exec "commonly-slot-$SLOT_N-pg" pg_isready -U "$PG_APP_USER" -d "$DB_NAME" >/dev/null 2>&1; do + tries=$((tries + 1)) + if [ "$tries" -gt 150 ]; then + echo "slot postgres did not become ready within 30s" >&2 + exit 1 + fi + sleep 0.2 + done + + echo "๐Ÿ”จ Building backend (Node $(node -v))..." + (cd "$ROOT/backend" && npm run build >"$SLOT_DIR/build-backend.log" 2>&1) || { + echo "backend build failed; see $SLOT_DIR/build-backend.log" >&2 + tail -n 20 "$SLOT_DIR/build-backend.log" >&2 + exit 1 + } + + # The server gets only what it needs. A fresh worktree has no backend/.env, so the slot's own + # session secret and local login user are passed here. + # exec makes the subshell's PID the node PID, so backend.pid names the server itself. + # Its stdio is detached, or the pipe into this script never closes. + (cd "$ROOT/backend" && exec env -i \ + PATH="$PATH" HOME="$HOME" TMPDIR="${TMPDIR:-/tmp}" LANG="${LANG:-en_US.UTF-8}" \ + PORT="$BACKEND_PORT" \ + MONGO_URI="mongodb://127.0.0.1:$mongo_port/$DB_NAME" \ + PG_HOST=127.0.0.1 PG_PORT="$pg_port" PG_USER="$PG_APP_USER" PG_PASSWORD="$PG_APP_PASSWORD" PG_DATABASE="$DB_NAME" \ + FRONTEND_URL="http://127.0.0.1:$FRONTEND_PORT" \ + JWT_SECRET="$JWT_SECRET" \ + LOCAL_DEV_LOGIN_ENABLED=true LOCAL_DEV_LOGIN_EMAIL="dev-slot-$SLOT_N@commonly.local" \ + LOCAL_DEV_LOGIN_PASSWORD="$LOCAL_DEV_LOGIN_PASSWORD" \ + RUN_WORKER=0 \ + NODE_OPTIONS="--max-old-space-size=768" \ + node dist/server.js "$SLOT_DIR/backend.log" 2>&1) /dev/null 2>&1 & + local launched_backend_pid=$! + record_pid "backend" "$launched_backend_pid" + wait_http "http://127.0.0.1:$BACKEND_PORT/api/health/live" backend "$SLOT_DIR/backend.log" + # Health alone only proves that something answers the port. The listener must be the process we launched. + local backend_pid + backend_pid="$(listening_pid "$BACKEND_PORT")" + if [ "$backend_pid" != "$launched_backend_pid" ]; then + echo "port $BACKEND_PORT answered from pid $backend_pid, not the slot server ($launched_backend_pid)" >&2 + exit 1 + fi + echo "$backend_pid" >"$SLOT_DIR/backend.pid" + + # Login checks the slot's login user and session secret together. Health passes without either. + # The user is created after the server starts listening, so wait for its ready line first. + tries=0 + until grep -q '^\[local-dev-login\] Ready' "$SLOT_DIR/backend.log" 2>/dev/null; do + tries=$((tries + 1)) + if [ "$tries" -gt 300 ]; then + echo "local login user was not ready on slot $SLOT_N; see $SLOT_DIR/backend.log" >&2 + exit 1 + fi + sleep 0.2 + done + local login_body + login_body="$(printf '{"email":"dev-slot-%s@commonly.local","password":"%s"}' "$SLOT_N" "$LOCAL_DEV_LOGIN_PASSWORD")" + if ! curl -fsS -H 'content-type: application/json' -d "$login_body" "http://127.0.0.1:$BACKEND_PORT/api/auth/login" 2>/dev/null | grep -q '"token"'; then + echo "local login failed on slot $SLOT_N; see $SLOT_DIR/backend.log" >&2 + exit 1 + fi + + echo "๐Ÿ”จ Building frontend..." + (cd "$ROOT/frontend" && REACT_APP_API_URL="http://127.0.0.1:$BACKEND_PORT" \ + ./node_modules/.bin/vite build --outDir "$SLOT_DIR/web" --emptyOutDir >"$SLOT_DIR/build-frontend.log" 2>&1) || { + echo "frontend build failed; see $SLOT_DIR/build-frontend.log" >&2 + tail -n 20 "$SLOT_DIR/build-frontend.log" >&2 + exit 1 + } + (cd "$ROOT/frontend" && exec ./node_modules/.bin/vite preview --outDir "$SLOT_DIR/web" \ + --host 127.0.0.1 --port "$FRONTEND_PORT" --strictPort "$SLOT_DIR/preview.log" 2>&1) /dev/null 2>&1 & + local launched_frontend_pid=$! + record_pid "frontend" "$launched_frontend_pid" + wait_http "http://127.0.0.1:$FRONTEND_PORT/" frontend "$SLOT_DIR/preview.log" + local frontend_pid + frontend_pid="$(listening_pid "$FRONTEND_PORT")" + if [ "$frontend_pid" != "$launched_frontend_pid" ]; then + echo "port $FRONTEND_PORT answered from pid $frontend_pid, not the slot preview ($launched_frontend_pid)" >&2 + exit 1 + fi + echo "$frontend_pid" >"$SLOT_DIR/frontend.pid" + + lock_acquire + node "$SLOTS_MJS" set-pids "$SLOT_N" "$backend_pid" "$frontend_pid" + lock_release + + local rss + rss="$(ps -o rss= -p "$backend_pid","$frontend_pid" | awk '{s+=$1} END {printf "%.0f", s/1024}')" + echo "โœ… Slot $SLOT_N is up: frontend http://127.0.0.1:$FRONTEND_PORT backend http://127.0.0.1:$BACKEND_PORT" + echo " RSS of backend + preview: ${rss} MB (excludes Docker VM and the slot's containers)" + echo " Stop it with: ./dev.sh gc (expires after 24 h; logs in $SLOT_DIR)" +} + +gc_ephemeral() { + lock_acquire + local line + node "$SLOTS_MJS" expired | while IFS= read -r line; do + [ -n "$line" ] || continue + local n db backend frontend dir + n="$(node -pe 'JSON.parse(process.argv[1]).n' "$line")" + db="$(node -pe 'JSON.parse(process.argv[1]).dbName' "$line")" + backend="$(node -pe 'JSON.parse(process.argv[1]).pids?.backend ?? ""' "$line")" + frontend="$(node -pe 'JSON.parse(process.argv[1]).pids?.frontend ?? ""' "$line")" + dir="$DEV_DIR/slot-$n" + echo "๐Ÿงน Reaping slot $n ($db)" + stop_pid "$backend" "dist/server.js" + stop_pid "$frontend" "vite" + # The slot stays claimed until its containers and volumes are gone. Otherwise a later + # claim could reuse the number while the old data still exists. + if ! docker info >/dev/null 2>&1; then + echo " Docker is not running; slot $n stays claimed for a later gc" >&2 + continue + fi + if ! remove_slot_containers "$n"; then + echo " could not remove slot $n's containers or volumes; slot stays claimed" >&2 + continue + fi + rm -rf "$dir" + node "$SLOTS_MJS" drop "$n" + done + lock_release + echo "โœ… gc done" +} + +case "${1:-}" in + up) up_ephemeral ;; + gc) gc_ephemeral ;; + *) + echo "usage: scripts/dev-ephemeral.sh up|gc" >&2 + exit 2 + ;; +esac diff --git a/scripts/dev-slots.mjs b/scripts/dev-slots.mjs new file mode 100755 index 0000000000..a2c7badcc9 --- /dev/null +++ b/scripts/dev-slots.mjs @@ -0,0 +1,150 @@ +#!/usr/bin/env node +// Slot registry for ./dev.sh up --ephemeral (D177, TASK-619). +// Each worktree gets one slot: a number, two ports and one Mongo/Postgres +// database name. State lives in ~/.commonly/dev/slots.json. Callers hold the +// registry lock (scripts/dev-ephemeral.sh); this script does no locking itself. +// +// claim reuse this worktree's slot or take the lowest free one +// set-pids +// expired list slots past their TTL, one JSON object per line +// drop forget a slot + +import fs from 'node:fs'; +import path from 'node:path'; +import os from 'node:os'; +import crypto from 'node:crypto'; + +const DIR = process.env.COMMONLY_DEV_DIR || path.join(os.homedir(), '.commonly', 'dev'); +const FILE = path.join(DIR, 'slots.json'); +const MAX_SLOTS = Number(process.env.COMMONLY_DEV_MAX_SLOTS || 8); +const TTL_MS = 24 * 60 * 60 * 1000; + +// Slot n owns backend 5100+10n and frontend 5101+10n. Ten ports per slot leaves room +// for the Vite HMR port and anything else a later step adds without renumbering. +const backendPort = (n) => 5100 + 10 * n; +const frontendPort = (n) => 5101 + 10 * n; + +// A missing file is an empty registry. Anything else that goes wrong (unreadable file, +// bad JSON, a wrong shape) must stop the caller: an empty registry here would hand a live +// slot's number to another worktree and lose the record of its processes and databases. +function load() { + let raw; + try { + raw = fs.readFileSync(FILE, 'utf8'); + } catch (error) { + if (error.code === 'ENOENT') return { slots: {} }; + throw error; + } + const registry = JSON.parse(raw); + if (!registry || typeof registry !== 'object' || !registry.slots || typeof registry.slots !== 'object' || Array.isArray(registry.slots)) { + throw new Error(`${FILE} is not a slot registry; move it aside and run again`); + } + return registry; +} + +function save(registry) { + fs.mkdirSync(DIR, { recursive: true }); + const tmp = `${FILE}.${process.pid}.tmp`; + fs.writeFileSync(tmp, `${JSON.stringify(registry, null, 2)}\n`); + fs.renameSync(tmp, FILE); +} + +function slotDir(n) { + return path.join(DIR, `slot-${n}`); +} + +function claim(worktreePath, slug) { + const key = crypto.createHash('sha1').update(path.resolve(worktreePath)).digest('hex').slice(0, 16); + const registry = load(); + const now = Date.now(); + let n = Object.keys(registry.slots).find((id) => registry.slots[id].key === key); + if (n === undefined) { + for (let candidate = 1; candidate <= MAX_SLOTS; candidate += 1) { + if (!registry.slots[String(candidate)]) { + n = String(candidate); + break; + } + } + } + if (n === undefined) { + process.stderr.write(`all ${MAX_SLOTS} ephemeral slots are taken; run ./dev.sh gc or raise COMMONLY_DEV_MAX_SLOTS\n`); + process.exit(3); + } + const previous = registry.slots[n] || {}; + const entry = { + key, + worktree: path.resolve(worktreePath), + dbName: `commonly_${slug}_${n}`, + backendPort: backendPort(Number(n)), + frontendPort: frontendPort(Number(n)), + createdAt: previous.createdAt || new Date(now).toISOString(), + expiresAt: new Date(now + TTL_MS).toISOString(), + pids: previous.pids || null, + }; + registry.slots[n] = entry; + save(registry); + // Shell-safe assignments for `eval`. Every value is generated here, so none needs quoting. + process.stdout.write( + [ + `SLOT_N=${n}`, + `SLOT_DIR=${slotDir(n)}`, + `DB_NAME=${entry.dbName}`, + `BACKEND_PORT=${entry.backendPort}`, + `FRONTEND_PORT=${entry.frontendPort}`, + `BACKEND_PID=${entry.pids?.backend ?? ''}`, + `FRONTEND_PID=${entry.pids?.frontend ?? ''}`, + '', + ].join('\n'), + ); +} + +// An empty argument keeps that process's recorded PID. The launcher records each PID as soon as +// it starts, so a run that fails later still leaves a PID the next run or gc can stop. +function setPids(n, backend, frontend) { + const registry = load(); + if (!registry.slots[n]) { + process.stderr.write(`no slot ${n}\n`); + process.exit(1); + } + const previous = registry.slots[n].pids || {}; + registry.slots[n].pids = { + backend: backend === '' ? previous.backend ?? null : Number(backend), + frontend: frontend === '' ? previous.frontend ?? null : Number(frontend), + }; + save(registry); +} + +function expired() { + const registry = load(); + const now = Date.now(); + for (const [n, entry] of Object.entries(registry.slots)) { + if (Date.parse(entry.expiresAt) <= now) { + process.stdout.write(`${JSON.stringify({ n, ...entry })}\n`); + } + } +} + +function drop(n) { + const registry = load(); + delete registry.slots[n]; + save(registry); +} + +const [command, ...args] = process.argv.slice(2); +switch (command) { + case 'claim': + claim(args[0], args[1]); + break; + case 'set-pids': + setPids(args[0], args[1], args[2]); + break; + case 'expired': + expired(); + break; + case 'drop': + drop(args[0]); + break; + default: + process.stderr.write('usage: dev-slots.mjs claim|set-pids|expired|drop\n'); + process.exit(2); +}