diff --git a/setup/run.py b/setup/run.py index 7a671e7a..ab7ef148 100644 --- a/setup/run.py +++ b/setup/run.py @@ -18,7 +18,8 @@ from pathlib import Path # Fix Windows console encoding to support Unicode -if sys.platform == 'win32': +IS_WINDOWS = sys.platform == 'win32' +if IS_WINDOWS: try: # Try to set UTF-8 encoding for stdout/stderr import io @@ -53,6 +54,48 @@ class Colors: BOLD = '\033[1m' NC = '\033[0m' # No Color +# Platform-safe emoji/unicode characters +class Icons: + """Platform-safe icons that work on all terminals.""" + if IS_WINDOWS: + ROCKET = ">" + PENCIL = "-" + USER = "*" + CHECK = "OK" + CHECKMARK = "[OK]" + CROSS = "X" + WARNING = "!" + ERROR = "ERROR" + SEARCH = "?" + BUILDING = "+" + STOP = "[]" + RESTART = "@" + CLIPBOARD = "#" + WRENCH = "*" + SEPARATOR = "=" * 44 + BOX_TOP = "+" + ("=" * 52) + "+" + BOX_MID = "|" + (" " * 52) + "|" + BOX_BTM = "+" + ("=" * 52) + "+" + else: + ROCKET = "πŸš€" + PENCIL = "πŸ“" + USER = "πŸ‘€" + CHECK = "βœ“" + CHECKMARK = "βœ…" + CROSS = "βœ—" + WARNING = "⚠️" + ERROR = "❌" + SEARCH = "πŸ”" + BUILDING = "πŸ—οΈ" + STOP = "πŸ›‘" + RESTART = "πŸ”„" + CLIPBOARD = "πŸ“‹" + WRENCH = "πŸ”§" + SEPARATOR = "━" * 44 + BOX_TOP = "β•”" + ("═" * 52) + "β•—" + BOX_MID = "β•‘" + (" " * 52) + "β•‘" + BOX_BTM = "β•š" + ("═" * 52) + "╝" + def print_color(color: str, message: str): """Print colored message.""" print(f"{color}{message}{Colors.NC}") @@ -60,14 +103,14 @@ def print_color(color: str, message: str): def print_header(): """Print startup header.""" print() - print_color(Colors.BOLD, "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━") - print_color(Colors.BOLD, f"πŸš€ {APP_DISPLAY_NAME} Quick Start") - print_color(Colors.BOLD, "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━") + print_color(Colors.BOLD, Icons.SEPARATOR) + print_color(Colors.BOLD, f"{Icons.ROCKET} {APP_DISPLAY_NAME} Quick Start") + print_color(Colors.BOLD, Icons.SEPARATOR) print() def prompt_for_config() -> dict: """Prompt user for configuration in interactive mode.""" - print_color(Colors.BLUE, "πŸ“ Configuration") + print_color(Colors.BLUE, f"{Icons.PENCIL} Configuration") print() # Environment name @@ -94,7 +137,7 @@ def prompt_for_config() -> dict: def prompt_for_admin() -> dict: """Prompt user for admin credentials.""" - print_color(Colors.BLUE, "πŸ‘€ Admin Account") + print_color(Colors.BLUE, f"{Icons.USER} Admin Account") print() # Email @@ -124,12 +167,12 @@ def check_docker(): text=True ) if result.returncode == 0: - print_color(Colors.GREEN, f"βœ… Docker found: {result.stdout.strip()}") + print_color(Colors.GREEN, f"{Icons.CHECKMARK} Docker found: {result.stdout.strip()}") return True except FileNotFoundError: pass - print_color(Colors.RED, "❌ Docker not found. Please install Docker Desktop.") + print_color(Colors.RED, f"{Icons.ERROR} Docker not found. Please install Docker Desktop.") return False def generate_env_file(env_name: str, port_offset: int, env_file: Path, secrets_file: Path, dev_mode: bool = False, quick_mode: bool = False): @@ -142,8 +185,8 @@ def generate_env_file(env_name: str, port_offset: int, env_file: Path, secrets_f if conflicts: if quick_mode: # In quick mode, automatically find available ports - print_color(Colors.YELLOW, f"⚠️ Port conflict detected: {conflicts}") - print_color(Colors.BLUE, "πŸ” Auto-finding available ports...") + print_color(Colors.YELLOW, f"{Icons.WARNING} Port conflict detected: {conflicts}") + print_color(Colors.BLUE, f"{Icons.SEARCH} Auto-finding available ports...") # Try incrementing port offset until we find available ports (max 100 attempts) for _ in range(100): @@ -153,13 +196,13 @@ def generate_env_file(env_name: str, port_offset: int, env_file: Path, secrets_f all_available, conflicts = validate_ports([backend_port, webui_port]) if all_available: - print_color(Colors.GREEN, f"βœ… Found available ports (offset: {port_offset})") + print_color(Colors.GREEN, f"{Icons.CHECKMARK} Found available ports (offset: {port_offset})") break else: - print_color(Colors.RED, "❌ Could not find available ports after 100 attempts") + print_color(Colors.RED, f"{Icons.ERROR} Could not find available ports after 100 attempts") return None else: - print_color(Colors.RED, f"❌ Port conflict: {conflicts}") + print_color(Colors.RED, f"{Icons.ERROR} Port conflict: {conflicts}") return None # Find available Redis database @@ -214,7 +257,7 @@ def generate_env_file(env_name: str, port_offset: int, env_file: Path, secrets_f env_file.write_text(env_content) os.chmod(env_file, 0o600) - print_color(Colors.GREEN, "βœ… Environment configured") + print_color(Colors.GREEN, f"{Icons.CHECKMARK} Environment configured") print(f" Name: {env_name}") print(f" Project: {compose_project_name}") print(f" Backend: {backend_port}") @@ -225,9 +268,9 @@ def generate_env_file(env_name: str, port_offset: int, env_file: Path, secrets_f # Ensure secrets.yaml exists created_new, _ = ensure_secrets_yaml(str(secrets_file)) if created_new: - print_color(Colors.GREEN, f"Secrets file written: βœ… {secrets_file}") + print_color(Colors.GREEN, f"Secrets file written: {Icons.CHECKMARK} {secrets_file}") else: - print(f"Secrets already configured: βœ… {secrets_file}") + print(f"Secrets already configured: {Icons.CHECKMARK} {secrets_file}") return { "backend_port": backend_port, @@ -256,22 +299,22 @@ def compose_up(dev_mode: bool, build: bool = False) -> bool: """Start containers (optionally with rebuild).""" # Ensure Docker networks exist if not ensure_networks(): - print_color(Colors.RED, "❌ Failed to create required Docker networks (ushadow-network, infra-network)") + print_color(Colors.RED, f"{Icons.ERROR} Failed to create required Docker networks (ushadow-network, infra-network)") print_color(Colors.YELLOW, " Make sure Docker is running and you have permissions to create networks") return False # Check/start infrastructure infra_running = check_infrastructure_running() if not infra_running: - print_color(Colors.YELLOW, "πŸ—οΈ Starting infrastructure...") + print_color(Colors.YELLOW, f"{Icons.BUILDING} Starting infrastructure...") success, message = start_infrastructure(INFRA_COMPOSE_FILE, INFRA_PROJECT_NAME) if not success: - print_color(Colors.RED, f"❌ {message}") + print_color(Colors.RED, f"{Icons.ERROR} {message}") return False mode_label = "dev" if dev_mode else "prod" action = "Building and starting" if build else "Starting" - print_color(Colors.BLUE, f"πŸš€ {action} {APP_DISPLAY_NAME} ({mode_label} mode)...") + print_color(Colors.BLUE, f"{Icons.ROCKET} {action} {APP_DISPLAY_NAME} ({mode_label} mode)...") cmd = get_compose_cmd(dev_mode) + ["up", "-d"] if build: @@ -280,42 +323,42 @@ def compose_up(dev_mode: bool, build: bool = False) -> bool: # Use native path format for cwd (subprocess handles it correctly) result = subprocess.run(cmd, cwd=str(PROJECT_ROOT)) if result.returncode != 0: - print_color(Colors.RED, "❌ Failed to start application") + print_color(Colors.RED, f"{Icons.ERROR} Failed to start application") return False - print_color(Colors.GREEN, "βœ… Done") + print_color(Colors.GREEN, f"{Icons.CHECKMARK} Done") return True def compose_down(dev_mode: bool) -> bool: """Stop containers.""" mode_label = "dev" if dev_mode else "prod" - print_color(Colors.BLUE, f"πŸ›‘ Stopping {APP_DISPLAY_NAME} ({mode_label} mode)...") + print_color(Colors.BLUE, f"{Icons.STOP} Stopping {APP_DISPLAY_NAME} ({mode_label} mode)...") cmd = get_compose_cmd(dev_mode) + ["down"] result = subprocess.run(cmd, cwd=str(PROJECT_ROOT)) if result.returncode != 0: - print_color(Colors.RED, "❌ Failed to stop application") + print_color(Colors.RED, f"{Icons.ERROR} Failed to stop application") return False - print_color(Colors.GREEN, "βœ… Stopped") + print_color(Colors.GREEN, f"{Icons.CHECKMARK} Stopped") return True def compose_restart(dev_mode: bool) -> bool: """Restart containers.""" mode_label = "dev" if dev_mode else "prod" - print_color(Colors.BLUE, f"πŸ”„ Restarting {APP_DISPLAY_NAME} ({mode_label} mode)...") + print_color(Colors.BLUE, f"{Icons.RESTART} Restarting {APP_DISPLAY_NAME} ({mode_label} mode)...") cmd = get_compose_cmd(dev_mode) + ["restart"] result = subprocess.run(cmd, cwd=str(PROJECT_ROOT)) if result.returncode != 0: - print_color(Colors.RED, "❌ Failed to restart application") + print_color(Colors.RED, f"{Icons.ERROR} Failed to restart application") return False - print_color(Colors.GREEN, "βœ… Restarted") + print_color(Colors.GREEN, f"{Icons.CHECKMARK} Restarted") return True @@ -333,23 +376,23 @@ def wait_and_open(backend_port: int, webui_port: int, open_browser: bool): print() if healthy: - print_color(Colors.GREEN + Colors.BOLD, f"βœ… {APP_DISPLAY_NAME} is ready!") + print_color(Colors.GREEN + Colors.BOLD, f"{Icons.CHECKMARK} {APP_DISPLAY_NAME} is ready!") else: - print_color(Colors.YELLOW, "⚠️ Backend is starting... (may take a moment)") + print_color(Colors.YELLOW, f"{Icons.WARNING} Backend is starting... (may take a moment)") # Print success box print() - print_color(Colors.BOLD, "╔════════════════════════════════════════════════════╗") - print_color(Colors.BOLD, "β•‘ β•‘") - print_color(Colors.BOLD, f"β•‘ πŸš€ {APP_DISPLAY_NAME} is ready! β•‘") - print_color(Colors.BOLD, "β•‘ β•‘") + print_color(Colors.BOLD, Icons.BOX_TOP) + print_color(Colors.BOLD, Icons.BOX_MID) + print_color(Colors.BOLD, f"β•‘ {Icons.ROCKET} {APP_DISPLAY_NAME} is ready! β•‘") + print_color(Colors.BOLD, Icons.BOX_MID) print_color(Colors.BOLD, f"β•‘ http://localhost:{webui_port} β•‘") - print_color(Colors.BOLD, "β•‘ β•‘") - print_color(Colors.BOLD, "β•šβ•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•") + print_color(Colors.BOLD, Icons.BOX_MID) + print_color(Colors.BOLD, Icons.BOX_BTM) print() # First-time setup instructions - print_color(Colors.BOLD, "πŸ“‹ First-Time Setup:") + print_color(Colors.BOLD, f"{Icons.CLIPBOARD} First-Time Setup:") print() print(" 1. Open the web interface (link above)") print(" 2. Complete the setup wizard:") @@ -441,9 +484,9 @@ def main(): # Always ensure secrets.yaml exists with auth keys created_new, secrets_data = ensure_secrets_yaml(str(secrets_file)) if created_new: - print_color(Colors.GREEN, f"Secrets file written: βœ… {secrets_file}") + print_color(Colors.GREEN, f"Secrets file written: {Icons.CHECKMARK} {secrets_file}") else: - print(f"Secrets already configured: βœ… {secrets_file}") + print(f"Secrets already configured: {Icons.CHECKMARK} {secrets_file}") # Check for existing config use_existing = False @@ -470,7 +513,7 @@ def main(): print() if use_existing: - print_color(Colors.GREEN, "βœ… Using existing configuration") + print_color(Colors.GREEN, f"{Icons.CHECKMARK} Using existing configuration") env_content = env_file.read_text() backend_port = DEFAULT_BACKEND_PORT webui_port = DEFAULT_WEBUI_PORT @@ -505,11 +548,11 @@ def main(): f.write("# Ushadow Secrets\n") f.write("# DO NOT COMMIT - Contains sensitive credentials\n\n") yaml.dump(secrets_data, f, default_flow_style=False, sort_keys=False) - print_color(Colors.GREEN, "βœ… Admin credentials saved to secrets.yaml") + print_color(Colors.GREEN, f"{Icons.CHECKMARK} Admin credentials saved to secrets.yaml") except Exception as e: - print_color(Colors.YELLOW, f"⚠️ Could not save admin credentials: {e}") + print_color(Colors.YELLOW, f"{Icons.WARNING} Could not save admin credentials: {e}") - print_color(Colors.BLUE, "πŸ”§ Generating configuration...") + print_color(Colors.BLUE, f"{Icons.WRENCH} Generating configuration...") print() config = generate_env_file( env_name=env_name, diff --git a/ushadow/frontend/src/components/BugReportButton.tsx b/ushadow/frontend/src/components/BugReportButton.tsx index 8e7f3c56..31763a55 100644 --- a/ushadow/frontend/src/components/BugReportButton.tsx +++ b/ushadow/frontend/src/components/BugReportButton.tsx @@ -36,6 +36,14 @@ function buildBugReportUrl(): string { export default function BugReportButton() { const { isDark } = useTheme() + // Check if we're running inside the launcher (via query parameter) + const isInLauncher = new URLSearchParams(window.location.search).has('launcher') + + // Don't render button when in launcher + if (isInLauncher) { + return null + } + return ( Result<(), String> { + let terminals = [ + ("gnome-terminal", vec!["--", "tmux", "attach", "-t", &format!("workmux:{}", window_name)]), + ("konsole", vec!["-e", "tmux", "attach", "-t", &format!("workmux:{}", window_name)]), + ("xfce4-terminal", vec!["-e", &format!("tmux attach -t workmux:{}", window_name)]), + ("alacritty", vec!["-e", "tmux", "attach", "-t", &format!("workmux:{}", window_name)]), + ("xterm", vec!["-e", "tmux", "attach", "-t", &format!("workmux:{}", window_name)]), + ]; + + for (terminal, args) in terminals { + if which::which(terminal).is_ok() { + let result = Command::new(terminal) + .args(&args) + .spawn(); + + if result.is_ok() { + return Ok(()); + } + } + } + + Err("No supported terminal emulator found".to_string()) +} + +#[cfg(target_os = "windows")] +fn open_terminal_windows(window_name: &str) -> Result<(), String> { + // Try Windows Terminal (modern) + if which::which("wt.exe").is_ok() { + let result = Command::new("wt.exe") + .args(&["-w", "0", "nt", "bash", "-c", &format!("tmux attach -t workmux:{}", window_name)]) + .spawn(); + + if result.is_ok() { + return Ok(()); + } + } + + // Fallback to cmd.exe + WSL bash + Command::new("cmd.exe") + .args(&["/c", "start", "bash", "-c", &format!("tmux attach -t workmux:{}", window_name)]) + .spawn() + .map_err(|e| format!("Failed to open terminal: {}", e))?; + + Ok(()) +} +``` + +### Option 3: Embedded Terminal (Future) + +Embed a terminal emulator directly in the launcher using `xterm.js` or similar. + +**Pros**: +- Fully cross-platform +- Consistent UX across all OSes +- Can integrate tightly with launcher UI +- No external dependencies + +**Cons**: +- Complex implementation +- Need to handle terminal rendering, input, etc. +- Larger app bundle size +- Performance concerns + +**Technologies**: +- [xterm.js](https://xtermjs.org/) - Terminal emulator for web +- [Tauri plugin](https://github.com/tauri-apps/tauri-plugin-websocket) - WebSocket for terminal I/O +- [pty-rs](https://github.com/hibariya/pty-rs) - Pseudo-terminal in Rust + +**Architecture**: +``` +β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” +β”‚ Launcher UI (React) β”‚ +β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ +β”‚ β”‚ Terminal Component β”‚ β”‚ +β”‚ β”‚ (xterm.js) β”‚ β”‚ +β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ +β”‚ β”‚ WebSocket β”‚ +β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ + β”‚ +β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” +β”‚ Rust Backend β”‚ +β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β–Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ +β”‚ β”‚ PTY Manager β”‚ β”‚ +β”‚ β”‚ (spawns bash/tmux) β”‚ β”‚ +β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ +β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ +``` + +### Option 4: User-Configurable Terminal Command + +Let users configure their preferred terminal in settings. + +**Pros**: +- Extremely flexible +- Users know what works on their system +- Easy to implement +- No guessing needed + +**Cons**: +- Requires user configuration +- Not "zero-config" +- Different syntax for each terminal + +**Implementation**: +```rust +// In settings/config +#[derive(Deserialize)] +struct LauncherConfig { + terminal_command: Option, +} + +// Usage +fn open_terminal(window_name: &str, config: &LauncherConfig) -> Result<(), String> { + let command = match &config.terminal_command { + Some(cmd) => cmd.replace("{window}", &format!("workmux:{}", window_name)), + None => default_terminal_command(window_name)?, + }; + + shell_command(&command) + .spawn() + .map_err(|e| format!("Failed to open terminal: {}", e))?; + + Ok(()) +} +``` + +**Config examples**: +```yaml +# ~/.ushadow/launcher.yml + +# macOS - Terminal.app +terminal_command: "osascript -e 'tell application \"Terminal\" to do script \"tmux attach -t {window}\"'" + +# macOS - iTerm2 +terminal_command: "open -a iTerm tmux attach -t {window}" + +# Linux - GNOME Terminal +terminal_command: "gnome-terminal -- tmux attach -t {window}" + +# Linux - Alacritty +terminal_command: "alacritty -e tmux attach -t {window}" + +# Windows - Windows Terminal +terminal_command: "wt.exe -w 0 nt bash -c 'tmux attach -t {window}'" +``` + +## Recommendation + +**Short-term (v0.6)**: Implement **Option 2** (Platform-Specific Detection) +- Add terminal detection for Linux (gnome-terminal, konsole, etc.) +- Add Windows Terminal support +- Keep current macOS implementation +- Document which terminals are supported + +**Medium-term (v0.7)**: Add **Option 4** (User Configuration) +- Let users override auto-detection +- Provide common templates in docs +- Fall back to auto-detection if not configured + +**Long-term (v1.0)**: Consider **Option 3** (Embedded Terminal) +- For ultimate cross-platform consistency +- Better integration with launcher UI +- Could show multiple terminals in tabs/panes + +## Implementation Plan + +### Phase 1: Linux Support (This Week) + +```rust +// Add to Cargo.toml +[dependencies] +which = "4.4" // For detecting available terminals + +// In worktree.rs +#[cfg(target_os = "linux")] +fn open_terminal_linux(window_name: &str) -> Result<(), String> { + // Try terminals in preference order + let terminals = [ + ("gnome-terminal", vec!["--", "tmux", "attach", "-t", &format!("workmux:{}", window_name)]), + ("konsole", vec!["-e", "tmux", "attach", "-t", &format!("workmux:{}", window_name)]), + ("xfce4-terminal", vec!["-e", &format!("tmux attach -t workmux:{}", window_name)]), + ("alacritty", vec!["-e", "tmux", "attach", "-t", &format!("workmux:{}", window_name)]), + ("kitty", vec!["tmux", "attach", "-t", &format!("workmux:{}", window_name)]), + ("xterm", vec!["-e", "tmux", "attach", "-t", &format!("workmux:{}", window_name)]), + ]; + + for (terminal_name, args) in terminals { + if which::which(terminal_name).is_ok() { + eprintln!("[open_terminal_linux] Using terminal: {}", terminal_name); + + let result = Command::new(terminal_name) + .args(&args) + .spawn(); + + match result { + Ok(_) => return Ok(()), + Err(e) => eprintln!("[open_terminal_linux] {} failed: {}", terminal_name, e), + } + } + } + + Err("No supported terminal emulator found. Please install gnome-terminal, konsole, alacritty, or xterm".to_string()) +} +``` + +### Phase 2: Windows Support (Next Week) + +```rust +#[cfg(target_os = "windows")] +fn open_terminal_windows(window_name: &str) -> Result<(), String> { + // Windows Terminal is the modern default + if which::which("wt.exe").is_ok() { + eprintln!("[open_terminal_windows] Using Windows Terminal"); + + let result = Command::new("wt.exe") + .args(&[ + "-w", "0", // Use existing window + "new-tab", // Create new tab + "--title", &format!("ushadow-{}", window_name), + "bash", + "-c", + &format!("tmux attach -t workmux:{}", window_name) + ]) + .spawn(); + + if result.is_ok() { + return Ok(()); + } + } + + // Fallback: try WSL bash in cmd + eprintln!("[open_terminal_windows] Falling back to cmd.exe + bash"); + Command::new("cmd.exe") + .args(&[ + "/c", "start", + "bash", + "-c", + &format!("tmux attach -t workmux:{}", window_name) + ]) + .spawn() + .map_err(|e| format!("Failed to open terminal: {}", e))?; + + Ok(()) +} +``` + +### Phase 3: Configuration Support (Later) + +Add to `~/.ushadow/launcher.yml`: +```yaml +terminal: + # Auto-detect by default + auto_detect: true + + # Override with custom command (optional) + # Use {window} as placeholder for tmux window name + custom_command: null + + # Example custom commands (uncomment to use): + # custom_command: "alacritty -e tmux attach -t {window}" + # custom_command: "wt.exe -w 0 nt bash -c 'tmux attach -t {window}'" +``` + +## Testing Matrix + +| OS | Terminal | Command | Status | +|----|----------|---------|--------| +| macOS | Terminal.app | osascript | βœ… Tested | +| macOS | iTerm2 | osascript | 🚧 TODO | +| macOS | Alacritty | alacritty -e | 🚧 TODO | +| Linux | gnome-terminal | gnome-terminal -- | 🚧 TODO | +| Linux | konsole | konsole -e | 🚧 TODO | +| Linux | xfce4-terminal | xfce4-terminal -e | 🚧 TODO | +| Linux | alacritty | alacritty -e | 🚧 TODO | +| Linux | kitty | kitty | 🚧 TODO | +| Linux | xterm | xterm -e | 🚧 TODO | +| Windows | Windows Terminal | wt.exe | 🚧 TODO | +| Windows | cmd.exe | cmd /c start | 🚧 TODO | + +## Open Questions + +1. **WSL on Windows**: Should we detect WSL vs native Windows and adjust tmux commands accordingly? +2. **SSH Sessions**: What happens if user is SSH'd into the machine? Should we detect this and skip terminal opening? +3. **Headless Servers**: How to handle running launcher on a server with no GUI? Just create tmux window without opening? +4. **Terminal Preferences**: Should we remember user's successful terminal and prefer it next time? +5. **Error Messages**: What should we show users if terminal opening fails? Link to docs? Suggest installing specific terminal? + +## Related Issues + +- #TODO: Create GitHub issue for Linux terminal support +- #TODO: Create GitHub issue for Windows terminal support +- #TODO: Create GitHub issue for embedded terminal exploration +- #TODO: Update TESTING.md with terminal testing matrix + +## References + +- [Tauri Shell API](https://tauri.app/v1/api/js/shell) +- [xterm.js](https://xtermjs.org/) +- [Windows Terminal CLI](https://learn.microsoft.com/en-us/windows/terminal/command-line-arguments) +- [GNOME Terminal Man Page](https://man.archlinux.org/man/gnome-terminal.1) +- [Alacritty Man Page](https://man.archlinux.org/man/alacritty.1.en) diff --git a/ushadow/launcher/GENERIC_INSTALLER.md b/ushadow/launcher/GENERIC_INSTALLER.md new file mode 100644 index 00000000..bce3f35f --- /dev/null +++ b/ushadow/launcher/GENERIC_INSTALLER.md @@ -0,0 +1,421 @@ +# Generic Prerequisite Installer + +The generic installer system makes it easy to add new prerequisites without writing custom installation code. All prerequisite installations are now driven by the `prerequisites.yaml` configuration file. + +## Quick Start + +### Adding a New Prerequisite + +1. Add the prerequisite to `prerequisites.yaml`: + +```yaml +prerequisites: + - id: nodejs + name: Node.js + display_name: Node.js + description: JavaScript runtime + platforms: [macos, windows, linux] + check_command: node --version + optional: false + category: development +``` + +2. Add installation methods for each platform: + +```yaml +installation_methods: + nodejs: + macos: + method: homebrew + package: node + windows: + method: winget + package: OpenJS.NodeJS + linux: + method: package_manager + packages: + apt: nodejs + yum: nodejs + dnf: nodejs +``` + +3. Use the generic installer from frontend: + +```typescript +import { invoke } from '@tauri-apps/api' + +// Install Node.js +await invoke('install_prerequisite', { prerequisiteId: 'nodejs' }) + +// Start a service (for services like Docker) +await invoke('start_prerequisite', { prerequisiteId: 'docker' }) +``` + +That's it! No Rust code changes needed. + +## Installation Methods + +The generic installer supports 6 installation strategies: + +### 1. Homebrew (macOS) + +Installs packages via Homebrew. Automatically detects if package is a cask or formula. + +```yaml +method: homebrew +package: docker # Package name +``` + +**Examples:** +- `docker` β†’ Installs as cask with admin privileges +- `git` β†’ Installs as formula +- `python@3.12` β†’ Installs specific version + +### 2. Winget (Windows) + +Installs via Windows Package Manager. + +```yaml +method: winget +package: Docker.DockerDesktop # Package ID +``` + +**Examples:** +- `Docker.DockerDesktop` +- `Git.Git` +- `OpenJS.NodeJS` + +### 3. Download + +Downloads installer and opens it for manual installation. + +```yaml +method: download +url: https://example.com/installer.exe +``` + +**Special Cases:** +- Homebrew `.pkg` files are downloaded and opened automatically +- Other files open the URL in the default browser + +### 4. Script + +Downloads and executes an installation script. + +```yaml +method: script +url: https://get.docker.com +``` + +**Process:** +1. Downloads script from URL +2. Saves to temp directory +3. Makes executable (Unix only) +4. Executes with bash + +**Security Note:** Only use scripts from trusted sources. + +### 5. Package Manager (Linux) + +Installs via system package manager (apt, yum, or dnf). + +```yaml +method: package_manager +packages: + apt: docker.io + yum: docker + dnf: docker +``` + +The installer automatically detects which package manager is available and uses the appropriate package name. + +### 6. Cargo (Rust) + +Installs Rust packages via cargo. + +```yaml +method: cargo +package: workmux +``` + +## API Reference + +### `install_prerequisite(prerequisite_id: String) -> Result` + +Generic installer that reads configuration and executes the appropriate installation method. + +```typescript +// TypeScript +const result = await invoke('install_prerequisite', { + prerequisiteId: 'docker' +}) +console.log(result) // "docker installed successfully via Homebrew" +``` + +```rust +// Rust +let result = install_prerequisite("docker".to_string()).await?; +``` + +**Process:** +1. Loads `prerequisites.yaml` +2. Finds installation method for current platform +3. Executes appropriate installation strategy +4. Returns success message or error + +### `start_prerequisite(prerequisite_id: String) -> Result` + +Starts a service prerequisite (only for prerequisites with `has_service: true`). + +```typescript +// TypeScript +const result = await invoke('start_prerequisite', { + prerequisiteId: 'docker' +}) +console.log(result) // "Docker Desktop starting..." +``` + +**Supported Services:** +- `docker` - macOS/Windows/Linux + +## Complete Example + +Here's a complete example of adding PostgreSQL as a prerequisite: + +### 1. Add to prerequisites.yaml + +```yaml +prerequisites: + - id: postgresql + name: PostgreSQL + display_name: PostgreSQL + description: Relational database + platforms: [macos, windows, linux] + check_command: psql --version + optional: true + has_service: true + category: infrastructure + +installation_methods: + postgresql: + macos: + method: homebrew + package: postgresql@15 + windows: + method: download + url: https://www.postgresql.org/download/windows/ + linux: + method: package_manager + packages: + apt: postgresql + yum: postgresql + dnf: postgresql +``` + +### 2. Use in Frontend + +```typescript +import { invoke } from '@tauri-apps/api' + +// Install PostgreSQL +try { + const result = await invoke('install_prerequisite', { + prerequisiteId: 'postgresql' + }) + console.log(result) +} catch (error) { + console.error('Installation failed:', error) +} + +// Start PostgreSQL (if it's a service) +try { + const result = await invoke('start_prerequisite', { + prerequisiteId: 'postgresql' + }) + console.log(result) +} catch (error) { + console.error('Start failed:', error) +} +``` + +## Migration from Old System + +### Before (Custom Installer) + +```rust +// src/commands/installer.rs +#[cfg(target_os = "macos")] +#[tauri::command] +pub async fn install_nodejs_macos() -> Result { + if !check_brew_installed() { + return Err("Homebrew is not installed".to_string()); + } + let output = brew_command() + .args(["install", "node"]) + .output() + .map_err(|e| format!("Failed to run brew: {}", e))?; + if output.status.success() { + Ok("Node.js installed successfully via Homebrew".to_string()) + } else { + let stderr = String::from_utf8_lossy(&output.stderr); + Err(format!("Brew install failed: {}", stderr)) + } +} + +#[cfg(target_os = "windows")] +#[tauri::command] +pub async fn install_nodejs_windows() -> Result { + // Windows implementation... +} + +// main.rs - Add to invoke_handler +install_nodejs_macos, +install_nodejs_windows, +``` + +### After (Generic Installer) + +```yaml +# prerequisites.yaml +installation_methods: + nodejs: + macos: + method: homebrew + package: node + windows: + method: winget + package: OpenJS.NodeJS +``` + +```typescript +// Frontend +await invoke('install_prerequisite', { prerequisiteId: 'nodejs' }) +``` + +**Benefits:** +- βœ… No Rust code changes +- βœ… No main.rs updates +- βœ… Easy to maintain +- βœ… Consistent across platforms +- βœ… YAML validation + +## Advanced Usage + +### Custom Installation Logic + +For prerequisites requiring custom installation logic, you can still add custom methods in `generic_installer.rs`: + +```rust +async fn execute_installation( + prereq_id: &str, + method: &InstallationMethod, + platform: &str, +) -> Result { + match method.method.as_str() { + "homebrew" => install_via_homebrew(prereq_id, method).await, + "custom_nodejs" => install_nodejs_custom(prereq_id, method).await, // Custom + _ => Err(format!("Unknown installation method: {}", method.method)) + } +} +``` + +Then in YAML: + +```yaml +nodejs: + macos: + method: custom_nodejs +``` + +### Platform Detection + +The installer automatically detects the platform: +- macOS β†’ `"macos"` +- Windows β†’ `"windows"` +- Linux β†’ `"linux"` + +You can have different installation methods per platform in the YAML. + +### Error Handling + +All installation methods return `Result`: +- `Ok(message)` - Installation succeeded with a success message +- `Err(message)` - Installation failed with an error message + +## Testing + +### Test Prerequisites Installation + +```bash +# Run the launcher in dev mode +cargo tauri dev + +# In the app, try installing a prerequisite +# Check the console for debug output +``` + +### Debug Mode + +Enable debug output to see what's happening: + +```rust +eprintln!("Installing {} via Homebrew: {}", prereq_id, package); +``` + +## Best Practices + +1. **Use Generic Installer** - Prefer adding to YAML over writing custom code +2. **Test on Each Platform** - Verify installation works on macOS/Windows/Linux +3. **Handle Errors Gracefully** - Provide helpful error messages +4. **Document Prerequisites** - Add clear descriptions in YAML +5. **Keep YAML Simple** - Avoid complex logic in configuration +6. **Version Pin When Needed** - Use specific versions (e.g., `python@3.12`) + +## Troubleshooting + +### Installation Fails Silently + +Check if the method is registered in `execute_installation()`: + +```rust +match method.method.as_str() { + "your_method" => { /* handler */ } + _ => Err(format!("Unknown installation method: {}", method.method)) +} +``` + +### Package Not Found + +- **Homebrew**: Check package name with `brew search ` +- **Winget**: Check package ID with `winget search ` +- **Linux**: Verify package name with `apt search ` etc. + +### Permission Denied + +Some installations require admin privileges: +- macOS: osascript with administrator privileges +- Windows: UAC prompt +- Linux: sudo + +The generic installer handles this automatically for supported methods. + +## Future Enhancements + +Potential improvements to the generic installer: + +1. **Post-install hooks** - Run commands after installation +2. **Dependency checking** - Ensure prerequisites are installed in order +3. **Version validation** - Check installed version meets requirements +4. **Rollback support** - Uninstall on failure +5. **Progress tracking** - Report installation progress +6. **Batch installation** - Install multiple prerequisites at once + +## Summary + +The generic installer makes it trivial to add new prerequisites: + +1. Add prerequisite definition to YAML +2. Add installation methods to YAML +3. Call `install_prerequisite(id)` from frontend + +No Rust code changes needed! ✨ diff --git a/ushadow/launcher/README.md b/ushadow/launcher/README.md index cf4d576b..407c618d 100644 --- a/ushadow/launcher/README.md +++ b/ushadow/launcher/README.md @@ -1,14 +1,69 @@ # Ushadow Desktop Launcher -A Tauri-based desktop application that manages Ushadow's Docker containers and provides a native app experience. +A Tauri-based desktop application for orchestrating parallel development environments with git worktrees, tmux sessions, and Docker containers. ## Features -- **Prerequisite Checking**: Verifies Docker and Tailscale are installed -- **Container Management**: Start/stop Docker containers with one click +### Core Functionality +- **Git Worktree Management**: Create, manage, and delete git worktrees for parallel development +- **Tmux Integration**: Persistent terminal sessions with automatic window management +- **Container Orchestration**: Start/stop Docker containers per environment +- **Environment Discovery**: Auto-detect and manage multiple environments +- **Fast Status Checks**: Cached Tailscale/Docker polling for instant feedback + +### Developer Experience +- **One-Click Terminal Access**: Open Terminal.app directly into environment's tmux session +- **VS Code Integration**: Launch VS Code with environment-specific colors +- **Real-time Status Badges**: Visual indicators for tmux activity (Working/Waiting/Done/Error) +- **Quick Environment Switching**: Manage multiple parallel tasks/features simultaneously +- **Merge & Cleanup**: Rebase and merge worktrees back to main with one click + +### Infrastructure +- **Prerequisite Checking**: Verifies Docker, Tailscale, Git, and Tmux - **System Tray**: Runs in background with quick access menu - **Cross-Platform**: Builds for macOS (DMG), Windows (EXE), and Linux (DEB/AppImage) +## Quick Start + +```bash +# Install dependencies +npm install + +# Start development mode +npm run tauri:dev + +# The launcher will: +# 1. Auto-detect existing environments/worktrees +# 2. Start tmux server if worktrees exist +# 3. Show all environments with real-time status +``` + +### First-Time Usage + +1. **Set Project Root**: Click the folder icon to point to your Ushadow repo +2. **Check Prerequisites**: Verify Docker, Tailscale, Git, Tmux are installed +3. **Start Infrastructure**: Start required containers (postgres, redis, etc.) +4. **Create Environment**: Click "New Environment" and choose: + - **Clone** - Create new git clone (traditional) + - **Worktree** - Create git worktree (recommended for parallel dev) + +### Using Tmux Sessions + +- **Purple Terminal Icon** on environment cards - Click to open Terminal and attach to tmux +- **Global "Tmux" Button** in header - View all sessions/windows +- **Status Badges** next to branch names - See what's running in each tmux window + +**Note**: Terminal opening currently works on **macOS only** (via Terminal.app). Linux/Windows support is planned. See [CROSS_PLATFORM_TERMINAL.md](./CROSS_PLATFORM_TERMINAL.md) for details. + +## Documentation + +- **[TMUX_INTEGRATION.md](./TMUX_INTEGRATION.md)** - Complete guide to tmux integration features (Phase 1) +- **[ROADMAP.md](./ROADMAP.md)** - Full vision including Vibe Kanban and remote management (Phases 2-4) +- **[CROSS_PLATFORM_TERMINAL.md](./CROSS_PLATFORM_TERMINAL.md)** - Cross-platform terminal opening strategy +- **[TESTING.md](./TESTING.md)** - Testing guidelines +- **[RELEASING.md](./RELEASING.md)** - Release process +- **[CHANGELOG.md](./CHANGELOG.md)** - Version history + ## Prerequisites ### Development diff --git a/ushadow/launcher/ROADMAP.md b/ushadow/launcher/ROADMAP.md new file mode 100644 index 00000000..c9a7a0f8 --- /dev/null +++ b/ushadow/launcher/ROADMAP.md @@ -0,0 +1,502 @@ +# Ushadow Launcher: Development Roadmap + +## Vision + +The Ushadow Launcher aims to be a comprehensive development environment orchestration tool that bridges project management (Vibe Kanban), local development (worktrees + tmux), and remote development (Tailscale-connected instances). It enables developers to seamlessly work on multiple tasks in parallel, switch between local and remote environments, and maintain persistent development sessions. + +## Current State (Phase 1: Local Tmux Integration) βœ… + +**Platform Support**: macOS only for terminal opening. Linux/Windows have placeholder code that won't work reliably. See [CROSS_PLATFORM_TERMINAL.md](./CROSS_PLATFORM_TERMINAL.md) for cross-platform strategy. + +### What We've Built + +**Fast Environment Detection** +- 10-second tailscale status caching +- Reduced environment ready time from 12+ seconds to ~2 seconds +- Smart polling that doesn't spam slow external checks + +**Persistent Tmux Sessions** +- Auto-start tmux server on launcher startup +- Single `workmux` session for all worktrees +- Per-environment tmux windows: `ushadow-{env-name}` +- Terminal.app integration (macOS) - click button to open and attach + +**Visual Feedback** +- Global "Tmux" button showing all sessions/windows +- Per-environment tmux button (purple terminal icon) +- Real-time activity badges (πŸ€– Working, πŸ’¬ Waiting, βœ… Done, ❌ Error) +- Activity log with success/error messages + +**Worktree Management** +- Create worktrees via launcher UI +- Merge & Cleanup with rebase +- Delete environments (containers + worktree + tmux) +- VS Code integration with environment colors + +### Architecture Foundation + +``` +β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” +β”‚ Ushadow Launcher (Tauri) β”‚ +β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ +β”‚ β”‚ Frontend β”‚ ◄──► β”‚ Rust Backend β”‚ β”‚ +β”‚ β”‚ (React/TS) β”‚ β”‚ (Commands) β”‚ β”‚ +β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ +β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ + β”‚ β”‚ β”‚ + β–Ό β–Ό β–Ό + β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β” + β”‚ Docker β”‚ β”‚ Tmux β”‚ β”‚ Git β”‚ + β”‚Compose β”‚ β”‚ Server β”‚ β”‚Worktree β”‚ + β””β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ +``` + +## Phase 2: Vibe Kanban Integration (In Progress) + +### Vision + +Vibe Kanban is a task management system that automatically provisions development environments for each task. When you pick up a task, you get a fresh worktree, tmux session, and containerized environment - all preconfigured and ready to code. + +### Observed Pattern + +Current Vibe Kanban worktrees follow this structure: +``` +/tmp/vibe-kanban/worktrees/ +β”œβ”€β”€ 5349-install-and-inte/ +β”‚ └── Ushadow/ (branch: vk/5349-install-and-inte) +β”œβ”€β”€ 8e65-install-and-inte/ +β”‚ └── Ushadow/ (branch: vk/8e65-install-and-inte) +└── a56a-create-an-overvi/ + └── Ushadow/ (branch: vk/a56a-create-an-overvi) +``` + +### Planned Integration + +**Task-Driven Worktree Creation** +- Detect Vibe Kanban tasks from API or webhook +- Auto-create worktree: `{task-id}-{task-description}` +- Auto-create branch: `vk/{task-id}-{task-description}` +- Auto-start containers with task-specific config +- Auto-create tmux window in workmux session + +**Kanban Board View in Launcher** +- Show tasks from Vibe Kanban API +- Display task status: Todo, In Progress, Review, Done +- Click task to create/switch to its environment +- Visual indication of which tasks have active environments +- Drag-and-drop to change task status (updates Kanban + git) + +**Task Context Awareness** +- Store task metadata in `.env.{task-id}` file +- Display task description in environment card +- Link to Kanban board from launcher +- Show task assignee, labels, due date +- Integration with PR creation (auto-link task ID) + +**Lifecycle Management** +- Auto-archive worktrees when task marked as Done +- Prompt to merge when moving to Review column +- Cleanup stale task environments (configurable timeout) +- Preserve tmux logs for completed tasks + +### Implementation Checklist + +**Backend (Rust)** +- [ ] Add Vibe Kanban API client +- [ ] Implement task polling/webhook receiver +- [ ] Create `create_task_environment(task_id, description)` command +- [ ] Add task metadata storage/retrieval +- [ ] Implement task status sync +- [ ] Add cleanup job for stale tasks + +**Frontend (TypeScript)** +- [ ] Create Kanban board component +- [ ] Add task cards with environment status +- [ ] Implement drag-and-drop status changes +- [ ] Add "Create from Task" dialog +- [ ] Show task metadata on environment cards +- [ ] Add task filtering/search + +**Integration** +- [ ] Define Vibe Kanban API contract +- [ ] Set up authentication/authorization +- [ ] Implement webhook receiver for task updates +- [ ] Add configuration UI for Kanban connection +- [ ] Create task template system + +### Configuration + +```yaml +# ~/.ushadow/vibe-kanban.yml +vibe_kanban: + enabled: true + api_url: "https://kanban.example.com/api" + api_token: "${VIBE_KANBAN_TOKEN}" + worktree_dir: "/tmp/vibe-kanban/worktrees" + auto_create_environments: true + auto_cleanup_days: 7 + branch_prefix: "vk" + task_id_format: "{id}-{slug}" +``` + +## Phase 3: Remote Development Management (Planned) + +### Vision + +Enable seamless development on remote machines (cloud VMs, development servers) with the same UX as local development. The launcher becomes a unified control panel for both local and remote environments. + +### Use Cases + +**Remote Development Server** +- Company provides beefy dev servers (GPU, RAM, CPU) +- Developers connect via Tailscale +- Launcher manages remote worktrees, tmux, containers +- Terminal sessions tunnel through to remote tmux +- VS Code Remote-SSH integration + +**Cloud Staging Environments** +- Each task gets a cloud instance for testing +- Auto-provision on task start +- Auto-destroy on task completion +- Share preview URL with team via Tailscale +- Cost tracking per task/developer + +**Multi-Region Development** +- Work on low-latency servers near customers +- Test region-specific features +- Replicate production topology +- Debug region-specific issues + +### Architecture + +``` +β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” +β”‚ Ushadow Launcher (Local) β”‚ +β”‚ Shows both local and remote environments β”‚ +β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ + β”‚ β”‚ + β–Ό β–Ό +β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” +β”‚ Local Machine β”‚ β”‚ Remote Server(s) β”‚ +β”‚ β”‚ β”‚ (via Tailscale) β”‚ +β”‚ β€’ Tmux β”‚ β”‚ β€’ Tmux β”‚ +β”‚ β€’ Docker β”‚ β”‚ β€’ Docker β”‚ +β”‚ β€’ Worktrees β”‚ β”‚ β€’ Worktrees β”‚ +β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ β€’ Ushadow Agent β”‚ + β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ +``` + +### Components + +**Ushadow Agent (Remote)** +- Lightweight daemon running on remote servers +- Exposes Tauri-like command API over HTTP/gRPC +- Manages worktrees, tmux, containers remotely +- Reports status back to launcher +- Handles authentication via Tailscale identity + +**Launcher Remote Manager** +- Discover remote agents via Tailscale +- Display remote environments alongside local ones +- Tunnel terminal connections (SSH + tmux) +- Sync git credentials securely +- Monitor remote resource usage + +**Terminal Tunneling** +- Click remote env's tmux button β†’ SSH tunnel opens +- Local Terminal.app connects to remote tmux +- Seamless experience (user doesn't see SSH) +- Clipboard sync over SSH +- Port forwarding for web UIs + +### Implementation Checklist + +**Phase 3.1: Remote Agent** +- [ ] Design Ushadow Agent API (gRPC/HTTP) +- [ ] Implement agent in Rust (reuse launcher commands) +- [ ] Add Tailscale identity authentication +- [ ] Package agent as systemd service +- [ ] Create agent installation script +- [ ] Build agent configuration UI + +**Phase 3.2: Remote Discovery** +- [ ] Implement Tailscale device discovery +- [ ] Detect Ushadow Agents on Tailscale network +- [ ] Show remote servers in launcher UI +- [ ] Display remote environment status +- [ ] Health checking for remote agents + +**Phase 3.3: Remote Control** +- [ ] Implement SSH tunnel management +- [ ] Remote tmux attach via SSH +- [ ] Remote command execution via agent +- [ ] VS Code Remote-SSH integration +- [ ] Port forwarding for web UIs + +**Phase 3.4: Provisioning** +- [ ] Terraform/cloud provider integration +- [ ] Auto-provision VMs for tasks +- [ ] Auto-destroy on task completion +- [ ] Cost estimation/tracking +- [ ] Multi-cloud support (AWS, GCP, Azure) + +### Security Considerations + +**Authentication** +- Tailscale identity as primary auth +- Agent API keys for additional security +- SSH key management (agent forwarding) +- No passwords, all key-based + +**Authorization** +- Per-agent ACLs (who can control what) +- Workspace isolation (multi-tenant support) +- Audit logging for remote commands +- Rate limiting on agent API + +**Data Protection** +- Git credential forwarding over SSH +- Encrypted environment variables +- Secrets management integration (Vault, 1Password) +- No secrets stored in launcher config + +## Phase 4: Advanced Features (Future) + +### Team Collaboration + +**Shared Environments** +- Multiple developers in same tmux session (tmate/teleconsole) +- Real-time code pairing +- Environment sharing via Tailscale URL +- Session recording/replay + +**Environment Templates** +- Pre-configured stacks (Next.js, Django, Go microservices) +- One-click environment setup from template +- Template marketplace/sharing +- Version-controlled templates + +### CI/CD Integration + +**PR Previews** +- Auto-create environment for each PR +- Run tests in isolated environment +- Deploy preview to Tailscale URL +- Auto-cleanup on PR merge/close + +**Pipeline Debugging** +- Reproduce CI environment locally +- Attach to failed pipeline containers +- Interactive debugging of CI failures +- Log aggregation across environments + +### Observability + +**Metrics & Monitoring** +- CPU/RAM/Disk usage per environment +- Container health metrics +- Tmux session activity tracking +- Cost attribution per task/developer + +**Distributed Tracing** +- Trace requests across environments +- Service mesh visualization +- Performance profiling +- Error tracking integration (Sentry) + +### AI/LLM Integration + +**Intelligent Environment Management** +- AI suggests which environments to cleanup +- Auto-detects stuck/idle containers +- Recommends resource allocation +- Task estimation based on environment usage + +**Code Context Awareness** +- Claude/GPT integration with environment context +- "Fix this error in my tmux session" +- "Deploy this branch to remote staging" +- Natural language environment control + +## Integration Points + +### External Tools + +| Tool | Integration | Status | +|------|-------------|--------| +| Git | Worktree creation, branch management | βœ… Complete | +| Tmux | Session management, terminal access | βœ… Complete | +| Docker | Container orchestration | βœ… Complete | +| VS Code | Editor integration, color coding | βœ… Complete | +| Tailscale | Networking, remote access | βœ… Partial | +| Vibe Kanban | Task management | 🚧 Planned | +| GitHub/GitLab | PR creation, CI status | 🚧 Planned | +| Slack/Discord | Notifications | 🚧 Planned | +| Terraform | Cloud provisioning | 🚧 Planned | +| Kubernetes | Container orchestration | 🚧 Planned | + +### API Design + +**Launcher β†’ Agent Communication** +```rust +// Unified command interface for local and remote +trait EnvironmentManager { + async fn create_worktree(name: String, base_branch: String) -> Result; + async fn start_containers(env_name: String) -> Result<()>; + async fn attach_tmux(env_name: String) -> Result<()>; + async fn get_status() -> Result; +} + +// Local implementation (current) +struct LocalManager { /* ... */ } + +// Remote implementation (future) +struct RemoteManager { + agent_url: String, + ssh_tunnel: SshTunnel, +} +``` + +**Vibe Kanban Integration** +```typescript +interface VibeKanbanTask { + id: string + title: string + description: string + status: 'todo' | 'in_progress' | 'review' | 'done' + assignee: string + labels: string[] + due_date: string | null + environment?: { + created: boolean + running: boolean + worktree_path: string + tmux_window: string + } +} + +interface VibeKanbanAPI { + getTasks(): Promise + updateTaskStatus(taskId: string, status: string): Promise + createEnvironment(taskId: string): Promise + destroyEnvironment(taskId: string): Promise +} +``` + +## Migration Path + +### From Current State β†’ Vibe Kanban Integration + +1. **Manual Testing** (Current) + - User manually creates worktrees in `/tmp/vibe-kanban/worktrees/` + - Tests naming conventions and workflows + - Validates integration points + +2. **API Stub** (Next) + - Create mock Vibe Kanban API + - Implement basic task CRUD + - Test launcher integration + +3. **Backend Integration** (Then) + - Connect to real Vibe Kanban API + - Implement webhook receiver + - Add task lifecycle management + +4. **UI Polish** (Finally) + - Build Kanban board view + - Add drag-and-drop + - Polish UX based on feedback + +### From Vibe Kanban β†’ Remote Management + +1. **Agent Development** + - Extract launcher commands into shared library + - Build standalone agent + - Test local agent on same machine + +2. **Remote Discovery** + - Integrate Tailscale device API + - Detect agents on network + - Display in launcher UI + +3. **Remote Control** + - Implement SSH tunneling + - Test remote tmux attach + - Validate remote command execution + +4. **Provisioning** + - Start with manual VM setup + - Add Terraform templates + - Automate end-to-end + +## Success Metrics + +### Phase 2 (Vibe Kanban) +- [ ] 90% of tasks have auto-created environments +- [ ] <10 seconds from task assignment to ready environment +- [ ] 0 manual worktree creation commands +- [ ] <1 minute to switch between task environments + +### Phase 3 (Remote Management) +- [ ] Remote environments feel as fast as local +- [ ] <5 second latency for terminal access +- [ ] 100% of remote commands succeed (reliability) +- [ ] <2 minutes to provision new cloud instance + +### Overall +- [ ] Developers work on 3+ parallel tasks seamlessly +- [ ] 50% reduction in environment setup time +- [ ] 80% reduction in "works on my machine" issues +- [ ] Net Promoter Score >50 + +## Questions to Answer + +### Vibe Kanban +- [ ] What is the Vibe Kanban API endpoint/protocol? +- [ ] How do we authenticate (API token, OAuth, Tailscale identity)? +- [ ] What triggers environment creation (task status change, webhook)? +- [ ] How do we handle task reassignment (transfer environment ownership)? +- [ ] What happens to environments when tasks are archived? + +### Remote Management +- [ ] Which cloud providers to support first (AWS, GCP, Azure)? +- [ ] What VM specs to use (CPU, RAM, disk)? +- [ ] How to handle cost allocation (per user, per task, per team)? +- [ ] Should we support on-prem servers (not just cloud)? +- [ ] How to handle agent updates (auto-update, manual)? + +### General +- [ ] Multi-tenancy: support multiple organizations/teams? +- [ ] Pricing model: free tier, per-user, per-environment? +- [ ] Windows/Linux support priority (currently macOS-focused)? +- [ ] Open source vs proprietary (current code, agent, Kanban)? + +## Next Steps + +### Immediate (This Week) +1. Document Vibe Kanban integration requirements +2. Design task β†’ environment mapping +3. Create mock Kanban API for testing +4. Build basic Kanban board UI component + +### Short-term (This Month) +1. Implement Vibe Kanban API client +2. Add webhook receiver for task updates +3. Build task-driven worktree creation +4. Test end-to-end workflow with real tasks + +### Medium-term (This Quarter) +1. Design Ushadow Agent API +2. Build agent prototype +3. Test agent on remote server via Tailscale +4. Implement SSH tunneling for remote tmux + +### Long-term (This Year) +1. Production-ready agent deployment +2. Cloud provisioning automation +3. Multi-cloud support +4. Team collaboration features + +--- + +**This roadmap is a living document. Update it as we build, learn, and pivot.** diff --git a/ushadow/launcher/TMUX_INTEGRATION.md b/ushadow/launcher/TMUX_INTEGRATION.md new file mode 100644 index 00000000..a59a6e6c --- /dev/null +++ b/ushadow/launcher/TMUX_INTEGRATION.md @@ -0,0 +1,413 @@ +# Tmux Integration for Ushadow Launcher + +## Overview + +The Ushadow Launcher integrates with tmux to provide persistent terminal sessions for git worktree environments. Each worktree can have its own dedicated tmux window, enabling developers to maintain multiple parallel development sessions with ease. + +**Note**: This document covers Phase 1 (Local Tmux Integration) of the Ushadow Launcher project. For the broader vision including Vibe Kanban integration and remote development management, see [ROADMAP.md](./ROADMAP.md). + +## Problems Solved + +### 1. Slow Environment Ready Detection +**Problem**: The launcher was polling tailscale status 7 times during environment startup, causing 12+ second delays even when containers were already running. + +**Solution**: Implemented 10-second caching for tailscale status checks in `discovery.rs`: +- First check is real, subsequent checks within 10 seconds use cached value +- Reduces startup time from ~12 seconds to ~2 seconds +- Cache stored in static `Mutex>` + +**Files**: `src-tauri/src/commands/discovery.rs` + +### 2. No Visual Indication of Tmux Sessions +**Problem**: Users couldn't see if tmux windows were created or what their status was. + +**Solution**: Added three layers of tmux visibility: +1. **Activity Log Feedback**: Shows `βœ“ Tmux window 'ushadow-{name}' created` messages +2. **Status Badges**: Real-time activity indicators on environment cards (πŸ€–/πŸ’¬/βœ…/❌) +3. **Tmux Info Dialog**: Global "Tmux" button in header shows all sessions/windows + +**Files**: `src/App.tsx`, `src/components/EnvironmentsPanel.tsx` + +### 3. Manual Tmux Requirement +**Problem**: Users had to manually start tmux before creating worktrees. + +**Solution**: Auto-start tmux in multiple places: +- When creating worktrees with workmux +- On launcher startup if worktrees exist +- Manual "Start Tmux Server" button in dialog +- Graceful fallback to regular git worktrees if tmux fails + +**Files**: `src-tauri/src/commands/worktree.rs`, `src/App.tsx` + +### 4. No Way to Open Tmux from Existing Environments +**Problem**: Users couldn't create/attach tmux windows for existing worktrees. + +**Solution**: Added purple "Tmux" button to each worktree environment card that: +- Creates tmux window if it doesn't exist +- Opens Terminal.app and attaches to the specific window +- Reuses existing windows (no duplicates) +- Shows visual feedback in activity log + +**Files**: `src/components/EnvironmentsPanel.tsx`, `src/App.tsx`, `src-tauri/src/commands/worktree.rs` + +## Architecture + +### Tmux Session Structure + +``` +workmux (session) +β”œβ”€β”€ 0: zsh (default window) +β”œβ”€β”€ 1: ushadow-blue (worktree window) +β”œβ”€β”€ 2: ushadow-gold (worktree window) +└── 3: ushadow-purple (worktree window) +``` + +- Single persistent `workmux` session for all worktrees +- Each worktree gets its own window: `ushadow-{env-name}` +- Windows are created in the worktree's directory +- Sessions persist across launcher restarts + +### Key Commands + +#### Backend (Rust) + +**`ensure_tmux_running()`** +- Checks if tmux server is running +- Creates "workmux" session if needed +- Returns success message + +**`attach_tmux_to_worktree(worktree_path: String, env_name: String)`** +- Ensures tmux is running +- Creates window `ushadow-{env-name}` if doesn't exist +- Opens Terminal.app (macOS) and attaches to window +- Returns status message + +**`get_tmux_info()`** +- Lists all tmux sessions and windows +- Shows helpful message if no server running +- Returns formatted string for display + +**`get_environment_tmux_status(env_name: String)`** +- Returns detailed status for specific environment +- Includes: exists, window_name, current_command, activity_status +- Used for real-time status badges + +#### Frontend (TypeScript) + +**`handleAttachTmux(env: UshadowEnvironment)`** +- Called when Tmux button clicked on environment card +- Validates environment has a path +- Calls backend `attachTmuxToWorktree()` +- Refreshes discovery to update status badges +- Shows feedback in activity log + +**Auto-start Logic in `refreshDiscovery()`** +- Detects if worktrees exist +- Silently calls `ensureTmuxRunning()` if needed +- Non-intrusive, doesn't spam logs + +## User Features + +### 1. Global Tmux Button (Header) +**Location**: Environments panel header, next to "New Environment" + +**Features**: +- Click to view all tmux sessions and windows +- Shows "Start Tmux Server" button if no server running +- Displays current command for each window +- Useful for debugging and verification + +**Usage**: +``` +Click "Tmux" β†’ See all sessions β†’ Click "Start Tmux Server" if needed +``` + +### 2. Per-Environment Tmux Button +**Location**: Purple terminal icon on worktree environment cards + +**Features**: +- Creates/reuses tmux window for that environment +- Opens Terminal.app and attaches you to the session +- Tooltip shows "Create/attach tmux window" or "Tmux window exists" +- Works for both running and stopped environments + +**Usage**: +``` +Click purple Terminal icon β†’ New Terminal window opens β†’ You're in tmux session +``` + +### 3. Status Badges +**Location**: Next to branch name on environment cards + +**Indicators**: +- `πŸ€– Working` - Active command running (npm, docker, etc.) +- `πŸ’¬ Waiting` - Shell waiting for input +- `βœ… Done` - Command completed successfully +- `❌ Error` - Command exited with error + +### 4. Auto-Start on Launcher Startup +**Behavior**: +- Launcher detects existing worktrees on startup +- Silently ensures tmux server is running +- No user intervention required +- No spam in activity log + +## Technical Implementation + +### Tailscale Caching + +**File**: `src-tauri/src/commands/discovery.rs` + +```rust +use std::sync::Mutex; +use std::time::{Duration, Instant}; + +static TAILSCALE_CACHE: Mutex> = Mutex::new(None); + +pub async fn discover_environments_with_config(...) -> Result { + let tailscale_ok = { + let mut cache = TAILSCALE_CACHE.lock().unwrap(); + let now = Instant::now(); + + if let Some((cached_ok, cached_time)) = *cache { + if now.duration_since(cached_time) < Duration::from_secs(10) { + return cached_ok; // Use cached value + } + } + + // Cache miss or expired - do real check + let (installed, connected, _) = check_tailscale(); + let ok = installed && connected; + *cache = Some((ok, now)); + ok + }; + // ... +} +``` + +**Benefits**: +- Reduces 7 checks to 1 real check + 6 cached checks +- 10-second TTL balances freshness vs performance +- Thread-safe with Mutex +- Zero impact on first check + +### Terminal Opening (macOS) + +**File**: `src-tauri/src/commands/worktree.rs` + +```rust +#[cfg(target_os = "macos")] +{ + let script = format!( + "tell application \"Terminal\" to do script \"tmux attach-session -t workmux:{} && exit\"", + window_name + ); + + let open_terminal = Command::new("osascript") + .arg("-e") + .arg(&script) + .output() + .map_err(|e| format!("Failed to open Terminal: {}", e))?; +} +``` + +**How it works**: +1. Uses AppleScript via `osascript` to control Terminal.app +2. Attaches to specific window: `workmux:ushadow-{env-name}` +3. Adds `&& exit` to close Terminal when detaching from tmux +4. Non-blocking - doesn't fail entire operation if Terminal opening fails + +### Window Reuse Logic + +**File**: `src-tauri/src/commands/worktree.rs` + +```rust +// Check if window already exists +let check_window = shell_command(&format!( + "tmux list-windows -a -F '#{{window_name}}' | grep '^{}'", + window_name +)).output(); + +let window_existed = matches!(check_window, Ok(ref output) if output.status.success()); + +// Create only if needed +if !window_existed { + let create_window = shell_command(&format!( + "tmux new-window -t workmux -n {} -c '{}'", + window_name, worktree_path + )).output()?; +} +``` + +**Benefits**: +- No duplicate windows +- Idempotent operation - safe to click multiple times +- Window persists across launcher restarts +- Can attach to existing work session + +## File Changes Summary + +### New Commands Added + +**Rust (`src-tauri/src/commands/worktree.rs`)**: +- `ensure_tmux_running()` - Auto-start tmux server +- `attach_tmux_to_worktree()` - Create/attach window and open terminal +- `get_tmux_info()` - List all sessions/windows +- Modified `create_worktree_with_workmux()` - Added auto-start logic + +**TypeScript (`src/hooks/useTauri.ts`)**: +- `getTmuxInfo()` - Wrapper for get_tmux_info +- `ensureTmuxRunning()` - Wrapper for ensure_tmux_running +- `attachTmuxToWorktree()` - Wrapper for attach_tmux_to_worktree + +### UI Components Modified + +**`src/components/EnvironmentsPanel.tsx`**: +- Added global "Tmux" button in header +- Added tmux info dialog with "Start Tmux Server" button +- Added per-environment tmux button (purple terminal icon) +- Added `onAttachTmux` callback prop + +**`src/App.tsx`**: +- Added `handleAttachTmux()` handler +- Modified `refreshDiscovery()` for auto-start +- Added tmux status check in `handleNewEnvWorktree()` +- Wired up `onAttachTmux` to EnvironmentsPanel + +### Backend Registration + +**`src-tauri/src/main.rs`**: +- Registered new commands in `invoke_handler![]` +- Imported new command functions + +## Testing Checklist + +### Auto-Start Tmux +- [ ] Stop tmux: `tmux kill-server` +- [ ] Start launcher +- [ ] Verify tmux auto-starts: `tmux list-sessions` +- [ ] Should see "workmux" session + +### Tmux Button (Per-Environment) +- [ ] Click purple terminal icon on any worktree +- [ ] Verify Terminal.app opens +- [ ] Verify you're attached to correct tmux window +- [ ] Verify working directory is worktree path +- [ ] Click button again, verify reuses existing window + +### Global Tmux Dialog +- [ ] Click "Tmux" button in header +- [ ] Verify shows all sessions and windows +- [ ] Stop tmux: `tmux kill-server` +- [ ] Click "Tmux" button again +- [ ] Verify shows "Start Tmux Server" button +- [ ] Click "Start Tmux Server" +- [ ] Verify creates workmux session + +### Fast Environment Ready Detection +- [ ] Start an environment +- [ ] Watch activity log +- [ ] Should declare ready in ~2 seconds, not 12+ +- [ ] Should NOT see 7 tailscale queries + +### Status Badges +- [ ] Create new worktree with tmux window +- [ ] Verify activity badge appears (πŸ€–/πŸ’¬/etc.) +- [ ] Run a command in tmux: `npm run dev` +- [ ] Refresh discovery +- [ ] Verify badge shows `πŸ€– npm` or similar + +## Known Limitations + +1. **macOS Only Terminal Opening**: The Terminal.app integration uses AppleScript and only works on macOS. Linux/Windows have placeholder code that may not work reliably. + +2. **Tmux Socket**: Uses default tmux socket at `/private/tmp/tmux-501/default`. If users have multiple tmux servers on different sockets, the launcher only sees the default one. + +3. **No Detach Prevention**: Users can detach from tmux manually, leaving the window running in background. This is by design but might be confusing. + +4. **Terminal Window Management**: Each click opens a new Terminal.app window. There's no automatic window reuse or tab creation in Terminal.app. + +## Future Enhancements + +### Possible Improvements +- [ ] iTerm2 integration option (many developers prefer iTerm) +- [ ] Configurable terminal emulator (user preference) +- [ ] Inline terminal within launcher app (embed xterm.js) +- [ ] Better tmux socket detection/configuration +- [ ] Tab-based terminal opening instead of new windows +- [ ] Tmux layout templates (split panes, predefined layouts) +- [ ] Command history per worktree +- [ ] Auto-run commands on tmux creation (npm install, etc.) + +### Architecture Considerations +- Consider migrating to workmux CLI's native integration +- Explore tauri shell plugin for cross-platform terminal support +- Evaluate embedded terminal solutions for better UX + +## Troubleshooting + +### Tmux Won't Start +**Symptom**: "Start Tmux Server" button fails + +**Solutions**: +1. Check tmux is installed: `which tmux` +2. Try manually: `tmux new-session -d -s workmux` +3. Check for hung tmux processes: `ps aux | grep tmux` +4. Kill hung processes: `pkill tmux` + +### Terminal Won't Open +**Symptom**: Clicking tmux button does nothing + +**Solutions**: +1. Check Console.app for osascript errors +2. Verify Terminal.app permissions in System Settings +3. Try manually: `osascript -e 'tell application "Terminal" to do script "echo test"'` +4. Restart launcher + +### Wrong Tmux Socket +**Symptom**: `tmux list-windows -a` shows "no server running" + +**Solutions**: +1. Find all tmux sockets: `ls -la /private/tmp/tmux-*/` +2. Attach to correct one: `tmux -S /path/to/socket attach` +3. Kill other tmux servers if needed +4. Ensure using default socket + +### Status Badges Not Updating +**Symptom**: Activity badges don't change after running commands + +**Solutions**: +1. Click refresh button in launcher +2. Wait for auto-refresh cycle (every few seconds) +3. Check tmux window name matches: `tmux list-windows -a | grep ushadow-` +4. Verify tmux command polling is working + +## Next: Vibe Kanban & Remote Management + +This tmux integration is Phase 1 of a larger vision. See [ROADMAP.md](./ROADMAP.md) for upcoming features: + +**Phase 2: Vibe Kanban Integration** +- Task-driven worktree creation +- Kanban board view in launcher +- Auto-provision environments for tasks +- Lifecycle management tied to task status + +**Phase 3: Remote Development Management** +- Unified control panel for local + remote environments +- Ushadow Agent running on remote servers +- Terminal tunneling via Tailscale +- Cloud VM provisioning automation + +**Phase 4: Advanced Features** +- Team collaboration (shared tmux sessions) +- CI/CD integration (PR previews) +- Observability (metrics, tracing) +- AI/LLM integration + +## References + +- [Ushadow Launcher Roadmap](./ROADMAP.md) - Full vision and development plan +- [Tauri Documentation](https://tauri.app/) +- [Tmux Documentation](https://github.com/tmux/tmux/wiki) +- [Workmux CLI](https://github.com/yourorg/workmux) (if applicable) +- [AppleScript Language Guide](https://developer.apple.com/library/archive/documentation/AppleScript/Conceptual/AppleScriptLangGuide/) diff --git a/ushadow/launcher/dist/index.html b/ushadow/launcher/dist/index.html index 8f567827..3ed998f8 100644 --- a/ushadow/launcher/dist/index.html +++ b/ushadow/launcher/dist/index.html @@ -5,8 +5,8 @@ Ushadow Launcher - - + +
diff --git a/ushadow/launcher/package.json b/ushadow/launcher/package.json index 5ed233a1..dc5a4dbc 100644 --- a/ushadow/launcher/package.json +++ b/ushadow/launcher/package.json @@ -1,6 +1,6 @@ { "name": "ushadow-launcher", - "version": "0.5.1", + "version": "0.6.3", "description": "Ushadow Desktop Launcher", "private": true, "type": "module", diff --git a/ushadow/launcher/public/iterm-icon.png b/ushadow/launcher/public/iterm-icon.png new file mode 100644 index 00000000..01821f9c Binary files /dev/null and b/ushadow/launcher/public/iterm-icon.png differ diff --git a/ushadow/launcher/src-tauri/Cargo.lock b/ushadow/launcher/src-tauri/Cargo.lock index cfdb13f8..d0b60188 100644 --- a/ushadow/launcher/src-tauri/Cargo.lock +++ b/ushadow/launcher/src-tauri/Cargo.lock @@ -3575,6 +3575,19 @@ dependencies = [ "syn 2.0.113", ] +[[package]] +name = "serde_yaml" +version = "0.9.34+deprecated" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a8b1a1a2ebf674015cc02edccce75287f1a0130d394307b36743c2f5d504b47" +dependencies = [ + "indexmap 2.12.1", + "itoa 1.0.17", + "ryu", + "serde", + "unsafe-libyaml", +] + [[package]] name = "serial" version = "0.4.0" @@ -4646,6 +4659,12 @@ version = "1.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493" +[[package]] +name = "unsafe-libyaml" +version = "0.2.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861" + [[package]] name = "url" version = "2.5.7" @@ -4660,7 +4679,7 @@ dependencies = [ [[package]] name = "ushadow-launcher" -version = "0.5.1" +version = "0.6.0" dependencies = [ "chrono", "dirs", @@ -4669,6 +4688,7 @@ dependencies = [ "reqwest", "serde", "serde_json", + "serde_yaml", "tauri", "tauri-build", "tokio", diff --git a/ushadow/launcher/src-tauri/Cargo.toml b/ushadow/launcher/src-tauri/Cargo.toml index 29a38948..4be8e177 100644 --- a/ushadow/launcher/src-tauri/Cargo.toml +++ b/ushadow/launcher/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "ushadow-launcher" -version = "0.5.1" +version = "0.6.3" description = "Ushadow Desktop Launcher" authors = ["Ushadow"] license = "MIT" @@ -14,6 +14,7 @@ tauri-build = { version = "1", features = [] } tauri = { version = "1", features = [ "clipboard-all", "path-all", "process-exit", "shell-execute", "process-relaunch", "shell-open", "process-command-api", "dialog-all", "notification-all", "system-tray"] } serde = { version = "1", features = ["derive"] } serde_json = "1" +serde_yaml = "0.9" tokio = { version = "1", features = ["process", "time", "macros", "rt-multi-thread", "io-util", "sync"] } reqwest = { version = "0.11", features = ["blocking"] } open = "5" diff --git a/ushadow/launcher/src-tauri/PREREQUISITES_CONFIG.md b/ushadow/launcher/src-tauri/PREREQUISITES_CONFIG.md new file mode 100644 index 00000000..aa9a5341 --- /dev/null +++ b/ushadow/launcher/src-tauri/PREREQUISITES_CONFIG.md @@ -0,0 +1,183 @@ +# Prerequisites Configuration + +The `prerequisites.yaml` file allows you to easily configure what software prerequisites are checked and required by the launcher. + +## Location + +- **Development**: `/Users/stu/repos/worktrees/ushadow/gold/ushadow/launcher/src-tauri/prerequisites.yaml` +- **Production**: Bundled with the app in the resources directory + +## Structure + +```yaml +prerequisites: + - id: prerequisite_id # Unique identifier + name: Software Name # Display name + display_name: Display Name # UI display name + description: Description # User-friendly description + platforms: [macos, windows, linux] # Supported platforms + check_command: command --version # Command to check installation + optional: false # Whether the prerequisite is required + category: development # Category for grouping +``` + +## Prerequisite Fields + +### Required Fields +- `id`: Unique identifier (e.g., "docker", "git") +- `name`: Software name +- `display_name`: Name shown in UI +- `description`: User-friendly description +- `platforms`: Array of supported platforms (`macos`, `windows`, `linux`) +- `optional`: Boolean indicating if prerequisite is required +- `category`: Group category (e.g., "development", "infrastructure", "networking") + +### Optional Fields +- `check_command`: Shell command to verify installation +- `check_commands`: Array of commands to try in order +- `check_running_command`: Command to check if service is running (for services like Docker) +- `check_connected_command`: Command to check connection status (for services like Tailscale) +- `fallback_paths`: Array of absolute paths to check if command fails +- `version_filter`: String to filter version output (e.g., "Python 3") +- `has_service`: Boolean indicating if this is a service that can be started/stopped +- `connection_validation`: Object with validation rules (e.g., `starts_with: "100."`) + +## Platform-Specific Paths + +For tools that might be installed in different locations per platform, use `fallback_paths`: + +```yaml +fallback_paths: + macos: + - /opt/homebrew/bin/docker + - /usr/local/bin/docker + windows: + - C:\Program Files\Docker\Docker\resources\bin\docker.exe + linux: + - /usr/bin/docker + - /usr/local/bin/docker +``` + +## Installation Methods + +You can also configure installation methods for each prerequisite: + +```yaml +installation_methods: + docker: + macos: + method: homebrew + package: docker + windows: + method: download + url: https://www.docker.com/products/docker-desktop + linux: + method: script + url: https://get.docker.com +``` + +### Supported Installation Methods +- `homebrew`: Install via Homebrew (macOS) +- `download`: Download installer from URL +- `script`: Run installation script from URL +- `package_manager`: Install via system package manager +- `cargo`: Install via Rust's cargo + +## Categories + +Organize prerequisites into logical groups: +- `package_manager`: Package managers (Homebrew) +- `development`: Development tools (Git, Python, Node.js) +- `infrastructure`: Infrastructure services (Docker) +- `networking`: Network tools (Tailscale) + +## Examples + +### Adding a New Prerequisite + +To add Node.js as a prerequisite: + +```yaml +prerequisites: + - id: node + name: Node.js + display_name: Node.js + description: JavaScript runtime + platforms: [macos, windows, linux] + check_command: node --version + optional: false + category: development +``` + +### Making a Prerequisite Optional + +```yaml + - id: vscode + name: VS Code + display_name: Visual Studio Code + description: Code editor + platforms: [macos, windows, linux] + check_command: code --version + optional: true # Not required for core functionality + category: development +``` + +### Adding Multiple Check Commands + +```yaml + - id: python + name: Python 3 + display_name: Python 3 + description: Python programming language + platforms: [macos, windows, linux] + check_commands: + - python3 --version # Try this first + - python --version # Fallback + version_filter: "Python 3" # Only accept Python 3.x + optional: false + category: development +``` + +## API Usage + +### From TypeScript/Frontend + +```typescript +import { invoke } from '@tauri-apps/api' + +// Get all prerequisites configuration +const config = await invoke('get_prerequisites_config') + +// Get prerequisites for current platform +const prereqs = await invoke('get_platform_prerequisites_config', { + platform: 'macos' +}) +``` + +### From Rust/Backend + +```rust +use crate::commands::prerequisites_config::PrerequisitesConfig; + +// Load configuration +let config = PrerequisitesConfig::load()?; + +// Get prerequisites for a platform +let macos_prereqs = config.get_platform_prerequisites("macos"); +``` + +## Testing + +To test your configuration changes: + +1. Edit `prerequisites.yaml` +2. Rebuild the launcher: `cargo build` +3. Run the launcher: `cargo tauri dev` +4. Check the Prerequisites panel to verify your changes + +## Notes + +- The YAML file is parsed at runtime, so you can modify it without recompiling +- Prerequisites are checked in the order they appear in the file +- Use `optional: true` for nice-to-have tools +- Platform values are case-sensitive: use `macos`, `windows`, `linux` diff --git a/ushadow/launcher/src-tauri/prerequisites.yaml b/ushadow/launcher/src-tauri/prerequisites.yaml new file mode 100644 index 00000000..eda439e7 --- /dev/null +++ b/ushadow/launcher/src-tauri/prerequisites.yaml @@ -0,0 +1,192 @@ +# Prerequisites Configuration +# This file defines what software needs to be installed and checked +# Modify this file to add/remove prerequisites or change detection logic + +prerequisites: + # Package Manager (macOS only) + - id: homebrew + name: Homebrew + display_name: Homebrew + description: Package manager for macOS + platforms: [macos] + check_command: brew --version + fallback_paths: + - /opt/homebrew/bin/brew # Apple Silicon + - /usr/local/bin/brew # Intel Mac + optional: false + category: package_manager + + # Version Control + - id: git + name: Git + display_name: Git + description: Version control system + platforms: [macos, windows, linux] + check_command: git --version + optional: false + category: development + + # Programming Languages + - id: python + name: Python 3 + display_name: Python 3 + description: Python programming language + platforms: [macos, windows, linux] + check_commands: + - python3 --version # Preferred + - python --version # Fallback + version_filter: "Python 3" # Only accept Python 3.x + optional: false + category: development + + # Container Platform + - id: docker + name: Docker + display_name: Docker + description: Container platform + platforms: [macos, windows, linux] + check_command: docker --version + check_running_command: docker info + fallback_paths: + macos: + - /usr/local/bin/docker + - /opt/homebrew/bin/docker + - /Applications/Docker.app/Contents/Resources/bin/docker + windows: + - C:\Program Files\Docker\Docker\resources\bin\docker.exe + - C:\ProgramData\DockerDesktop\version-bin\docker.exe + linux: + - /usr/bin/docker + - /usr/local/bin/docker + - /snap/bin/docker + optional: false + has_service: true # Can be started/stopped + category: infrastructure + + # VPN/Networking + - id: tailscale + name: Tailscale + display_name: Tailscale + description: Zero-config VPN + platforms: [macos, windows, linux] + check_command: tailscale --version + check_connected_command: tailscale ip -4 + connection_validation: + starts_with: "100." # Tailscale IPs start with 100.x.x.x + optional: true + category: networking + + # Python Package Installer (Required) + - id: uv + name: uv + display_name: uv + description: Fast Python package installer + platforms: [macos, windows, linux] + check_command: uv --version + optional: false + category: development + + - id: workmux + name: workmux + display_name: workmux + description: Worktree multiplexer + platforms: [macos, windows, linux] + check_command: workmux --version + optional: true + category: development + + - id: tmux + name: tmux + display_name: tmux + description: Terminal multiplexer + platforms: [macos, linux] + check_command: tmux -V + optional: true + category: development + +# Installation methods per platform +installation_methods: + homebrew: + macos: + method: script + url: https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh + + git: + macos: + method: homebrew + package: git + windows: + method: download + url: https://git-scm.com/download/win + linux: + method: package_manager + packages: + apt: git + yum: git + dnf: git + + python: + macos: + method: homebrew + package: python@3.12 + windows: + method: download + url: https://www.python.org/downloads/ + linux: + method: package_manager + packages: + apt: python3 + yum: python3 + dnf: python3 + + docker: + macos: + method: homebrew + package: docker + windows: + method: download + url: https://www.docker.com/products/docker-desktop + linux: + method: script + url: https://get.docker.com + + tailscale: + macos: + method: homebrew + package: tailscale + windows: + method: download + url: https://tailscale.com/download/windows + linux: + method: script + url: https://tailscale.com/install.sh + + uv: + macos: + method: homebrew + package: uv + windows: + method: script + url: https://astral.sh/uv/install.ps1 + linux: + method: script + url: https://astral.sh/uv/install.sh + + workmux: + macos: + method: cargo + package: workmux + linux: + method: cargo + package: workmux + + tmux: + macos: + method: homebrew + package: tmux + linux: + method: package_manager + packages: + apt: tmux + yum: tmux + dnf: tmux diff --git a/ushadow/launcher/src-tauri/src/commands/discovery.rs b/ushadow/launcher/src-tauri/src/commands/discovery.rs index 8b0a645a..66383174 100644 --- a/ushadow/launcher/src-tauri/src/commands/discovery.rs +++ b/ushadow/launcher/src-tauri/src/commands/discovery.rs @@ -1,6 +1,6 @@ use std::collections::{HashMap, HashSet}; use std::sync::Mutex; -use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; +use std::time::{Duration, Instant, UNIX_EPOCH}; use crate::models::{DiscoveryResult, EnvironmentStatus, InfraService, UshadowEnvironment, WorktreeInfo}; use super::prerequisites::{check_docker, check_tailscale}; use super::utils::silent_command; @@ -14,6 +14,88 @@ const INFRA_PATTERNS: &[(&str, &str)] = &[ ("qdrant", "Qdrant"), ]; +/// Determine base branch using git merge-base +/// Finds which branch (main or dev) is the closest ancestor +fn determine_base_branch(repo_path: &str, branch: &str) -> Option { + // Special case: if branch is exactly main or dev + if branch == "main" || branch == "master" { + return Some("main".to_string()); + } + if branch == "dev" { + return Some("dev".to_string()); + } + + // Get merge-base with dev + let dev_merge_base = silent_command("git") + .args(["-C", repo_path, "merge-base", branch, "dev"]) + .output(); + + // Get merge-base with main + let main_merge_base = silent_command("git") + .args(["-C", repo_path, "merge-base", branch, "main"]) + .output(); + + if let (Ok(dev_out), Ok(main_out)) = (dev_merge_base, main_merge_base) { + if dev_out.status.success() && main_out.status.success() { + let dev_base = String::from_utf8_lossy(&dev_out.stdout).trim().to_string(); + let main_base = String::from_utf8_lossy(&main_out.stdout).trim().to_string(); + + // If the merge-bases are different, the branch is based on whichever is more recent + if dev_base != main_base { + // Check if dev_base is an ancestor of main_base (meaning main is more recent) + let check = silent_command("git") + .args(["-C", repo_path, "merge-base", "--is-ancestor", &dev_base, &main_base]) + .output(); + + if let Ok(output) = check { + if output.status.success() { + // dev_base is ancestor of main_base, so main is the closer base + return Some("main".to_string()); + } else { + // dev_base is NOT an ancestor of main_base, so dev is the closer base + return Some("dev".to_string()); + } + } + } else { + // Merge bases are the same - check which branch tip is closer + // Count commits from branch to dev + let dev_count = silent_command("git") + .args(["-C", repo_path, "rev-list", "--count", &format!("{}..{}", "dev", branch)]) + .output(); + + let main_count = silent_command("git") + .args(["-C", repo_path, "rev-list", "--count", &format!("{}..{}", "main", branch)]) + .output(); + + if let (Ok(dev_c), Ok(main_c)) = (dev_count, main_count) { + if dev_c.status.success() && main_c.status.success() { + let dev_commits = String::from_utf8_lossy(&dev_c.stdout).trim().parse::().unwrap_or(999); + let main_commits = String::from_utf8_lossy(&main_c.stdout).trim().parse::().unwrap_or(999); + + // Fewer commits = closer base + if dev_commits < main_commits { + return Some("dev".to_string()); + } else { + return Some("main".to_string()); + } + } + } + } + } + } + + // Fallback to path-based detection + if repo_path.contains("/ushadow-dev/") || repo_path.contains("/worktrees-dev/") || + repo_path.contains("\\ushadow-dev\\") || repo_path.contains("\\worktrees-dev\\") { + Some("dev".to_string()) + } else if repo_path.contains("/ushadow/") || repo_path.contains("/worktrees/") || + repo_path.contains("\\ushadow\\") || repo_path.contains("\\worktrees\\") { + Some("main".to_string()) + } else { + None + } +} + /// Environment container info struct EnvContainerInfo { backend_port: Option, @@ -218,6 +300,8 @@ pub async fn discover_environments_with_config( (None, None) => None, }; + let base_branch = determine_base_branch(&wt.path, &wt.branch); + environments.push(UshadowEnvironment { name: name.clone(), color: primary, @@ -233,6 +317,7 @@ pub async fn discover_environments_with_config( containers, is_worktree: true, created_at: final_created_at, + base_branch, }); } @@ -259,6 +344,19 @@ pub async fn discover_environments_with_config( }; let running = status == EnvironmentStatus::Running; + // For non-worktree environments, we don't have a branch name, so just use path-based detection + let base_branch = info.working_dir.as_ref().and_then(|wd| { + if wd.contains("/ushadow-dev/") || wd.contains("/worktrees-dev/") || + wd.contains("\\ushadow-dev\\") || wd.contains("\\worktrees-dev\\") { + Some("dev".to_string()) + } else if wd.contains("/ushadow/") || wd.contains("/worktrees/") || + wd.contains("\\ushadow\\") || wd.contains("\\worktrees\\") { + Some("main".to_string()) + } else { + None + } + }); + environments.push(UshadowEnvironment { name: name.clone(), color: primary, @@ -274,6 +372,7 @@ pub async fn discover_environments_with_config( containers: info.containers, is_worktree: false, created_at: info.created_at, + base_branch, }); } @@ -376,7 +475,7 @@ fn get_container_working_dir(container_name: &str) -> Option { return None; } - let working_dir = String::from_utf8_lossy(&output.stdout).trim().to_string(); + let _working_dir = String::from_utf8_lossy(&output.stdout).trim().to_string(); // Docker returns the working dir inside the container (e.g., "/app") // We need to map this to the host path using volume mounts diff --git a/ushadow/launcher/src-tauri/src/commands/docker.rs b/ushadow/launcher/src-tauri/src/commands/docker.rs index bb8509dc..d8e36a90 100644 --- a/ushadow/launcher/src-tauri/src/commands/docker.rs +++ b/ushadow/launcher/src-tauri/src/commands/docker.rs @@ -355,81 +355,56 @@ pub async fn start_environment(state: State<'_, AppState>, env_name: String, env ((hash % 50) * 10) as u16 // Gives offsets: 0, 10, 20, ... 490 }; - let mut log_messages = Vec::new(); + let mut status_log = Vec::new(); // User-visible status messages + let mut debug_log = Vec::new(); // Detailed debug info (only shown on error) - log_messages.push(format!("========== INITIALIZING ENVIRONMENT ==========")); - log_messages.push(format!("Working directory: {}", working_dir)); - log_messages.push(format!("ENV_NAME={}", env_name)); - log_messages.push(format!("PORT_OFFSET={} (calculated from env name hash)", port_offset)); + // Log to both status and debug + status_log.push(format!("Initializing environment '{}'...", env_name)); + debug_log.push(format!("========== INITIALIZING ENVIRONMENT ==========")); + debug_log.push(format!("Working directory: {}", working_dir)); + debug_log.push(format!("ENV_NAME={}", env_name)); + debug_log.push(format!("PORT_OFFSET={} (calculated from env name hash)", port_offset)); - // Install uv if needed - let install_script = if cfg!(target_os = "windows") { - std::path::Path::new(&working_dir).join("scripts/install-uv.ps1") - } else { - std::path::Path::new(&working_dir).join("scripts/install-uv.sh") - }; - - log_messages.push(format!("Checking for uv install script at: {}", install_script.display())); - - if install_script.exists() { - log_messages.push(format!("βœ“ Found install script, running: {}", install_script.display())); - - let install_output = if cfg!(target_os = "windows") { - log_messages.push(format!("Executing: powershell -ExecutionPolicy Bypass -File \"{}\"", install_script.display())); - shell_command("powershell") - .args(["-ExecutionPolicy", "Bypass", "-File", install_script.to_str().unwrap()]) - .current_dir(&working_dir) - .output() - } else { - log_messages.push(format!("Executing: bash \"{}\"", install_script.display())); - shell_command("bash") - .arg(install_script.to_str().unwrap()) - .current_dir(&working_dir) - .output() - }; - - match install_output { - Ok(out) => { - let install_stdout = String::from_utf8_lossy(&out.stdout); - let install_stderr = String::from_utf8_lossy(&out.stderr); - - if !install_stdout.is_empty() { - log_messages.push(format!("uv installer stdout:\n{}", install_stdout)); - } - if !install_stderr.is_empty() { - log_messages.push(format!("uv installer stderr:\n{}", install_stderr)); - } + // Find uv executable (assumes uv is installed via prerequisites) + let uv_cmd = find_uv_executable(); + debug_log.push(format!("Using uv at: {}", uv_cmd)); - if !out.status.success() { - log_messages.push(format!("⚠ uv installer exited with status: {}", out.status)); - } else { - log_messages.push(format!("βœ“ uv installer completed successfully")); - } - } - Err(e) => { - log_messages.push(format!("⚠ Failed to run uv installer: {}", e)); - } - } + // Verify uv is accessible + let uv_check = if uv_cmd == "uv" { + // If using PATH, verify with --version + shell_command("uv --version").output().is_ok() } else { - log_messages.push(format!("βœ— Install script NOT found at: {}", install_script.display())); - } - - // Find uv executable (handle Windows PATH not being updated) - let uv_cmd = find_uv_executable(); - log_messages.push(format!("Looking for uv executable...")); - log_messages.push(format!("Using uv at: {}", uv_cmd)); + // If using specific path, verify file exists + std::path::Path::new(&uv_cmd).exists() + }; - // Check if uv actually exists - if uv_cmd != "uv" && !std::path::Path::new(&uv_cmd).exists() { - log_messages.push(format!("⚠ uv not found at: {}", uv_cmd)); + if !uv_check { + let error_msg = format!( + "uv not found or not accessible (tried: {})\n\nPlease install uv via the Prerequisites panel before starting an environment.", + uv_cmd + ); + status_log.push(error_msg.clone()); + debug_log.push(error_msg); + return Err(format!("{}\n\n=== Debug Log ===\n{}", + status_log.join("\n"), + debug_log.join("\n"))); } // Run setup with uv in dev mode with calculated port offset // Note: Removed --skip-admin flag so admin user can be auto-created from secrets.yaml - log_messages.push(format!("Running: {} run --with pyyaml setup/run.py --dev --quick", uv_cmd)); + status_log.push(format!("Running setup script...")); + debug_log.push(format!("Running: {} run --with pyyaml setup/run.py --dev --quick", uv_cmd)); // Build the full command string for shell execution // Pass PORT_OFFSET for compatibility with both old and new setup scripts + // Platform-specific command syntax + #[cfg(target_os = "windows")] + let setup_command = format!( + "cd '{}'; $env:ENV_NAME='{}'; $env:PORT_OFFSET='{}'; {} run --with pyyaml setup/run.py --dev --quick", + working_dir, env_name, port_offset, uv_cmd + ); + + #[cfg(not(target_os = "windows"))] let setup_command = format!( "cd '{}' && ENV_NAME={} PORT_OFFSET={} {} run --with pyyaml setup/run.py --dev --quick", working_dir, env_name, port_offset, uv_cmd @@ -438,18 +413,20 @@ pub async fn start_environment(state: State<'_, AppState>, env_name: String, env let output = shell_command(&setup_command) .output() .map_err(|e| { - let error_log = log_messages.join("\n"); - format!("{}\n\nFailed to run setup (uv not found at '{}'. Try installing manually: https://docs.astral.sh/uv/getting-started/installation/): {}", error_log, uv_cmd, e) + let full_log = format!("{}\n\n=== Debug Log ===\n{}", + status_log.join("\n"), + debug_log.join("\n")); + format!("{}\n\nFailed to run setup (uv not found at '{}'. Try installing manually: https://docs.astral.sh/uv/getting-started/installation/): {}", full_log, uv_cmd, e) })?; let stderr = String::from_utf8_lossy(&output.stderr); let stdout = String::from_utf8_lossy(&output.stdout); if !stdout.is_empty() { - log_messages.push(format!("Setup stdout:\n{}", stdout)); + debug_log.push(format!("Setup stdout:\n{}", stdout)); } if !stderr.is_empty() { - log_messages.push(format!("Setup stderr:\n{}", stderr)); + debug_log.push(format!("Setup stderr:\n{}", stderr)); } if !output.status.success() { @@ -468,17 +445,22 @@ pub async fn start_environment(state: State<'_, AppState>, env_name: String, env &error_lines[..] }; - let error_log = log_messages.join("\n"); + // On error, show both status and debug logs + let full_log = format!("{}\n\n=== Debug Log ===\n{}", + status_log.join("\n"), + debug_log.join("\n")); + return Err(format!( - "{}\n\nFailed to initialize environment '{}'\n\nError output:\n{}", - error_log, + "{}\n\n❌ Failed to initialize environment '{}'\n\nError output:\n{}", + full_log, env_name, context_lines.join("\n") )); } - log_messages.push(format!("βœ“ Environment '{}' initialized and started", env_name)); - return Ok(log_messages.join("\n")); + // On success, only show status log + status_log.push(format!("βœ“ Environment '{}' initialized and started", env_name)); + return Ok(status_log.join("\n")); } // Containers exist and are stopped - just start them diff --git a/ushadow/launcher/src-tauri/src/commands/generic_installer.rs b/ushadow/launcher/src-tauri/src/commands/generic_installer.rs new file mode 100644 index 00000000..e089569c --- /dev/null +++ b/ushadow/launcher/src-tauri/src/commands/generic_installer.rs @@ -0,0 +1,414 @@ +use super::prerequisites_config::{PrerequisitesConfig, InstallationMethod}; +use super::utils::{silent_command, shell_command}; +#[cfg(target_os = "macos")] +use super::installer::{check_brew_installed, get_brew_path}; +use std::process::Command; + +/// Generic installer that reads from YAML configuration +#[tauri::command] +pub async fn install_prerequisite(prerequisite_id: String) -> Result { + // Load prerequisites config + let config = PrerequisitesConfig::load()?; + + // Get current platform + let platform = get_current_platform(); + + // Find installation method for this prerequisite on this platform + let installation_methods = config.installation_methods + .ok_or_else(|| format!("No installation methods defined in config"))?; + + let prereq_methods = installation_methods.get(&prerequisite_id) + .ok_or_else(|| format!("No installation method found for '{}'", prerequisite_id))?; + + let method = prereq_methods.get(&platform) + .ok_or_else(|| format!("No installation method for '{}' on platform '{}'", prerequisite_id, platform))?; + + // Execute the installation based on method type + execute_installation(&prerequisite_id, method, &platform).await +} + +/// Get the start command for a service prerequisite +#[tauri::command] +pub async fn start_prerequisite(prerequisite_id: String) -> Result { + // Load prerequisites config + let config = PrerequisitesConfig::load()?; + + // Get the prerequisite definition + let prereq = config.get_prerequisite(&prerequisite_id) + .ok_or_else(|| format!("Prerequisite '{}' not found", prerequisite_id))?; + + // Check if this prerequisite has a service + if !prereq.has_service.unwrap_or(false) { + return Err(format!("'{}' is not a service that can be started", prerequisite_id)); + } + + // Platform-specific start logic + let platform = get_current_platform(); + match (prerequisite_id.as_str(), platform.as_str()) { + ("docker", "macos") => start_docker_macos().await, + ("docker", "windows") => start_docker_windows().await, + ("docker", "linux") => start_docker_linux().await, + _ => Err(format!("Start not implemented for '{}' on '{}'", prerequisite_id, platform)) + } +} + +/// Execute installation based on method type +async fn execute_installation( + prereq_id: &str, + method: &InstallationMethod, + _platform: &str, +) -> Result { + match method.method.as_str() { + "homebrew" => install_via_homebrew(prereq_id, method).await, + "winget" => install_via_winget(prereq_id, method).await, + "download" => install_via_download(prereq_id, method).await, + "script" => install_via_script(prereq_id, method).await, + "package_manager" => install_via_package_manager(prereq_id, method).await, + "cargo" => install_via_cargo(prereq_id, method).await, + _ => Err(format!("Unknown installation method: {}", method.method)) + } +} + +/// Install via Homebrew (macOS) +#[cfg(target_os = "macos")] +async fn install_via_homebrew(prereq_id: &str, method: &InstallationMethod) -> Result { + if !check_brew_installed() { + return Err("Homebrew is not installed".to_string()); + } + + let package = method.package.as_ref() + .ok_or_else(|| "No package specified for Homebrew installation".to_string())?; + + let brew_path = get_brew_path(); + + // Determine if this is a cask or formula + let is_cask = prereq_id == "docker" || prereq_id == "tailscale"; + + let args = if is_cask { + vec!["install", "--cask", package] + } else { + vec!["install", package] + }; + + eprintln!("Installing {} via Homebrew: {} {}", prereq_id, brew_path, args.join(" ")); + + // For apps that require admin privileges (like Docker), use osascript + if prereq_id == "docker" { + let script = format!( + r#"do shell script "{} install --cask {}" with administrator privileges"#, + brew_path, package + ); + + let output = Command::new("osascript") + .args(["-e", &script]) + .output() + .map_err(|e| format!("Failed to run osascript: {}", e))?; + + if output.status.success() { + Ok(format!("{} installed successfully via Homebrew", prereq_id)) + } else { + let stderr = String::from_utf8_lossy(&output.stderr); + if stderr.contains("User canceled") || stderr.contains("-128") { + Err("Installation cancelled by user".to_string()) + } else { + Err(format!("Homebrew install failed: {}", stderr)) + } + } + } else { + // For other packages, run brew directly + let output = silent_command(&brew_path) + .args(&args) + .output() + .map_err(|e| format!("Failed to run brew: {}", e))?; + + if output.status.success() { + Ok(format!("{} installed successfully via Homebrew", prereq_id)) + } else { + let stderr = String::from_utf8_lossy(&output.stderr); + Err(format!("Homebrew install failed: {}", stderr)) + } + } +} + +#[cfg(not(target_os = "macos"))] +async fn install_via_homebrew(_prereq_id: &str, _method: &InstallationMethod) -> Result { + Err("Homebrew installation is only available on macOS".to_string()) +} + +/// Install via winget (Windows) +async fn install_via_winget(prereq_id: &str, method: &InstallationMethod) -> Result { + let package = method.package.as_ref() + .ok_or_else(|| "No package specified for winget installation".to_string())?; + + eprintln!("Installing {} via winget: {}", prereq_id, package); + + let output = silent_command("winget") + .args([ + "install", + "--id", package, + "-e", + "--source", "winget", + "--accept-package-agreements", + "--accept-source-agreements" + ]) + .output() + .map_err(|e| format!("Failed to run winget: {}", e))?; + + if output.status.success() { + Ok(format!("{} installed successfully via winget", prereq_id)) + } else { + let stderr = String::from_utf8_lossy(&output.stderr); + Err(format!("winget install failed: {}", stderr)) + } +} + +/// Install via download (opens URL for manual download) +async fn install_via_download(prereq_id: &str, method: &InstallationMethod) -> Result { + let url = method.url.as_ref() + .ok_or_else(|| "No URL specified for download installation".to_string())?; + + eprintln!("Opening download URL for {}: {}", prereq_id, url); + + // Special handling for Homebrew - download and open .pkg + if prereq_id == "homebrew" { + let pkg_url = "https://github.com/Homebrew/brew/releases/download/5.0.9/Homebrew-5.0.9.pkg"; + let tmp_dir = std::env::temp_dir(); + let pkg_path = tmp_dir.join("Homebrew-5.0.9.pkg"); + + // Download the pkg file + let response = reqwest::get(pkg_url) + .await + .map_err(|e| format!("Failed to download installer: {}", e))?; + + if !response.status().is_success() { + return Err(format!("Failed to download installer: HTTP {}", response.status())); + } + + let bytes = response.bytes() + .await + .map_err(|e| format!("Failed to read installer data: {}", e))?; + + std::fs::write(&pkg_path, bytes) + .map_err(|e| format!("Failed to save installer: {}", e))?; + + // Open the .pkg file + Command::new("open") + .arg(&pkg_path) + .output() + .map_err(|e| format!("Failed to open installer: {}", e))?; + + return Ok("Installer opened. Please follow the prompts to complete installation.".to_string()); + } + + // For other downloads, just open the URL in browser + open::that(url) + .map_err(|e| format!("Failed to open URL: {}", e))?; + + Ok(format!("Opening download page for {}. Please follow the installation instructions.", prereq_id)) +} + +/// Install via script (download and execute installation script) +async fn install_via_script(prereq_id: &str, method: &InstallationMethod) -> Result { + let url = method.url.as_ref() + .ok_or_else(|| "No URL specified for script installation".to_string())?; + + eprintln!("Installing {} via script: {}", prereq_id, url); + + // Download script + let response = reqwest::get(url) + .await + .map_err(|e| format!("Failed to download installation script: {}", e))?; + + if !response.status().is_success() { + return Err(format!("Failed to download script: HTTP {}", response.status())); + } + + let script_content = response.text() + .await + .map_err(|e| format!("Failed to read script: {}", e))?; + + // Platform-specific script handling + #[cfg(target_os = "windows")] + { + // Save as PowerShell script + let tmp_dir = std::env::temp_dir(); + let script_path = tmp_dir.join(format!("install_{}.ps1", prereq_id)); + std::fs::write(&script_path, script_content) + .map_err(|e| format!("Failed to save script: {}", e))?; + + // Execute with PowerShell (shell_command already wraps in powershell -NoProfile -Command) + let cmd = format!("& \"{}\"", script_path.display()); + let output = shell_command(&cmd) + .output() + .map_err(|e| format!("Failed to execute script: {}", e))?; + + if output.status.success() { + Ok(format!("{} installed successfully via script", prereq_id)) + } else { + let stderr = String::from_utf8_lossy(&output.stderr); + let stdout = String::from_utf8_lossy(&output.stdout); + Err(format!("Script installation failed:\nstderr: {}\nstdout: {}", stderr, stdout)) + } + } + + #[cfg(not(target_os = "windows"))] + { + // Save as shell script + let tmp_dir = std::env::temp_dir(); + let script_path = tmp_dir.join(format!("install_{}.sh", prereq_id)); + std::fs::write(&script_path, script_content) + .map_err(|e| format!("Failed to save script: {}", e))?; + + // Make executable + use std::os::unix::fs::PermissionsExt; + let metadata = std::fs::metadata(&script_path) + .map_err(|e| format!("Failed to get script metadata: {}", e))?; + let mut permissions = metadata.permissions(); + permissions.set_mode(0o755); + std::fs::set_permissions(&script_path, permissions) + .map_err(|e| format!("Failed to set script permissions: {}", e))?; + + // Execute script + let output = shell_command(&format!("bash {}", script_path.display())) + .output() + .map_err(|e| format!("Failed to execute script: {}", e))?; + + if output.status.success() { + Ok(format!("{} installed successfully via script", prereq_id)) + } else { + let stderr = String::from_utf8_lossy(&output.stderr); + Err(format!("Script installation failed: {}", stderr)) + } + } +} + +/// Install via package manager (apt, yum, dnf) +async fn install_via_package_manager(prereq_id: &str, method: &InstallationMethod) -> Result { + let packages = method.packages.as_ref() + .ok_or_else(|| "No packages specified for package manager installation".to_string())?; + + // Detect package manager + let (pkg_mgr, package) = if let Some(pkg) = packages.get("apt") { + ("apt", pkg) + } else if let Some(pkg) = packages.get("yum") { + ("yum", pkg) + } else if let Some(pkg) = packages.get("dnf") { + ("dnf", pkg) + } else { + return Err("No supported package manager found".to_string()); + }; + + eprintln!("Installing {} via {}: {}", prereq_id, pkg_mgr, package); + + let args = match pkg_mgr { + "apt" => vec!["install", "-y", package], + "yum" | "dnf" => vec!["install", "-y", package], + _ => return Err(format!("Unsupported package manager: {}", pkg_mgr)) + }; + + let output = Command::new("sudo") + .arg(pkg_mgr) + .args(&args) + .output() + .map_err(|e| format!("Failed to run {}: {}", pkg_mgr, e))?; + + if output.status.success() { + Ok(format!("{} installed successfully via {}", prereq_id, pkg_mgr)) + } else { + let stderr = String::from_utf8_lossy(&output.stderr); + Err(format!("{} install failed: {}", pkg_mgr, stderr)) + } +} + +/// Install via cargo +async fn install_via_cargo(prereq_id: &str, method: &InstallationMethod) -> Result { + let package = method.package.as_ref() + .ok_or_else(|| "No package specified for cargo installation".to_string())?; + + eprintln!("Installing {} via cargo: {}", prereq_id, package); + + let output = shell_command(&format!("cargo install {}", package)) + .output() + .map_err(|e| format!("Failed to run cargo: {}", e))?; + + if output.status.success() { + Ok(format!("{} installed successfully via cargo", prereq_id)) + } else { + let stderr = String::from_utf8_lossy(&output.stderr); + Err(format!("cargo install failed: {}", stderr)) + } +} + +/// Start Docker on macOS +async fn start_docker_macos() -> Result { + Command::new("open") + .args(["-a", "Docker"]) + .output() + .map_err(|e| format!("Failed to open Docker Desktop: {}", e))?; + + Ok("Docker Desktop starting...".to_string()) +} + +/// Start Docker on Windows +async fn start_docker_windows() -> Result { + use std::path::Path; + + let paths = vec![ + r"C:\Program Files\Docker\Docker\Docker Desktop.exe", + r"C:\Program Files\Docker\Docker Desktop.exe", + ]; + + for path in paths { + if Path::new(path).exists() { + Command::new(path) + .spawn() + .map_err(|e| format!("Failed to start Docker Desktop: {}", e))?; + + return Ok("Docker Desktop starting...".to_string()); + } + } + + Err("Docker Desktop.exe not found".to_string()) +} + +/// Start Docker on Linux +async fn start_docker_linux() -> Result { + // Try systemctl first + let systemctl_output = Command::new("systemctl") + .args(["start", "docker"]) + .output(); + + if let Ok(output) = systemctl_output { + if output.status.success() { + return Ok("Docker service started via systemctl".to_string()); + } + } + + // Fallback to service command + let service_output = Command::new("service") + .args(["docker", "start"]) + .output(); + + if let Ok(output) = service_output { + if output.status.success() { + return Ok("Docker service started via service command".to_string()); + } + } + + Err("Failed to start Docker service. Try: sudo systemctl start docker".to_string()) +} + +/// Get current platform string +fn get_current_platform() -> String { + #[cfg(target_os = "macos")] + return "macos".to_string(); + + #[cfg(target_os = "windows")] + return "windows".to_string(); + + #[cfg(target_os = "linux")] + return "linux".to_string(); + + #[cfg(not(any(target_os = "macos", target_os = "windows", target_os = "linux")))] + return "unknown".to_string(); +} diff --git a/ushadow/launcher/src-tauri/src/commands/installer.rs b/ushadow/launcher/src-tauri/src/commands/installer.rs index d84593e3..b086dc67 100644 --- a/ushadow/launcher/src-tauri/src/commands/installer.rs +++ b/ushadow/launcher/src-tauri/src/commands/installer.rs @@ -1,4 +1,6 @@ -use super::utils::{silent_command, shell_command}; +use super::utils::silent_command; +#[cfg(target_os = "macos")] +use super::utils::shell_command; use std::process::Command; use super::prerequisites::check_homebrew; @@ -256,7 +258,7 @@ pub async fn start_docker_desktop_windows() -> Result { for path in paths { if Path::new(path).exists() { - let output = Command::new(path) + Command::new(path) .spawn() .map_err(|e| format!("Failed to start Docker Desktop: {}", e))?; @@ -481,7 +483,7 @@ pub fn check_project_dir(path: String) -> Result { /// Clone the Ushadow repository #[tauri::command] -pub async fn clone_ushadow_repo(target_dir: String) -> Result { +pub async fn clone_ushadow_repo(target_dir: String, branch: Option) -> Result { use super::permissions::check_path_permissions; use super::utils::expand_tilde; @@ -515,9 +517,16 @@ pub async fn clone_ushadow_repo(target_dir: String) -> Result { } } + // Build clone command with optional branch + let mut args = vec!["clone", "--depth", "1"]; + if let Some(ref b) = branch { + args.extend(&["--branch", b.as_str()]); + } + args.extend(&[USHADOW_REPO_URL, &target_dir]); + // Clone the repository let output = silent_command("git") - .args(["clone", "--depth", "1", USHADOW_REPO_URL, &target_dir]) + .args(&args) .output() .map_err(|e| format!("Failed to run git clone: {}", e))?; @@ -539,7 +548,8 @@ pub async fn clone_ushadow_repo(target_dir: String) -> Result { return Err(format!("Clone completed but go.sh not found - may not be a valid Ushadow repo")); } - Ok(format!("Successfully cloned Ushadow to {}", target_dir)) + let branch_msg = branch.map(|b| format!(" (branch: {})", b)).unwrap_or_default(); + Ok(format!("Successfully cloned Ushadow to {}{}", target_dir, branch_msg)) } /// Update an existing Ushadow repository safely (stash, pull, stash pop) @@ -654,6 +664,127 @@ pub async fn install_git_macos() -> Result { Err("macOS Git installation is only available on macOS".to_string()) } +/// Get current branch of a git repository +#[tauri::command] +pub fn get_current_branch(path: String) -> Result { + let output = silent_command("git") + .args(["-C", &path, "rev-parse", "--abbrev-ref", "HEAD"]) + .output() + .map_err(|e| format!("Failed to run git: {}", e))?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + return Err(format!("Failed to get current branch: {}", stderr)); + } + + let branch = String::from_utf8_lossy(&output.stdout).trim().to_string(); + Ok(branch) +} + +/// Checkout a branch in a git repository +#[tauri::command] +pub fn checkout_branch(path: String, branch: String) -> Result { + let output = silent_command("git") + .args(["-C", &path, "checkout", &branch]) + .output() + .map_err(|e| format!("Failed to run git: {}", e))?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + return Err(format!("Failed to checkout branch: {}", stderr)); + } + + Ok(format!("Checked out branch: {}", branch)) +} + +/// Determine which base branch (main or dev) a worktree branch was created from +/// Uses git merge-base to check ancestry +#[tauri::command] +pub fn get_base_branch(repo_path: String, branch: String) -> Result, String> { + // First check if main and dev branches exist + let branches_output = silent_command("git") + .args(["-C", &repo_path, "branch", "-a"]) + .output() + .map_err(|e| format!("Failed to list branches: {}", e))?; + + if !branches_output.status.success() { + return Ok(None); + } + + let branches = String::from_utf8_lossy(&branches_output.stdout); + let has_main = branches.lines().any(|l| l.contains("main") || l.contains("master")); + let has_dev = branches.lines().any(|l| l.contains("dev") && !l.contains("develop")); + + if !has_main && !has_dev { + return Ok(None); + } + + // Check if current branch is exactly main or dev + if branch == "main" || branch == "master" { + return Ok(Some("main".to_string())); + } + if branch == "dev" { + return Ok(Some("dev".to_string())); + } + + // Check ancestry using merge-base + // Try dev first + if has_dev { + let dev_check = silent_command("git") + .args(["-C", &repo_path, "merge-base", "--is-ancestor", "dev", &branch]) + .output(); + + if let Ok(output) = dev_check { + if output.status.success() { + // Branch has dev as ancestor, now check if it's more recent than main + if has_main { + let main_base = silent_command("git") + .args(["-C", &repo_path, "rev-parse", "main"]) + .output(); + let dev_base = silent_command("git") + .args(["-C", &repo_path, "rev-parse", "dev"]) + .output(); + + if let (Ok(main_out), Ok(dev_out)) = (main_base, dev_base) { + if main_out.status.success() && dev_out.status.success() { + let main_sha = String::from_utf8_lossy(&main_out.stdout).trim().to_string(); + let dev_sha = String::from_utf8_lossy(&dev_out.stdout).trim().to_string(); + + // Check if dev is ahead of main (i.e., dev is based on main + extra commits) + let is_dev_ahead = silent_command("git") + .args(["-C", &repo_path, "merge-base", "--is-ancestor", &main_sha, &dev_sha]) + .output(); + + if let Ok(ahead_check) = is_dev_ahead { + if ahead_check.status.success() { + // Dev is ahead of main, so this branch is from dev + return Ok(Some("dev".to_string())); + } + } + } + } + } + return Ok(Some("dev".to_string())); + } + } + } + + // Check if main is ancestor + if has_main { + let main_check = silent_command("git") + .args(["-C", &repo_path, "merge-base", "--is-ancestor", "main", &branch]) + .output(); + + if let Ok(output) = main_check { + if output.status.success() { + return Ok(Some("main".to_string())); + } + } + } + + Ok(None) +} + #[cfg(test)] mod tests { use super::*; diff --git a/ushadow/launcher/src-tauri/src/commands/mod.rs b/ushadow/launcher/src-tauri/src/commands/mod.rs index 781a4974..73d54f2a 100644 --- a/ushadow/launcher/src-tauri/src/commands/mod.rs +++ b/ushadow/launcher/src-tauri/src/commands/mod.rs @@ -1,7 +1,9 @@ mod docker; mod discovery; mod prerequisites; +mod prerequisites_config; mod installer; +mod generic_installer; mod utils; mod permissions; mod settings; @@ -12,7 +14,9 @@ pub mod worktree; pub use docker::*; pub use discovery::*; pub use prerequisites::*; +pub use prerequisites_config::*; pub use installer::*; +pub use generic_installer::*; pub use permissions::*; pub use settings::*; pub use worktree::*; diff --git a/ushadow/launcher/src-tauri/src/commands/prerequisites.rs b/ushadow/launcher/src-tauri/src/commands/prerequisites.rs index 96be7ecd..aeb26bc3 100644 --- a/ushadow/launcher/src-tauri/src/commands/prerequisites.rs +++ b/ushadow/launcher/src-tauri/src/commands/prerequisites.rs @@ -1,5 +1,11 @@ use crate::models::PrerequisiteStatus; use super::utils::{silent_command, shell_command}; +use std::env; + +/// Check if we're in mock mode (for testing) +fn is_mock_mode() -> bool { + env::var("MOCK_PREREQUISITES").is_ok() +} /// Check if Docker is installed and running /// Tries login shell first, then falls back to known paths diff --git a/ushadow/launcher/src-tauri/src/commands/prerequisites_config.rs b/ushadow/launcher/src-tauri/src/commands/prerequisites_config.rs new file mode 100644 index 00000000..52a6ecb3 --- /dev/null +++ b/ushadow/launcher/src-tauri/src/commands/prerequisites_config.rs @@ -0,0 +1,136 @@ +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; +use std::path::PathBuf; + +/// A single prerequisite definition +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct PrerequisiteDefinition { + pub id: String, + pub name: String, + pub display_name: String, + pub description: String, + pub platforms: Vec, + pub check_command: Option, + pub check_commands: Option>, + pub check_running_command: Option, + pub check_connected_command: Option, + pub fallback_paths: Option>, + pub version_filter: Option, + pub optional: bool, + pub has_service: Option, + pub category: String, + #[serde(skip)] + pub platform_specific_paths: Option>>, + pub connection_validation: Option, +} + +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct ConnectionValidation { + pub starts_with: Option, +} + +/// Installation method definition +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct InstallationMethod { + pub method: String, + pub package: Option, + pub url: Option, + pub packages: Option>, +} + +/// Root configuration structure +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct PrerequisitesConfig { + pub prerequisites: Vec, + pub installation_methods: Option>>, +} + +impl PrerequisitesConfig { + /// Load prerequisites configuration from YAML file + pub fn load() -> Result { + // Get the path to the prerequisites.yaml file + // In development: src-tauri/prerequisites.yaml + // In production: resources/prerequisites.yaml (bundled with app) + let config_path = Self::get_config_path()?; + + let yaml_content = std::fs::read_to_string(&config_path) + .map_err(|e| format!("Failed to read prerequisites config at {:?}: {}", config_path, e))?; + + let mut config: PrerequisitesConfig = serde_yaml::from_str(&yaml_content) + .map_err(|e| format!("Failed to parse prerequisites config: {}", e))?; + + // Post-process: extract platform-specific paths + for prereq in &mut config.prerequisites { + if let Some(_fallback_paths) = &prereq.fallback_paths { + // Check if this is a map-like structure in the YAML + // For now, keep it simple and use the Vec structure + // In a future enhancement, we could parse platform-specific paths differently + } + } + + Ok(config) + } + + /// Get the path to the prerequisites config file + fn get_config_path() -> Result { + // Try development path first (during development/testing) + let dev_path = PathBuf::from("prerequisites.yaml"); + if dev_path.exists() { + return Ok(dev_path); + } + + // Try relative to src-tauri directory + let src_tauri_path = PathBuf::from("src-tauri/prerequisites.yaml"); + if src_tauri_path.exists() { + return Ok(src_tauri_path); + } + + // Try parent directory (when running from src-tauri/target/debug) + let parent_path = PathBuf::from("../prerequisites.yaml"); + if parent_path.exists() { + return Ok(parent_path); + } + + // Try two levels up + let parent2_path = PathBuf::from("../../prerequisites.yaml"); + if parent2_path.exists() { + return Ok(parent2_path); + } + + // In production, try the resources directory + // Note: This will be available when the app is built and packaged + // For now, we rely on the development paths above + + Err(format!( + "Could not find prerequisites.yaml. Tried:\n - {:?}\n - {:?}\n - {:?}\n - {:?}", + dev_path, src_tauri_path, parent_path, parent2_path + )) + } + + /// Get a prerequisite definition by ID + pub fn get_prerequisite(&self, id: &str) -> Option<&PrerequisiteDefinition> { + self.prerequisites.iter().find(|p| p.id == id) + } + + /// Get all prerequisites for the current platform + pub fn get_platform_prerequisites(&self, platform: &str) -> Vec<&PrerequisiteDefinition> { + self.prerequisites + .iter() + .filter(|p| p.platforms.contains(&platform.to_string())) + .collect() + } +} + +/// Tauri command to get prerequisites configuration +#[tauri::command] +pub fn get_prerequisites_config() -> Result { + PrerequisitesConfig::load() +} + +/// Tauri command to get prerequisites for current platform +#[tauri::command] +pub fn get_platform_prerequisites_config(platform: String) -> Result, String> { + let config = PrerequisitesConfig::load()?; + let prereqs = config.get_platform_prerequisites(&platform); + Ok(prereqs.into_iter().cloned().collect()) +} diff --git a/ushadow/launcher/src-tauri/src/commands/worktree.rs b/ushadow/launcher/src-tauri/src/commands/worktree.rs index 87c7e4f8..fc0a30f5 100644 --- a/ushadow/launcher/src-tauri/src/commands/worktree.rs +++ b/ushadow/launcher/src-tauri/src/commands/worktree.rs @@ -728,7 +728,7 @@ pub async fn create_worktree_with_workmux( main_repo: String, name: String, base_branch: Option, - background: Option, + _background: Option, ) -> Result { // Force lowercase to avoid Docker Compose naming issues let name = name.to_lowercase(); @@ -1317,7 +1317,7 @@ end tell"#, { // For Linux/Windows - create dedicated tmux session per environment // Try common terminal emulators in order of preference - let env_name = window_name.strip_prefix("ushadow-").unwrap_or(&window_name); + let _env_name = window_name.strip_prefix("ushadow-").unwrap_or(&window_name); // Create the tmux session if it doesn't exist let _ = shell_command(&format!( @@ -1325,12 +1325,16 @@ end tell"#, window_name, window_name, worktree_path )).output(); + // Create tmux commands before the array to extend their lifetime + let xfce_cmd = format!("tmux attach-session -t {}", window_name); + let xterm_cmd = format!("tmux attach-session -t {}", window_name); + // Try different terminal emulators let terminals = vec![ ("gnome-terminal", vec!["--", "tmux", "attach-session", "-t", &window_name]), ("konsole", vec!["-e", "tmux", "attach-session", "-t", &window_name]), - ("xfce4-terminal", vec!["-e", &format!("tmux attach-session -t {}", window_name)]), - ("xterm", vec!["-e", &format!("tmux attach-session -t {}", window_name)]), + ("xfce4-terminal", vec!["-e", xfce_cmd.as_str()]), + ("xterm", vec!["-e", xterm_cmd.as_str()]), ]; for (terminal, args) in terminals { diff --git a/ushadow/launcher/src-tauri/src/main.rs b/ushadow/launcher/src-tauri/src/main.rs index a01bcff9..85e9cebb 100644 --- a/ushadow/launcher/src-tauri/src/main.rs +++ b/ushadow/launcher/src-tauri/src/main.rs @@ -29,6 +29,10 @@ use commands::{AppState, check_prerequisites, discover_environments, get_os_type open_tmux_in_terminal, capture_tmux_pane, get_claude_status, // Settings load_launcher_settings, save_launcher_settings, write_credentials_to_worktree, + // Prerequisites config + get_prerequisites_config, get_platform_prerequisites_config, + // Generic installer + install_prerequisite, start_prerequisite, // Permissions check_install_path}; use tauri::{ @@ -174,6 +178,12 @@ fn main() { load_launcher_settings, save_launcher_settings, write_credentials_to_worktree, + // Prerequisites config + get_prerequisites_config, + get_platform_prerequisites_config, + // Generic installer + install_prerequisite, + start_prerequisite, ]) .setup(|app| { let window = app.get_window("main").unwrap(); diff --git a/ushadow/launcher/src-tauri/src/models.rs b/ushadow/launcher/src-tauri/src/models.rs index f9a9b3c0..1a4f89b8 100644 --- a/ushadow/launcher/src-tauri/src/models.rs +++ b/ushadow/launcher/src-tauri/src/models.rs @@ -81,6 +81,7 @@ pub struct UshadowEnvironment { pub containers: Vec, pub is_worktree: bool, // True if this environment is a git worktree pub created_at: Option, // Unix timestamp (seconds since epoch) + pub base_branch: Option, // "main" or "dev" - which base branch this worktree was created from } /// Infrastructure service status diff --git a/ushadow/launcher/src-tauri/tauri.conf.json b/ushadow/launcher/src-tauri/tauri.conf.json index ae9c58c0..d33a1949 100644 --- a/ushadow/launcher/src-tauri/tauri.conf.json +++ b/ushadow/launcher/src-tauri/tauri.conf.json @@ -8,7 +8,7 @@ }, "package": { "productName": "Ushadow", - "version": "0.5.1" + "version": "0.6.3" }, "tauri": { "allowlist": { @@ -91,7 +91,7 @@ } }, "security": { - "csp": "default-src 'self'; script-src 'self' 'unsafe-inline'; connect-src 'self' http://localhost:* ws://localhost:*; img-src 'self' data: http://localhost:*; style-src 'self' 'unsafe-inline'" + "csp": "default-src 'self'; script-src 'self' 'unsafe-inline'; connect-src 'self' http://localhost:* https://localhost:* ws://localhost:* wss://localhost:*; img-src 'self' data: http://localhost:* https://localhost:*; style-src 'self' 'unsafe-inline'; frame-src http://localhost:* https://localhost:*" }, "systemTray": { "iconPath": "icons/icon.png", diff --git a/ushadow/launcher/src/App.tsx b/ushadow/launcher/src/App.tsx index f78885db..0f1dd72e 100644 --- a/ushadow/launcher/src/App.tsx +++ b/ushadow/launcher/src/App.tsx @@ -1,6 +1,6 @@ import { useState, useEffect, useCallback, useRef } from 'react' import { tauri, type Prerequisites, type Discovery, type UshadowEnvironment } from './hooks/useTauri' -import { useAppStore } from './store/appStore' +import { useAppStore, type BranchType } from './store/appStore' import { useWindowFocus } from './hooks/useWindowFocus' import { useTmuxMonitoring } from './hooks/useTmuxMonitoring' import { writeText, readText } from '@tauri-apps/api/clipboard' @@ -15,7 +15,8 @@ import { NewEnvironmentDialog } from './components/NewEnvironmentDialog' import { TmuxManagerDialog } from './components/TmuxManagerDialog' import { SettingsDialog } from './components/SettingsDialog' import { EmbeddedView } from './components/EmbeddedView' -import { RefreshCw, Settings, Zap, Loader2, FolderOpen, Pencil, Terminal, Sliders } from 'lucide-react' +import { BranchToggle } from './components/BranchToggle' +import { RefreshCw, Settings, Zap, Loader2, FolderOpen, Pencil, Terminal, Sliders, Package, FolderGit2 } from 'lucide-react' import { getColors } from './utils/colors' function App() { @@ -24,6 +25,8 @@ function App() { dryRunMode, showDevTools, setShowDevTools, + logExpanded, + setLogExpanded, appMode, setAppMode, spoofedPrereqs, @@ -32,6 +35,16 @@ function App() { setProjectRoot, worktreesDir, setWorktreesDir, + activeBranch, + setActiveBranch, + mainBranchPath, + setMainBranchPath, + devBranchPath, + setDevBranchPath, + mainWorktreesPath, + setMainWorktreesPath, + devWorktreesPath, + setDevWorktreesPath, } = useAppStore() // State @@ -45,10 +58,10 @@ function App() { const [loadingInfra, setLoadingInfra] = useState(false) const [loadingEnv, setLoadingEnv] = useState(null) const [showProjectDialog, setShowProjectDialog] = useState(false) + const [dialogBranchContext, setDialogBranchContext] = useState(undefined) const [showNewEnvDialog, setShowNewEnvDialog] = useState(false) const [showTmuxManager, setShowTmuxManager] = useState(false) const [showSettingsDialog, setShowSettingsDialog] = useState(false) - const [logExpanded, setLogExpanded] = useState(true) const [embeddedView, setEmbeddedView] = useState<{ url: string; envName: string; envColor: string; envPath: string | null } | null>(null) const [creatingEnvs, setCreatingEnvs] = useState<{ name: string; status: 'cloning' | 'starting' | 'error'; path?: string; error?: string }[]>([]) const [shouldAutoLaunch, setShouldAutoLaunch] = useState(false) @@ -288,18 +301,30 @@ function App() { const defaultDir = await tauri.getDefaultProjectDir() + // Migration: if old projectRoot exists but new fields are empty, migrate to branch-aware storage + if (projectRoot && !mainBranchPath) { + log('Migrating to branch-aware storage...', 'info') + setMainBranchPath(projectRoot) + setMainWorktreesPath(worktreesDir || `${projectRoot}/../worktrees/ushadow`) + setActiveBranch('main') + log('Migration complete', 'success') + } + // Track if this is first time setup (showing project dialog) let isFirstTimeSetup = false // Show project setup dialog on first launch if no project root is configured - if (!projectRoot) { + if (!projectRoot && !mainBranchPath) { setProjectRoot(defaultDir) setShowProjectDialog(true) isFirstTimeSetup = true log('Please configure your repository location', 'step') } else { // Sync existing project root to Rust backend - await tauri.setProjectRoot(projectRoot) + const currentRoot = mainBranchPath || projectRoot + if (currentRoot) { + await tauri.setProjectRoot(currentRoot) + } } // Check prerequisites immediately (system-wide, no project needed) @@ -712,8 +737,9 @@ function App() { } } - const handleOpenInApp = (env: { name: string; color?: string; localhost_url: string | null; webui_port: number | null; backend_port: number | null; path: string | null }) => { - const url = env.localhost_url || `http://localhost:${env.webui_port || env.backend_port}` + const handleOpenInApp = (env: { name: string; color?: string; localhost_url: string | null; tailscale_url?: string | null; webui_port: number | null; backend_port: number | null; path: string | null }) => { + // Prefer Tailscale URL if available, otherwise use localhost + const url = env.tailscale_url || env.localhost_url || `http://localhost:${env.webui_port || env.backend_port}` const colors = getColors(env.color || env.name) log(`Opening ${env.name} in embedded view...`, 'info') setEmbeddedView({ url, envName: env.name, envColor: colors.primary, envPath: env.path }) @@ -992,42 +1018,61 @@ function App() { setShowProjectDialog(false) try { - // Save the project root and worktrees directory - await tauri.setProjectRoot(path) - setProjectRoot(path) - setWorktreesDir(worktreesPath) - log(`Installation path set to ${path}`, 'success') + // Save to branch-specific paths + const targetBranch = dialogBranchContext || activeBranch + if (targetBranch === 'dev') { + setDevBranchPath(path) + setDevWorktreesPath(worktreesPath) + log(`Dev branch path set to ${path}`, 'success') + } else { + setMainBranchPath(path) + setMainWorktreesPath(worktreesPath) + log(`Main branch path set to ${path}`, 'success') + } log(`Worktrees directory set to ${worktreesPath}`, 'info') - // Check if repo already exists - const status = await tauri.checkProjectDir(path) + // If this matches the active branch, update current paths too + if (targetBranch === activeBranch) { + await tauri.setProjectRoot(path) + setProjectRoot(path) + setWorktreesDir(worktreesPath) - if (status.exists && status.is_valid_repo) { - // Existing repo found - run discovery - log('Found existing Ushadow repository', 'info') - const disc = await refreshDiscovery() + // Check if repo already exists + const status = await tauri.checkProjectDir(path) - // Auto-switch to quick mode if no environments exist - if (disc && disc.environments.length === 0) { + if (status.exists && status.is_valid_repo) { + // Existing repo found - run discovery + log('Found existing Ushadow repository', 'info') + const disc = await refreshDiscovery() + + // Auto-switch to quick mode if no environments exist + if (disc && disc.environments.length === 0) { + setAppMode('quick') + log('Ready for quick launch', 'step') + } + } else { + // Repo doesn't exist - will be cloned when user presses Launch setAppMode('quick') - log('Ready for quick launch', 'step') + log('Press Launch to install Ushadow', 'step') } } else { - // Repo doesn't exist - will be cloned when user presses Launch - setAppMode('quick') - log('Press Launch to install Ushadow', 'step') + // Different branch than active - just saved the path + log(`${targetBranch} branch configured (currently on ${activeBranch})`, 'info') } + + // Reset dialog context + setDialogBranchContext(undefined) } catch (err) { log(`Failed to set installation path`, 'error', false) log(String(err), 'error', true) } } - const handleClone = async (path: string) => { + const handleClone = async (path: string, branch?: string) => { try { - console.log('DEBUG handleClone: Starting clone for path:', path) + console.log('DEBUG handleClone: Starting clone for path:', path, 'branch:', branch) console.log('DEBUG handleClone: dryRunMode =', dryRunMode) - + // Check if repo already exists at this location const status = await tauri.checkProjectDir(path) console.log('DEBUG handleClone: checkProjectDir status =', status) @@ -1044,14 +1089,15 @@ function App() { } } else { // No repo - clone fresh - log(`Cloning Ushadow to ${path}...`, 'step') + const branchMsg = branch ? ` (branch: ${branch})` : '' + log(`Cloning Ushadow to ${path}${branchMsg}...`, 'step') if (dryRunMode) { - log('[DRY RUN] Would clone repository', 'warning') + log(`[DRY RUN] Would clone repository${branchMsg}`, 'warning') await new Promise(r => setTimeout(r, 2000)) log('[DRY RUN] Clone simulated', 'success') } else { - console.log('DEBUG handleClone: Calling tauri.cloneUshadowRepo with path:', path) - const result = await tauri.cloneUshadowRepo(path) + console.log('DEBUG handleClone: Calling tauri.cloneUshadowRepo with path:', path, 'branch:', branch) + const result = await tauri.cloneUshadowRepo(path, branch) console.log('DEBUG handleClone: Clone result from Rust:', result) log(result, 'success') } @@ -1085,6 +1131,63 @@ function App() { } } + const handleSwitchBranch = async (newBranch: BranchType) => { + if (activeBranch === newBranch) return + + // Note: Running environments will continue running in the background. + // They're Docker containers that are independent of branch switching. + log(`Switching to ${newBranch} branch...`, 'step') + + // Get target path + const targetPath = newBranch === 'main' ? mainBranchPath : devBranchPath + const targetWorktreesPath = newBranch === 'main' ? mainWorktreesPath : devWorktreesPath + + // If not configured, show setup dialog + if (!targetPath) { + setDialogBranchContext(newBranch) + setShowProjectDialog(true) + log(`Please configure ${newBranch} branch location`, 'step') + return + } + + try { + // Check if repo exists at path + const status = await tauri.checkProjectDir(targetPath) + + if (!status.is_valid_repo) { + if (status.exists) { + log(`Directory exists at ${targetPath} but is not a valid repo`, 'warning') + } + log(`Cloning ${newBranch} branch...`, 'step') + await handleClone(targetPath, newBranch) + } else { + // Verify and checkout correct branch + try { + const currentBranch = await tauri.getCurrentBranch(targetPath) + if (currentBranch !== newBranch) { + log(`Checking out ${newBranch} branch...`, 'step') + await tauri.checkoutBranch(targetPath, newBranch) + } + await tauri.updateUshadowRepo(targetPath) + } catch (err) { + log(`Branch verification failed: ${err}`, 'warning') + } + } + + // Switch active branch + setActiveBranch(newBranch) + await tauri.setProjectRoot(targetPath) + setProjectRoot(targetPath) + setWorktreesDir(targetWorktreesPath) + + // Refresh discovery + log(`Switched to ${newBranch} branch`, 'success') + await refreshDiscovery() + } catch (err) { + log(`Failed to switch branch: ${err}`, 'error') + } + } + // Quick launch (for quick mode) const handleQuickLaunch = async () => { console.log('DEBUG: handleQuickLaunch started') @@ -1292,25 +1395,37 @@ function App() {
- {/* Mode Toggle */} + {/* Page Navigation */}
+
@@ -1349,8 +1464,8 @@ function App() { {/* Main Content */}
- {appMode === 'quick' ? ( - /* Quick Mode - Single button */ + {appMode === 'launch' ? ( + /* Launch Page - One-Click Launch */

One-Click Launch

@@ -1359,6 +1474,13 @@ function App() {

+ {/* Branch Selector */} + + {/* Project Folder Display */}
@@ -1379,8 +1501,8 @@ function App() { onClick={handleQuickLaunch} disabled={isLaunching} className={`px-12 py-4 rounded-xl transition-all font-semibold text-lg flex items-center justify-center gap-3 ${ - isLaunching - ? 'bg-surface-600 cursor-not-allowed' + isLaunching + ? 'bg-surface-600 cursor-not-allowed' : 'bg-gradient-brand hover:opacity-90 hover:shadow-lg hover:shadow-primary-500/20 active:scale-95' }`} data-testid="quick-launch-button" @@ -1398,20 +1520,25 @@ function App() { )}
- ) : ( - /* Dev Mode - Two column layout */ -
-
- {/* Left Column - Folders, Prerequisites & Infrastructure */} -
- setShowProjectDialog(true)} - /> + ) : appMode === 'install' ? ( + /* Install Page - Prerequisites & Infrastructure Setup */ +
+
+

Setup & Installation

+

+ Install prerequisites and configure your single environment +

+
+ + setShowProjectDialog(true)} + /> + + {/* Prerequisites and Infrastructure Side-by-Side */} +
+
{/* Dev Tools Panel - appears below Prerequisites */} {showDevTools && } -
- {/* Resize handle */} -
+ +
- {/* Right Column - Environments */} -
- setShowNewEnvDialog(true)} - onOpenInApp={handleOpenInApp} - onMerge={handleMerge} - onDelete={handleDelete} - onAttachTmux={handleAttachTmux} - onDismissError={(name) => setCreatingEnvs(prev => prev.filter(e => e.name !== name))} - loadingEnv={loadingEnv} - tmuxStatuses={tmuxStatuses} - /> -
+ {/* Single Environment Section for Consumers */} +
+

Your Environment

+ {discovery?.environments.length === 0 ? ( +
+

No environment created yet

+ +
+ ) : ( +
+ {discovery.environments.map(env => ( +
+
+
+ {env.name} + + {env.status} + +
+
+ {env.running ? ( + <> + + + + ) : ( + + )} +
+
+ ))} +
+ )}
+ ) : ( + /* Environments Page - Worktree Management */ +
+ setShowNewEnvDialog(true)} + onOpenInApp={handleOpenInApp} + onMerge={handleMerge} + onDelete={handleDelete} + onAttachTmux={handleAttachTmux} + onDismissError={(name) => setCreatingEnvs(prev => prev.filter(e => e.name !== name))} + loadingEnv={loadingEnv} + tmuxStatuses={tmuxStatuses} + /> +
)}
@@ -1477,8 +1658,17 @@ function App() { isOpen={showProjectDialog} defaultPath={projectRoot} defaultWorktreesPath={worktreesDir} - onClose={() => setShowProjectDialog(false)} + onClose={() => { + setShowProjectDialog(false) + setDialogBranchContext(undefined) + }} onSetup={handleProjectSetup} + branchContext={dialogBranchContext} + suggestedParentPath={ + dialogBranchContext === 'dev' && mainBranchPath + ? mainBranchPath.split('/').slice(0, -1).join('/') + : undefined + } /> {/* New Environment Dialog */} diff --git a/ushadow/launcher/src/components/BranchToggle.tsx b/ushadow/launcher/src/components/BranchToggle.tsx new file mode 100644 index 00000000..f0209041 --- /dev/null +++ b/ushadow/launcher/src/components/BranchToggle.tsx @@ -0,0 +1,42 @@ +import { GitBranch } from 'lucide-react' +import type { BranchType } from '../store/appStore' + +interface BranchToggleProps { + activeBranch: BranchType + onSwitch: (branch: BranchType) => void + disabled?: boolean +} + +export function BranchToggle({ activeBranch, onSwitch, disabled }: BranchToggleProps) { + return ( +
+ +
+ + +
+
+ ) +} diff --git a/ushadow/launcher/src/components/EmbeddedView.tsx b/ushadow/launcher/src/components/EmbeddedView.tsx index a28c8036..6ee9e686 100644 --- a/ushadow/launcher/src/components/EmbeddedView.tsx +++ b/ushadow/launcher/src/components/EmbeddedView.tsx @@ -10,8 +10,16 @@ interface EmbeddedViewProps { } export function EmbeddedView({ url, envName, envColor, envPath, onClose }: EmbeddedViewProps) { + // Add launcher query param so frontend knows to hide footer + const displayUrl = url + const iframeUrl = url + ? url.includes('?') + ? `${url}&launcher=true` + : `${url}?launcher=true` + : '' + const handleOpenExternal = () => { - tauri.openBrowser(url) + tauri.openBrowser(displayUrl) } const handleRefresh = () => { @@ -51,7 +59,14 @@ export function EmbeddedView({ url, envName, envColor, envPath, onClose }: Embed {envName} - {url} +
@@ -109,7 +124,7 @@ export function EmbeddedView({ url, envName, envColor, envPath, onClose }: Embed