Compare commits
79 Commits
draft-pr-t
...
frontdev
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
36aadbebec | ||
|
|
bedba3b75e | ||
|
|
771d67c849 | ||
|
|
46d486960a | ||
|
|
ca73267cbb | ||
|
|
6eabb8db63 | ||
|
|
5cc8f20cd2 | ||
|
|
ea5312e940 | ||
|
|
c62ba73ce4 | ||
|
|
98ac164ac8 | ||
|
|
ed6aaeeea3 | ||
|
|
5e415c0a12 | ||
|
|
35dded1347 | ||
|
|
aedf012c84 | ||
|
|
62fea97547 | ||
|
|
96c2d88d91 | ||
|
|
5eb308ad35 | ||
|
|
cfa04e1188 | ||
|
|
0d520f730b | ||
|
|
8cdc7d6e9e | ||
|
|
51077245c7 | ||
|
|
9e387c3559 | ||
|
|
7aea965803 | ||
|
|
8446e3b551 | ||
|
|
a6d6136d57 | ||
|
|
c93b2e287c | ||
|
|
93f927c1c1 | ||
|
|
83f21510f3 | ||
|
|
5c1f69ddcd | ||
|
|
3f2bd424c7 | ||
|
|
0d5f42e89e | ||
|
|
dfad07881d | ||
|
|
7b6ba7093a | ||
|
|
8042e33c38 | ||
|
|
57d23c92c5 | ||
|
|
f21140f7cd | ||
|
|
2800226bd4 | ||
|
|
2ae82796fc | ||
|
|
d1290ba777 | ||
|
|
9de0060884 | ||
|
|
3df8964fc6 | ||
|
|
fb0b2234ba | ||
|
|
4064ec3a3d | ||
|
|
0db6cbd10c | ||
|
|
ab91f78017 | ||
|
|
1e0245ca9a | ||
|
|
bead746bb8 | ||
|
|
268b5ee2a8 | ||
|
|
24f2571b37 | ||
|
|
ed1a655317 | ||
|
|
a4b440a20d | ||
|
|
b550be8711 | ||
|
|
071129f03b | ||
|
|
e9ac1ce9eb | ||
|
|
587142ddac | ||
|
|
d9ed0c318f | ||
|
|
4959a0553a | ||
|
|
c4323e40c1 | ||
|
|
696b8de1ed | ||
|
|
5f6dda9060 | ||
|
|
8490f4435d | ||
|
|
fa2f65e512 | ||
|
|
b9dec43d2a | ||
|
|
5227b76c2f | ||
|
|
1643d654a8 | ||
|
|
65a8789dfd | ||
|
|
6299e7a36a | ||
|
|
bcbfe4659d | ||
|
|
b2fac069df | ||
|
|
4ab08cb6a1 | ||
|
|
d6d4d85d8f | ||
|
|
81d386d365 | ||
|
|
a1b878842f | ||
|
|
00c3e9baf0 | ||
|
|
118dcb59af | ||
|
|
bd583be239 | ||
|
|
c1f7cb5d42 | ||
|
|
d502ef5029 | ||
|
|
bc7ca9982b |
@@ -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
|
||||
@@ -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
|
||||
@@ -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
3
.gitignore
vendored
@@ -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
|
||||
|
||||
10
.mcp.json
10
.mcp.json
@@ -3,10 +3,12 @@
|
||||
"svelte": {
|
||||
"type": "http",
|
||||
"url": "https://mcp.svelte.dev/mcp"
|
||||
},
|
||||
"playwright": {
|
||||
"command": "npx",
|
||||
"args": ["@playwright/mcp@latest"]
|
||||
}
|
||||
},
|
||||
"playwright": {
|
||||
"command": "npx",
|
||||
"args": [
|
||||
"@playwright/mcp@latest"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
234
Dockerfile.sandbox
Normal file
234
Dockerfile.sandbox
Normal file
@@ -0,0 +1,234 @@
|
||||
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 && \
|
||||
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
|
||||
|
||||
# ── 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
|
||||
|
||||
# ── 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 \
|
||||
&& 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"]
|
||||
@@ -172,6 +172,25 @@ 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.
|
||||
|
||||
## Login
|
||||
|
||||
Default credentials: `admin@windmill.dev` / `changeme`
|
||||
|
||||
10
dev-dashboard/.gitignore
vendored
Normal file
10
dev-dashboard/.gitignore
vendored
Normal file
@@ -0,0 +1,10 @@
|
||||
node_modules/
|
||||
bun.lock
|
||||
backend/node_modules/
|
||||
backend/bun.lock
|
||||
frontend/node_modules/
|
||||
frontend/bun.lock
|
||||
frontend/dist/
|
||||
frontend/.vite/
|
||||
public/
|
||||
.env
|
||||
247
dev-dashboard/README.md
Normal file
247
dev-dashboard/README.md
Normal file
@@ -0,0 +1,247 @@
|
||||
# Dev Dashboard
|
||||
|
||||
Web-based dashboard for managing Windmill development worktrees. Lets you create, monitor, and interact with multiple isolated development environments, each running its own AI coding agent (Claude or Codex), backend, and frontend.
|
||||
|
||||
## Quick start
|
||||
|
||||
```bash
|
||||
# 1. Install dependencies
|
||||
cargo install workmux # worktree orchestrator
|
||||
sudo apt install tmux socat # (or brew install tmux socat)
|
||||
curl -fsSL https://bun.sh/install | bash # bun >1.3.5 required
|
||||
|
||||
# 2. Create the workmux global config
|
||||
mkdir -p ~/.config/workmux
|
||||
cat > ~/.config/workmux/config.yaml << 'EOF'
|
||||
nerdfont: false
|
||||
|
||||
sandbox:
|
||||
image: windmill-sandbox
|
||||
|
||||
# Forward R2/AWS credentials into sandbox containers (for screenshot uploads).
|
||||
# The actual values come from dev-dashboard/.env, sourced by dev.sh/run.sh.
|
||||
env_passthrough:
|
||||
- AWS_ACCESS_KEY_ID
|
||||
- AWS_SECRET_ACCESS_KEY
|
||||
- R2_ENDPOINT
|
||||
- R2_BUCKET
|
||||
- R2_PUBLIC_URL
|
||||
|
||||
extra_mounts:
|
||||
# Codex agent credentials
|
||||
- host_path: ~/.codex
|
||||
guest_path: /tmp/.codex
|
||||
writable: true
|
||||
# EE repo access (optional — only needed for enterprise features)
|
||||
- host_path: ~/windmill-ee-private
|
||||
writable: true
|
||||
- host_path: ~/windmill-ee-private__worktrees
|
||||
writable: true
|
||||
EOF
|
||||
|
||||
# 3. (Optional) Build sandbox image — only needed for agent-yolo profile
|
||||
docker build -f Dockerfile.sandbox -t windmill-sandbox .
|
||||
|
||||
# 4. Install frontend deps
|
||||
cd dev-dashboard/frontend && bun install && cd ..
|
||||
|
||||
# 5. Start the dashboard
|
||||
./dev.sh # dev mode (hot reload), UI on :5112
|
||||
# or
|
||||
./run.sh # production mode (build + serve), UI on :4173
|
||||
|
||||
# 6. Open http://localhost:5112
|
||||
```
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
Browser (localhost:5112)
|
||||
│
|
||||
├── REST API (/api/*) ──┐
|
||||
└── WebSocket (/ws/*) ──┤
|
||||
│
|
||||
Vite dev proxy
|
||||
│
|
||||
Backend (localhost:5111)
|
||||
│
|
||||
┌──────────────┼──────────────┐
|
||||
│ │ │
|
||||
workmux CLI tmux sessions socat
|
||||
(worktree (terminal (port forwarding
|
||||
lifecycle) access) for sandboxes)
|
||||
```
|
||||
|
||||
**Backend** — Bun/TypeScript HTTP + WebSocket server (`backend/src/server.ts`). Exposes two interfaces:
|
||||
|
||||
- **REST API** (`/api/*`) — CRUD for worktrees. Wraps the `workmux` CLI to create/remove/merge worktrees and runs `socat` port forwarding for Docker sandbox containers. The `GET /api/worktrees` endpoint enriches each worktree with its directory, assigned ports (from `.env.local`), and whether the backend/frontend services are actually responding.
|
||||
- **WebSocket** (`/ws/*`) — Live terminal connection. This is what makes the in-browser terminal work. See [Terminal streaming](#terminal-streaming) below.
|
||||
|
||||
**Frontend** — Svelte 5 SPA with Tailwind CSS and xterm.js (`frontend/src/`). Provides a two-panel UI: worktree list sidebar + embedded terminal. Polls the REST API every 5 seconds for status updates. The terminal is rendered by [xterm.js](https://xtermjs.org/), which handles all terminal emulation (escape sequences, colors, cursor, scrollback) in a `<canvas>`/DOM element.
|
||||
|
||||
### Terminal streaming
|
||||
|
||||
The WebSocket provides a bidirectional bridge between xterm.js in the browser and a tmux session on the server. The data flow:
|
||||
|
||||
```
|
||||
Browser (xterm.js) ←— WebSocket —→ Backend ←— stdin/stdout pipes —→ script (PTY) ←— tmux attach —→ tmux grouped session
|
||||
```
|
||||
|
||||
When a worktree is selected, the frontend opens a WebSocket to `/ws/<worktree>` and sends an initial `resize` message with the terminal dimensions. The backend then:
|
||||
|
||||
1. Spawns `script -q -c "... tmux attach-session ..." /dev/null` — `script` allocates a real PTY (pseudo-terminal), which is necessary for tmux to produce proper terminal escape sequences, colors, and cursor movement.
|
||||
2. The tmux command creates a **grouped session** (`tmux new-session -t <main-session>`), which is a separate "view" into the same tmux windows. This allows the dashboard and a real terminal to view the same worktree simultaneously without fighting over window/pane focus.
|
||||
3. An async reader loop reads the PTY's stdout and forwards the data over the WebSocket as `{ type: "output" }` messages, which xterm.js renders.
|
||||
4. Keystrokes arrive as `{ type: "input" }` messages and are written to the PTY's stdin pipe.
|
||||
5. Resize events trigger `tmux resize-window` to keep dimensions in sync.
|
||||
|
||||
Output is also buffered in a scrollback array (up to 5000 chunks) so that reconnecting clients receive recent history immediately.
|
||||
|
||||
### Worktree Profiles
|
||||
|
||||
When creating a worktree, you pick a profile that determines what runs inside it:
|
||||
|
||||
| Profile | What it does |
|
||||
|---------|-------------|
|
||||
| `full` | Agent + Cargo backend + Vite frontend (uses pane layout from `.workmux.yaml`) |
|
||||
| `agent-yolo` | Agent runs inside a Docker sandbox container with `--dangerously-skip-permissions`. Socat forwards the container's ports to the host so they're reachable from your browser. |
|
||||
|
||||
## Prerequisites
|
||||
|
||||
### Required tools
|
||||
|
||||
| Tool | Min version | Purpose |
|
||||
|------|-------------|---------|
|
||||
| [**bun**](https://bun.sh) | >1.3.5 | Runtime for both backend and frontend dev server |
|
||||
| [**workmux**](https://github.com/raine/workmux) | latest | Worktree + tmux orchestration (`cargo install workmux` or see its repo) |
|
||||
| **tmux** | 3.x | Terminal multiplexer — workmux manages sessions/windows through it |
|
||||
| **socat** | 1.7+ | TCP port forwarding for sandbox containers (only needed for `agent-yolo` profile) |
|
||||
| **git** | 2.x | Worktree management |
|
||||
| **docker** | 28+ | Only needed for `agent-yolo` sandbox profile |
|
||||
|
||||
### Workmux global config
|
||||
|
||||
Workmux reads a global config from `~/.config/workmux/config.yaml`. Create it if it doesn't exist:
|
||||
|
||||
```yaml
|
||||
nerdfont: false
|
||||
|
||||
sandbox:
|
||||
image: windmill-sandbox
|
||||
env_passthrough:
|
||||
- AWS_ACCESS_KEY_ID
|
||||
- AWS_SECRET_ACCESS_KEY
|
||||
- R2_ENDPOINT
|
||||
- R2_BUCKET
|
||||
- R2_PUBLIC_URL
|
||||
extra_mounts:
|
||||
- host_path: ~/.codex
|
||||
guest_path: /tmp/.codex
|
||||
writable: true
|
||||
- host_path: ~/windmill-ee-private
|
||||
writable: true
|
||||
- host_path: ~/windmill-ee-private__worktrees
|
||||
writable: true
|
||||
```
|
||||
|
||||
**Fields:**
|
||||
|
||||
- **`nerdfont`** — Set to `true` if your terminal uses a Nerd Font (adds icons to `workmux list` output). Default `false`.
|
||||
- **`sandbox.image`** — Docker image used for `agent-yolo` sandboxed worktrees. Must be pre-built with `workmux sandbox build` or pulled with `workmux sandbox pull`.
|
||||
- **`sandbox.env_passthrough`** — Host env vars to forward into sandbox containers (global config only). Used here for R2 screenshot upload credentials.
|
||||
- **`sandbox.extra_mounts`** — Additional bind mounts into sandbox containers. Mounts Codex credentials and the EE repo for enterprise features.
|
||||
|
||||
To build the sandbox image (from the Windmill repo root):
|
||||
|
||||
```bash
|
||||
docker build -f Dockerfile.sandbox -t windmill-sandbox .
|
||||
```
|
||||
|
||||
### Workmux project config
|
||||
|
||||
The repo-level `.workmux.yaml` at the Windmill root configures how worktrees are created. Key settings:
|
||||
|
||||
- **`post_create`** — Runs `./scripts/worktree-env` after creating a worktree, which generates a `.env.local` file with unique `BACKEND_PORT` and `FRONTEND_PORT` assignments so multiple worktrees don't collide.
|
||||
- **`panes`** — Defines the tmux pane layout for `full` profile: agent pane (focused), backend pane (`cargo watch`), and frontend pane (`npm run dev`).
|
||||
- **`files.copy`** — Copies `backend/.env` and `scripts/` into each new worktree.
|
||||
|
||||
## Running
|
||||
|
||||
From the `dev-dashboard/` directory:
|
||||
|
||||
```bash
|
||||
./dev.sh
|
||||
```
|
||||
|
||||
This starts both backend and frontend, with logs prefixed `[BE]` / `[FE]`. `Ctrl+C` stops both.
|
||||
|
||||
You can also start them separately:
|
||||
|
||||
```bash
|
||||
# Terminal 1: backend (auto-reloads on save)
|
||||
cd backend && bun run dev
|
||||
|
||||
# Terminal 2: frontend (Vite dev server)
|
||||
cd frontend && bun run dev
|
||||
```
|
||||
|
||||
Open http://localhost:5112 in your browser.
|
||||
|
||||
### Cursor IDE integration
|
||||
|
||||
The top bar has a **Cursor** button that opens the selected worktree's directory in Cursor IDE via the `cursor://` protocol. Click the gear icon next to it to configure SSH remote host.
|
||||
|
||||
By default, clicking the button reuses an existing Cursor window. To always open in a **new window**, add this to your Cursor `settings.json` (`Cmd+Shift+P` → "Preferences: Open Settings (JSON)"):
|
||||
|
||||
```json
|
||||
"window.openFoldersInNewWindow": "on"
|
||||
```
|
||||
|
||||
### Keyboard shortcuts
|
||||
|
||||
| Shortcut | Action |
|
||||
|----------|--------|
|
||||
| `Cmd+Up/Down` | Navigate between worktrees |
|
||||
| `Cmd+K` | Create new worktree |
|
||||
| `Cmd+D` | Remove selected worktree |
|
||||
|
||||
### Environment variables
|
||||
|
||||
| Variable | Default | Description |
|
||||
|----------|---------|-------------|
|
||||
| `DASHBOARD_PORT` | `5111` | Backend API port |
|
||||
|
||||
The frontend dev server is hardcoded to port `5112` and proxies `/api/*` and `/ws/*` to the backend.
|
||||
|
||||
### Screenshot uploads (optional)
|
||||
|
||||
Sandbox agents can take screenshots of the frontend UI with Playwright and upload them to a Cloudflare R2 bucket for use in PR descriptions. To enable this, create a `dev-dashboard/.env` file (already gitignored):
|
||||
|
||||
```bash
|
||||
# Cloudflare R2 credentials — get from:
|
||||
# Dashboard → R2 → Manage R2 API Tokens → Create API Token (Object Read & Write, scoped to your bucket)
|
||||
AWS_ACCESS_KEY_ID=<your-r2-access-key>
|
||||
AWS_SECRET_ACCESS_KEY=<your-r2-secret-key>
|
||||
|
||||
# Account ID is on the R2 overview page (right sidebar)
|
||||
R2_ENDPOINT=https://<ACCOUNT_ID>.r2.cloudflarestorage.com
|
||||
R2_BUCKET=windmill-screenshots
|
||||
|
||||
# Enable public access on the bucket (Settings → Public access → r2.dev subdomain)
|
||||
R2_PUBLIC_URL=https://pub-<hash>.r2.dev
|
||||
```
|
||||
|
||||
When these are set, `dev.sh`/`run.sh` source the file and the env vars are inlined onto the `workmux sandbox agent` command. The workmux global config's `env_passthrough` (see [above](#workmux-global-config)) forwards them into the container. The agent's system prompt automatically includes screenshot instructions when R2 is configured.
|
||||
|
||||
## API
|
||||
|
||||
| Method | Endpoint | Description |
|
||||
|--------|----------|-------------|
|
||||
| `GET` | `/api/worktrees` | List all worktrees with status, ports, and service health |
|
||||
| `POST` | `/api/worktrees` | Create a worktree (`{ branch, profile?, agent?, prompt? }`) |
|
||||
| `DELETE` | `/api/worktrees/:name` | Remove a worktree |
|
||||
| `POST` | `/api/worktrees/:name/open` | Open/focus a worktree's tmux window |
|
||||
| `POST` | `/api/worktrees/:name/close` | Close a worktree's tmux window (keeps the worktree) |
|
||||
| `POST` | `/api/worktrees/:name/send` | Send a prompt to the worktree's agent (`{ prompt }`) |
|
||||
| `GET` | `/api/worktrees/:name/status` | Get agent status for a worktree |
|
||||
| `WS` | `/ws/:worktree` | Terminal WebSocket (xterm.js ↔ tmux) |
|
||||
13
dev-dashboard/backend/package.json
Normal file
13
dev-dashboard/backend/package.json
Normal file
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"name": "windmill-dev-dashboard-backend",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "bun --watch src/server.ts",
|
||||
"start": "bun src/server.ts"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/bun": "latest",
|
||||
"typescript": "^5.0.0"
|
||||
}
|
||||
}
|
||||
15
dev-dashboard/backend/src/env.ts
Normal file
15
dev-dashboard/backend/src/env.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
/** Read key=value pairs from a worktree's .env.local file. */
|
||||
export function readEnvLocal(wtDir: string): Record<string, string> {
|
||||
try {
|
||||
const content = Bun.spawnSync(["cat", `${wtDir}/.env.local`], { stdout: "pipe" });
|
||||
const text = new TextDecoder().decode(content.stdout).trim();
|
||||
const env: Record<string, string> = {};
|
||||
for (const line of text.split("\n")) {
|
||||
const match = line.match(/^(\w+)=(.*)$/);
|
||||
if (match) env[match[1]] = match[2];
|
||||
}
|
||||
return env;
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
290
dev-dashboard/backend/src/server.ts
Normal file
290
dev-dashboard/backend/src/server.ts
Normal file
@@ -0,0 +1,290 @@
|
||||
import {
|
||||
listWorktrees,
|
||||
getStatus,
|
||||
addWorktree,
|
||||
removeWorktree,
|
||||
openWorktree,
|
||||
mergeWorktree,
|
||||
readEnvLocal,
|
||||
type Profile,
|
||||
type Agent,
|
||||
} from "./workmux";
|
||||
import { reconcileForwarding, stopAll } from "./socat";
|
||||
import {
|
||||
attach,
|
||||
detach,
|
||||
write,
|
||||
resize,
|
||||
selectPane,
|
||||
getScrollback,
|
||||
setCallbacks,
|
||||
clearCallbacks,
|
||||
cleanupStaleSessions,
|
||||
} from "./terminal";
|
||||
|
||||
const PORT = parseInt(process.env.DASHBOARD_PORT || "5111");
|
||||
|
||||
function ts(): string {
|
||||
return new Date().toISOString().slice(11, 23);
|
||||
}
|
||||
|
||||
/** Map branch name → worktree directory using git worktree list. */
|
||||
function getWorktreePaths(): Map<string, string> {
|
||||
const result = Bun.spawnSync(["git", "worktree", "list", "--porcelain"], { stdout: "pipe" });
|
||||
const output = new TextDecoder().decode(result.stdout);
|
||||
const paths = new Map<string, string>();
|
||||
let currentPath = "";
|
||||
for (const line of output.split("\n")) {
|
||||
if (line.startsWith("worktree ")) {
|
||||
currentPath = line.slice("worktree ".length);
|
||||
} else if (line.startsWith("branch ")) {
|
||||
// branch refs/heads/foo → "foo"
|
||||
const branch = line.slice("branch ".length).replace("refs/heads/", "");
|
||||
// Also map by directory basename (workmux uses basename as branch key)
|
||||
const basename = currentPath.split("/").pop() ?? "";
|
||||
paths.set(branch, currentPath);
|
||||
if (basename !== branch) paths.set(basename, currentPath);
|
||||
}
|
||||
}
|
||||
return paths;
|
||||
}
|
||||
|
||||
/** Check if a port has a service responding (not just a TCP handshake). */
|
||||
function isPortListening(port: number): Promise<boolean> {
|
||||
return new Promise((resolve) => {
|
||||
const timeout = setTimeout(() => { resolve(false); }, 1000);
|
||||
fetch(`http://127.0.0.1:${port}/`, { signal: AbortSignal.timeout(1000) })
|
||||
.then((res) => { clearTimeout(timeout); resolve(true); })
|
||||
.catch(() => { clearTimeout(timeout); resolve(false); });
|
||||
});
|
||||
}
|
||||
|
||||
function jsonResponse(data: unknown, status = 200): Response {
|
||||
return new Response(JSON.stringify(data), {
|
||||
status,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
function errorResponse(message: string, status = 500): Response {
|
||||
return jsonResponse({ error: message }, status);
|
||||
}
|
||||
|
||||
interface WsData {
|
||||
worktree: string;
|
||||
attached: boolean;
|
||||
}
|
||||
|
||||
function makeCallbacks(ws: { send: (data: string) => void; readyState: number }) {
|
||||
return {
|
||||
onData: (data: string) => {
|
||||
if (ws.readyState <= 1) {
|
||||
ws.send(JSON.stringify({ type: "output", data }));
|
||||
}
|
||||
},
|
||||
onExit: (exitCode: number) => {
|
||||
if (ws.readyState <= 1) {
|
||||
ws.send(JSON.stringify({ type: "exit", exitCode }));
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
Bun.serve<WsData>({
|
||||
port: PORT,
|
||||
idleTimeout: 255, // seconds; worktree removal can take >10s
|
||||
|
||||
async fetch(req, server) {
|
||||
const url = new URL(req.url);
|
||||
|
||||
const wsMatch = url.pathname.match(/^\/ws\/(.+)$/);
|
||||
if (wsMatch) {
|
||||
const worktree = decodeURIComponent(wsMatch[1]);
|
||||
const upgraded = server.upgrade(req, { data: { worktree, attached: false } });
|
||||
if (upgraded) return undefined as unknown as Response;
|
||||
return new Response("WebSocket upgrade failed", { status: 400 });
|
||||
}
|
||||
|
||||
if (url.pathname.startsWith("/api/")) {
|
||||
return handleApi(req, url);
|
||||
}
|
||||
|
||||
return new Response("Not Found", { status: 404 });
|
||||
},
|
||||
|
||||
websocket: {
|
||||
open(ws) {
|
||||
console.log(`[ws:${ts()}] open worktree=${ws.data.worktree}`);
|
||||
},
|
||||
|
||||
async message(ws, message) {
|
||||
try {
|
||||
const msg = JSON.parse(typeof message === "string" ? message : new TextDecoder().decode(message));
|
||||
const { worktree } = ws.data;
|
||||
|
||||
switch (msg.type) {
|
||||
case "input":
|
||||
write(worktree, msg.data);
|
||||
break;
|
||||
case "selectPane":
|
||||
if (ws.data.attached && typeof msg.pane === "number") {
|
||||
console.log(`[ws:${ts()}] selectPane pane=${msg.pane} worktree=${worktree}`);
|
||||
selectPane(worktree, msg.pane);
|
||||
}
|
||||
break;
|
||||
case "resize":
|
||||
if (!ws.data.attached) {
|
||||
// First resize = client reporting actual dimensions. Spawn now.
|
||||
ws.data.attached = true;
|
||||
console.log(`[ws:${ts()}] first resize (attaching) worktree=${worktree} cols=${msg.cols} rows=${msg.rows}`);
|
||||
try {
|
||||
const initialPane = typeof msg.initialPane === "number" ? msg.initialPane : undefined;
|
||||
if (initialPane !== undefined) {
|
||||
console.log(`[ws:${ts()}] initialPane=${initialPane} worktree=${worktree}`);
|
||||
}
|
||||
await attach(worktree, msg.cols, msg.rows, initialPane);
|
||||
const { onData, onExit } = makeCallbacks(ws);
|
||||
setCallbacks(worktree, onData, onExit);
|
||||
const scrollback = getScrollback(worktree);
|
||||
console.log(`[ws:${ts()}] attached worktree=${worktree} scrollback=${scrollback.length} bytes`);
|
||||
if (scrollback) {
|
||||
ws.send(JSON.stringify({ type: "scrollback", data: scrollback }));
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
const errMsg = err instanceof Error ? err.message : String(err);
|
||||
console.log(`[ws:${ts()}] attach failed worktree=${worktree}: ${errMsg}`);
|
||||
ws.send(JSON.stringify({ type: "error", message: errMsg }));
|
||||
ws.close();
|
||||
}
|
||||
} else {
|
||||
resize(worktree, msg.cols, msg.rows);
|
||||
}
|
||||
break;
|
||||
}
|
||||
} catch {
|
||||
// Ignore malformed messages
|
||||
}
|
||||
},
|
||||
|
||||
async close(ws) {
|
||||
console.log(`[ws:${ts()}] close worktree=${ws.data.worktree} attached=${ws.data.attached}`);
|
||||
clearCallbacks(ws.data.worktree);
|
||||
await detach(ws.data.worktree);
|
||||
console.log(`[ws:${ts()}] close complete worktree=${ws.data.worktree}`);
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
async function handleApi(req: Request, url: URL): Promise<Response> {
|
||||
const method = req.method;
|
||||
const parts = url.pathname.slice(5).split("/").filter(Boolean);
|
||||
|
||||
try {
|
||||
// GET /api/worktrees
|
||||
if (parts[0] === "worktrees" && parts.length === 1 && method === "GET") {
|
||||
const [worktrees, status] = await Promise.all([listWorktrees(), getStatus()]);
|
||||
const wtPaths = getWorktreePaths();
|
||||
const merged = await Promise.all(worktrees.map(async (wt) => {
|
||||
const st = status.find(s =>
|
||||
s.worktree.includes(wt.branch) || s.worktree.startsWith(wt.branch)
|
||||
);
|
||||
const wtDir = wtPaths.get(wt.branch);
|
||||
const env = wtDir ? readEnvLocal(wtDir) : {};
|
||||
const backendPort = env.BACKEND_PORT ? parseInt(env.BACKEND_PORT) : null;
|
||||
const frontendPort = env.FRONTEND_PORT ? parseInt(env.FRONTEND_PORT) : null;
|
||||
const [backendRunning, frontendRunning] = await Promise.all([
|
||||
backendPort ? isPortListening(backendPort) : false,
|
||||
frontendPort ? isPortListening(frontendPort) : false,
|
||||
]);
|
||||
return {
|
||||
...wt,
|
||||
dir: wtDir ?? null,
|
||||
status: st?.status ?? "",
|
||||
elapsed: st?.elapsed ?? "",
|
||||
title: st?.title ?? "",
|
||||
profile: env.PROFILE || null,
|
||||
agentName: env.AGENT || null,
|
||||
backendPort,
|
||||
frontendPort,
|
||||
backendRunning,
|
||||
frontendRunning,
|
||||
};
|
||||
}));
|
||||
return jsonResponse(merged);
|
||||
}
|
||||
|
||||
// POST /api/worktrees
|
||||
if (parts[0] === "worktrees" && parts.length === 1 && method === "POST") {
|
||||
const body = await req.json() as { branch?: string; prompt?: string; profile?: string; agent?: string };
|
||||
if (!body.branch) {
|
||||
return errorResponse("branch is required", 400);
|
||||
}
|
||||
const validProfiles = ["full", "agent-yolo"] as const;
|
||||
const validAgents = ["claude", "codex"] as const;
|
||||
const profile = validProfiles.includes(body.profile as any) ? body.profile as Profile : "full";
|
||||
const agent = validAgents.includes(body.agent as any) ? body.agent as Agent : "claude";
|
||||
console.log(`[worktree:add] branch=${body.branch} agent=${agent} profile=${profile}${body.prompt ? ` prompt="${body.prompt.slice(0, 80)}"` : ""}`);
|
||||
const result = await addWorktree(body.branch, { prompt: body.prompt, profile, agent });
|
||||
console.log(`[worktree:add] done branch=${body.branch}: ${result}`);
|
||||
return jsonResponse({ message: result }, 201);
|
||||
}
|
||||
|
||||
// DELETE /api/worktrees/:name
|
||||
if (parts[0] === "worktrees" && parts.length === 2 && method === "DELETE") {
|
||||
const name = decodeURIComponent(parts[1]);
|
||||
console.log(`[worktree:rm] name=${name}`);
|
||||
const result = await removeWorktree(name);
|
||||
console.log(`[worktree:rm] done name=${name}: ${result}`);
|
||||
return jsonResponse({ message: result });
|
||||
}
|
||||
|
||||
// POST /api/worktrees/:name/open
|
||||
if (parts[0] === "worktrees" && parts.length === 3 && parts[2] === "open" && method === "POST") {
|
||||
const name = decodeURIComponent(parts[1]);
|
||||
console.log(`[worktree:open] name=${name}`);
|
||||
return jsonResponse({ message: await openWorktree(name) });
|
||||
}
|
||||
|
||||
// POST /api/worktrees/:name/merge
|
||||
if (parts[0] === "worktrees" && parts.length === 3 && parts[2] === "merge" && method === "POST") {
|
||||
const name = decodeURIComponent(parts[1]);
|
||||
console.log(`[worktree:merge] name=${name}`);
|
||||
const result = await mergeWorktree(name);
|
||||
console.log(`[worktree:merge] done name=${name}: ${result}`);
|
||||
return jsonResponse({ message: result });
|
||||
}
|
||||
|
||||
// GET /api/worktrees/:name/status
|
||||
if (parts[0] === "worktrees" && parts.length === 3 && parts[2] === "status" && method === "GET") {
|
||||
const name = decodeURIComponent(parts[1]);
|
||||
const status = await getStatus();
|
||||
const match = status.find(s => s.worktree.includes(name));
|
||||
return jsonResponse(match ?? { status: "unknown" });
|
||||
}
|
||||
|
||||
return errorResponse("Not Found", 404);
|
||||
} catch (err: unknown) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
console.error(`[api:error] ${method} ${url.pathname}: ${message}`);
|
||||
return errorResponse(message);
|
||||
}
|
||||
}
|
||||
|
||||
// Ensure tmux server is running (needs at least one session to persist)
|
||||
const tmuxCheck = Bun.spawnSync(["tmux", "list-sessions"], { stdout: "pipe", stderr: "pipe" });
|
||||
if (tmuxCheck.exitCode !== 0) {
|
||||
Bun.spawnSync(["tmux", "new-session", "-d", "-s", "0"]);
|
||||
console.log("Started tmux session");
|
||||
}
|
||||
|
||||
cleanupStaleSessions();
|
||||
|
||||
// Re-establish socat forwarding for any sandbox containers still running
|
||||
const wtPathsForReconcile = getWorktreePaths();
|
||||
reconcileForwarding((branch) => wtPathsForReconcile.get(branch));
|
||||
|
||||
// Clean shutdown: kill socat processes
|
||||
process.on("SIGINT", () => { stopAll(); process.exit(0); });
|
||||
process.on("SIGTERM", () => { stopAll(); process.exit(0); });
|
||||
|
||||
console.log(`Dev Dashboard API running at http://localhost:${PORT}`);
|
||||
130
dev-dashboard/backend/src/socat.ts
Normal file
130
dev-dashboard/backend/src/socat.ts
Normal file
@@ -0,0 +1,130 @@
|
||||
/**
|
||||
* Manages socat port forwarding for sandbox containers.
|
||||
*
|
||||
* When a worktree runs inside a Docker sandbox, its ports are only reachable
|
||||
* via the container's bridge IP. socat forwards host ports to the container
|
||||
* so the browser (over SSH) can reach them.
|
||||
*/
|
||||
|
||||
import { $ } from "bun";
|
||||
import { readEnvLocal } from "./env";
|
||||
|
||||
interface ForwardingEntry {
|
||||
branch: string;
|
||||
containerIp: string;
|
||||
ports: { host: number; proc: ReturnType<typeof Bun.spawn> }[];
|
||||
}
|
||||
|
||||
const registry = new Map<string, ForwardingEntry>();
|
||||
|
||||
/** Get the bridge IP of a running sandbox container for a worktree branch. */
|
||||
async function getContainerIp(branch: string): Promise<string | null> {
|
||||
try {
|
||||
// Container names follow the pattern wm-{branch}-*
|
||||
const ps = await $`docker ps --filter name=wm-${branch}- --format {{.ID}}`.text();
|
||||
const containerId = ps.trim().split("\n")[0];
|
||||
if (!containerId) return null;
|
||||
const ip = (await $`docker inspect ${containerId} --format {{.NetworkSettings.IPAddress}}`.text()).trim();
|
||||
return ip || null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Start socat forwarding for a sandbox worktree. Returns true if forwarding was started. */
|
||||
export async function startForwarding(branch: string, wtDir: string): Promise<boolean> {
|
||||
// Don't double-start
|
||||
if (registry.has(branch)) return true;
|
||||
|
||||
const containerIp = await getContainerIp(branch);
|
||||
if (!containerIp) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const env = readEnvLocal(wtDir);
|
||||
const backendPort = env.BACKEND_PORT ? parseInt(env.BACKEND_PORT) : null;
|
||||
const frontendPort = env.FRONTEND_PORT ? parseInt(env.FRONTEND_PORT) : null;
|
||||
|
||||
const entry: ForwardingEntry = { branch, containerIp, ports: [] };
|
||||
|
||||
for (const port of [backendPort, frontendPort]) {
|
||||
if (!port) continue;
|
||||
const proc = Bun.spawn([
|
||||
"socat",
|
||||
`TCP-LISTEN:${port},fork,reuseaddr`,
|
||||
`TCP:${containerIp}:${port}`,
|
||||
], { stdout: "ignore", stderr: "pipe" });
|
||||
// Consume the exit promise so Bun reaps the child (prevents zombies)
|
||||
proc.exited.then(() => {});
|
||||
entry.ports.push({ host: port, proc });
|
||||
console.log(`[socat] forwarding :${port} → ${containerIp}:${port} (branch=${branch}, pid=${proc.pid})`);
|
||||
}
|
||||
|
||||
if (entry.ports.length > 0) {
|
||||
registry.set(branch, entry);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Stop socat forwarding for a worktree. */
|
||||
export function stopForwarding(branch: string): void {
|
||||
const entry = registry.get(branch);
|
||||
if (!entry) return;
|
||||
|
||||
for (const { host, proc } of entry.ports) {
|
||||
try {
|
||||
proc.kill();
|
||||
console.log(`[socat] stopped :${host} (branch=${branch}, pid=${proc.pid})`);
|
||||
} catch {
|
||||
// Already exited
|
||||
}
|
||||
}
|
||||
registry.delete(branch);
|
||||
}
|
||||
|
||||
/**
|
||||
* Reconcile socat forwarding on startup.
|
||||
* Kills any orphaned socat processes from a previous run, then starts
|
||||
* forwarding for any running sandbox containers.
|
||||
*/
|
||||
export async function reconcileForwarding(getWorktreeDir: (branch: string) => string | undefined): Promise<void> {
|
||||
try {
|
||||
// Kill orphaned socat processes from previous dashboard runs
|
||||
try {
|
||||
await $`pkill -f ${"socat TCP-LISTEN.*TCP:172\\."}`.quiet();
|
||||
console.log("[socat] reconcile: killed orphaned socat processes");
|
||||
} catch {
|
||||
// No orphans found (pkill exits non-zero when no match)
|
||||
}
|
||||
|
||||
const ps = await $`docker ps --filter name=wm- --format {{.Names}}`.text();
|
||||
const names = ps.trim().split("\n").filter(Boolean);
|
||||
|
||||
for (const name of names) {
|
||||
// Container name format: wm-{branch}-{pid}
|
||||
const match = name.match(/^wm-(.+)-\d+$/);
|
||||
if (!match) continue;
|
||||
const branch = match[1];
|
||||
|
||||
if (registry.has(branch)) continue;
|
||||
|
||||
const wtDir = getWorktreeDir(branch);
|
||||
if (!wtDir) {
|
||||
console.log(`[socat] reconcile: no worktree dir found for ${branch}, skipping`);
|
||||
continue;
|
||||
}
|
||||
|
||||
console.log(`[socat] reconcile: starting forwarding for ${branch}`);
|
||||
await startForwarding(branch, wtDir);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(`[socat] reconcile failed:`, err);
|
||||
}
|
||||
}
|
||||
|
||||
/** Stop all forwarding (for clean shutdown). */
|
||||
export function stopAll(): void {
|
||||
for (const branch of [...registry.keys()]) {
|
||||
stopForwarding(branch);
|
||||
}
|
||||
}
|
||||
212
dev-dashboard/backend/src/terminal.ts
Normal file
212
dev-dashboard/backend/src/terminal.ts
Normal file
@@ -0,0 +1,212 @@
|
||||
import { FileSink } from "bun";
|
||||
import { getTmuxSession } from "./workmux";
|
||||
|
||||
interface TerminalSession {
|
||||
proc: ReturnType<typeof Bun.spawn>;
|
||||
groupedSessionName: string;
|
||||
scrollback: string[];
|
||||
onData: ((data: string) => void) | null;
|
||||
onExit: ((exitCode: number) => void) | null;
|
||||
}
|
||||
|
||||
const SESSION_PREFIX = "wm-dash-";
|
||||
const MAX_SCROLLBACK = 5000;
|
||||
const sessions = new Map<string, TerminalSession>();
|
||||
let sessionCounter = 0;
|
||||
|
||||
function ts(): string {
|
||||
return new Date().toISOString().slice(11, 23);
|
||||
}
|
||||
|
||||
function groupedName(): string {
|
||||
return `${SESSION_PREFIX}${++sessionCounter}`;
|
||||
}
|
||||
|
||||
/** Kill any orphaned wm-dash-* tmux sessions left from previous server runs. */
|
||||
export function cleanupStaleSessions(): void {
|
||||
try {
|
||||
const result = Bun.spawnSync(
|
||||
["tmux", "list-sessions", "-F", "#{session_name}"],
|
||||
{ stdout: "pipe", stderr: "pipe" }
|
||||
);
|
||||
if (result.exitCode !== 0) return;
|
||||
const lines = new TextDecoder().decode(result.stdout).trim().split("\n");
|
||||
for (const name of lines) {
|
||||
if (name.startsWith(SESSION_PREFIX)) {
|
||||
Bun.spawnSync(["tmux", "kill-session", "-t", name]);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// No tmux server running
|
||||
}
|
||||
}
|
||||
|
||||
/** Kill a tmux session by name, ignoring errors. */
|
||||
function killTmuxSession(name: string): void {
|
||||
try {
|
||||
Bun.spawnSync(["tmux", "kill-session", "-t", name]);
|
||||
} catch {}
|
||||
}
|
||||
|
||||
export async function attach(
|
||||
worktreeName: string,
|
||||
cols: number,
|
||||
rows: number,
|
||||
initialPane?: number
|
||||
): Promise<string> {
|
||||
console.log(`[term:${ts()}] attach(${worktreeName}) cols=${cols} rows=${rows} existing=${sessions.has(worktreeName)}`);
|
||||
if (sessions.has(worktreeName)) {
|
||||
console.log(`[term:${ts()}] attach(${worktreeName}) detaching existing session first`);
|
||||
await detach(worktreeName);
|
||||
console.log(`[term:${ts()}] attach(${worktreeName}) detach complete`);
|
||||
}
|
||||
|
||||
const tmuxSession = await getTmuxSession();
|
||||
const gName = groupedName();
|
||||
const windowTarget = `wm-${worktreeName}`;
|
||||
console.log(`[term:${ts()}] attach(${worktreeName}) tmuxSession=${tmuxSession} gName=${gName} window=${windowTarget}`);
|
||||
|
||||
// Kill stale session with same name if it exists (leftover from previous server run)
|
||||
killTmuxSession(gName);
|
||||
|
||||
const paneTarget = `${gName}:${windowTarget}.${initialPane ?? 0}`;
|
||||
const cmd = [
|
||||
`tmux new-session -d -s "${gName}" -t "${tmuxSession}"`,
|
||||
`tmux set-option -t "${gName}" mouse on`,
|
||||
`tmux set-option -t "${gName}" set-clipboard on`,
|
||||
`tmux select-window -t "${gName}:${windowTarget}"`,
|
||||
// Unzoom if a previous session left a pane zoomed (zoom state is shared across grouped sessions)
|
||||
`if [ "$(tmux display-message -t '${gName}:${windowTarget}' -p '#{window_zoomed_flag}')" = "1" ]; then tmux resize-pane -Z -t '${gName}:${windowTarget}'; fi`,
|
||||
`tmux select-pane -t "${paneTarget}"`,
|
||||
// On mobile, zoom the selected pane to fill the window
|
||||
...(initialPane !== undefined ? [`tmux resize-pane -Z -t "${paneTarget}"`] : []),
|
||||
`stty rows ${rows} cols ${cols}`,
|
||||
`exec tmux attach-session -t "${gName}"`,
|
||||
].join(" && ");
|
||||
|
||||
const session: TerminalSession = {
|
||||
proc: null as any,
|
||||
groupedSessionName: gName,
|
||||
scrollback: [],
|
||||
onData: null,
|
||||
onExit: null,
|
||||
};
|
||||
|
||||
sessions.set(worktreeName, session);
|
||||
|
||||
const proc = Bun.spawn(["script", "-q", "-c", cmd, "/dev/null"], {
|
||||
stdin: "pipe",
|
||||
stdout: "pipe",
|
||||
stderr: "pipe",
|
||||
env: { ...process.env, TERM: "xterm-256color" },
|
||||
});
|
||||
|
||||
session.proc = proc;
|
||||
console.log(`[term:${ts()}] attach(${worktreeName}) spawned pid=${proc.pid}`);
|
||||
|
||||
// Read stdout → push to scrollback + callback
|
||||
(async () => {
|
||||
const reader = proc.stdout.getReader();
|
||||
try {
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
const str = new TextDecoder().decode(value);
|
||||
session.scrollback.push(str);
|
||||
if (session.scrollback.length > MAX_SCROLLBACK) {
|
||||
session.scrollback.shift();
|
||||
}
|
||||
session.onData?.(str);
|
||||
}
|
||||
} catch {
|
||||
// Stream closed
|
||||
}
|
||||
})();
|
||||
|
||||
proc.exited.then((exitCode) => {
|
||||
console.log(`[term:${ts()}] proc exited(${worktreeName}) pid=${proc.pid} code=${exitCode}`);
|
||||
// Only clean up if this session is still the active one (not replaced by a new attach)
|
||||
if (sessions.get(worktreeName) === session) {
|
||||
session.onExit?.(exitCode);
|
||||
sessions.delete(worktreeName);
|
||||
} else {
|
||||
console.log(`[term:${ts()}] proc exited(${worktreeName}) stale session, skipping cleanup`);
|
||||
}
|
||||
killTmuxSession(gName);
|
||||
});
|
||||
|
||||
return worktreeName;
|
||||
}
|
||||
|
||||
export async function detach(worktreeName: string): Promise<void> {
|
||||
const session = sessions.get(worktreeName);
|
||||
if (!session) {
|
||||
console.log(`[term:${ts()}] detach(${worktreeName}) no session found`);
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`[term:${ts()}] detach(${worktreeName}) killing pid=${session.proc.pid} tmux=${session.groupedSessionName}`);
|
||||
session.proc.kill();
|
||||
sessions.delete(worktreeName);
|
||||
|
||||
killTmuxSession(session.groupedSessionName);
|
||||
console.log(`[term:${ts()}] detach(${worktreeName}) done`);
|
||||
}
|
||||
|
||||
export function write(worktreeName: string, data: string): void {
|
||||
const session = sessions.get(worktreeName);
|
||||
if (!session) {
|
||||
console.log(`[term:${ts()}] write(${worktreeName}) NO SESSION - input dropped (${data.length} bytes)`);
|
||||
return;
|
||||
}
|
||||
if (!session.proc.stdin) {
|
||||
console.log(`[term:${ts()}] write(${worktreeName}) NO STDIN - input dropped (${data.length} bytes)`);
|
||||
return;
|
||||
}
|
||||
(session.proc.stdin as FileSink).write(new TextEncoder().encode(data));
|
||||
}
|
||||
|
||||
export function resize(worktreeName: string, cols: number, rows: number): void {
|
||||
const session = sessions.get(worktreeName);
|
||||
if (!session) return;
|
||||
// Resize via tmux directly (we don't have access to script's internal PTY)
|
||||
Bun.spawnSync(["tmux", "resize-window", "-t", session.groupedSessionName, "-x", String(cols), "-y", String(rows)]);
|
||||
}
|
||||
|
||||
export function getScrollback(worktreeName: string): string {
|
||||
return sessions.get(worktreeName)?.scrollback.join("") ?? "";
|
||||
}
|
||||
|
||||
export function setCallbacks(
|
||||
worktreeName: string,
|
||||
onData: (data: string) => void,
|
||||
onExit: (exitCode: number) => void
|
||||
): void {
|
||||
const session = sessions.get(worktreeName);
|
||||
if (session) {
|
||||
session.onData = onData;
|
||||
session.onExit = onExit;
|
||||
}
|
||||
}
|
||||
|
||||
export function selectPane(worktreeName: string, paneIndex: number): void {
|
||||
const session = sessions.get(worktreeName);
|
||||
if (!session) {
|
||||
console.log(`[term:${ts()}] selectPane(${worktreeName}) no session found`);
|
||||
return;
|
||||
}
|
||||
const windowTarget = `wm-${worktreeName}`;
|
||||
const target = `${session.groupedSessionName}:${windowTarget}.${paneIndex}`;
|
||||
console.log(`[term:${ts()}] selectPane(${worktreeName}) pane=${paneIndex} target=${target}`);
|
||||
const r1 = Bun.spawnSync(["tmux", "select-pane", "-t", target]);
|
||||
const r2 = Bun.spawnSync(["tmux", "resize-pane", "-Z", "-t", target]);
|
||||
console.log(`[term:${ts()}] selectPane(${worktreeName}) select=${r1.exitCode} zoom=${r2.exitCode}`);
|
||||
}
|
||||
|
||||
export function clearCallbacks(worktreeName: string): void {
|
||||
const session = sessions.get(worktreeName);
|
||||
if (session) {
|
||||
session.onData = null;
|
||||
session.onExit = null;
|
||||
}
|
||||
}
|
||||
272
dev-dashboard/backend/src/workmux.ts
Normal file
272
dev-dashboard/backend/src/workmux.ts
Normal file
@@ -0,0 +1,272 @@
|
||||
import { $ } from "bun";
|
||||
import { startForwarding, stopForwarding } from "./socat";
|
||||
import { readEnvLocal } from "./env";
|
||||
|
||||
export interface Worktree {
|
||||
branch: string;
|
||||
agent: string;
|
||||
mux: string;
|
||||
unmerged: string;
|
||||
path: string;
|
||||
}
|
||||
|
||||
export interface WorktreeStatus {
|
||||
worktree: string;
|
||||
status: string;
|
||||
elapsed: string;
|
||||
title: string;
|
||||
}
|
||||
|
||||
function parseTable<T>(output: string, mapper: (cols: string[]) => T): T[] {
|
||||
const lines = output.trim().split("\n").filter(Boolean);
|
||||
if (lines.length < 2) return [];
|
||||
|
||||
const headerLine = lines[0];
|
||||
|
||||
// Find column positions based on header spacing
|
||||
const colStarts: number[] = [];
|
||||
let inSpace = true;
|
||||
for (let i = 0; i < headerLine.length; i++) {
|
||||
if (headerLine[i] !== " " && inSpace) {
|
||||
colStarts.push(i);
|
||||
inSpace = false;
|
||||
} else if (headerLine[i] === " " && !inSpace) {
|
||||
inSpace = true;
|
||||
}
|
||||
}
|
||||
|
||||
return lines.slice(1).map(line => {
|
||||
const cols = colStarts.map((start, idx) => {
|
||||
const end = idx + 1 < colStarts.length ? colStarts[idx + 1] : line.length;
|
||||
return line.slice(start, end).trim();
|
||||
});
|
||||
return mapper(cols);
|
||||
});
|
||||
}
|
||||
|
||||
export async function listWorktrees(): Promise<Worktree[]> {
|
||||
const result = await $`workmux list`.text();
|
||||
return parseTable(result, (cols) => ({
|
||||
branch: cols[0] ?? "",
|
||||
agent: cols[1] ?? "",
|
||||
mux: cols[2] ?? "",
|
||||
unmerged: cols[3] ?? "",
|
||||
path: cols[4] ?? "",
|
||||
}));
|
||||
}
|
||||
|
||||
export async function getStatus(): Promise<WorktreeStatus[]> {
|
||||
const result = await $`workmux status`.text();
|
||||
return parseTable(result, (cols) => ({
|
||||
worktree: cols[0] ?? "",
|
||||
status: cols[1] ?? "",
|
||||
elapsed: cols[2] ?? "",
|
||||
title: cols[3] ?? "",
|
||||
}));
|
||||
}
|
||||
|
||||
async function runChecked(args: string[]): Promise<string> {
|
||||
const proc = Bun.spawn(args, { stdout: "pipe", stderr: "pipe" });
|
||||
const stdout = await new Response(proc.stdout).text();
|
||||
const stderr = await new Response(proc.stderr).text();
|
||||
const exitCode = await proc.exited;
|
||||
|
||||
if (exitCode !== 0) {
|
||||
const msg = `${args.join(" ")} failed (exit ${exitCode}): ${stderr || stdout}`;
|
||||
console.error(`[workmux:exec] ${msg}`);
|
||||
throw new Error(msg);
|
||||
}
|
||||
return stdout.trim();
|
||||
}
|
||||
|
||||
export type Profile = "full" | "agent-yolo";
|
||||
export type Agent = "claude" | "codex";
|
||||
|
||||
export { readEnvLocal } from "./env";
|
||||
|
||||
function buildSandboxSystemPrompt(env: Record<string, string>): string {
|
||||
const backendPort = env.BACKEND_PORT || "8000";
|
||||
const frontendPort = env.FRONTEND_PORT || "3000";
|
||||
const hasR2 = !!(process.env.R2_ENDPOINT && process.env.R2_BUCKET && process.env.R2_PUBLIC_URL);
|
||||
console.log(`[workmux:buildSandboxSystemPrompt] hasR2=${hasR2}`);
|
||||
const lines: string[] = [
|
||||
"You are running inside a sandboxed container with full permissions.",
|
||||
`This worktree is configured with the following ports:`,
|
||||
`- Backend: port ${backendPort}. Start with: cd backend && PORT=${backendPort} DATABASE_URL=postgres://postgres:changeme@localhost:5432/windmill cargo watch -x run`,
|
||||
`- Frontend: port ${frontendPort}. Start with: cd frontend && REMOTE=http://localhost:${backendPort} npm run dev -- --port ${frontendPort} --host 0.0.0.0`,
|
||||
];
|
||||
if (hasR2) {
|
||||
lines.push(
|
||||
`--- 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:${frontendPort}/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: /<branch>/screenshot.png)`,
|
||||
);
|
||||
}
|
||||
return lines.join(" ");
|
||||
}
|
||||
|
||||
/** Env vars to forward into the sandbox container (via workmux env_passthrough). */
|
||||
const SANDBOX_ENV_PASSTHROUGH = [
|
||||
"AWS_ACCESS_KEY_ID",
|
||||
"AWS_SECRET_ACCESS_KEY",
|
||||
"R2_ENDPOINT",
|
||||
"R2_BUCKET",
|
||||
"R2_PUBLIC_URL",
|
||||
];
|
||||
|
||||
/** Build an inline env prefix (e.g. "KEY=val KEY2=val2 ") from process.env. */
|
||||
function buildEnvPrefix(): string {
|
||||
const parts: string[] = [];
|
||||
for (const key of SANDBOX_ENV_PASSTHROUGH) {
|
||||
const val = process.env[key];
|
||||
if (val) {
|
||||
// Shell-escape the value (single quotes, escaping inner single quotes)
|
||||
const escaped = val.replace(/'/g, "'\\''");
|
||||
parts.push(`${key}='${escaped}'`);
|
||||
}
|
||||
}
|
||||
return parts.length > 0 ? parts.join(" ") + " " : "";
|
||||
}
|
||||
|
||||
function buildSandboxAgentCmd(env: Record<string, string>, agent: Agent): string {
|
||||
const prompt = buildSandboxSystemPrompt(env);
|
||||
const innerEscaped = prompt.replace(/["\\$`]/g, "\\$&");
|
||||
const envPrefix = buildEnvPrefix();
|
||||
|
||||
if (agent === "codex") {
|
||||
return `${envPrefix}workmux sandbox agent -- codex --yolo -c '"developer_instructions=${innerEscaped}"'`;
|
||||
}
|
||||
return `${envPrefix}workmux sandbox agent -- claude --dangerously-skip-permissions --append-system-prompt '"${innerEscaped}"'`;
|
||||
}
|
||||
|
||||
function ensureTmux(): void {
|
||||
const check = Bun.spawnSync(["tmux", "list-sessions"], { stdout: "pipe", stderr: "pipe" });
|
||||
if (check.exitCode !== 0) {
|
||||
Bun.spawnSync(["tmux", "new-session", "-d", "-s", "0"]);
|
||||
console.log("[workmux] restarted tmux session");
|
||||
}
|
||||
}
|
||||
|
||||
export async function addWorktree(
|
||||
branch: string,
|
||||
opts?: { prompt?: string; profile?: Profile; agent?: Agent }
|
||||
): Promise<string> {
|
||||
ensureTmux();
|
||||
const profile = opts?.profile ?? "full";
|
||||
const agent = opts?.agent ?? "claude";
|
||||
const args: string[] = ["workmux", "add", "-b"]; // -b = background (don't switch tmux)
|
||||
|
||||
// Skip default pane commands for non-full profiles
|
||||
if (profile !== "full") {
|
||||
args.push("-C"); // --no-pane-cmds
|
||||
}
|
||||
|
||||
// Enable sandbox for yolo profile (safe to skip permissions inside container)
|
||||
if (profile === "agent-yolo") {
|
||||
args.push("-S"); // --sandbox
|
||||
}
|
||||
|
||||
if (opts?.prompt) args.push("-p", opts.prompt);
|
||||
args.push(branch);
|
||||
|
||||
console.log(`[workmux:add] running: ${args.join(" ")}`);
|
||||
const result = await runChecked(args);
|
||||
console.log(`[workmux:add] result: ${result}`);
|
||||
|
||||
const windowTarget = `wm-${branch}`;
|
||||
|
||||
// Read worktree dir and log assigned ports
|
||||
const wtDirResult = Bun.spawnSync(
|
||||
["tmux", "display-message", "-t", `${windowTarget}.0`, "-p", "#{pane_current_path}"],
|
||||
{ stdout: "pipe" }
|
||||
);
|
||||
const wtDir = new TextDecoder().decode(wtDirResult.stdout).trim();
|
||||
const env = readEnvLocal(wtDir);
|
||||
console.log(`[workmux:add] branch=${branch} dir=${wtDir} ports: backend=${env.BACKEND_PORT || "8000"} frontend=${env.FRONTEND_PORT || "3000"}`);
|
||||
|
||||
// Append profile to .env.local (worktree-env creates it, we just add to it)
|
||||
if (wtDir) {
|
||||
const envPath = `${wtDir}/.env.local`;
|
||||
const existing = await Bun.file(envPath).text().catch(() => "");
|
||||
if (!existing.includes("PROFILE=")) {
|
||||
await Bun.write(envPath, existing.trimEnd() + `\nPROFILE=${profile}\nAGENT=${agent}\n`);
|
||||
}
|
||||
}
|
||||
|
||||
// For non-full profiles, kill extra panes and send commands
|
||||
if (profile !== "full") {
|
||||
// Kill extra panes (highest index first to avoid shifting)
|
||||
const paneCountResult = Bun.spawnSync(
|
||||
["tmux", "list-panes", "-t", windowTarget, "-F", "#{pane_index}"],
|
||||
{ stdout: "pipe" }
|
||||
);
|
||||
const paneIds = new TextDecoder().decode(paneCountResult.stdout).trim().split("\n");
|
||||
// Kill all panes except pane 0
|
||||
for (let i = paneIds.length - 1; i >= 1; i--) {
|
||||
Bun.spawnSync(["tmux", "kill-pane", "-t", `${windowTarget}.${paneIds[i]}`]);
|
||||
}
|
||||
// Build and send agent command for sandbox (env vars are inlined as a prefix)
|
||||
const agentCmd = buildSandboxAgentCmd(env, agent);
|
||||
console.log(`[workmux] sending command to ${windowTarget}.0:\n${agentCmd}`);
|
||||
Bun.spawnSync(["tmux", "send-keys", "-t", `${windowTarget}.0`, agentCmd, "Enter"]);
|
||||
// Open a shell pane on the right (1/3 width) in the worktree dir
|
||||
Bun.spawnSync(["tmux", "split-window", "-h", "-t", `${windowTarget}.0`, "-l", "25%", "-c", wtDir]);
|
||||
// Keep focus on the agent pane (left)
|
||||
Bun.spawnSync(["tmux", "select-pane", "-t", `${windowTarget}.0`]);
|
||||
|
||||
// Start socat port forwarding for sandbox containers (non-blocking).
|
||||
// The container takes a few seconds to start after the tmux command is sent,
|
||||
// so we poll in the background rather than blocking the API response.
|
||||
if (profile === "agent-yolo" && wtDir) {
|
||||
(async () => {
|
||||
console.log(`[socat] waiting for container to start for ${branch}...`);
|
||||
for (let i = 1; i <= 15; i++) {
|
||||
await new Promise(r => setTimeout(r, 2000));
|
||||
if (await startForwarding(branch, wtDir)) return;
|
||||
console.log(`[socat] container not ready for ${branch}, retrying (${i}/15)...`);
|
||||
}
|
||||
console.error(`[socat] gave up waiting for container for ${branch} after 30s`);
|
||||
})();
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
export async function removeWorktree(name: string): Promise<string> {
|
||||
console.log(`[workmux:rm] running: workmux rm --force ${name}`);
|
||||
stopForwarding(name);
|
||||
const result = await runChecked(["workmux", "rm", "--force", name]);
|
||||
console.log(`[workmux:rm] result: ${result}`);
|
||||
return result;
|
||||
}
|
||||
|
||||
export async function openWorktree(name: string): Promise<string> {
|
||||
return runChecked(["workmux", "open", name]);
|
||||
}
|
||||
|
||||
export async function mergeWorktree(name: string): Promise<string> {
|
||||
console.log(`[workmux:merge] running: workmux merge ${name}`);
|
||||
stopForwarding(name);
|
||||
const result = await runChecked(["workmux", "merge", name]);
|
||||
console.log(`[workmux:merge] result: ${result}`);
|
||||
return result;
|
||||
}
|
||||
|
||||
export async function getTmuxSession(): Promise<string> {
|
||||
try {
|
||||
const result = await $`tmux list-windows -a -F "#{session_name}:#{window_name}"`.text();
|
||||
for (const line of result.trim().split("\n")) {
|
||||
const [session, window] = line.split(":");
|
||||
if (window?.startsWith("wm-")) {
|
||||
return session!;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// No tmux server running
|
||||
}
|
||||
return "0";
|
||||
}
|
||||
14
dev-dashboard/backend/tsconfig.json
Normal file
14
dev-dashboard/backend/tsconfig.json
Normal file
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ESNext",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"outDir": "dist",
|
||||
"rootDir": "src",
|
||||
"types": ["bun-types"]
|
||||
},
|
||||
"include": ["src/**/*.ts"]
|
||||
}
|
||||
27
dev-dashboard/dev.sh
Executable file
27
dev-dashboard/dev.sh
Executable file
@@ -0,0 +1,27 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
cd "$(dirname "$0")"
|
||||
|
||||
# Load env vars (R2 credentials, etc.) if present
|
||||
if [ -f .env ]; then
|
||||
set -a; source .env; set +a
|
||||
fi
|
||||
|
||||
cleanup() {
|
||||
kill $BE_PID $FE_PID 2>/dev/null || true
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
# Backend (bun --watch)
|
||||
cd backend
|
||||
bun run dev 2>&1 | sed 's/^/[BE] /' &
|
||||
BE_PID=$!
|
||||
cd ..
|
||||
|
||||
# Frontend (vite dev)
|
||||
cd frontend
|
||||
bun run dev 2>&1 | sed 's/^/[FE] /' &
|
||||
FE_PID=$!
|
||||
cd ..
|
||||
|
||||
wait
|
||||
15
dev-dashboard/frontend/index.html
Normal file
15
dev-dashboard/frontend/index.html
Normal file
@@ -0,0 +1,15 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta
|
||||
name="viewport"
|
||||
content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no"
|
||||
/>
|
||||
<title>Windmill Dev Dashboard</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
23
dev-dashboard/frontend/package.json
Normal file
23
dev-dashboard/frontend/package.json
Normal file
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"name": "windmill-dev-dashboard-frontend",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite --host 0.0.0.0",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview --host 0.0.0.0",
|
||||
"check": "svelte-check --tsconfig ./tsconfig.json"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@sveltejs/vite-plugin-svelte": "^5.0.0",
|
||||
"@tailwindcss/vite": "^4.2.0",
|
||||
"@xterm/addon-fit": "^0.10.0",
|
||||
"@xterm/addon-web-links": "^0.11.0",
|
||||
"@xterm/xterm": "^5.5.0",
|
||||
"svelte": "^5.0.0",
|
||||
"svelte-check": "^4.0.0",
|
||||
"tailwindcss": "^4.2.0",
|
||||
"typescript": "^5.0.0",
|
||||
"vite": "^6.0.0"
|
||||
}
|
||||
}
|
||||
319
dev-dashboard/frontend/src/App.svelte
Normal file
319
dev-dashboard/frontend/src/App.svelte
Normal file
@@ -0,0 +1,319 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from "svelte";
|
||||
import WorktreeList from "./lib/WorktreeList.svelte";
|
||||
import TopBar from "./lib/TopBar.svelte";
|
||||
import Terminal from "./lib/Terminal.svelte";
|
||||
import ConfirmDialog from "./lib/ConfirmDialog.svelte";
|
||||
import CreateWorktreeDialog from "./lib/CreateWorktreeDialog.svelte";
|
||||
import SettingsDialog from "./lib/SettingsDialog.svelte";
|
||||
import PaneBar from "./lib/PaneBar.svelte";
|
||||
import type { WorktreeInfo } from "./lib/types";
|
||||
import type { Profile, Agent } from "./lib/api";
|
||||
import * as api from "./lib/api";
|
||||
|
||||
let worktrees = $state<WorktreeInfo[]>([]);
|
||||
let selectedBranch = $state<string | null>(null);
|
||||
let removeBranch = $state<string | null>(null);
|
||||
let mergeBranch = $state<string | null>(null);
|
||||
let merging = $state(false);
|
||||
let mergeError = $state("");
|
||||
let removingBranches = $state<Set<string>>(new Set());
|
||||
const SSH_STORAGE_KEY = "wt-ssh-host";
|
||||
let showCreateDialog = $state(false);
|
||||
let showSettingsDialog = $state(false);
|
||||
let creating = $state(false);
|
||||
let sshHost = $state(localStorage.getItem(SSH_STORAGE_KEY) ?? "");
|
||||
|
||||
// Mobile state
|
||||
let isMobile = $state(false);
|
||||
let sidebarOpen = $state(false);
|
||||
let activePane = $state(0);
|
||||
let terminalRef: { sendSelectPane: (pane: number) => void } | undefined = $state();
|
||||
|
||||
let visibleWorktrees = $derived(
|
||||
worktrees.filter((w) => w.path === "(here)" || w.branch === "main" || w.mux === "✓")
|
||||
);
|
||||
let selectedWorktree = $derived(visibleWorktrees.find((w) => w.branch === selectedBranch));
|
||||
let isMain = $derived(selectedWorktree?.path === "(here)" || selectedBranch === "main");
|
||||
let canConnect = $derived(!!selectedBranch && !isMain);
|
||||
|
||||
let paneBarProfile = $derived(
|
||||
selectedWorktree?.profile === "full" || selectedWorktree?.profile === "agent-yolo"
|
||||
? selectedWorktree.profile as "full" | "agent-yolo"
|
||||
: null
|
||||
);
|
||||
let showPaneBar = $derived(isMobile && canConnect && paneBarProfile !== null);
|
||||
|
||||
async function refresh() {
|
||||
try {
|
||||
worktrees = await api.fetchWorktrees();
|
||||
} catch (err) {
|
||||
console.error("Failed to refresh:", err);
|
||||
}
|
||||
}
|
||||
|
||||
function randomName(len: number): string {
|
||||
const chars = "abcdefghijklmnopqrstuvwxyz0123456789";
|
||||
let result = "";
|
||||
for (let i = 0; i < len; i++) {
|
||||
result += chars[Math.floor(Math.random() * chars.length)];
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/** Sanitize user input into a valid git branch name */
|
||||
function sanitizeBranchName(raw: string): string {
|
||||
return raw
|
||||
.replace(/\s+/g, "-") // spaces → dashes
|
||||
.replace(/[~^:?*\[\]\\]+/g, "") // remove git-invalid chars
|
||||
.replace(/\.{2,}/g, ".") // collapse ".." → "."
|
||||
.replace(/\/{2,}/g, "/") // collapse consecutive slashes
|
||||
.replace(/-{2,}/g, "-") // collapse consecutive dashes
|
||||
.replace(/^[.\-/]+|[.\-/]+$/g, "") // no leading/trailing . - /
|
||||
.replace(/\.lock$/i, ""); // no trailing .lock
|
||||
}
|
||||
|
||||
async function handleCreate(name: string, profile: Profile, agent: Agent) {
|
||||
const branch = (name && sanitizeBranchName(name)) || randomName(8);
|
||||
creating = true;
|
||||
try {
|
||||
await api.createWorktree(branch, profile, agent);
|
||||
await api.openWorktree(branch);
|
||||
showCreateDialog = false;
|
||||
await refresh();
|
||||
selectedBranch = branch;
|
||||
} catch (err) {
|
||||
alert(`Failed to create: ${err instanceof Error ? err.message : err}`);
|
||||
} finally {
|
||||
creating = false;
|
||||
}
|
||||
}
|
||||
|
||||
function selectNeighborOf(branch: string) {
|
||||
if (selectedBranch !== branch) return;
|
||||
const idx = visibleWorktrees.findIndex((w) => w.branch === branch);
|
||||
const neighbor = visibleWorktrees[idx - 1] ?? visibleWorktrees[idx + 1];
|
||||
const isNeighborMain = neighbor && (neighbor.path === "(here)" || neighbor.branch === "main");
|
||||
selectedBranch = neighbor && !isNeighborMain ? neighbor.branch : null;
|
||||
}
|
||||
|
||||
async function handleRemove() {
|
||||
const branch = removeBranch;
|
||||
if (!branch) return;
|
||||
removeBranch = null;
|
||||
selectNeighborOf(branch);
|
||||
|
||||
removingBranches = new Set([...removingBranches, branch]);
|
||||
try {
|
||||
await api.removeWorktree(branch);
|
||||
await refresh();
|
||||
} catch (err) {
|
||||
alert(`Failed to remove: ${err instanceof Error ? err.message : err}`);
|
||||
} finally {
|
||||
removingBranches = new Set([...removingBranches].filter((b) => b !== branch));
|
||||
}
|
||||
}
|
||||
|
||||
async function handleMerge() {
|
||||
const branch = mergeBranch;
|
||||
if (!branch) return;
|
||||
|
||||
merging = true;
|
||||
mergeError = "";
|
||||
try {
|
||||
await api.mergeWorktree(branch);
|
||||
mergeBranch = null;
|
||||
selectNeighborOf(branch);
|
||||
await refresh();
|
||||
} catch (err) {
|
||||
mergeError = err instanceof Error ? err.message : String(err);
|
||||
} finally {
|
||||
merging = false;
|
||||
}
|
||||
}
|
||||
|
||||
function selectNeighborWorktree(direction: -1 | 1) {
|
||||
const selectable = visibleWorktrees.filter(
|
||||
(w) => w.path !== "(here)" && w.branch !== "main" && !removingBranches.has(w.branch)
|
||||
);
|
||||
if (selectable.length === 0) return;
|
||||
if (!selectedBranch) {
|
||||
selectedBranch = selectable[direction === 1 ? 0 : selectable.length - 1].branch;
|
||||
return;
|
||||
}
|
||||
const idx = selectable.findIndex((w) => w.branch === selectedBranch);
|
||||
const next = idx + direction;
|
||||
if (next >= 0 && next < selectable.length) {
|
||||
selectedBranch = selectable[next].branch;
|
||||
}
|
||||
}
|
||||
|
||||
function handleKeydown(e: KeyboardEvent) {
|
||||
// Ignore shortcuts when a dialog is open (let dialog handle its own keys)
|
||||
if (showCreateDialog || removeBranch || mergeBranch) return;
|
||||
|
||||
const mod = e.metaKey || e.ctrlKey;
|
||||
if (!mod) return;
|
||||
|
||||
if (e.key === "ArrowUp") {
|
||||
e.preventDefault();
|
||||
selectNeighborWorktree(-1);
|
||||
} else if (e.key === "ArrowDown") {
|
||||
e.preventDefault();
|
||||
selectNeighborWorktree(1);
|
||||
} else if (e.key === "k" || e.key === "K") {
|
||||
e.preventDefault();
|
||||
if (!creating) showCreateDialog = true;
|
||||
} else if (e.key === "m" || e.key === "M") {
|
||||
e.preventDefault();
|
||||
if (selectedBranch && !isMain) mergeBranch = selectedBranch;
|
||||
} else if (e.key === "d" || e.key === "D") {
|
||||
e.preventDefault();
|
||||
if (selectedBranch && !isMain) removeBranch = selectedBranch;
|
||||
}
|
||||
}
|
||||
|
||||
function handlePaneSelect(pane: number) {
|
||||
activePane = pane;
|
||||
terminalRef?.sendSelectPane(pane);
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
refresh();
|
||||
const interval = setInterval(refresh, 5000);
|
||||
window.addEventListener("keydown", handleKeydown);
|
||||
|
||||
const mq = window.matchMedia("(max-width: 768px)");
|
||||
isMobile = mq.matches;
|
||||
function onMqChange(e: MediaQueryListEvent) { isMobile = e.matches; }
|
||||
mq.addEventListener("change", onMqChange);
|
||||
|
||||
return () => {
|
||||
clearInterval(interval);
|
||||
window.removeEventListener("keydown", handleKeydown);
|
||||
mq.removeEventListener("change", onMqChange);
|
||||
};
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="flex h-screen bg-surface text-primary">
|
||||
<!-- Sidebar: fixed overlay on mobile, static on desktop -->
|
||||
{#if !isMobile || sidebarOpen}
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
{#if isMobile}
|
||||
<div
|
||||
class="fixed inset-0 bg-black/50 z-40"
|
||||
onclick={() => (sidebarOpen = false)}
|
||||
onkeydown={(e) => { if (e.key === "Escape") sidebarOpen = false; }}
|
||||
></div>
|
||||
{/if}
|
||||
<aside class="{isMobile ? 'fixed inset-0 z-50 w-full' : 'w-[220px] min-w-[220px]'} bg-sidebar border-r border-edge flex flex-col overflow-hidden">
|
||||
<div class="flex items-center justify-between p-4 border-b border-edge">
|
||||
<h1 class="text-base font-semibold">Windmill</h1>
|
||||
<div class="flex items-center gap-2">
|
||||
<button
|
||||
class="h-8 px-2 gap-1.5 rounded-md border border-edge bg-surface text-accent text-xs flex items-center justify-center cursor-pointer hover:bg-hover disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
onclick={() => (showCreateDialog = true)}
|
||||
disabled={creating}
|
||||
title="New Worktree (Cmd+K)"
|
||||
><span class="text-lg leading-none">+</span> New</button>
|
||||
{#if isMobile}
|
||||
<button
|
||||
class="h-8 w-8 rounded-md border border-edge bg-surface text-muted text-sm flex items-center justify-center cursor-pointer hover:bg-hover"
|
||||
onclick={() => (sidebarOpen = false)}
|
||||
title="Close sidebar"
|
||||
>×</button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
<WorktreeList
|
||||
worktrees={visibleWorktrees}
|
||||
selected={selectedBranch}
|
||||
removing={removingBranches}
|
||||
onselect={(b) => { selectedBranch = b; if (isMobile) sidebarOpen = false; }}
|
||||
onremove={(b) => (removeBranch = b)}
|
||||
/>
|
||||
{#if !isMobile}
|
||||
<div class="shrink-0 border-t border-edge px-4 py-3 text-[11px] text-muted flex flex-col gap-1">
|
||||
<div class="flex justify-between"><span>Navigate</span><kbd class="opacity-60">Cmd+Up/Down</kbd></div>
|
||||
<div class="flex justify-between"><span>New worktree</span><kbd class="opacity-60">Cmd+K</kbd></div>
|
||||
<div class="flex justify-between"><span>Merge</span><kbd class="opacity-60">Cmd+M</kbd></div>
|
||||
<div class="flex justify-between"><span>Remove</span><kbd class="opacity-60">Cmd+D</kbd></div>
|
||||
</div>
|
||||
{/if}
|
||||
</aside>
|
||||
{/if}
|
||||
|
||||
<main class="flex-1 min-w-0 flex flex-col overflow-hidden">
|
||||
<TopBar
|
||||
name={selectedBranch}
|
||||
worktree={selectedWorktree}
|
||||
{sshHost}
|
||||
{isMobile}
|
||||
ontogglesidebar={() => (sidebarOpen = !sidebarOpen)}
|
||||
onmerge={() => { if (selectedBranch) mergeBranch = selectedBranch; }}
|
||||
onremove={() => { if (selectedBranch) removeBranch = selectedBranch; }}
|
||||
onsettings={() => (showSettingsDialog = true)}
|
||||
/>
|
||||
|
||||
{#if canConnect}
|
||||
{#key selectedBranch}
|
||||
<Terminal
|
||||
worktree={selectedBranch!}
|
||||
{isMobile}
|
||||
initialPane={isMobile ? activePane : undefined}
|
||||
bind:this={terminalRef}
|
||||
/>
|
||||
{/key}
|
||||
{:else}
|
||||
<div class="flex-1 flex items-center justify-center text-muted text-sm">
|
||||
<p>
|
||||
{#if isMain}
|
||||
Main worktree — use workmux to manage
|
||||
{:else}
|
||||
Select a worktree from the sidebar to connect
|
||||
{/if}
|
||||
</p>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if showPaneBar}
|
||||
<PaneBar {activePane} profile={paneBarProfile!} onselect={handlePaneSelect} />
|
||||
{/if}
|
||||
</main>
|
||||
</div>
|
||||
|
||||
{#if showCreateDialog}
|
||||
<CreateWorktreeDialog
|
||||
loading={creating}
|
||||
oncreate={handleCreate}
|
||||
oncancel={() => (showCreateDialog = false)}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
{#if removeBranch}
|
||||
<ConfirmDialog
|
||||
message={`Remove worktree "${removeBranch}"? This action cannot be undone.`}
|
||||
onconfirm={handleRemove}
|
||||
oncancel={() => (removeBranch = null)}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
{#if mergeBranch}
|
||||
<ConfirmDialog
|
||||
message={`Merge worktree "${mergeBranch}" into main? The worktree will be removed after merging.`}
|
||||
confirmLabel="Merge"
|
||||
variant="accent"
|
||||
loading={merging}
|
||||
error={mergeError}
|
||||
onconfirm={handleMerge}
|
||||
oncancel={() => { mergeBranch = null; mergeError = ""; }}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
{#if showSettingsDialog}
|
||||
<SettingsDialog
|
||||
onsave={(host) => { sshHost = host; showSettingsDialog = false; }}
|
||||
onclose={() => (showSettingsDialog = false)}
|
||||
/>
|
||||
{/if}
|
||||
72
dev-dashboard/frontend/src/app.css
Normal file
72
dev-dashboard/frontend/src/app.css
Normal file
@@ -0,0 +1,72 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
@theme {
|
||||
--color-surface: #0d1117;
|
||||
--color-sidebar: #161b22;
|
||||
--color-topbar: #1c2128;
|
||||
--color-hover: #21262d;
|
||||
--color-active: #1f6feb33;
|
||||
--color-edge: #30363d;
|
||||
--color-primary: #e6edf3;
|
||||
--color-muted: #8b949e;
|
||||
--color-accent: #58a6ff;
|
||||
--color-danger: #f85149;
|
||||
--color-success: #3fb950;
|
||||
--color-warning: #d29922;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family:
|
||||
-apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif;
|
||||
}
|
||||
|
||||
/* dialog styling (no tailwind equivalents for ::backdrop) */
|
||||
dialog[open] {
|
||||
margin: auto;
|
||||
}
|
||||
dialog::backdrop {
|
||||
background: rgba(0, 0, 0, 0.6);
|
||||
}
|
||||
|
||||
dialog textarea {
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
dialog textarea:focus {
|
||||
outline: none;
|
||||
border-color: var(--color-accent);
|
||||
}
|
||||
|
||||
/* spinner */
|
||||
@keyframes spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
.spinner {
|
||||
display: inline-block;
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border: 1.5px solid currentColor;
|
||||
border-top-color: transparent;
|
||||
border-radius: 50%;
|
||||
animation: spin 0.6s linear infinite;
|
||||
}
|
||||
|
||||
/* xterm overrides */
|
||||
.xterm {
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* Mobile: increase touch targets */
|
||||
@media (max-width: 768px) {
|
||||
/* Prevent overscroll/bounce on iOS */
|
||||
html,
|
||||
body {
|
||||
overflow: hidden;
|
||||
position: fixed;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
}
|
||||
38
dev-dashboard/frontend/src/lib/ConfirmDialog.svelte
Normal file
38
dev-dashboard/frontend/src/lib/ConfirmDialog.svelte
Normal file
@@ -0,0 +1,38 @@
|
||||
<script lang="ts">
|
||||
let { message, loading = false, error = "", confirmLabel = "Remove", variant = "danger", onconfirm, oncancel }: {
|
||||
message: string;
|
||||
loading?: boolean;
|
||||
error?: string;
|
||||
confirmLabel?: string;
|
||||
variant?: "danger" | "accent";
|
||||
onconfirm: () => void;
|
||||
oncancel: () => void;
|
||||
} = $props();
|
||||
|
||||
let dialogEl: HTMLDialogElement;
|
||||
let confirmBtn: HTMLButtonElement;
|
||||
|
||||
$effect(() => {
|
||||
dialogEl?.showModal();
|
||||
confirmBtn?.focus();
|
||||
});
|
||||
|
||||
const btn = "px-3 py-1.5 rounded-md border border-edge bg-surface text-primary text-xs cursor-pointer hover:bg-hover";
|
||||
</script>
|
||||
|
||||
<dialog bind:this={dialogEl} onclose={oncancel} class="bg-sidebar text-primary border border-edge rounded-xl p-6 max-w-[380px] w-[90%]">
|
||||
<form method="dialog" onsubmit={(e) => { e.preventDefault(); onconfirm(); }}>
|
||||
<h2 class="text-base mb-4">Confirm</h2>
|
||||
<p class="text-[13px] text-muted mb-6">{message}</p>
|
||||
{#if error}<p class="text-[12px] text-danger mb-4 -mt-2 whitespace-pre-wrap">{error}</p>{/if}
|
||||
<div class="flex justify-end gap-2">
|
||||
<button type="button" class={btn} onclick={oncancel} disabled={loading}>Cancel</button>
|
||||
<button
|
||||
bind:this={confirmBtn}
|
||||
type="submit"
|
||||
class="{btn} !text-white hover:!opacity-90 disabled:!opacity-50 disabled:!cursor-not-allowed flex items-center gap-1.5 {variant === 'accent' ? '!bg-accent !border-accent' : '!bg-danger !border-danger'}"
|
||||
disabled={loading}
|
||||
>{#if loading}<span class="spinner"></span>{/if} {confirmLabel}</button>
|
||||
</div>
|
||||
</form>
|
||||
</dialog>
|
||||
150
dev-dashboard/frontend/src/lib/CreateWorktreeDialog.svelte
Normal file
150
dev-dashboard/frontend/src/lib/CreateWorktreeDialog.svelte
Normal file
@@ -0,0 +1,150 @@
|
||||
<script lang="ts">
|
||||
import type { Profile, Agent } from "./api";
|
||||
|
||||
const AGENTS: { value: Agent; label: string }[] = [
|
||||
{ value: "claude", label: "Claude" },
|
||||
{ value: "codex", label: "Codex" },
|
||||
];
|
||||
|
||||
const PROFILES: { value: Profile; label: string }[] = [
|
||||
{ value: "full", label: "Full (agent + backend + frontend)" },
|
||||
{ value: "agent-yolo", label: "Agent (sandboxed, yolo mode)" },
|
||||
];
|
||||
|
||||
let {
|
||||
loading = false,
|
||||
oncreate,
|
||||
oncancel,
|
||||
}: {
|
||||
loading?: boolean;
|
||||
oncreate: (name: string, profile: Profile, agent: Agent) => void;
|
||||
oncancel: () => void;
|
||||
} = $props();
|
||||
|
||||
const STORAGE_KEY = "wt-default-profile";
|
||||
const AGENT_STORAGE_KEY = "wt-default-agent";
|
||||
const savedProfile = localStorage.getItem(STORAGE_KEY) as Profile | null;
|
||||
const savedAgent = localStorage.getItem(AGENT_STORAGE_KEY) as Agent | null;
|
||||
|
||||
let name = $state("");
|
||||
let agent = $state<Agent>(savedAgent ?? "claude");
|
||||
let profile = $state<Profile>(savedProfile ?? "full");
|
||||
let saveDefault = $state(false);
|
||||
|
||||
let dialogEl: HTMLDialogElement;
|
||||
|
||||
$effect(() => {
|
||||
dialogEl?.showModal();
|
||||
});
|
||||
|
||||
function handleKeydown(e: KeyboardEvent) {
|
||||
if (e.key === "ArrowUp" || e.key === "ArrowDown") {
|
||||
e.preventDefault();
|
||||
const idx = PROFILES.findIndex((p) => p.value === profile);
|
||||
const next = e.key === "ArrowDown"
|
||||
? (idx + 1) % PROFILES.length
|
||||
: (idx - 1 + PROFILES.length) % PROFILES.length;
|
||||
profile = PROFILES[next].value;
|
||||
}
|
||||
}
|
||||
|
||||
const btn =
|
||||
"px-3 py-1.5 rounded-md border border-edge bg-surface text-primary text-xs cursor-pointer hover:bg-hover";
|
||||
</script>
|
||||
|
||||
<dialog
|
||||
bind:this={dialogEl}
|
||||
onclose={oncancel}
|
||||
onkeydown={handleKeydown}
|
||||
class="bg-sidebar text-primary border border-edge rounded-xl p-6 max-w-[380px] w-[90%]"
|
||||
>
|
||||
<form
|
||||
method="dialog"
|
||||
onsubmit={(e) => {
|
||||
e.preventDefault();
|
||||
if (saveDefault) {
|
||||
localStorage.setItem(STORAGE_KEY, profile);
|
||||
localStorage.setItem(AGENT_STORAGE_KEY, agent);
|
||||
} else {
|
||||
localStorage.removeItem(STORAGE_KEY);
|
||||
localStorage.removeItem(AGENT_STORAGE_KEY);
|
||||
}
|
||||
oncreate(name.trim(), profile, agent);
|
||||
}}
|
||||
>
|
||||
<h2 class="text-base mb-4">New Worktree</h2>
|
||||
<div class="mb-4">
|
||||
<label class="block text-xs text-muted mb-1.5" for="wt-name"
|
||||
>Name <span class="opacity-60">(optional)</span></label
|
||||
>
|
||||
<input
|
||||
id="wt-name"
|
||||
type="text"
|
||||
class="w-full px-2.5 py-1.5 rounded-md border border-edge bg-surface text-primary text-[13px] placeholder:text-muted/50 outline-none focus:border-accent"
|
||||
placeholder="auto-generated if empty"
|
||||
bind:value={name}
|
||||
/>
|
||||
</div>
|
||||
<div class="flex gap-2 mb-4">
|
||||
{#each AGENTS as a}
|
||||
<label
|
||||
class="flex-1 flex items-center justify-center gap-2 p-2.5 rounded-lg border cursor-pointer text-[13px] transition-colors
|
||||
{agent === a.value
|
||||
? 'border-accent bg-accent/10'
|
||||
: 'border-edge hover:bg-hover'}"
|
||||
>
|
||||
<input
|
||||
type="radio"
|
||||
name="agent"
|
||||
value={a.value}
|
||||
checked={agent === a.value}
|
||||
onchange={() => (agent = a.value)}
|
||||
class="accent-[var(--accent)]"
|
||||
/>
|
||||
{a.label}
|
||||
</label>
|
||||
{/each}
|
||||
</div>
|
||||
<div class="flex flex-col gap-2 mb-6">
|
||||
{#each PROFILES as p}
|
||||
<label
|
||||
class="flex items-center gap-2.5 p-2.5 rounded-lg border cursor-pointer text-[13px] transition-colors
|
||||
{profile === p.value
|
||||
? 'border-accent bg-accent/10'
|
||||
: 'border-edge hover:bg-hover'}"
|
||||
>
|
||||
<input
|
||||
type="radio"
|
||||
name="profile"
|
||||
value={p.value}
|
||||
checked={profile === p.value}
|
||||
onchange={() => (profile = p.value)}
|
||||
class="accent-[var(--accent)]"
|
||||
/>
|
||||
{p.label}
|
||||
</label>
|
||||
{/each}
|
||||
</div>
|
||||
<label
|
||||
class="flex items-center gap-2 mb-4 text-[13px] text-muted cursor-pointer"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
bind:checked={saveDefault}
|
||||
class="accent-[var(--accent)]"
|
||||
/>
|
||||
Save as default
|
||||
</label>
|
||||
<div class="flex justify-end gap-2">
|
||||
<button type="button" class={btn} onclick={oncancel} disabled={loading}
|
||||
>Cancel</button
|
||||
>
|
||||
<button
|
||||
type="submit"
|
||||
class="{btn} !bg-accent !text-white !border-accent hover:!opacity-90 disabled:!opacity-50 disabled:!cursor-not-allowed flex items-center gap-1.5"
|
||||
disabled={loading}
|
||||
>{#if loading}<span class="spinner"></span>{/if} Create</button
|
||||
>
|
||||
</div>
|
||||
</form>
|
||||
</dialog>
|
||||
42
dev-dashboard/frontend/src/lib/PaneBar.svelte
Normal file
42
dev-dashboard/frontend/src/lib/PaneBar.svelte
Normal file
@@ -0,0 +1,42 @@
|
||||
<script lang="ts">
|
||||
let { activePane, profile, onselect }: {
|
||||
activePane: number;
|
||||
profile: "full" | "agent-yolo";
|
||||
onselect: (pane: number) => void;
|
||||
} = $props();
|
||||
|
||||
const panesByProfile = {
|
||||
"full": [
|
||||
{ index: 0, label: "Claude" },
|
||||
{ index: 1, label: "Backend" },
|
||||
{ index: 2, label: "Frontend" },
|
||||
],
|
||||
"agent-yolo": [
|
||||
{ index: 0, label: "Claude" },
|
||||
{ index: 1, label: "Shell" },
|
||||
],
|
||||
};
|
||||
|
||||
let panes = $derived(panesByProfile[profile] ?? panesByProfile["full"]);
|
||||
</script>
|
||||
|
||||
<nav class="flex items-stretch bg-topbar border-t border-edge pane-bar">
|
||||
{#each panes as p (p.index)}
|
||||
<button
|
||||
type="button"
|
||||
class="flex-1 py-3 text-sm font-medium cursor-pointer border-none bg-transparent {activePane === p.index ? 'text-accent pane-active' : 'text-muted'}"
|
||||
onclick={() => onselect(p.index)}
|
||||
>
|
||||
{p.label}
|
||||
</button>
|
||||
{/each}
|
||||
</nav>
|
||||
|
||||
<style>
|
||||
.pane-bar {
|
||||
padding-bottom: env(safe-area-inset-bottom, 0px);
|
||||
}
|
||||
.pane-active {
|
||||
box-shadow: inset 0 2px 0 0 var(--color-accent);
|
||||
}
|
||||
</style>
|
||||
41
dev-dashboard/frontend/src/lib/SendDialog.svelte
Normal file
41
dev-dashboard/frontend/src/lib/SendDialog.svelte
Normal file
@@ -0,0 +1,41 @@
|
||||
<script lang="ts">
|
||||
let { onsubmit, oncancel }: {
|
||||
onsubmit: (prompt: string) => void;
|
||||
oncancel: () => void;
|
||||
} = $props();
|
||||
|
||||
let dialogEl: HTMLDialogElement;
|
||||
let prompt = $state("");
|
||||
|
||||
$effect(() => {
|
||||
dialogEl?.showModal();
|
||||
});
|
||||
|
||||
function handleSubmit(e: SubmitEvent) {
|
||||
e.preventDefault();
|
||||
const trimmed = prompt.trim();
|
||||
if (trimmed) onsubmit(trimmed);
|
||||
}
|
||||
|
||||
const btn = "px-3 py-1.5 rounded-md border border-edge bg-surface text-primary text-xs cursor-pointer hover:bg-hover";
|
||||
</script>
|
||||
|
||||
<dialog bind:this={dialogEl} onclose={oncancel} class="bg-sidebar text-primary border border-edge rounded-xl p-6 max-w-[440px] w-[90%]">
|
||||
<form onsubmit={handleSubmit}>
|
||||
<h2 class="text-base mb-4">Send Prompt</h2>
|
||||
<label class="block text-[13px] text-muted mb-3">
|
||||
Prompt
|
||||
<textarea
|
||||
rows="4"
|
||||
required
|
||||
placeholder="Implement the feature..."
|
||||
bind:value={prompt}
|
||||
class="block w-full mt-1 p-2 bg-surface border border-edge rounded-md text-primary text-[13px]"
|
||||
></textarea>
|
||||
</label>
|
||||
<div class="flex justify-end gap-2 mt-4">
|
||||
<button type="button" class={btn} onclick={oncancel}>Cancel</button>
|
||||
<button type="submit" class="{btn} !bg-accent !text-white !border-accent hover:!opacity-90">Send</button>
|
||||
</div>
|
||||
</form>
|
||||
</dialog>
|
||||
58
dev-dashboard/frontend/src/lib/SettingsDialog.svelte
Normal file
58
dev-dashboard/frontend/src/lib/SettingsDialog.svelte
Normal file
@@ -0,0 +1,58 @@
|
||||
<script lang="ts">
|
||||
const STORAGE_KEY = "wt-ssh-host";
|
||||
|
||||
let { onsave, onclose }: {
|
||||
onsave: (sshHost: string) => void;
|
||||
onclose: () => void;
|
||||
} = $props();
|
||||
|
||||
let sshHost = $state(localStorage.getItem(STORAGE_KEY) ?? "");
|
||||
let dialogEl: HTMLDialogElement;
|
||||
let inputEl: HTMLInputElement;
|
||||
|
||||
$effect(() => {
|
||||
dialogEl?.showModal();
|
||||
inputEl?.focus();
|
||||
});
|
||||
|
||||
function handleSave() {
|
||||
const trimmed = sshHost.trim();
|
||||
if (trimmed) {
|
||||
localStorage.setItem(STORAGE_KEY, trimmed);
|
||||
} else {
|
||||
localStorage.removeItem(STORAGE_KEY);
|
||||
}
|
||||
onsave(trimmed);
|
||||
}
|
||||
|
||||
const btn = "px-3 py-1.5 rounded-md border border-edge bg-surface text-primary text-xs cursor-pointer hover:bg-hover";
|
||||
</script>
|
||||
|
||||
<dialog bind:this={dialogEl} onclose={onclose} class="bg-sidebar text-primary border border-edge rounded-xl p-6 max-w-[380px] w-[90%]">
|
||||
<form method="dialog" onsubmit={(e) => { e.preventDefault(); handleSave(); }}>
|
||||
<h2 class="text-base mb-4">Settings</h2>
|
||||
<div class="mb-4">
|
||||
<label class="block text-xs text-muted mb-1.5" for="ssh-host">
|
||||
SSH Host <span class="opacity-60">(for "Open in Cursor")</span>
|
||||
</label>
|
||||
<input
|
||||
bind:this={inputEl}
|
||||
id="ssh-host"
|
||||
type="text"
|
||||
class="w-full px-2.5 py-1.5 rounded-md border border-edge bg-surface text-primary text-[13px] placeholder:text-muted/50 outline-none focus:border-accent"
|
||||
placeholder="e.g. devbox or 10.0.0.5"
|
||||
bind:value={sshHost}
|
||||
/>
|
||||
<p class="text-[11px] text-muted mt-1.5">
|
||||
Must match an entry in your local <code class="text-accent/80">~/.ssh/config</code>. Leave empty for local mode.
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex justify-end gap-2">
|
||||
<button type="button" class={btn} onclick={onclose}>Cancel</button>
|
||||
<button
|
||||
type="submit"
|
||||
class="{btn} !bg-accent !text-white !border-accent hover:!opacity-90"
|
||||
>Save</button>
|
||||
</div>
|
||||
</form>
|
||||
</dialog>
|
||||
143
dev-dashboard/frontend/src/lib/Terminal.svelte
Normal file
143
dev-dashboard/frontend/src/lib/Terminal.svelte
Normal file
@@ -0,0 +1,143 @@
|
||||
<script lang="ts">
|
||||
import { onMount, onDestroy } from "svelte";
|
||||
import { Terminal } from "@xterm/xterm";
|
||||
import { FitAddon } from "@xterm/addon-fit";
|
||||
import { WebLinksAddon } from "@xterm/addon-web-links";
|
||||
import "@xterm/xterm/css/xterm.css";
|
||||
|
||||
let { worktree, isMobile = false, initialPane }: {
|
||||
worktree: string;
|
||||
isMobile?: boolean;
|
||||
initialPane?: number;
|
||||
} = $props();
|
||||
|
||||
let containerEl: HTMLDivElement;
|
||||
let term: Terminal;
|
||||
let fitAddon: FitAddon;
|
||||
let ws: WebSocket;
|
||||
let resizeObs: ResizeObserver;
|
||||
|
||||
export function sendSelectPane(pane: number) {
|
||||
if (ws?.readyState === WebSocket.OPEN) {
|
||||
ws.send(JSON.stringify({ type: "selectPane", pane }));
|
||||
}
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
term = new Terminal({
|
||||
cursorBlink: true,
|
||||
theme: {
|
||||
background: "#0d1117",
|
||||
foreground: "#e6edf3",
|
||||
cursor: "#58a6ff",
|
||||
selectionBackground: "#264f78",
|
||||
},
|
||||
fontFamily: "'JetBrains Mono', 'Fira Code', 'Cascadia Code', Menlo, monospace",
|
||||
fontSize: isMobile ? 13 : 11,
|
||||
scrollback: 10000,
|
||||
});
|
||||
|
||||
fitAddon = new FitAddon();
|
||||
term.loadAddon(fitAddon);
|
||||
term.loadAddon(new WebLinksAddon());
|
||||
term.open(containerEl);
|
||||
|
||||
// Prevent browser context menu so tmux right-click works unobstructed
|
||||
containerEl.addEventListener("contextmenu", (e) => e.preventDefault());
|
||||
|
||||
// Handle OSC 52 sequences from tmux → write to system clipboard
|
||||
term.parser.registerOscHandler(52, (data) => {
|
||||
const idx = data.indexOf(";");
|
||||
if (idx !== -1) {
|
||||
const b64 = data.slice(idx + 1);
|
||||
try {
|
||||
const text = atob(b64);
|
||||
navigator.clipboard.writeText(text);
|
||||
} catch {}
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
// Auto-copy on xterm.js selection (e.g. when user Shift+drags to bypass tmux mouse)
|
||||
term.onSelectionChange(() => {
|
||||
const sel = term.getSelection();
|
||||
if (sel) {
|
||||
navigator.clipboard.writeText(sel);
|
||||
}
|
||||
});
|
||||
|
||||
// Let app-level shortcuts (Cmd+Arrow, Cmd+N, Cmd+D) bubble up instead of
|
||||
// being consumed by xterm. Return false → xterm ignores the event.
|
||||
term.attachCustomKeyEventHandler((e: KeyboardEvent) => {
|
||||
if (e.type !== "keydown") return true;
|
||||
const mod = e.metaKey || e.ctrlKey;
|
||||
if (mod && (e.key === "ArrowUp" || e.key === "ArrowDown")) return false;
|
||||
if (mod && (e.key === "k" || e.key === "K")) return false;
|
||||
if (mod && (e.key === "d" || e.key === "D")) return false;
|
||||
return true;
|
||||
});
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
fitAddon.fit();
|
||||
term.focus();
|
||||
});
|
||||
|
||||
const protocol = location.protocol === "https:" ? "wss:" : "ws:";
|
||||
ws = new WebSocket(`${protocol}//${location.host}/ws/${encodeURIComponent(worktree)}`);
|
||||
|
||||
ws.onmessage = (event) => {
|
||||
try {
|
||||
const msg = JSON.parse(event.data);
|
||||
switch (msg.type) {
|
||||
case "scrollback":
|
||||
case "output":
|
||||
term.write(msg.data);
|
||||
break;
|
||||
case "exit":
|
||||
term.writeln(`\r\n\x1b[33m[Process exited with code ${msg.exitCode}]\x1b[0m`);
|
||||
break;
|
||||
case "error":
|
||||
term.writeln(`\r\n\x1b[31m[Error: ${msg.message}]\x1b[0m`);
|
||||
break;
|
||||
}
|
||||
} catch {
|
||||
// Ignore malformed messages
|
||||
}
|
||||
};
|
||||
|
||||
ws.onopen = () => {
|
||||
fitAddon.fit();
|
||||
const msg: Record<string, unknown> = { type: "resize", cols: term.cols, rows: term.rows };
|
||||
if (isMobile && initialPane !== undefined) {
|
||||
msg.initialPane = initialPane;
|
||||
}
|
||||
ws.send(JSON.stringify(msg));
|
||||
};
|
||||
|
||||
ws.onclose = () => {
|
||||
term.writeln("\r\n\x1b[90m[Disconnected]\x1b[0m");
|
||||
};
|
||||
|
||||
term.onData((data) => {
|
||||
if (ws.readyState === WebSocket.OPEN) {
|
||||
ws.send(JSON.stringify({ type: "input", data }));
|
||||
}
|
||||
});
|
||||
|
||||
resizeObs = new ResizeObserver(() => {
|
||||
fitAddon.fit();
|
||||
if (ws.readyState === WebSocket.OPEN) {
|
||||
ws.send(JSON.stringify({ type: "resize", cols: term.cols, rows: term.rows }));
|
||||
}
|
||||
});
|
||||
resizeObs.observe(containerEl);
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
resizeObs?.disconnect();
|
||||
ws?.close();
|
||||
term?.dispose();
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="flex-1 min-h-0 w-full p-1 overflow-hidden" bind:this={containerEl}></div>
|
||||
82
dev-dashboard/frontend/src/lib/TopBar.svelte
Normal file
82
dev-dashboard/frontend/src/lib/TopBar.svelte
Normal file
@@ -0,0 +1,82 @@
|
||||
<script lang="ts">
|
||||
import type { WorktreeInfo } from "./types";
|
||||
|
||||
let { name, worktree, sshHost, isMobile = false, ontogglesidebar, onmerge, onremove, onsettings }: {
|
||||
name: string | null;
|
||||
worktree: WorktreeInfo | undefined;
|
||||
sshHost: string;
|
||||
isMobile?: boolean;
|
||||
ontogglesidebar?: () => void;
|
||||
onmerge: () => void;
|
||||
onremove: () => void;
|
||||
onsettings: () => void;
|
||||
} = $props();
|
||||
|
||||
let cursorUrl = $derived.by(() => {
|
||||
const dir = worktree?.dir;
|
||||
if (!dir) return null;
|
||||
if (sshHost) {
|
||||
return `cursor://vscode-remote/ssh-remote+${sshHost}${dir}`;
|
||||
}
|
||||
return `cursor://file${dir}`;
|
||||
});
|
||||
|
||||
const btn = "px-3 py-1.5 rounded-md border border-edge bg-surface text-primary text-xs cursor-pointer hover:bg-hover";
|
||||
</script>
|
||||
|
||||
<div class="flex items-center justify-between px-4 py-2 bg-topbar border-b border-edge min-h-12">
|
||||
<div class="flex items-center gap-3">
|
||||
{#if isMobile && ontogglesidebar}
|
||||
<button
|
||||
type="button"
|
||||
class="p-1 -ml-1 cursor-pointer bg-transparent border-none text-muted hover:text-primary"
|
||||
onclick={ontogglesidebar}
|
||||
title="Toggle sidebar"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="3" y1="6" x2="21" y2="6"/><line x1="3" y1="12" x2="21" y2="12"/><line x1="3" y1="18" x2="21" y2="18"/></svg>
|
||||
</button>
|
||||
{/if}
|
||||
<span class="text-sm font-semibold truncate">{name ?? "Select a worktree"}</span>
|
||||
{#if !isMobile}
|
||||
{#if worktree?.backendPort}
|
||||
<a
|
||||
href="{window.location.protocol}//{window.location.hostname}:{worktree.backendPort}"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
class="text-[11px] px-1.5 py-0.5 rounded border font-mono no-underline hover:opacity-80 {worktree.backendRunning ? 'text-success border-success/40' : 'text-muted border-edge pointer-events-none'}"
|
||||
>BE :{worktree.backendPort}</a>
|
||||
{/if}
|
||||
{#if worktree?.frontendPort}
|
||||
<a
|
||||
href="{window.location.protocol}//{window.location.hostname}:{worktree.frontendPort}"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
class="text-[11px] px-1.5 py-0.5 rounded border font-mono no-underline hover:opacity-80 {worktree.frontendRunning ? 'text-success border-success/40' : 'text-muted border-edge pointer-events-none'}"
|
||||
>FE :{worktree.frontendPort}</a>
|
||||
{/if}
|
||||
{#if cursorUrl}
|
||||
<a
|
||||
href={cursorUrl}
|
||||
class="text-[11px] px-1.5 py-0.5 rounded-l border border-accent/40 text-accent font-mono no-underline hover:opacity-80"
|
||||
title="Open in Cursor"
|
||||
>Cursor</a><button
|
||||
type="button"
|
||||
class="text-[11px] px-1 py-0.5 rounded-r border border-l-0 border-accent/40 text-accent cursor-pointer bg-transparent hover:opacity-80"
|
||||
title="Cursor SSH settings"
|
||||
onclick={onsettings}
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z"/><circle cx="12" cy="12" r="3"/></svg>
|
||||
</button>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
{#if name}
|
||||
<div class="flex gap-2 items-center">
|
||||
{#if !isMobile}
|
||||
<span class="text-xs px-2 py-0.5 rounded-xl bg-hover">{worktree?.status || worktree?.agent || ""}</span>
|
||||
{/if}
|
||||
<button class="{btn} !text-accent !border-accent hover:!bg-accent/10" onclick={onmerge} title="Merge worktree">{isMobile ? "M" : "Merge"}</button>
|
||||
<button class="{btn} !text-danger !border-danger hover:!bg-danger/10" onclick={onremove} title="Remove worktree">{isMobile ? "R" : "Remove"}</button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
65
dev-dashboard/frontend/src/lib/WorktreeList.svelte
Normal file
65
dev-dashboard/frontend/src/lib/WorktreeList.svelte
Normal file
@@ -0,0 +1,65 @@
|
||||
<script lang="ts">
|
||||
import type { WorktreeInfo } from "./types";
|
||||
|
||||
let { worktrees, selected, removing, onselect, onremove }: {
|
||||
worktrees: WorktreeInfo[];
|
||||
selected: string | null;
|
||||
removing: Set<string>;
|
||||
onselect: (branch: string) => void;
|
||||
onremove: (branch: string) => void;
|
||||
} = $props();
|
||||
|
||||
function dotColor(agent: string): string {
|
||||
if (agent === "working") return "bg-success";
|
||||
if (agent === "waiting") return "bg-warning";
|
||||
if (agent === "error") return "bg-danger";
|
||||
return "bg-muted";
|
||||
}
|
||||
</script>
|
||||
|
||||
<ul class="list-none overflow-y-auto flex-1 p-2">
|
||||
{#each worktrees as wt (wt.branch)}
|
||||
{@const isMain = wt.path === "(here)" || wt.branch === "main"}
|
||||
{@const isActive = wt.branch === selected}
|
||||
{@const isRemoving = removing.has(wt.branch)}
|
||||
<li class="mb-0.5 group relative {isRemoving ? 'opacity-40 pointer-events-none' : ''}">
|
||||
<button
|
||||
type="button"
|
||||
class="w-full py-2.5 px-3 rounded-md border cursor-pointer flex flex-col gap-1 text-left text-inherit text-sm bg-transparent hover:bg-hover {isActive ? 'bg-active border-accent' : 'border-transparent'}"
|
||||
onclick={() => onselect(wt.branch)}
|
||||
>
|
||||
<span class="font-medium truncate pr-5">{wt.branch}</span>
|
||||
<span class="flex gap-2 text-[11px] text-muted items-center flex-wrap">
|
||||
<span><span class="inline-block w-2 h-2 rounded-full mr-1 align-middle {dotColor(wt.agent)}"></span>{wt.agent || "none"}</span>
|
||||
{#if wt.agentName}
|
||||
<span>{wt.agentName}</span>
|
||||
{/if}
|
||||
{#if wt.profile}
|
||||
<span>{wt.profile}</span>
|
||||
{/if}
|
||||
{#if isMain}
|
||||
<span>main</span>
|
||||
{/if}
|
||||
</span>
|
||||
{#if wt.backendPort || wt.frontendPort}
|
||||
<span class="flex gap-2 text-[11px] text-muted font-mono">
|
||||
{#if wt.backendPort}
|
||||
<span class={wt.backendRunning ? 'text-success' : ''}>BE:{wt.backendPort}</span>
|
||||
{/if}
|
||||
{#if wt.frontendPort}
|
||||
<span class={wt.frontendRunning ? 'text-success' : ''}>FE:{wt.frontendPort}</span>
|
||||
{/if}
|
||||
</span>
|
||||
{/if}
|
||||
</button>
|
||||
{#if !isMain}
|
||||
<button
|
||||
type="button"
|
||||
class="absolute top-2 right-2 w-5 h-5 rounded flex items-center justify-center text-muted hover:text-danger hover:bg-hover opacity-0 group-hover:opacity-100 transition-opacity cursor-pointer"
|
||||
title="Remove worktree"
|
||||
onclick={(e) => { e.stopPropagation(); onremove(wt.branch); }}
|
||||
>×</button>
|
||||
{/if}
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
41
dev-dashboard/frontend/src/lib/api.ts
Normal file
41
dev-dashboard/frontend/src/lib/api.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import type { WorktreeInfo } from "./types";
|
||||
|
||||
async function api<T = unknown>(path: string, opts?: RequestInit): Promise<T> {
|
||||
const res = await fetch(`/api/${path}`, {
|
||||
headers: { "Content-Type": "application/json" },
|
||||
...opts,
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok) throw new Error(data.error || `HTTP ${res.status}`);
|
||||
return data as T;
|
||||
}
|
||||
|
||||
export function fetchWorktrees(): Promise<WorktreeInfo[]> {
|
||||
return api<WorktreeInfo[]>("worktrees");
|
||||
}
|
||||
|
||||
export type Profile = "full" | "agent-yolo";
|
||||
export type Agent = "claude" | "codex";
|
||||
|
||||
export function createWorktree(
|
||||
branch: string,
|
||||
profile: Profile = "full",
|
||||
agent: Agent = "claude",
|
||||
): Promise<unknown> {
|
||||
return api("worktrees", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ branch, profile, agent }),
|
||||
});
|
||||
}
|
||||
|
||||
export function removeWorktree(name: string): Promise<unknown> {
|
||||
return api(`worktrees/${encodeURIComponent(name)}`, { method: "DELETE" });
|
||||
}
|
||||
|
||||
export function openWorktree(name: string): Promise<unknown> {
|
||||
return api(`worktrees/${encodeURIComponent(name)}/open`, { method: "POST" });
|
||||
}
|
||||
|
||||
export function mergeWorktree(name: string): Promise<unknown> {
|
||||
return api(`worktrees/${encodeURIComponent(name)}/merge`, { method: "POST" });
|
||||
}
|
||||
16
dev-dashboard/frontend/src/lib/types.ts
Normal file
16
dev-dashboard/frontend/src/lib/types.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
export interface WorktreeInfo {
|
||||
branch: string;
|
||||
agent: string;
|
||||
mux: string;
|
||||
path: string;
|
||||
dir: string | null;
|
||||
status: string;
|
||||
elapsed: string;
|
||||
title: string;
|
||||
profile: string | null;
|
||||
agentName: string | null;
|
||||
backendPort: number | null;
|
||||
frontendPort: number | null;
|
||||
backendRunning: boolean;
|
||||
frontendRunning: boolean;
|
||||
}
|
||||
5
dev-dashboard/frontend/src/main.ts
Normal file
5
dev-dashboard/frontend/src/main.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
import "./app.css";
|
||||
import App from "./App.svelte";
|
||||
import { mount } from "svelte";
|
||||
|
||||
mount(App, { target: document.getElementById("app")! });
|
||||
2
dev-dashboard/frontend/src/vite-env.d.ts
vendored
Normal file
2
dev-dashboard/frontend/src/vite-env.d.ts
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
/// <reference types="svelte" />
|
||||
/// <reference types="vite/client" />
|
||||
5
dev-dashboard/frontend/svelte.config.js
Normal file
5
dev-dashboard/frontend/svelte.config.js
Normal file
@@ -0,0 +1,5 @@
|
||||
import { vitePreprocess } from "@sveltejs/vite-plugin-svelte";
|
||||
|
||||
export default {
|
||||
preprocess: vitePreprocess(),
|
||||
};
|
||||
13
dev-dashboard/frontend/tsconfig.json
Normal file
13
dev-dashboard/frontend/tsconfig.json
Normal file
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ESNext",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"isolatedModules": true,
|
||||
"verbatimModuleSyntax": true
|
||||
},
|
||||
"include": ["src/**/*.ts", "src/**/*.svelte", "vite.config.ts"]
|
||||
}
|
||||
27
dev-dashboard/frontend/vite.config.ts
Normal file
27
dev-dashboard/frontend/vite.config.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import { defineConfig } from "vite";
|
||||
import { svelte } from "@sveltejs/vite-plugin-svelte";
|
||||
import tailwindcss from "@tailwindcss/vite";
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [svelte(), tailwindcss()],
|
||||
server: {
|
||||
port: 5112,
|
||||
proxy: {
|
||||
"/api": "http://localhost:5111",
|
||||
"/ws": {
|
||||
target: "ws://localhost:5111",
|
||||
ws: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
preview: {
|
||||
port: 4173,
|
||||
proxy: {
|
||||
"/api": "http://localhost:5111",
|
||||
"/ws": {
|
||||
target: "ws://localhost:5111",
|
||||
ws: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
32
dev-dashboard/run.sh
Executable file
32
dev-dashboard/run.sh
Executable file
@@ -0,0 +1,32 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
cd "$(dirname "$0")"
|
||||
|
||||
# Load env vars (R2 credentials, etc.) if present
|
||||
if [ -f .env ]; then
|
||||
set -a; source .env; set +a
|
||||
fi
|
||||
|
||||
# Build frontend
|
||||
cd frontend
|
||||
bun run build
|
||||
cd ..
|
||||
|
||||
cleanup() {
|
||||
kill $BE_PID $FE_PID 2>/dev/null || true
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
# Backend (production)
|
||||
cd backend
|
||||
bun run start 2>&1 | sed 's/^/[BE] /' &
|
||||
BE_PID=$!
|
||||
cd ..
|
||||
|
||||
# Frontend (preview built assets)
|
||||
cd frontend
|
||||
bun run preview 2>&1 | sed 's/^/[FE] /' &
|
||||
FE_PID=$!
|
||||
cd ..
|
||||
|
||||
wait
|
||||
@@ -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,8 +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
|
||||
|
||||
@@ -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 <<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 [ -x "./backend/substitute_ee_code.sh" ]; then
|
||||
./backend/substitute_ee_code.sh -d "$ee_worktree_dir"
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
Reference in New Issue
Block a user