#!/usr/bin/env -S uv run --script #MISE silent=true #MISE description="Start the development environment for a project" # /// script # dependencies = ["rich"] # /// import argparse import signal import subprocess import sys import threading import time import json from pathlib import Path from typing import Optional, List, Dict from rich.console import Console from rich.panel import Panel from rich.table import Table from rich.live import Live from rich.layout import Layout from rich.status import Status from rich.prompt import Prompt console = Console() # Configuration DEFAULT_FRONTEND_CMD = ["pnpm", "run", "dev"] DEFAULT_BACKEND_CMD = ["uv", "run", "uvicorn", "api.server:app", "--reload"] DEFAULT_FRONTEND_PORT = 3000 DEFAULT_BACKEND_PORT = 8000 class ProcessManager: def __init__(self): self.processes: Dict[str, subprocess.Popen] = {} self.running = False self.logs: Dict[str, List[str]] = {"frontend": [], "backend": []} def start_process(self, name: str, cmd: List[str], cwd: Optional[str] = None) -> bool: """Start a process and track it.""" try: console.print(f"[dim]Starting {name} in {cwd or 'current directory'}: {' '.join(cmd)}[/dim]") process = subprocess.Popen( cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, bufsize=1, universal_newlines=True, cwd=cwd ) self.processes[name] = process # Start thread to capture output thread = threading.Thread( target=self._capture_output, args=(name, process), daemon=True ) thread.start() return True except Exception as e: console.print(f"[red]Failed to start {name}: {e}[/red]") return False def _capture_output(self, name: str, process: subprocess.Popen): """Capture process output in a separate thread.""" while True: stdout = process.stdout assert stdout is not None output = stdout.readline() if output == '' and process.poll() is not None: break if output: # Keep only last 50 lines self.logs[name].append(output.strip()) if len(self.logs[name]) > 50: self.logs[name].pop(0) def stop_all(self): """Stop all processes.""" self.running = False for name, process in self.processes.items(): try: process.terminate() process.wait(timeout=5) except subprocess.TimeoutExpired: process.kill() except Exception: pass def is_running(self) -> bool: """Check if any process is still running.""" return any(p.poll() is None for p in self.processes.values()) def get_status(self) -> Dict[str, str]: """Get status of all processes.""" status = {} for name, process in self.processes.items(): if process.poll() is None: status[name] = "🟢 Running" else: status[name] = f"🔴 Stopped (exit code: {process.poll()})" return status def find_project_root() -> Optional[Path]: """Find the project root directory by looking for package.json.""" current_dir = Path(__file__).resolve().parent # Start from the script directory and go up while current_dir != current_dir.parent: if (current_dir / "package.json").exists(): return current_dir current_dir = current_dir.parent return None def get_available_apps(project_root: Path) -> List[str]: """Get list of available apps in the monorepo.""" apps_dir = project_root / "apps" if not apps_dir.exists(): return [] apps = [] for app_dir in apps_dir.iterdir(): if app_dir.is_dir() and (app_dir / "package.json").exists(): apps.append(app_dir.name) return sorted(apps) def get_package_name(app_path: Path) -> str: """Get the package name from package.json.""" try: with open(app_path / "package.json", 'r') as f: package_data = json.load(f) return package_data.get("name", "") except (FileNotFoundError, json.JSONDecodeError): return "" def check_dependencies(project_root: Path, app_name: str | None = None) -> Dict[str, bool]: """Check if required dependencies are available.""" deps = {} # Check pnpm global availability try: subprocess.run(["pnpm", "--version"], capture_output=True, check=True) deps["pnpm"] = True except (subprocess.CalledProcessError, FileNotFoundError): deps["pnpm"] = False # Check uv global availability try: subprocess.run(["uv", "--version"], capture_output=True, check=True) deps["uv"] = True except (subprocess.CalledProcessError, FileNotFoundError): deps["uv"] = False # Check project structure deps["package.json"] = (project_root / "package.json").exists() deps["apps directory"] = (project_root / "apps").exists() # If app is specified, check app-specific dependencies if app_name: app_path = project_root / "apps" / app_name # Check pnpm workspace install for specific app if deps["pnpm"] and (app_path / "package.json").exists(): try: # Use pnpm list to check if the workspace package is valid subprocess.run( ["pnpm", "list", "--filter", app_name, "--depth", "0"], capture_output=True, check=True, cwd=str(project_root), timeout=15 ) deps[f"pnpm install ({app_name})"] = True except (subprocess.CalledProcessError, subprocess.TimeoutExpired): deps[f"pnpm install ({app_name})"] = False else: deps[f"pnpm install ({app_name})"] = False # Check uv sync for specific app if deps["uv"] and (app_path / "api").exists(): try: # Check if we can sync the package (dry-run) subprocess.run( ["uv", "sync", "--package", app_name, "--dry-run"], capture_output=True, check=True, cwd=str(project_root), timeout=15 ) deps[f"uv sync ({app_name})"] = True except (subprocess.CalledProcessError, subprocess.TimeoutExpired): deps[f"uv sync ({app_name})"] = False else: deps[f"uv sync ({app_name})"] = False return deps def display_dependency_status(deps: Dict[str, bool]) -> bool: """Display dependency status and return if all are satisfied.""" table = Table(title="🔍 Dependency Check") table.add_column("Dependency", style="cyan") table.add_column("Status", style="bold") table.add_column("Description", style="dim") descriptions = { "pnpm": "Node.js package manager", "uv": "Python package manager", "package.json": "Root project configuration", "apps directory": "Apps directory for monorepo" } all_good = True for dep, available in deps.items(): if available: status = "[green]✅ Available[/green]" else: status = "[red]❌ Missing[/red]" all_good = False # Generate description for app-specific dependencies description = descriptions.get(dep, "") if "pnpm install" in dep: description = "Frontend dependencies installation" elif "uv sync" in dep: description = "Backend dependencies synchronization" table.add_row(dep, status, description) console.print(table) return all_good def display_available_apps(apps: List[str], project_root: Path, show_numbers: bool = False): """Display available apps.""" if not apps: console.print("[yellow]No apps found in the monorepo.[/yellow]") return table = Table(title="📱 Available Apps") if show_numbers: table.add_column("No.", style="bold yellow", width=4) table.add_column("App Name", style="cyan") table.add_column("Frontend", style="green") table.add_column("Backend", style="blue") for i, app in enumerate(apps, 1): app_path = project_root / "apps" / app has_frontend = (app_path / "package.json").exists() has_backend = (app_path / "api").exists() frontend_status = "✅" if has_frontend else "❌" backend_status = "✅" if has_backend else "❌" if show_numbers: table.add_row(str(i), app, frontend_status, backend_status) else: table.add_row(app, frontend_status, backend_status) console.print(table) def interactive_select_app(available_apps: List[str], project_root: Path) -> Optional[str]: """Interactively select an app from the available apps.""" if not available_apps: console.print("[red]❌ No apps found in the monorepo.[/red]") return None console.print("\n[bold]Available apps in monorepo:[/bold]") display_available_apps(available_apps, project_root, show_numbers=True) # Create choices with numbers choices = [str(i) for i in range(1, len(available_apps) + 1)] console.print() try: choice = Prompt.ask( "Select an app to start", choices=choices, default="1" ) selected_app = available_apps[int(choice) - 1] console.print(f"[green]✅ Selected app: {selected_app}[/green]") return selected_app except (ValueError, IndexError, KeyboardInterrupt): console.print("[yellow]Selection cancelled.[/yellow]") return None def create_live_dashboard(manager: ProcessManager, app_name: str) -> Layout: """Create a live dashboard layout.""" layout = Layout() layout.split_column( Layout(name="header", size=3), Layout(name="body"), Layout(name="footer", size=3) ) layout["body"].split_row( Layout(name="frontend"), Layout(name="backend") ) return layout def update_dashboard(layout: Layout, manager: ProcessManager, app_name: str): """Update the dashboard with current status.""" # Header layout["header"].update( Panel( f"[bold blue]🚀 Development Environment - {app_name}[/bold blue]\n" "[dim]Press Ctrl+C to stop all services[/dim]", border_style="blue" ) ) # Status status = manager.get_status() # Frontend panel frontend_logs = "\n".join(manager.logs.get("frontend", [])[-10:]) # Last 10 lines frontend_status = status.get("frontend", "🔴 Not started") layout["frontend"].update( Panel( f"[bold]Status:[/bold] {frontend_status}\n\n" f"[dim]Recent output:[/dim]\n{frontend_logs}", title="🎨 Frontend (pnpm)", border_style="green" if "Running" in frontend_status else "red" ) ) # Backend panel backend_logs = "\n".join(manager.logs.get("backend", [])[-10:]) # Last 10 lines backend_status = status.get("backend", "🔴 Not started") layout["backend"].update( Panel( f"[bold]Status:[/bold] {backend_status}\n\n" f"[dim]Recent output:[/dim]\n{backend_logs}", title="🔧 Backend (uvicorn)", border_style="green" if "Running" in backend_status else "red" ) ) # Footer layout["footer"].update( Panel( f"[bold]Frontend:[/bold] http://localhost:{DEFAULT_FRONTEND_PORT} | " f"[bold]Backend:[/bold] http://localhost:{DEFAULT_BACKEND_PORT}", border_style="blue" ) ) def start_development_environment( app_name: str, frontend_cmd: List[str], backend_cmd: List[str], show_dashboard: bool = True, project_root: Path | None = None ) -> bool: """Start the development environment.""" manager = ProcessManager() # Set up signal handler for graceful shutdown def signal_handler(sig, frame): console.print(f"\n[yellow]Shutting down development environment for {app_name}...[/yellow]") manager.stop_all() sys.exit(0) signal.signal(signal.SIGINT, signal_handler) signal.signal(signal.SIGTERM, signal_handler) assert project_root is not None, "project_root must be provided" # Check dependencies first console.print("[bold]Checking dependencies...[/bold]") deps = check_dependencies(project_root, app_name) if not display_dependency_status(deps): console.print("\n[red]❌ Some dependencies are missing. Please install them first.[/red]") return False console.print("\n[green]✅ All dependencies are available![/green]") # Set up paths app_path = project_root / "apps" / app_name frontend_cwd = str(app_path) if (app_path / "package.json").exists() else None backend_cwd = str(app_path) if (app_path / "api").exists() else None # Show startup configuration config_panel = Panel.fit( f"[bold]App:[/bold] {app_name}\n" f"[bold]Frontend:[/bold] {' '.join(frontend_cmd)} (cwd: {frontend_cwd or 'N/A'})\n" f"[bold]Backend:[/bold] {' '.join(backend_cmd)} (cwd: {backend_cwd or 'N/A'})\n" f"[bold]Frontend URL:[/bold] http://localhost:{DEFAULT_FRONTEND_PORT}\n" f"[bold]Backend URL:[/bold] http://localhost:{DEFAULT_BACKEND_PORT}", title="🚀 Development Configuration", border_style="green" ) console.print(config_panel) # Start processes console.print("\n[bold]Starting development servers...[/bold]") # Start frontend if available if frontend_cwd: with Status("Starting frontend server...", spinner="dots"): if not manager.start_process("frontend", frontend_cmd, frontend_cwd): console.print("[red]Failed to start frontend[/red]") return False time.sleep(2) # Give it a moment to start else: console.print("[yellow]No frontend found for this app[/yellow]") # Start backend if available if backend_cwd: with Status("Starting backend server...", spinner="dots"): if not manager.start_process("backend", backend_cmd, backend_cwd): console.print("[red]Failed to start backend[/red]") return False time.sleep(2) # Give it a moment to start else: console.print("[yellow]No backend found for this app[/yellow]") if not manager.processes: console.print("[red]No services to start for this app[/red]") return False manager.running = True if show_dashboard: # Live dashboard layout = create_live_dashboard(manager, app_name) with Live(layout, refresh_per_second=2, screen=True): try: while manager.is_running(): update_dashboard(layout, manager, app_name) time.sleep(0.5) except KeyboardInterrupt: pass else: # Simple mode - just wait try: console.print(f"\n[green]✅ Development environment started for {app_name}![/green]") console.print("[dim]Press Ctrl+C to stop all services[/dim]") while manager.is_running(): time.sleep(1) except KeyboardInterrupt: pass # Cleanup console.print(f"\n[yellow]Stopping all services for {app_name}...[/yellow]") manager.stop_all() console.print("[green]✅ Development environment stopped.[/green]") return True def main(): # Find project root project_root = find_project_root() if not project_root: console.print("[red]❌ Could not find project root (package.json not found)[/red]") sys.exit(1) console.print(f"[dim]Project root: {project_root}[/dim]") # Get available apps available_apps = get_available_apps(project_root) parser = argparse.ArgumentParser( description="Start development environment with frontend and backend servers for a monorepo app", formatter_class=argparse.RawDescriptionHelpFormatter, epilog=f""" Available apps: {', '.join(available_apps) if available_apps else 'None found'} Examples: python dev.py # Interactive app selection python dev.py --app bursatil # Start bursatil app directly python dev.py --app ocp --no-dashboard # Start ocp app without dashboard python dev.py --list-apps # List available apps python dev.py --app bursatil --frontend-cmd "pnpm run build" # Custom frontend command """ ) parser.add_argument( "--app", choices=available_apps, help="App to start (if not provided, you'll be prompted to select one)" ) parser.add_argument( "--frontend-cmd", default=" ".join(DEFAULT_FRONTEND_CMD), help="Frontend command to run (default: pnpm run dev)" ) parser.add_argument( "--backend-cmd", default=" ".join(DEFAULT_BACKEND_CMD), help="Backend command to run (default: uv run uvicorn api.server:app --reload)" ) parser.add_argument( "--no-dashboard", action="store_true", help="Disable the live dashboard" ) parser.add_argument( "--check-deps", action="store_true", help="Only check dependencies and exit" ) parser.add_argument( "--list-apps", action="store_true", help="List available apps and exit" ) args = parser.parse_args() # Handle --check-deps if args.check_deps: console.print("[bold]Checking dependencies...[/bold]") deps = check_dependencies(project_root) display_dependency_status(deps) return # Handle --list-apps if args.list_apps: console.print("[bold]Available apps in monorepo:[/bold]") display_available_apps(available_apps, project_root) return # Handle app selection if not args.app: if not available_apps: console.print("[red]❌ No apps found in the monorepo.[/red]") sys.exit(1) # Interactive selection selected_app = interactive_select_app(available_apps, project_root) if not selected_app: sys.exit(1) args.app = selected_app # Parse commands frontend_cmd = args.frontend_cmd.split() backend_cmd = args.backend_cmd.split() show_dashboard = not args.no_dashboard # Start development environment success = start_development_environment( args.app, frontend_cmd, backend_cmd, show_dashboard, project_root ) sys.exit(0 if success else 1) if __name__ == "__main__": try: main() except KeyboardInterrupt: console.print("\n[yellow]Operation cancelled by user.[/yellow]") sys.exit(1) except Exception as e: console.print(f"[red]Unexpected error: {e}[/red]") sys.exit(1)