internal: workmux config (#8065)

* config

* nit

* add wmdev config

* remove playwright mcp

* add asciicinema
This commit is contained in:
centdix
2026-02-24 08:09:49 +01:00
committed by GitHub
parent b97216cf37
commit 207dcdb4f7
11 changed files with 421 additions and 141 deletions

View File

@@ -1,30 +0,0 @@
#!/usr/bin/env bash
# Resolve _ee.rs symlinks to actual files so Claude can read them
# This script runs before each user prompt is processed
set -e
PROJECT_DIR="${CLAUDE_PROJECT_DIR:-/home/farhad/windmill}"
MANIFEST_FILE="$PROJECT_DIR/.claude/hooks/.symlink-manifest"
# Find all _ee.rs symlinks and store their targets
find "$PROJECT_DIR" -name "*_ee.rs" -type l 2>/dev/null | while read -r symlink; do
target=$(readlink -f "$symlink" 2>/dev/null) || continue
# Only process if target file exists
if [[ -f "$target" ]]; then
# Store symlink path and target in manifest
echo "$symlink|$target" >> "$MANIFEST_FILE.tmp"
# Replace symlink with actual file content
rm "$symlink"
cp "$target" "$symlink"
fi
done
# Atomically replace manifest
if [[ -f "$MANIFEST_FILE.tmp" ]]; then
mv "$MANIFEST_FILE.tmp" "$MANIFEST_FILE"
fi
exit 0

View File

@@ -1,36 +0,0 @@
#!/usr/bin/env bash
# Restore _ee.rs symlinks after Claude finishes processing
# This script runs when Claude stops
# IMPORTANT: Copies any modifications back to the target before restoring symlinks
set -e
PROJECT_DIR="${CLAUDE_PROJECT_DIR:-/home/farhad/windmill}"
MANIFEST_FILE="$PROJECT_DIR/.claude/hooks/.symlink-manifest"
# Check if manifest exists
if [[ ! -f "$MANIFEST_FILE" ]]; then
exit 0
fi
# Read manifest and restore symlinks
while IFS='|' read -r symlink target; do
if [[ -n "$symlink" && -n "$target" ]]; then
# If the file exists (not a symlink) and target exists, copy changes back
if [[ -f "$symlink" && ! -L "$symlink" && -e "$target" ]]; then
# Copy the potentially modified file back to the target
cp "$symlink" "$target"
fi
# Remove the regular file (which was a copy)
rm -f "$symlink" 2>/dev/null || true
# Recreate the symlink
ln -s "$target" "$symlink" 2>/dev/null || true
fi
done < "$MANIFEST_FILE"
# Clean up manifest
rm -f "$MANIFEST_FILE"
exit 0

View File

@@ -1,5 +1,8 @@
{
"permissions": {
"additionalDirectories": [
"../windmill-ee-private"
],
"allow": [
"Bash(ls:*)",
"Bash(grep:*)",
@@ -63,39 +66,6 @@
},
"enableAllProjectMcpServers": true,
"hooks": {
"UserPromptSubmit": [
{
"hooks": [
{
"type": "command",
"command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/resolve-symlinks.sh",
"timeout": 30
}
]
}
],
"Stop": [
{
"hooks": [
{
"type": "command",
"command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/restore-symlinks.sh",
"timeout": 30
}
]
}
],
"SessionEnd": [
{
"hooks": [
{
"type": "command",
"command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/restore-symlinks.sh",
"timeout": 30
}
]
}
],
"PostToolUse": [
{
"matcher": "Edit|Write",

3
.gitignore vendored
View File

@@ -17,6 +17,9 @@ rust-client/Cargo.toml
# Worktree-generated port isolation
.env.local
# Worktree-specific Claude Code settings (generated by scripts/worktree-env)
.claude/settings.local.json
# Symlinked cache directories (for git worktrees)
backend/target
frontend/node_modules

View File

@@ -3,10 +3,6 @@
"svelte": {
"type": "http",
"url": "https://mcp.svelte.dev/mcp"
},
"playwright": {
"command": "npx",
"args": ["@playwright/mcp@latest"]
}
}
}

66
.wmdev.yaml Normal file
View File

@@ -0,0 +1,66 @@
services:
- name: BE
portEnv: BACKEND_PORT
- name: FE
portEnv: FRONTEND_PORT
profiles:
default:
name: default
sandbox:
name: sandbox
systemPrompt: >
You are running inside a sandboxed container with full permissions.
This worktree is configured with the following ports:
- Backend: port ${BACKEND_PORT}.
Start with: cd backend && PORT=${BACKEND_PORT}
DATABASE_URL=postgres://postgres:changeme@localhost:5432/windmill
cargo watch -x run
- Frontend: port ${FRONTEND_PORT}.
Start with: cd frontend && REMOTE=http://localhost:${BACKEND_PORT}
npm run dev -- --port ${FRONTEND_PORT} --host 0.0.0.0
--- Screenshots ---
You can take screenshots of the frontend UI and upload them to R2
for use in PR descriptions.
1) Take a screenshot:
bunx playwright screenshot --browser chromium
http://localhost:${FRONTEND_PORT}/path/to/page /tmp/screenshot.png
2) Upload to R2:
aws s3 cp /tmp/screenshot.png
"s3://$(printenv R2_BUCKET)/$(git rev-parse --abbrev-ref HEAD)/screenshot.png"
--endpoint-url "$(printenv R2_ENDPOINT)"
3) The public URL will be:
$(printenv R2_PUBLIC_URL)/<branch>/screenshot.png
4) Include screenshots in PR descriptions as markdown images:
![description]($(printenv R2_PUBLIC_URL)/<branch>/screenshot.png)
--- Terminal Recordings (asciinema) ---
You can record terminal sessions and upload them for sharing.
asciinema is pre-installed at /usr/local/bin/asciinema.
1) Write a shell script with the commands to demo. Add sleep
delays for readable pacing:
- 0.5s after printing a "$ command" line (lets viewer read it)
- 1.5-2s after command output (lets viewer absorb the result)
- Set GIT_PAGER=cat and PAGER=cat to prevent pager hangs
2) Record headlessly:
asciinema rec --headless --overwrite \
-c "bash /tmp/demo.sh" \
--window-size 120x50 \
--title "Description of demo" \
/tmp/demo.cast
3) Upload to asciinema.org:
XDG_DATA_HOME=/tmp/.local/share \
asciinema upload --server-url https://asciinema.org /tmp/demo.cast
envPassthrough:
- AWS_ACCESS_KEY_ID
- AWS_SECRET_ACCESS_KEY
- R2_ENDPOINT
- R2_BUCKET
- R2_PUBLIC_URL

View File

@@ -46,11 +46,20 @@ pre_remove:
- ./scripts/worktree-cleanup
panes:
- command: <agent>
- command: >-
claude --append-system-prompt
"You are running inside a tmux session with other panes running services.\n
Pane layout (current window):\n
- Pane 0: this pane (claude agent)\n
- Pane 1: backend (cargo watch -x run)\n
- Pane 2: frontend (npm run dev)\n\n
To check logs, use: \`tmux capture-pane -t .1 -p -S -50\` (backend) or \`tmux capture-pane -t .2 -p -S -50\` (frontend).\n
When restarting backend or frontend, make sure to use the ports listed in .env.local.\n
Because we are running backend with cargo watch, to verify your changes, just check the logs in the backend pane. No need for cargo check."
focus: true
- command: 'ROOT="$(git rev-parse --show-toplevel)"; [ -f "$ROOT/.env.local" ] && source "$ROOT/.env.local"; cd "$ROOT/backend" && PORT=${BACKEND_PORT:-8000} cargo watch -x run'
split: horizontal
- command: 'ROOT="$(git rev-parse --show-toplevel)"; [ -f "$ROOT/.env.local" ] && source "$ROOT/.env.local"; cd "$ROOT/frontend" && npm install && npm run generate-backend-client && REMOTE=${REMOTE:-http://localhost:${BACKEND_PORT:-8000}} npm run dev -- --port ${FRONTEND_PORT:-3000}'
- command: 'ROOT="$(git rev-parse --show-toplevel)"; [ -f "$ROOT/.env.local" ] && source "$ROOT/.env.local"; cd "$ROOT/frontend" && npm install && npm run generate-backend-client && REMOTE=${REMOTE:-http://localhost:${BACKEND_PORT:-8000}} npm run dev -- --port ${FRONTEND_PORT:-3000} --host 0.0.0.0'
split: vertical
files:
@@ -61,3 +70,6 @@ files:
sandbox:
enabled: false
toolchain: off
# image, host_commands, and extra_mounts configured in global
# ~/.config/workmux/config.yaml — see README_WORKMUX_DEV.md for required
# extra_mounts (windmill-ee-private access in sandbox)

238
Dockerfile.sandbox Normal file
View File

@@ -0,0 +1,238 @@
FROM debian:bookworm-slim
RUN apt-get update && apt-get install -y --no-install-recommends \
curl \
ca-certificates \
git \
iptables \
gosu \
sudo \
unzip \
# Rust native build deps (for cargo check)
pkg-config \
cmake \
clang \
mold \
libtool \
libssl-dev \
libxml2-dev \
libxmlsec1-dev \
libxslt1-dev \
libffi-dev \
zlib1g-dev \
libcurl4-openssl-dev \
libclang-dev \
libkrb5-dev \
libsasl2-dev \
# PostgreSQL (for local DB during development)
postgresql \
postgresql-client \
# Node.js 22 (for npm run check / frontend dev)
&& curl -fsSL https://deb.nodesource.com/setup_22.x | bash - \
&& apt-get install -y --no-install-recommends nodejs \
&& rm -rf /var/lib/apt/lists/* \
# Container runs as arbitrary UIDs (--user uid:gid). These three lines make
# sudo work for any UID:
# 1) NOPASSWD rule so sudo never prompts for a password
# 2) Writable passwd/group so the entrypoint can register the dynamic UID
# 3) Writable shadow so unix_chkpwd can validate the account (without this,
# sudo fails with "account validation failure, is your account locked?")
&& echo "ALL ALL=(ALL) NOPASSWD: ALL" > /etc/sudoers.d/sandbox \
&& chmod 0440 /etc/sudoers.d/sandbox \
&& chmod 666 /etc/passwd /etc/group /etc/shadow
# ── GitHub CLI (for PR creation) ──────────────────────────────────────────────
RUN curl -fsSL https://cli.github.com/packages/githubcli-archive-keyring.gpg \
-o /usr/share/keyrings/githubcli-archive-keyring.gpg \
&& echo "deb [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyrings/githubcli-archive-keyring.gpg] https://cli.github.com/packages stable main" \
> /etc/apt/sources.list.d/github-cli.list \
&& apt-get update && apt-get install -y --no-install-recommends gh \
&& rm -rf /var/lib/apt/lists/*
# ── Rust toolchain ────────────────────────────────────────────────────────────
# Install under /usr/local/lib/ so bins are world-readable with default umask.
# CARGO_HOME is overridden to /tmp/.cargo at the end for mutable runtime state.
ENV RUSTUP_HOME=/usr/local/lib/rustup CARGO_HOME=/usr/local/lib/cargo
RUN curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | \
sh -s -- -y --default-toolchain stable --profile minimal && \
ln -s /usr/local/lib/cargo/bin/* /usr/local/bin/
RUN cargo install sqlx-cli --no-default-features --features native-tls,postgres && \
cargo install cargo-watch && \
cargo install --locked --git https://github.com/asciinema/asciinema && \
ln -sf /usr/local/lib/cargo/bin/sqlx /usr/local/bin/sqlx && \
ln -sf /usr/local/lib/cargo/bin/cargo-watch /usr/local/bin/cargo-watch && \
ln -sf /usr/local/lib/cargo/bin/asciinema /usr/local/bin/asciinema
# ── Register dynamic runtime users ───────────────────────────────────────────
RUN cat <<'SCRIPT' > /usr/local/bin/register-dynamic-user.sh
#!/bin/sh
set -eu
uid="${1:-}"
gid="${2:-}"
if [ -z "$uid" ] || [ -z "$gid" ]; then
echo "register-dynamic-user: usage: register-dynamic-user <uid> <gid>" >&2
exit 1
fi
if ! getent group "$gid" >/dev/null 2>&1; then
echo "sandbox:x:${gid}:" >> /etc/group
fi
if ! getent passwd "$uid" >/dev/null 2>&1; then
echo "sandbox:x:${uid}:${gid}:sandbox:/tmp:/bin/sh" >> /etc/passwd
fi
# Add a shadow entry ("*" = no password) so unix_chkpwd doesn't reject sudo.
if ! grep -q "^sandbox:" /etc/shadow 2>/dev/null; then
echo "sandbox:*:19000:0:99999:7:::" >> /etc/shadow
fi
SCRIPT
RUN chmod +x /usr/local/bin/register-dynamic-user.sh
# ── Network init script (iptables firewall + privilege drop) ──────────────────
RUN cat <<'SCRIPT' > /usr/local/bin/network-init.sh
#!/bin/bash
set -euo pipefail
if [ -n "${WM_PROXY_HOST:-}" ] && [ -n "${WM_PROXY_PORT:-}" ]; then
# Resolve hostnames to ALL IPs (multi-A records, round-robin DNS)
PROXY_IPS=$(getent ahostsv4 "$WM_PROXY_HOST" | awk '{print $1}' | sort -u)
RPC_HOST="${WM_RPC_HOST:-$WM_PROXY_HOST}"
RPC_IPS=$(getent ahostsv4 "$RPC_HOST" | awk '{print $1}' | sort -u)
if [ -z "$PROXY_IPS" ] || [ -z "$RPC_IPS" ]; then
echo "network-init: failed to resolve proxy/RPC host" >&2
exit 1
fi
# IPv4: default deny outbound
iptables -P OUTPUT DROP
iptables -A OUTPUT -o lo -j ACCEPT
iptables -A OUTPUT -m state --state ESTABLISHED,RELATED -j ACCEPT
# Allow DNS (UDP/TCP 53) to configured nameservers.
if [ -f /etc/resolv.conf ]; then
grep '^nameserver' /etc/resolv.conf | awk '{print $2}' | while read -r ns; do
iptables -A OUTPUT -d "$ns" -p udp --dport 53 -j ACCEPT
iptables -A OUTPUT -d "$ns" -p tcp --dport 53 -j ACCEPT
done
fi
# Allow ALL resolved proxy IPs (handles multi-A DNS)
for ip in $PROXY_IPS; do
iptables -A OUTPUT -d "$ip" -p tcp --dport "$WM_PROXY_PORT" -j ACCEPT
done
# Allow ALL resolved RPC IPs
if [ -n "${WM_RPC_PORT:-}" ]; then
for ip in $RPC_IPS; do
iptables -A OUTPUT -d "$ip" -p tcp --dport "$WM_RPC_PORT" -j ACCEPT
done
fi
# Reject (not drop) everything else to fail fast instead of hanging
iptables -A OUTPUT -j REJECT
# IPv6: block entirely to prevent leaks (fail closed)
if ip6tables -L -n >/dev/null 2>&1; then
ip6tables -P OUTPUT DROP
ip6tables -A OUTPUT -o lo -j ACCEPT
ip6tables -A OUTPUT -m state --state ESTABLISHED,RELATED -j ACCEPT
ip6tables -A OUTPUT -j REJECT
else
if ! sysctl -w net.ipv6.conf.all.disable_ipv6=1 2>/dev/null; then
echo "network-init: failed to block IPv6 (neither ip6tables nor sysctl available)" >&2
exit 1
fi
fi
fi
# Add sandbox user/group so sudo works after dropping privileges.
if [ -z "${WM_TARGET_UID:-}" ] || [ -z "${WM_TARGET_GID:-}" ]; then
echo "network-init: WM_TARGET_UID and WM_TARGET_GID are required" >&2
exit 1
fi
/usr/local/bin/register-dynamic-user.sh "${WM_TARGET_UID}" "${WM_TARGET_GID}"
# Fix PTY ownership so the unprivileged user can read/write the terminal.
if [ -t 0 ]; then
chown "${WM_TARGET_UID}:${WM_TARGET_GID}" "$(tty)"
fi
# Drop privileges and exec the user command.
exec gosu "${WM_TARGET_UID}:${WM_TARGET_GID}" env HOME=/tmp "$@"
SCRIPT
RUN chmod +x /usr/local/bin/network-init.sh
# ── workmux (sandbox RPC) ────────────────────────────────────────────────────
RUN curl -fsSL https://raw.githubusercontent.com/raine/workmux/main/scripts/install.sh | bash
# ── Claude Code ───────────────────────────────────────────────────────────────
RUN curl -fsSL https://claude.ai/install.sh | bash && \
target="$(readlink -f /root/.local/bin/claude)" && \
mv /root/.local/share/claude /usr/local/lib/claude && \
ln -s "/usr/local/lib/claude/versions/$(basename "$target")" /usr/local/bin/claude && \
mkdir -p /tmp/.local/bin && \
ln -s /usr/local/bin/claude /tmp/.local/bin/claude && \
chmod -R a+rwX /tmp/.local
# ── Codex ─────────────────────────────────────────────────────────────────────
RUN npm i -g @openai/codex
# ── Bun ───────────────────────────────────────────────────────────────────────
ENV BUN_INSTALL=/usr/local/lib/bun
RUN curl -fsSL https://bun.sh/install | bash && \
ln -s /usr/local/lib/bun/bin/bun /usr/local/bin/bun && \
ln -s /usr/local/lib/bun/bin/bunx /usr/local/bin/bunx
# ── Playwright + Chromium (for screenshots) ──────────────────────────────────
ENV PLAYWRIGHT_BROWSERS_PATH=/usr/local/lib/playwright-browsers
RUN bun add -g @playwright/test \
&& bunx playwright install chromium --with-deps \
&& chmod -R a+rwX /usr/local/lib/playwright-browsers \
&& chmod -R a+rwX /usr/local/lib/bun/install \
&& rm -rf /var/lib/apt/lists/* /tmp/bunx-*
# ── AWS CLI (for S3-compatible uploads to R2) ─────────────────────────────────
RUN curl -fsSL "https://awscli.amazonaws.com/awscli-exe-linux-x86_64.zip" -o /tmp/awscliv2.zip \
&& unzip -q /tmp/awscliv2.zip -d /tmp \
&& /tmp/aws/install \
&& rm -rf /tmp/aws /tmp/awscliv2.zip
ENV AWS_DEFAULT_REGION=auto
# ── Runtime env for arbitrary UID ─────────────────────────────────────────────
# Mutable state goes to /tmp (writable by any UID). Toolchains stay read-only.
ENV CARGO_HOME=/tmp/.cargo BUN_TMPDIR=/tmp
# ── Entrypoint ────────────────────────────────────────────────────────────────
RUN cat <<'ENTRY' > /usr/local/bin/entrypoint.sh
#!/bin/sh
/usr/local/bin/register-dynamic-user.sh "$(id -u)" "$(id -g)"
# Start PostgreSQL (unix socket in /tmp, owned by postgres user)
mkdir -p /tmp/pgdata && sudo chown postgres:postgres /tmp/pgdata
if [ ! -f /tmp/pgdata/PG_VERSION ]; then
sudo -u postgres /usr/lib/postgresql/15/bin/initdb -D /tmp/pgdata --auth=trust
fi
sudo -u postgres /usr/lib/postgresql/15/bin/pg_ctl -D /tmp/pgdata -l /tmp/pg.log start -o "-k /tmp"
sudo -u postgres psql -h /tmp -c "CREATE ROLE sandbox SUPERUSER LOGIN" 2>/dev/null || true
sudo -u postgres createdb -h /tmp windmill 2>/dev/null || true
# Run database migrations so sqlx compile-time checks work
if [ -d "$PWD/backend/migrations" ]; then
DATABASE_URL="postgres://sandbox@localhost/windmill?host=/tmp" \
sqlx migrate run --source "$PWD/backend/migrations" 2>/dev/null || true
fi
# Install frontend dependencies and generate backend client
if [ -d "$PWD/frontend" ]; then
(cd "$PWD/frontend" && npm install && npm run generate-backend-client) 2>/dev/null || true
fi
exec "$@"
ENTRY
RUN chmod +x /usr/local/bin/entrypoint.sh
ENTRYPOINT ["/usr/local/bin/entrypoint.sh"]

View File

@@ -172,6 +172,26 @@ The setup is defined in `.workmux.yaml` at the repo root. Key sections:
- **`files.copy`**: Copies `backend/.env` and `scripts/` into each worktree
- **`files.symlink`**: Symlinks `node_modules` and `.svelte-kit` to avoid reinstalling per worktree
## Enterprise (EE) Code Access
The enterprise source code lives in the `windmill-ee-private` repository (sibling to this repo). When you create a worktree, `scripts/worktree-env` automatically creates a matching EE worktree on the same branch and configures Claude Code's `additionalDirectories` to grant access.
### Sandbox setup
When using sandbox mode, the container needs explicit mounts to access the EE repo. Add the following to your global workmux config (`~/.config/workmux/config.yaml`):
```yaml
sandbox:
extra_mounts:
- host_path: ~/windmill-ee-private
writable: true
- host_path: ~/windmill-ee-private__worktrees
writable: true
```
This mounts both the main EE repo (used by the main worktree) and the EE worktrees directory (used by feature worktrees) into every sandbox container.
## Cursor SSH Integration (`wmc`)
`wm-cursor` (aliased as `wmc`) gives each worktree its own Cursor SSH remote window with an independently-focused tmux session. All windows are visible in the status bar across all Cursor terminals, but each one is focused on its own worktree.

View File

@@ -1,11 +1,34 @@
#!/usr/bin/env bash
set -euo pipefail
# Use WM_WORKTREE_PATH (set by workmux) so this works regardless of cwd
wt_dir="${WM_WORKTREE_PATH:-.}"
echo "[cleanup] cwd=$(pwd) WM_WORKTREE_PATH=${WM_WORKTREE_PATH:-<unset>} wt_dir=$wt_dir"
# Kill backend/frontend processes using this worktree's ports
if [ -f "$wt_dir/.env.local" ]; then
source "$wt_dir/.env.local"
echo "[cleanup] .env.local found: BACKEND_PORT=${BACKEND_PORT:-<unset>} FRONTEND_PORT=${FRONTEND_PORT:-<unset>}"
for port in "${BACKEND_PORT:-}" "${FRONTEND_PORT:-}"; do
[ -z "$port" ] && continue
pid=$(lsof -ti "TCP:${port}" -sTCP:LISTEN 2>/dev/null || true)
if [ -n "$pid" ]; then
kill "$pid" 2>/dev/null && echo "[cleanup] Killed process $pid on port $port" \
|| echo "[cleanup] Warning: Could not kill process $pid on port $port"
else
echo "[cleanup] No process listening on port $port"
fi
done
else
echo "[cleanup] No .env.local at $wt_dir/.env.local"
fi
# Remove the matching windmill-ee-private worktree if one exists
wt_basename=$(basename "$(pwd)")
wt_basename=$(basename "$wt_dir")
# Check parent directory first (sibling to worktree root), then fall back to home
parent_dir="$(cd "$(pwd)/.." && pwd)"
parent_dir="$(cd "$wt_dir/.." && pwd)"
echo "[cleanup] wt_basename=$wt_basename parent_dir=$parent_dir"
if [ -d "${parent_dir}/windmill-ee-private" ]; then
ee_repo="${parent_dir}/windmill-ee-private"
else
@@ -13,11 +36,9 @@ else
fi
ee_worktree_dir="${ee_repo}__worktrees/${wt_basename}"
echo "[cleanup] ee_repo=$ee_repo ee_worktree_dir=$ee_worktree_dir exists=$([ -d "$ee_worktree_dir" ] && echo yes || echo no)"
if [ -d "$ee_worktree_dir" ]; then
git -C "$ee_repo" worktree remove "$ee_worktree_dir" --force 2>/dev/null \
&& echo "Removed EE worktree at $ee_worktree_dir" \
|| echo "Warning: Could not remove EE worktree at $ee_worktree_dir"
&& echo "[cleanup] Removed EE worktree at $ee_worktree_dir" \
|| echo "[cleanup] Warning: Could not remove EE worktree at $ee_worktree_dir"
fi
# Clean up Cursor grouped tmux session
tmux kill-session -t "cursor-${wt_basename}" 2>/dev/null || true

View File

@@ -5,30 +5,29 @@ port_in_use() {
lsof -nP -iTCP:"$1" -sTCP:LISTEN &>/dev/null
}
find_port() {
local port=$1
while port_in_use "$port"; do
((port++))
done
echo "$port"
}
if [[ -z "${WM_SLOT:-}" ]]; then
# Scan .env.local files of existing worktrees to find which slots are claimed,
# then pick the lowest free slot. This avoids collisions when worktrees are
# removed and new ones created (position-based indexing would re-use slots
# still held by surviving worktrees).
used_slots=()
current_dir="$(pwd)"
while IFS= read -r wt_path; do
[[ "$wt_path" == "$current_dir" ]] && continue
if [[ -f "$wt_path/.env.local" ]]; then
bp=$(grep '^BACKEND_PORT=' "$wt_path/.env.local" | cut -d= -f2 || true)
if [[ -n "$bp" && "$bp" -gt 8000 ]]; then
used_slots+=("$(( (bp - 8000) / 10 ))")
fi
fi
done < <(git worktree list --porcelain | sed -n 's/^worktree //p')
if [[ -z "${WM_SLOT:-}" ]]; then
# Auto-assign: find the first slot (1-99) where both ports are free
# Slot 0 (8000/3000) is reserved for the main worktree
for slot in $(seq 1 99); do
bp=$((8000 + slot * 10))
fp=$((3000 + slot * 10))
if ! port_in_use "$bp" && ! port_in_use "$fp"; then
WM_SLOT=$slot
break
fi
# Find lowest available slot (slot 0 = 8000/3000 is reserved for main)
WM_SLOT=1
while [[ " ${used_slots[*]:-} " == *" $WM_SLOT "* ]]; do
((WM_SLOT++))
done
if [[ -z "${WM_SLOT:-}" ]]; then
echo "ERROR: No available slot found (tried 1-99)" >&2
exit 1
fi
echo "Auto-assigned slot $WM_SLOT"
echo "Auto-assigned slot $WM_SLOT (used: ${used_slots[*]:-none})"
fi
# Slot-based: predictable ports for SSH forwarding
@@ -37,8 +36,7 @@ backend_port=$((8000 + WM_SLOT * 10))
frontend_port=$((3000 + WM_SLOT * 10))
if port_in_use "$backend_port" || port_in_use "$frontend_port"; then
echo "ERROR: Slot $WM_SLOT ports ($backend_port/$frontend_port) already in use" >&2
exit 1
echo "WARNING: Slot $WM_SLOT ports ($backend_port/$frontend_port) already in use" >&2
fi
# Generate .env.local with port overrides
@@ -50,6 +48,12 @@ EOF
echo "Created .env.local with ports: backend=$backend_port, frontend=$frontend_port"
# --- Allow direnv so the nix devshell activates in pane commands ---
if command -v direnv &>/dev/null && [ -f .envrc ]; then
direnv allow
echo "direnv allowed"
fi
# --- Create matching windmill-ee-private worktree ---
# Check parent directory first (sibling to worktree root), then fall back to home
parent_dir="$(cd "$(pwd)/.." && pwd)"
@@ -81,8 +85,24 @@ if [ -d "$ee_repo" ]; then
echo "EE worktree already exists at $ee_worktree_dir"
fi
# Point Claude Code additionalDirectories at the EE worktree
if [ -d "$ee_worktree_dir" ]; then
ee_rel=$(python3 -c "import os; print(os.path.relpath('$ee_worktree_dir', '$(pwd)'))" 2>/dev/null || echo "$ee_worktree_dir")
mkdir -p .claude
cat > .claude/settings.local.json <<EOFCLAUDE
{
"permissions": {
"additionalDirectories": [
"$ee_rel"
]
}
}
EOFCLAUDE
echo "Created .claude/settings.local.json with EE path: $ee_rel"
# Create symlinks from backend crates to the EE worktree
if [ -d "$ee_worktree_dir" ] && [ -x "./backend/substitute_ee_code.sh" ]; then
if [ -x "./backend/substitute_ee_code.sh" ]; then
./backend/substitute_ee_code.sh -d "$ee_worktree_dir"
fi
fi
fi