Manage isolated Convex preview environments with per-worktree port assignment. MANDATORY before any Playwright, E2E test, or browser testing.
Manages isolated Convex preview environments. Each worktree gets its own backend, database, seeded data, and unique port.
This skill is MANDATORY before:
/preview or /preview start — Create/resume preview and start dev server/preview stop — Stop this worktree's dev server only/preview status — Check preview status across all worktreesFollow these steps IN ORDER. Steps 3-9 must run atomically — do not stop halfway.
WORKTREE_ROOT=$(git rev-parse --show-toplevel)
WORKTREE_NAME=$(basename "$WORKTREE_ROOT")
MAIN_REPO_PATH="{{MAIN_REPO_PATH}}"
if [ ! -f "$WORKTREE_ROOT/.env.local" ]; then
cp "$MAIN_REPO_PATH/.env.local" "$WORKTREE_ROOT/.env.local"
fi
Check if this worktree already has a port assigned and a server running:
if [ -f "$MAIN_REPO_PATH/.preview-ports.registry" ]; then
EXISTING_PORT=$(grep "^$WORKTREE_NAME=" "$MAIN_REPO_PATH/.preview-ports.registry" | cut -d= -f2)
if [ -n "$EXISTING_PORT" ]; then
lsof -ti ":$EXISTING_PORT" | xargs kill 2>/dev/null || true
echo "Killed existing server on port $EXISTING_PORT"
fi
fi
Read the deploy key from .env.local and deploy. The --cmd flag captures the preview URL to a temp file:
source "$WORKTREE_ROOT/.env.local"
npx convex deploy \
--preview-create "$WORKTREE_NAME" \
--cmd "echo \"\$VITE_CONVEX_URL\" > /tmp/preview-$WORKTREE_NAME-url.txt" \
--cmd-url-env-var-name VITE_CONVEX_URL \
-y
PREVIEW_URL=$(cat /tmp/preview-$WORKTREE_NAME-url.txt)
echo "Preview URL: $PREVIEW_URL"
CRITICAL: Each --preview-create creates a FRESH deployment:
Ports are allocated from range 5173-5273. The shared registry is at $MAIN_REPO_PATH/.preview-ports.registry.
Atomic locking via mkdir (cannot race):
LOCK="$MAIN_REPO_PATH/.preview-ports.lock"
# Try to acquire lock (30-second stale timeout)
while ! mkdir "$LOCK" 2>/dev/null; do
LOCK_AGE=$(( $(date +%s) - $(stat -f %m "$LOCK" 2>/dev/null || echo 0) ))
if [ "$LOCK_AGE" -gt 30 ]; then
rm -rf "$LOCK"
echo "Removed stale lock"
else
sleep 1
fi
done
# Read registry, find next available port
REGISTRY="$MAIN_REPO_PATH/.preview-ports.registry"
touch "$REGISTRY"
EXISTING_PORT=$(grep "^$WORKTREE_NAME=" "$REGISTRY" | cut -d= -f2)
if [ -n "$EXISTING_PORT" ]; then
PORT="$EXISTING_PORT"
else
# Find next available port in 5173-5273
USED_PORTS=$(cut -d= -f2 "$REGISTRY" | sort -n)
PORT=5173
while echo "$USED_PORTS" | grep -q "^$PORT$"; do
PORT=$((PORT + 1))
done
echo "$WORKTREE_NAME=$PORT" >> "$REGISTRY"
fi
# Release lock
rm -rf "$LOCK"
echo "Port assigned: $PORT"
Read each secret from .env.local and set on the Convex preview:
source "$WORKTREE_ROOT/.env.local"
npx convex env set --preview-name "$WORKTREE_NAME" RESEND_API_KEY "$RESEND_API_KEY"
npx convex env set --preview-name "$WORKTREE_NAME" RESEND_FROM_EMAIL "${RESEND_FROM_EMAIL:[email protected]}"
npx convex env set --preview-name "$WORKTREE_NAME" JWT_PRIVATE_KEY -- "$JWT_PRIVATE_KEY"
npx convex env set --preview-name "$WORKTREE_NAME" JWKS "$JWKS"
npx convex env set --preview-name "$WORKTREE_NAME" SITE_URL "http://localhost:$PORT"
Add any other env vars your project needs (e.g., AI API keys).
npx convex run seed:seedData --preview-name "$WORKTREE_NAME"
This creates test users and data for E2E tests. The seed script should be idempotent (upsert, not insert).
Update .env.local with the preview-specific values:
# Read existing secrets
source "$WORKTREE_ROOT/.env.local"
# Rewrite with preview-specific additions
cat > "$WORKTREE_ROOT/.env.local" << ENVEOF
# Secrets
CONVEX_DEPLOY_KEY=$CONVEX_DEPLOY_KEY
RESEND_API_KEY=$RESEND_API_KEY
JWT_PRIVATE_KEY=$JWT_PRIVATE_KEY
JWKS=$JWKS
# Preview-specific (set by /preview)
CONVEX_PREVIEW_NAME=$WORKTREE_NAME
VITE_CONVEX_URL=$PREVIEW_URL
CONVEX_URL=$PREVIEW_URL
# PostHog
VITE_POSTHOG_KEY=$VITE_POSTHOG_KEY
VITE_POSTHOG_HOST=${VITE_POSTHOG_HOST:-https://eu.i.posthog.com}
# Dev server
BASE_URL=http://localhost:$PORT
DEV_PORT=$PORT
ENVEOF
Start ONLY the web dev server — not the root pnpm dev (which runs concurrently with Convex). The Convex backend is already deployed to a preview environment in step 4.
cd "$WORKTREE_ROOT"
pnpm --filter '{apps/web}' dev --port $PORT --strictPort
--strictPort ensures Vite fails if the port is already taken (instead of silently picking another). This keeps the port registry accurate.
The server runs in the foreground. Use Ctrl+C to stop, or /preview stop from another terminal.
Preview ready:
Worktree: $WORKTREE_NAME
Port: $PORT
URL: http://localhost:$PORT
Convex: $PREVIEW_URL
Seeded: yes
WORKTREE_NAME=$(basename "$(git rev-parse --show-toplevel)")
MAIN_REPO_PATH="{{MAIN_REPO_PATH}}"
PORT=$(grep "^$WORKTREE_NAME=" "$MAIN_REPO_PATH/.preview-ports.registry" | cut -d= -f2)
if [ -n "$PORT" ]; then
lsof -ti ":$PORT" | xargs kill 2>/dev/null || true
echo "Stopped server on port $PORT"
else
echo "No port found for $WORKTREE_NAME"
fi
rm -f /tmp/preview-$WORKTREE_NAME-url.txt
Do NOT remove the port from the registry — it stays allocated for this worktree.
Read all entries from the port registry. For each:
lsof -i :$PORT)MAIN_REPO_PATH="{{MAIN_REPO_PATH}}"
REGISTRY="$MAIN_REPO_PATH/.preview-ports.registry"
if [ ! -f "$REGISTRY" ]; then
echo "No previews registered"
exit 0
fi
while IFS='=' read -r name port; do
if lsof -i ":$port" > /dev/null 2>&1; then
echo "$name → http://localhost:$port (running)"
else
echo "$name → port $port (stopped)"
fi
done < "$REGISTRY"
.env.local