402 lines
11 KiB
Bash
Executable File
402 lines
11 KiB
Bash
Executable File
#!/usr/bin/env zsh
|
|
emulate -L zsh
|
|
setopt err_exit no_unset pipe_fail
|
|
|
|
# wm-cursor: Manage Cursor SSH remote windows with grouped tmux sessions
|
|
# Each worktree gets its own Cursor window with an independently-focused
|
|
# grouped tmux session, sharing the same window list in the status bar.
|
|
|
|
# --- Resolve script path (must be at top level, not inside a function) ---
|
|
|
|
local script_path=${0:A}
|
|
|
|
# --- Lazy Cursor CLI resolution (only when needed) ---
|
|
|
|
local cursor_bin=
|
|
|
|
resolve_cursor_cli() {
|
|
[[ -n $cursor_bin ]] && return 0
|
|
|
|
local -a cursor_bins=(~/.cursor-server/cli/servers/*/server/bin/remote-cli/cursor(NOm))
|
|
if (( ${#cursor_bins} == 0 )); then
|
|
print -u2 "Error: Cursor remote CLI not found in ~/.cursor-server/cli/servers/"
|
|
exit 1
|
|
fi
|
|
cursor_bin=${cursor_bins[1]}
|
|
|
|
# Refresh Cursor IPC socket (tmux may hold a stale one)
|
|
# Multiple stale sockets may exist; probe to find a live one
|
|
local sock
|
|
for sock in /tmp/vscode-ipc-*.sock(NOm); do
|
|
if timeout 2 env VSCODE_IPC_HOOK_CLI=$sock $cursor_bin --status &>/dev/null; then
|
|
export VSCODE_IPC_HOOK_CLI=$sock
|
|
break
|
|
fi
|
|
done
|
|
}
|
|
|
|
# --- Helper functions ---
|
|
|
|
ensure_tmux() {
|
|
if [[ -z ${TMUX-} ]]; then
|
|
print -u2 "Error: Not inside a tmux session"
|
|
exit 1
|
|
fi
|
|
}
|
|
|
|
check_dev_db() {
|
|
if ! docker ps --format '{{.Names}}' 2>/dev/null | grep -q '^windmill-db-dev$'; then
|
|
print -u2 "Warning: windmill-db-dev container is not running"
|
|
fi
|
|
}
|
|
|
|
setup_grouped_session() {
|
|
local handle=$1 worktree_path=$2
|
|
local session_name=cursor-${handle}
|
|
|
|
# Detect the current main tmux session
|
|
local main_session=$(tmux display-message -p '#S')
|
|
|
|
# Create grouped session (shares windows with the main session)
|
|
if ! tmux has-session -t $session_name 2>/dev/null; then
|
|
tmux new-session -d -t $main_session -s $session_name
|
|
fi
|
|
|
|
# Focus on the worktree's window
|
|
tmux select-window -t ${session_name}:wm-${handle} 2>/dev/null || true
|
|
|
|
# Write .vscode/settings.json in the worktree if it doesn't already exist
|
|
local settings_file=${worktree_path}/.vscode/settings.json
|
|
if [[ ! -f $settings_file ]]; then
|
|
mkdir -p ${settings_file:h}
|
|
|
|
# Read ports from .env.local if available
|
|
local env_file=${worktree_path}/.env.local
|
|
local ports_config=""
|
|
if [[ -f $env_file ]]; then
|
|
local backend_port frontend_port
|
|
source $env_file
|
|
backend_port=${BACKEND_PORT-}
|
|
frontend_port=${FRONTEND_PORT-}
|
|
if [[ -n $backend_port && -n $frontend_port ]]; then
|
|
ports_config=',
|
|
"remote.autoForwardPorts": true,
|
|
"remote.otherPortsAttributes": {
|
|
"onAutoForward": "ignore"
|
|
},
|
|
"remote.portsAttributes": {
|
|
"'$backend_port'": { "label": "Backend", "onAutoForward": "silent" },
|
|
"'$frontend_port'": { "label": "Frontend", "onAutoForward": "openBrowserOnce" }
|
|
}'
|
|
fi
|
|
fi
|
|
|
|
cat > $settings_file <<SETTINGS
|
|
{
|
|
"rust-analyzer.initializeStopped": true,
|
|
"terminal.integrated.defaultProfile.linux": "wm-tmux",
|
|
"terminal.integrated.profiles.linux": {
|
|
"wm-tmux": {
|
|
"path": "tmux",
|
|
"args": ["attach-session", "-t", "${session_name}"]
|
|
}
|
|
}${ports_config}
|
|
}
|
|
SETTINGS
|
|
print "Created ${settings_file}"
|
|
fi
|
|
}
|
|
|
|
# --- Feature flag parsing ---
|
|
# Extracts --features <value> from args, exports CARGO_FEATURES, returns remaining args.
|
|
# Usage: parse_features_flag "$@"; set -- "${remaining_args[@]}"
|
|
|
|
parse_flags() {
|
|
remaining_args=()
|
|
while (( $# )); do
|
|
case $1 in
|
|
--features)
|
|
if (( $# < 2 )); then
|
|
print -u2 "Error: --features requires a value"
|
|
exit 1
|
|
fi
|
|
export CARGO_FEATURES=$2
|
|
shift 2
|
|
;;
|
|
--features=*)
|
|
export CARGO_FEATURES=${1#--features=}
|
|
shift
|
|
;;
|
|
--clone-db)
|
|
export WM_CLONE_DB=1
|
|
shift
|
|
;;
|
|
*)
|
|
remaining_args+=("$1")
|
|
shift
|
|
;;
|
|
esac
|
|
done
|
|
}
|
|
|
|
# --- Subcommands ---
|
|
|
|
cmd_add() {
|
|
resolve_cursor_cli
|
|
ensure_tmux
|
|
check_dev_db
|
|
|
|
parse_flags "$@"
|
|
set -- "${remaining_args[@]}"
|
|
|
|
# Snapshot worktree list before
|
|
local -a before=("${(@f)$(git worktree list --porcelain | grep '^worktree ')}")
|
|
|
|
workmux add -b "$@"
|
|
|
|
# Diff to find the new entry
|
|
local -a after=("${(@f)$(git worktree list --porcelain | grep '^worktree ')}")
|
|
local -a new=(${after:|before})
|
|
|
|
if (( ${#new} == 0 )); then
|
|
print -u2 "Error: Could not detect new worktree path"
|
|
exit 1
|
|
fi
|
|
|
|
local new_path=${new[1]#worktree }
|
|
local handle=${new_path:t}
|
|
|
|
print "New worktree: ${handle} at ${new_path}"
|
|
setup_grouped_session $handle $new_path
|
|
$cursor_bin -n $new_path
|
|
print "Opened Cursor for ${handle}"
|
|
}
|
|
|
|
cmd_open() {
|
|
local name=${1:?Usage: wm-cursor open <name>}
|
|
shift
|
|
|
|
resolve_cursor_cli
|
|
ensure_tmux
|
|
check_dev_db
|
|
|
|
parse_flags "$@"
|
|
set -- "${remaining_args[@]}"
|
|
|
|
# Write CARGO_FEATURES to .env.local if specified
|
|
if [[ -n ${CARGO_FEATURES-} ]]; then
|
|
local wt_env=$(workmux path $name)/.env.local
|
|
if [[ -f $wt_env ]]; then
|
|
# Remove existing CARGO_FEATURES line and append new one
|
|
sed -i '/^CARGO_FEATURES=/d' $wt_env
|
|
echo "CARGO_FEATURES=$CARGO_FEATURES" >> $wt_env
|
|
fi
|
|
fi
|
|
|
|
local wt_path=$(workmux path $name)
|
|
local prev_target=$(tmux display-message -p '#{session_name}:#{window_index}')
|
|
|
|
workmux open $name "$@"
|
|
tmux select-window -t $prev_target
|
|
setup_grouped_session $name $wt_path
|
|
$cursor_bin -n $wt_path
|
|
print "Opened Cursor for ${name}"
|
|
}
|
|
|
|
cmd_close() {
|
|
local name=${1:?Usage: wm-cursor close <name>}
|
|
|
|
tmux kill-session -t cursor-${name} 2>/dev/null || true
|
|
workmux close $name
|
|
}
|
|
|
|
cmd_open_ee() {
|
|
local name=${1:?Usage: wm-cursor open-ee <name>}
|
|
|
|
resolve_cursor_cli
|
|
local wt_path=$(workmux path $name)
|
|
local main_repo_root="$(cd "$(git -C "$wt_path" rev-parse --git-common-dir 2>/dev/null)/.." && pwd)"
|
|
|
|
# Find ee repo (same discovery logic as worktree-env)
|
|
local ee_repo="" candidate
|
|
for candidate in \
|
|
"${main_repo_root:+${main_repo_root}/../windmill-ee-private}" \
|
|
"${wt_path}/../windmill-ee-private" \
|
|
"${HOME}/windmill-ee-private" \
|
|
"${HOME}/projects/windmill-ee-private"; do
|
|
if [[ -n $candidate ]] && [[ -d $candidate ]]; then
|
|
ee_repo=${candidate:A}
|
|
break
|
|
fi
|
|
done
|
|
|
|
if [[ -z $ee_repo ]]; then
|
|
print -u2 "Error: Could not find windmill-ee-private repo"
|
|
exit 1
|
|
fi
|
|
|
|
local ee_worktree_dir="${ee_repo}__worktrees/${name}"
|
|
if [[ ! -d $ee_worktree_dir ]]; then
|
|
print -u2 "Error: EE worktree not found at ${ee_worktree_dir}"
|
|
exit 1
|
|
fi
|
|
|
|
$cursor_bin -n $ee_worktree_dir
|
|
print "Opened Cursor for EE worktree: ${ee_worktree_dir}"
|
|
}
|
|
|
|
cmd_setup() {
|
|
local repo_root=${1:?Usage: wm-cursor setup <repo-root>}
|
|
repo_root=${repo_root:A}
|
|
local vscode_dir=${repo_root}/.vscode
|
|
|
|
mkdir -p $vscode_dir
|
|
|
|
# --- tasks.json ---
|
|
local tasks_file=${vscode_dir}/tasks.json
|
|
local write_tasks=true
|
|
if [[ -f $tasks_file ]]; then
|
|
print -n "tasks.json already exists. Overwrite? [y/N] "
|
|
read -q || { print; write_tasks=false }
|
|
print
|
|
fi
|
|
if $write_tasks; then
|
|
cat > $tasks_file <<'TASKS'
|
|
{
|
|
"version": "2.0.0",
|
|
"tasks": [
|
|
{
|
|
"label": "Start dev DB",
|
|
"type": "shell",
|
|
"command": "./start-dev-db.sh",
|
|
"options": { "shell": { "executable": "/bin/bash" } },
|
|
"runOptions": { "runOn": "folderOpen" },
|
|
"presentation": { "reveal": "silent", "close": true },
|
|
"problemMatcher": []
|
|
}
|
|
]
|
|
}
|
|
TASKS
|
|
print "Wrote ${tasks_file}"
|
|
fi
|
|
|
|
# --- settings.json (merge wm-cursor keys, preserve existing) ---
|
|
local settings_file=${vscode_dir}/settings.json
|
|
local wmc_settings='
|
|
{
|
|
"rust-analyzer.initializeStopped": true,
|
|
"terminal.integrated.defaultProfile.linux": "wm-tmux",
|
|
"terminal.integrated.profiles.linux": {
|
|
"wm-tmux": {
|
|
"path": "tmux",
|
|
"args": ["new-session", "-A", "-s", "main"]
|
|
}
|
|
},
|
|
"remote.autoForwardPorts": true,
|
|
"remote.otherPortsAttributes": {
|
|
"onAutoForward": "ignore"
|
|
},
|
|
"remote.portsAttributes": {
|
|
"8000": { "label": "Backend", "onAutoForward": "silent" },
|
|
"3000": { "label": "Frontend", "onAutoForward": "openBrowserOnce" },
|
|
"5432": { "label": "PostgreSQL", "onAutoForward": "silent" }
|
|
}
|
|
}'
|
|
|
|
if [[ -f $settings_file ]]; then
|
|
# Strip // comments so jq can parse, merge, then write back
|
|
local existing
|
|
existing=$(python3 -c '
|
|
import json, re, sys
|
|
text = sys.stdin.read()
|
|
# Remove // comments only outside of strings
|
|
text = re.sub(r'"'"'("(?:[^"\\]|\\.)*")|//[^\n]*'"'"', lambda m: m.group(1) or "", text)
|
|
json.dump(json.loads(text), sys.stdout, indent=2)
|
|
' < $settings_file)
|
|
jq --argjson wmc "$wmc_settings" '. * $wmc' <<< "$existing" > ${settings_file}.tmp \
|
|
&& mv ${settings_file}.tmp $settings_file
|
|
print "Merged wm-cursor settings into ${settings_file}"
|
|
else
|
|
jq . <<< "$wmc_settings" > $settings_file
|
|
print "Created ${settings_file}"
|
|
fi
|
|
|
|
# --- zsh alias + completion ---
|
|
local rc=${ZDOTDIR:-$HOME}/.zshrc
|
|
local alias_line="alias wmc=${(q)script_path}"
|
|
|
|
if [[ -f $rc ]] && grep -qF 'alias wmc=' $rc; then
|
|
sed -i "s|^alias wmc=.*|${alias_line}|" $rc
|
|
print "Updated wmc alias in ${rc}"
|
|
else
|
|
print "\n# wm-cursor alias\n${alias_line}" >> $rc
|
|
print "Added wmc alias to ${rc}"
|
|
fi
|
|
|
|
local eval_line='eval "$(wmc completions)"'
|
|
if ! grep -qF 'wmc completions' $rc; then
|
|
print "${eval_line}" >> $rc
|
|
print "Added completions eval to ${rc}"
|
|
fi
|
|
}
|
|
|
|
cmd_completions() {
|
|
cat <<'COMP'
|
|
_wmc_worktree_names() {
|
|
local -a names
|
|
names=(${(f)"$(git worktree list --porcelain 2>/dev/null | sed -n 's|^worktree .*/||p' | tail -n +2)"})
|
|
_describe 'worktree' names
|
|
}
|
|
|
|
_wmc() {
|
|
local -a subcmds=(
|
|
'add:Create worktree + open Cursor'
|
|
'open:Open Cursor for existing worktree'
|
|
'open-ee:Open EE worktree in Cursor'
|
|
'close:Clean up grouped tmux session'
|
|
'setup:Set up .vscode settings, tasks + wmc alias'
|
|
'completions:Print zsh completions'
|
|
)
|
|
|
|
if (( CURRENT == 2 )); then
|
|
_describe 'subcommand' subcmds
|
|
else
|
|
case $words[2] in
|
|
open|open-ee|close)
|
|
_wmc_worktree_names
|
|
;;
|
|
esac
|
|
fi
|
|
}
|
|
|
|
compdef _wmc wmc wm-cursor
|
|
COMP
|
|
}
|
|
|
|
# --- Main ---
|
|
|
|
case ${1-} in
|
|
add) shift; cmd_add "$@" ;;
|
|
open) shift; cmd_open "$@" ;;
|
|
open-ee) shift; cmd_open_ee "$@" ;;
|
|
close) shift; cmd_close "$@" ;;
|
|
setup) shift; cmd_setup "$@" ;;
|
|
completions) cmd_completions ;;
|
|
*)
|
|
print -u2 "Usage: wm-cursor <add|open|open-ee|close|setup|completions> [args...]"
|
|
print -u2 ""
|
|
print -u2 "Subcommands:"
|
|
print -u2 " add [--features <f>] [workmux-add-args...] Create worktree + open Cursor"
|
|
print -u2 " open <name> [--features <f>] Open Cursor for existing worktree"
|
|
print -u2 " open-ee <name> Open EE worktree in Cursor"
|
|
print -u2 " close <name> Clean up grouped tmux session"
|
|
print -u2 " setup <repo-root> Set up .vscode settings, tasks + wmc alias"
|
|
print -u2 " completions Print zsh completions (use with eval)"
|
|
print -u2 ""
|
|
print -u2 "Options:"
|
|
print -u2 " --features <features> Cargo features for the backend (e.g. \"enterprise,parquet\")"
|
|
print -u2 " --clone-db Clone the main 'windmill' database instead of creating an empty one"
|
|
exit 1
|
|
;;
|
|
esac
|