diff --git a/.claude/hooks/resolve-symlinks.sh b/.claude/hooks/resolve-symlinks.sh deleted file mode 100755 index 31e1585f37..0000000000 --- a/.claude/hooks/resolve-symlinks.sh +++ /dev/null @@ -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 diff --git a/.claude/hooks/restore-symlinks.sh b/.claude/hooks/restore-symlinks.sh deleted file mode 100755 index 162444ea19..0000000000 --- a/.claude/hooks/restore-symlinks.sh +++ /dev/null @@ -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 diff --git a/.claude/settings.json b/.claude/settings.json index eac00f2227..e9b7444a1b 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -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", @@ -130,4 +100,4 @@ "typescript-lsp@claude-plugins-official": true, "code-review@claude-plugins-official": true } -} +} \ No newline at end of file diff --git a/.gitignore b/.gitignore index 2c3dae5c35..8cad7e4df2 100644 --- a/.gitignore +++ b/.gitignore @@ -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 diff --git a/.mcp.json b/.mcp.json index d71108f978..8a587025cd 100644 --- a/.mcp.json +++ b/.mcp.json @@ -3,10 +3,6 @@ "svelte": { "type": "http", "url": "https://mcp.svelte.dev/mcp" - }, - "playwright": { - "command": "npx", - "args": ["@playwright/mcp@latest"] } } } \ No newline at end of file diff --git a/.wmdev.yaml b/.wmdev.yaml new file mode 100644 index 0000000000..de37706de6 --- /dev/null +++ b/.wmdev.yaml @@ -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)//screenshot.png + 4) Include screenshots in PR descriptions as markdown images: + ![description]($(printenv R2_PUBLIC_URL)//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 diff --git a/.workmux.yaml b/.workmux.yaml index 58c85be4ad..327a9d15f5 100644 --- a/.workmux.yaml +++ b/.workmux.yaml @@ -46,11 +46,20 @@ pre_remove: - ./scripts/worktree-cleanup panes: - - command: + - 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) \ No newline at end of file diff --git a/Dockerfile.sandbox b/Dockerfile.sandbox new file mode 100644 index 0000000000..230244de95 --- /dev/null +++ b/Dockerfile.sandbox @@ -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 " >&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"] \ No newline at end of file diff --git a/README_WORKMUX_DEV.md b/README_WORKMUX_DEV.md index 8d9c296e0f..facaa4e863 100644 --- a/README_WORKMUX_DEV.md +++ b/README_WORKMUX_DEV.md @@ -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. diff --git a/scripts/worktree-cleanup b/scripts/worktree-cleanup index 12ea53a74c..c4904e764d 100755 --- a/scripts/worktree-cleanup +++ b/scripts/worktree-cleanup @@ -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:-} 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:-} FRONTEND_PORT=${FRONTEND_PORT:-}" + 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" -fi - -# Clean up Cursor grouped tmux session -tmux kill-session -t "cursor-${wt_basename}" 2>/dev/null || true + && echo "[cleanup] Removed EE worktree at $ee_worktree_dir" \ + || echo "[cleanup] Warning: Could not remove EE worktree at $ee_worktree_dir" +fi \ No newline at end of file diff --git a/scripts/worktree-env b/scripts/worktree-env index f1284674f8..a90e8e4454 100755 --- a/scripts/worktree-env +++ b/scripts/worktree-env @@ -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 - # 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 + # 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') + + # 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 - # Create symlinks from backend crates to the EE worktree - if [ -d "$ee_worktree_dir" ] && [ -x "./backend/substitute_ee_code.sh" ]; then - ./backend/substitute_ee_code.sh -d "$ee_worktree_dir" + # 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 <