forked from innovacion/Mayacontigo
583 lines
19 KiB
Python
Executable File
583 lines
19 KiB
Python
Executable File
#!/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)
|