forked from innovacion/Mayacontigo
ic
This commit is contained in:
290
.mise/tasks/container/push.py
Executable file
290
.mise/tasks/container/push.py
Executable file
@@ -0,0 +1,290 @@
|
||||
#!/usr/bin/env -S uv run --script
|
||||
#MISE silent=true
|
||||
#MISE description="Push the container to Azure"
|
||||
# /// script
|
||||
# dependencies = ["rich"]
|
||||
# ///
|
||||
import argparse
|
||||
import subprocess
|
||||
import sys
|
||||
from typing import Optional
|
||||
|
||||
from rich.console import Console
|
||||
from rich.panel import Panel
|
||||
from rich.progress import Progress, SpinnerColumn, TextColumn
|
||||
from rich.prompt import Prompt, Confirm
|
||||
from rich.table import Table
|
||||
|
||||
console = Console()
|
||||
|
||||
# Azure Container Registry configuration
|
||||
AZURE_REGISTRY = "iaservicecontainers.azurecr.io"
|
||||
IMAGE_PREFIX = "mayacontigo/"
|
||||
|
||||
|
||||
def run_docker_command(command: list[str], description: str) -> bool:
|
||||
"""Run a docker command with rich progress indication."""
|
||||
with Progress(
|
||||
SpinnerColumn(),
|
||||
TextColumn("[progress.description]{task.description}"),
|
||||
console=console,
|
||||
transient=True,
|
||||
) as progress:
|
||||
task = progress.add_task(description, total=None)
|
||||
|
||||
try:
|
||||
subprocess.run(
|
||||
command,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=True
|
||||
)
|
||||
progress.update(task, description=f"✅ {description}")
|
||||
return True
|
||||
except subprocess.CalledProcessError as e:
|
||||
progress.update(task, description=f"❌ {description}")
|
||||
console.print(f"[red]Error running command: {' '.join(command)}[/red]")
|
||||
console.print(f"[red]Error output: {e.stderr}[/red]")
|
||||
return False
|
||||
except FileNotFoundError:
|
||||
progress.update(task, description=f"❌ {description}")
|
||||
console.print("[red]Docker command not found. Please ensure Docker is installed and in your PATH.[/red]")
|
||||
return False
|
||||
|
||||
|
||||
def check_docker_image_exists(image_name: str) -> bool:
|
||||
"""Check if a Docker image exists locally."""
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["docker", "images", "-q", image_name],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=True
|
||||
)
|
||||
return bool(result.stdout.strip())
|
||||
except subprocess.CalledProcessError:
|
||||
return False
|
||||
|
||||
|
||||
def get_available_images() -> list[str]:
|
||||
"""Get list of available Docker images filtered by prefix."""
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["docker", "images", "--format", "{{.Repository}}:{{.Tag}}"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=True
|
||||
)
|
||||
images = [line.strip() for line in result.stdout.split('\n') if line.strip()]
|
||||
# Filter images to only include those with the specified prefix
|
||||
filtered_images = [img for img in images if not img.startswith('<none>') and img.startswith(IMAGE_PREFIX)]
|
||||
return filtered_images
|
||||
except subprocess.CalledProcessError:
|
||||
return []
|
||||
|
||||
|
||||
def display_available_images(images: list[str], show_numbers: bool = False) -> None:
|
||||
"""Display available Docker images in a nice table."""
|
||||
if not images:
|
||||
console.print(f"[yellow]No Docker images found locally with prefix '{IMAGE_PREFIX}'.[/yellow]")
|
||||
return
|
||||
|
||||
table = Table(title=f"Available Docker Images (prefix: {IMAGE_PREFIX})")
|
||||
|
||||
if show_numbers:
|
||||
table.add_column("No.", style="bold yellow", width=4)
|
||||
|
||||
table.add_column("Image", style="cyan")
|
||||
table.add_column("Registry Status", style="magenta")
|
||||
|
||||
for i, image in enumerate(images[:10], 1): # Show first 10 images
|
||||
registry_status = "🏷️ Ready to tag" if not image.startswith(AZURE_REGISTRY) else "🚀 Already tagged"
|
||||
|
||||
if show_numbers:
|
||||
table.add_row(str(i), image, registry_status)
|
||||
else:
|
||||
table.add_row(image, registry_status)
|
||||
|
||||
if len(images) > 10:
|
||||
remaining_text = f"[dim]and {len(images) - 10} more[/dim]"
|
||||
if show_numbers:
|
||||
table.add_row("...", "...", remaining_text)
|
||||
else:
|
||||
table.add_row("...", remaining_text)
|
||||
|
||||
console.print(table)
|
||||
|
||||
|
||||
def interactive_mode() -> Optional[str]:
|
||||
"""Run in interactive mode to select an image."""
|
||||
console.print(Panel.fit(
|
||||
"[bold blue]🐳 Docker Image Push to Azure[/bold blue]\n"
|
||||
"[dim]Interactive mode - Let's select an image to push[/dim]",
|
||||
border_style="blue"
|
||||
))
|
||||
|
||||
# Get available images
|
||||
console.print("\n[bold]Fetching available Docker images...[/bold]")
|
||||
images = get_available_images()
|
||||
|
||||
if not images:
|
||||
console.print(f"[red]No Docker images found with prefix '{IMAGE_PREFIX}'. Please build an image first.[/red]")
|
||||
return None
|
||||
|
||||
display_available_images(images, show_numbers=True)
|
||||
|
||||
# Create choices with numbers
|
||||
choices = [str(i) for i in range(1, len(images) + 1)]
|
||||
|
||||
console.print()
|
||||
|
||||
try:
|
||||
choice = Prompt.ask(
|
||||
"Select an image to push",
|
||||
choices=choices,
|
||||
default="1"
|
||||
)
|
||||
|
||||
selected_image = images[int(choice) - 1]
|
||||
console.print(f"[green]✅ Selected image: {selected_image}[/green]")
|
||||
return selected_image
|
||||
|
||||
except (ValueError, IndexError, KeyboardInterrupt):
|
||||
console.print("[yellow]Selection cancelled.[/yellow]")
|
||||
return None
|
||||
|
||||
|
||||
def push_image(image_name: str, force: bool = False) -> bool:
|
||||
"""Push a Docker image to Azure Container Registry."""
|
||||
# Validate image name
|
||||
if not image_name:
|
||||
console.print("[red]Error: Image name cannot be empty[/red]")
|
||||
return False
|
||||
|
||||
# Check if image has the required prefix
|
||||
if not image_name.startswith(IMAGE_PREFIX):
|
||||
console.print(f"[red]Error: Image '{image_name}' does not have the required prefix '{IMAGE_PREFIX}'[/red]")
|
||||
return False
|
||||
|
||||
# Check if image exists locally
|
||||
if not check_docker_image_exists(image_name):
|
||||
console.print(f"[red]Error: Image '{image_name}' not found locally[/red]")
|
||||
console.print(f"[yellow]Tip: Run 'docker images' to see available images with prefix '{IMAGE_PREFIX}'[/yellow]")
|
||||
return False
|
||||
|
||||
# Prepare Azure registry image name (remove the prefix since it will be added by the registry)
|
||||
image_name_without_prefix = image_name[len(IMAGE_PREFIX):]
|
||||
azure_image_name = f"{AZURE_REGISTRY}/{image_name_without_prefix}"
|
||||
|
||||
# Show what we're about to do
|
||||
info_panel = Panel.fit(
|
||||
f"[bold]Image:[/bold] {image_name}\n"
|
||||
f"[bold]Target:[/bold] {azure_image_name}\n"
|
||||
f"[bold]Registry:[/bold] {AZURE_REGISTRY}",
|
||||
title="🚀 Push Configuration",
|
||||
border_style="green"
|
||||
)
|
||||
console.print(info_panel)
|
||||
|
||||
# Confirm action unless force is specified
|
||||
if not force:
|
||||
if not Confirm.ask(f"\nProceed with pushing [cyan]{image_name}[/cyan] to Azure?"):
|
||||
console.print("[yellow]Operation cancelled.[/yellow]")
|
||||
return False
|
||||
|
||||
console.print("\n[bold]Starting push process...[/bold]")
|
||||
|
||||
# Tag the image
|
||||
if not run_docker_command(
|
||||
["docker", "tag", image_name, azure_image_name],
|
||||
f"Tagging image as {azure_image_name}"
|
||||
):
|
||||
return False
|
||||
|
||||
# Push the image
|
||||
if not run_docker_command(
|
||||
["docker", "push", azure_image_name],
|
||||
f"Pushing {azure_image_name} to registry"
|
||||
):
|
||||
return False
|
||||
|
||||
# Success message
|
||||
success_panel = Panel.fit(
|
||||
f"[bold green]✅ Successfully pushed![/bold green]\n"
|
||||
f"[dim]Image: {azure_image_name}[/dim]",
|
||||
title="🎉 Success",
|
||||
border_style="green"
|
||||
)
|
||||
console.print(success_panel)
|
||||
return True
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Push Docker images to Azure Container Registry",
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||
epilog=f"""
|
||||
Examples:
|
||||
python push.py {IMAGE_PREFIX}my-app:latest # Push specific image
|
||||
python push.py --interactive # Interactive mode
|
||||
python push.py {IMAGE_PREFIX}my-app:latest --force # Skip confirmation
|
||||
python push.py --list # List available images
|
||||
"""
|
||||
)
|
||||
|
||||
parser.add_argument(
|
||||
"image_name",
|
||||
nargs="?",
|
||||
help="Name of the Docker image to push"
|
||||
)
|
||||
|
||||
parser.add_argument(
|
||||
"-i", "--interactive",
|
||||
action="store_true",
|
||||
help="Run in interactive mode"
|
||||
)
|
||||
|
||||
parser.add_argument(
|
||||
"-f", "--force",
|
||||
action="store_true",
|
||||
help="Skip confirmation prompts"
|
||||
)
|
||||
|
||||
parser.add_argument(
|
||||
"--list",
|
||||
action="store_true",
|
||||
help="List available Docker images and exit"
|
||||
)
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
# Handle --list option
|
||||
if args.list:
|
||||
console.print("[bold]Available Docker Images:[/bold]")
|
||||
images = get_available_images()
|
||||
display_available_images(images)
|
||||
return
|
||||
|
||||
# Determine image name
|
||||
image_name = args.image_name
|
||||
|
||||
# Interactive mode or no image provided
|
||||
if args.interactive or not image_name:
|
||||
image_name = interactive_mode()
|
||||
if not image_name:
|
||||
sys.exit(1)
|
||||
|
||||
# Push the image
|
||||
success = push_image(image_name, args.force)
|
||||
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)
|
||||
337
.mise/tasks/container/start.py
Executable file
337
.mise/tasks/container/start.py
Executable file
@@ -0,0 +1,337 @@
|
||||
#!/usr/bin/env -S uv run --script
|
||||
#MISE silent=true
|
||||
#MISE description="Run the container for a project"
|
||||
# /// script
|
||||
# dependencies = ["rich"]
|
||||
# ///
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import subprocess
|
||||
import sys
|
||||
from typing import Optional
|
||||
|
||||
from rich.console import Console
|
||||
from rich.panel import Panel
|
||||
from rich.progress import Progress, SpinnerColumn, TextColumn
|
||||
from rich.prompt import Prompt, Confirm
|
||||
from rich.table import Table
|
||||
|
||||
console = Console()
|
||||
|
||||
# Configuration
|
||||
VAULT_ADDRESS = "https://vault.ia-innovacion.work"
|
||||
DOCKER_REGISTRY = "mayacontigo"
|
||||
DEFAULT_PORT = 9000
|
||||
CONTAINER_PORT = 80
|
||||
|
||||
|
||||
def run_command(command: list[str], description: str, capture_output: bool = True) -> tuple[bool, str]:
|
||||
"""Run a command with rich progress indication."""
|
||||
with Progress(
|
||||
SpinnerColumn(),
|
||||
TextColumn("[progress.description]{task.description}"),
|
||||
console=console,
|
||||
transient=True,
|
||||
) as progress:
|
||||
task = progress.add_task(description, total=None)
|
||||
|
||||
try:
|
||||
result = subprocess.run(
|
||||
command,
|
||||
capture_output=capture_output,
|
||||
text=True,
|
||||
check=True
|
||||
)
|
||||
progress.update(task, description=f"✅ {description}")
|
||||
return True, result.stdout.strip() if capture_output else ""
|
||||
except subprocess.CalledProcessError as e:
|
||||
progress.update(task, description=f"❌ {description}")
|
||||
error_msg = e.stderr if capture_output else str(e)
|
||||
console.print(f"[red]Error running command: {' '.join(command)}[/red]")
|
||||
if error_msg:
|
||||
console.print(f"[red]Error output: {error_msg}[/red]")
|
||||
return False, ""
|
||||
except FileNotFoundError:
|
||||
progress.update(task, description=f"❌ {description}")
|
||||
console.print(f"[red]Command not found: {command[0]}. Please ensure it's installed and in your PATH.[/red]")
|
||||
return False, ""
|
||||
|
||||
|
||||
def get_vault_token() -> Optional[str]:
|
||||
"""Get vault token from Vault CLI."""
|
||||
success, output = run_command(
|
||||
["vault", "token", "lookup", "-format=json", f"-address={VAULT_ADDRESS}"],
|
||||
"Fetching Vault token"
|
||||
)
|
||||
|
||||
if not success:
|
||||
return None
|
||||
|
||||
try:
|
||||
data = json.loads(output)
|
||||
return data.get("data", {}).get("id")
|
||||
except json.JSONDecodeError:
|
||||
console.print("[red]Failed to parse Vault token response[/red]")
|
||||
return None
|
||||
|
||||
|
||||
def check_docker_image_exists(app_name: str) -> bool:
|
||||
"""Check if the Docker image exists locally or can be pulled."""
|
||||
image_name = f"{DOCKER_REGISTRY}/{app_name}"
|
||||
|
||||
# First check if image exists locally
|
||||
success, _ = run_command(
|
||||
["docker", "images", "-q", image_name],
|
||||
f"Checking if {image_name} exists locally"
|
||||
)
|
||||
|
||||
if success:
|
||||
return True
|
||||
|
||||
# If not local, try to pull it
|
||||
console.print(f"[yellow]Image {image_name} not found locally. Attempting to pull...[/yellow]")
|
||||
success, _ = run_command(
|
||||
["docker", "pull", image_name],
|
||||
f"Pulling {image_name}"
|
||||
)
|
||||
|
||||
return success
|
||||
|
||||
|
||||
def get_available_apps() -> list[str]:
|
||||
"""Get list of available Docker images from the registry."""
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["docker", "images", "--format", "{{.Repository}}:{{.Tag}}"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=True
|
||||
)
|
||||
images = [line.strip() for line in result.stdout.split('\n') if line.strip()]
|
||||
# Filter for banortegpt registry images
|
||||
apps = []
|
||||
for img in images:
|
||||
if img.startswith(f"{DOCKER_REGISTRY}/"):
|
||||
app_name = img.replace(f"{DOCKER_REGISTRY}/", "").split(":")[0]
|
||||
if app_name not in apps:
|
||||
apps.append(app_name)
|
||||
return apps
|
||||
except subprocess.CalledProcessError:
|
||||
return []
|
||||
|
||||
|
||||
def display_available_apps(apps: list[str]) -> None:
|
||||
"""Display available apps in a nice table."""
|
||||
if not apps:
|
||||
console.print("[yellow]No apps found locally.[/yellow]")
|
||||
console.print("[dim]Try pulling an image first or check your Docker registry connection.[/dim]")
|
||||
return
|
||||
|
||||
table = Table(title="Available Apps")
|
||||
table.add_column("App Name", style="cyan")
|
||||
table.add_column("Image", style="magenta")
|
||||
table.add_column("Status", style="green")
|
||||
|
||||
for app in apps:
|
||||
image = f"{DOCKER_REGISTRY}/{app}"
|
||||
status = "🚀 Ready to run"
|
||||
table.add_row(app, image, status)
|
||||
|
||||
console.print(table)
|
||||
|
||||
|
||||
def interactive_mode() -> Optional[str]:
|
||||
"""Run in interactive mode to select an app."""
|
||||
console.print(Panel.fit(
|
||||
"[bold blue]🐳 Docker Container Starter[/bold blue]\n"
|
||||
"[dim]Interactive mode - Let's select an app to run[/dim]",
|
||||
border_style="blue"
|
||||
))
|
||||
|
||||
# Get available apps
|
||||
console.print("\n[bold]Fetching available apps...[/bold]")
|
||||
apps = get_available_apps()
|
||||
|
||||
if not apps:
|
||||
console.print("[red]No apps found. Please ensure you have Docker images available.[/red]")
|
||||
return None
|
||||
|
||||
display_available_apps(apps)
|
||||
|
||||
# Prompt for app selection
|
||||
console.print("\n[bold]Select an app to run:[/bold]")
|
||||
app_name = Prompt.ask(
|
||||
"Enter the app name (or press Enter to see suggestions)",
|
||||
default="",
|
||||
show_default=False
|
||||
)
|
||||
|
||||
if not app_name:
|
||||
# Show suggestions
|
||||
console.print("\n[dim]Here are your available apps:[/dim]")
|
||||
for i, app in enumerate(apps, 1):
|
||||
console.print(f" {i}. [cyan]{app}[/cyan]")
|
||||
|
||||
choice = Prompt.ask(
|
||||
"Enter app name or number from suggestions",
|
||||
default="1"
|
||||
)
|
||||
|
||||
try:
|
||||
choice_num = int(choice)
|
||||
if 1 <= choice_num <= len(apps):
|
||||
app_name = apps[choice_num - 1]
|
||||
else:
|
||||
console.print("[red]Invalid choice.[/red]")
|
||||
return None
|
||||
except ValueError:
|
||||
app_name = choice
|
||||
|
||||
return app_name
|
||||
|
||||
|
||||
def start_container(app_name: str, port: int = DEFAULT_PORT, force: bool = False) -> bool:
|
||||
"""Start a Docker container for the specified app."""
|
||||
# Validate app name
|
||||
if not app_name:
|
||||
console.print("[red]Error: App name cannot be empty[/red]")
|
||||
return False
|
||||
|
||||
# Check if image exists
|
||||
if not check_docker_image_exists(app_name):
|
||||
console.print(f"[red]Error: Image for app '{app_name}' not found[/red]")
|
||||
return False
|
||||
|
||||
# Get vault token
|
||||
console.print("\n[bold]Getting Vault token...[/bold]")
|
||||
vault_token = get_vault_token()
|
||||
if not vault_token:
|
||||
console.print("[red]Failed to get Vault token[/red]")
|
||||
return False
|
||||
|
||||
# Prepare container configuration
|
||||
image_name = f"{DOCKER_REGISTRY}/{app_name}"
|
||||
|
||||
# Show what we're about to do
|
||||
info_panel = Panel.fit(
|
||||
f"[bold]App:[/bold] {app_name}\n"
|
||||
f"[bold]Image:[/bold] {image_name}\n"
|
||||
f"[bold]Port:[/bold] {port}:{CONTAINER_PORT}\n"
|
||||
f"[bold]Vault:[/bold] {VAULT_ADDRESS}\n"
|
||||
f"[bold]Token:[/bold] {'*' * 8}...{vault_token[-4:] if len(vault_token) > 8 else '****'}",
|
||||
title="🚀 Container Configuration",
|
||||
border_style="green"
|
||||
)
|
||||
console.print(info_panel)
|
||||
|
||||
# Confirm action unless force is specified
|
||||
if not force:
|
||||
if not Confirm.ask(f"\nStart container for [cyan]{app_name}[/cyan] on port {port}?"):
|
||||
console.print("[yellow]Operation cancelled.[/yellow]")
|
||||
return False
|
||||
|
||||
console.print("\n[bold]Starting container...[/bold]")
|
||||
|
||||
# Build docker run command
|
||||
docker_cmd = [
|
||||
"docker", "run",
|
||||
"-p", f"{port}:{CONTAINER_PORT}",
|
||||
"--rm",
|
||||
"-it",
|
||||
"-e", f"VAULT_TOKEN={vault_token}",
|
||||
image_name
|
||||
]
|
||||
|
||||
# Run the container (this will be interactive)
|
||||
console.print(f"[green]Running: {' '.join(docker_cmd[:7])}... {image_name}[/green]")
|
||||
console.print(f"[dim]Container will be available at: http://localhost:{port}[/dim]")
|
||||
console.print("[dim]Press Ctrl+C to stop the container[/dim]\n")
|
||||
|
||||
try:
|
||||
subprocess.run(docker_cmd, check=True)
|
||||
return True
|
||||
except subprocess.CalledProcessError as e:
|
||||
console.print(f"[red]Container failed to start: {e}[/red]")
|
||||
return False
|
||||
except KeyboardInterrupt:
|
||||
console.print("\n[yellow]Container stopped by user.[/yellow]")
|
||||
return True
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Start Docker containers for Banorte GPT apps",
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||
epilog="""
|
||||
Examples:
|
||||
python start.py my-app # Start specific app
|
||||
python start.py --interactive # Interactive mode
|
||||
python start.py my-app --port 8080 # Custom port
|
||||
python start.py my-app --force # Skip confirmation
|
||||
"""
|
||||
)
|
||||
|
||||
parser.add_argument(
|
||||
"app_name",
|
||||
nargs="?",
|
||||
help="Name of the app to start"
|
||||
)
|
||||
|
||||
parser.add_argument(
|
||||
"-i", "--interactive",
|
||||
action="store_true",
|
||||
help="Run in interactive mode"
|
||||
)
|
||||
|
||||
parser.add_argument(
|
||||
"-p", "--port",
|
||||
type=int,
|
||||
default=DEFAULT_PORT,
|
||||
help=f"Port to bind to (default: {DEFAULT_PORT})"
|
||||
)
|
||||
|
||||
parser.add_argument(
|
||||
"-f", "--force",
|
||||
action="store_true",
|
||||
help="Skip confirmation prompts"
|
||||
)
|
||||
|
||||
parser.add_argument(
|
||||
"--list",
|
||||
action="store_true",
|
||||
help="List available apps and exit"
|
||||
)
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
# Handle --list option
|
||||
if args.list:
|
||||
console.print("[bold]Available Apps:[/bold]")
|
||||
apps = get_available_apps()
|
||||
display_available_apps(apps)
|
||||
return
|
||||
|
||||
# Determine app name
|
||||
app_name = args.app_name
|
||||
|
||||
# Interactive mode or no app provided
|
||||
if args.interactive or not app_name:
|
||||
app_name = interactive_mode()
|
||||
if not app_name:
|
||||
sys.exit(1)
|
||||
|
||||
# Start the container
|
||||
success = start_container(app_name, args.port, args.force)
|
||||
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)
|
||||
582
.mise/tasks/dev.py
Executable file
582
.mise/tasks/dev.py
Executable file
@@ -0,0 +1,582 @@
|
||||
#!/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)
|
||||
326
.mise/tasks/new.py
Executable file
326
.mise/tasks/new.py
Executable file
@@ -0,0 +1,326 @@
|
||||
#!/usr/bin/env -S uv run --script
|
||||
#MISE silent=true
|
||||
#MISE description="Create a new project from our templates"
|
||||
# /// script
|
||||
# dependencies = ["rich", "pyyaml"]
|
||||
# ///
|
||||
"""
|
||||
Slick app generator script with Rich formatting
|
||||
"""
|
||||
|
||||
import sys
|
||||
import subprocess
|
||||
import shutil
|
||||
import yaml
|
||||
from pathlib import Path
|
||||
|
||||
from rich.console import Console
|
||||
from rich.panel import Panel
|
||||
from rich.progress import Progress, SpinnerColumn, TextColumn, BarColumn, TimeElapsedColumn
|
||||
from rich.text import Text
|
||||
from rich.prompt import Prompt
|
||||
from rich.table import Table
|
||||
from rich import box
|
||||
|
||||
console = Console()
|
||||
|
||||
def show_banner():
|
||||
"""Display a stylish banner"""
|
||||
banner = Text("🚀 RAG TEMPLATE", style="bold magenta")
|
||||
banner.stylize("bold cyan", 0, 4) # Style the rocket emoji
|
||||
|
||||
panel = Panel(
|
||||
banner,
|
||||
box=box.DOUBLE,
|
||||
border_style="bright_blue",
|
||||
padding=(1, 2)
|
||||
)
|
||||
console.print(panel)
|
||||
|
||||
def validate_app_name(app_name):
|
||||
"""Validate the app name"""
|
||||
if not app_name:
|
||||
return False, "App name cannot be empty"
|
||||
|
||||
if not app_name.replace('_', '').replace('-', '').isalnum():
|
||||
return False, "App name can only contain letters, numbers, hyphens, and underscores"
|
||||
|
||||
if len(app_name) < 2:
|
||||
return False, "App name must be at least 2 characters long"
|
||||
|
||||
if len(app_name) > 50:
|
||||
return False, "App name must be less than 50 characters long"
|
||||
|
||||
return True, "Valid app name"
|
||||
|
||||
def run_command(command, description, cwd=None):
|
||||
"""Run a command with progress indicator"""
|
||||
try:
|
||||
with Progress(
|
||||
SpinnerColumn(),
|
||||
TextColumn("[progress.description]{task.description}"),
|
||||
BarColumn(),
|
||||
TimeElapsedColumn(),
|
||||
console=console,
|
||||
transient=True
|
||||
) as progress:
|
||||
task = progress.add_task(description, total=None)
|
||||
|
||||
result = subprocess.run(
|
||||
command,
|
||||
shell=True,
|
||||
cwd=cwd,
|
||||
capture_output=True,
|
||||
text=True
|
||||
)
|
||||
|
||||
progress.update(task, completed=True)
|
||||
|
||||
if result.returncode != 0:
|
||||
console.print(f"❌ [red]Error running command:[/red] {command}")
|
||||
console.print(f"[red]Error output:[/red] {result.stderr}")
|
||||
return False
|
||||
|
||||
return True
|
||||
except Exception as e:
|
||||
console.print(f"❌ [red]Exception occurred:[/red] {str(e)}")
|
||||
return False
|
||||
|
||||
class CustomYAMLDumper(yaml.SafeDumper):
|
||||
"""Custom YAML dumper that preserves list formatting"""
|
||||
|
||||
def write_line_break(self, data=None):
|
||||
super().write_line_break(data)
|
||||
if len(self.indents) == 1:
|
||||
super().write_line_break()
|
||||
|
||||
def custom_yaml_dump(data, stream=None):
|
||||
"""Custom YAML dump function that maintains proper list formatting"""
|
||||
class ListDumper(yaml.SafeDumper):
|
||||
def increase_indent(self, flow=False, indentless=False):
|
||||
return super().increase_indent(flow, False)
|
||||
|
||||
def represent_list(self, data):
|
||||
return self.represent_sequence('tag:yaml.org,2002:seq', data, flow_style=False)
|
||||
|
||||
def represent_dict(self, data):
|
||||
return self.represent_mapping('tag:yaml.org,2002:map', data, flow_style=False)
|
||||
|
||||
ListDumper.add_representer(list, ListDumper.represent_list)
|
||||
ListDumper.add_representer(dict, ListDumper.represent_dict)
|
||||
|
||||
return yaml.dump(data, stream=stream, Dumper=ListDumper,
|
||||
default_flow_style=False, indent=2, sort_keys=False,
|
||||
allow_unicode=True, width=1000)
|
||||
|
||||
def find_available_port(compose_path):
|
||||
"""Find the next available port for the new service"""
|
||||
used_ports = set()
|
||||
|
||||
try:
|
||||
with open(compose_path, 'r') as f:
|
||||
compose_data = yaml.safe_load(f)
|
||||
|
||||
# Extract used ports from existing services
|
||||
services = compose_data.get('services', {})
|
||||
for service_name, service_config in services.items():
|
||||
if service_name == 'traefik': # Skip traefik
|
||||
continue
|
||||
ports = service_config.get('ports', [])
|
||||
for port_mapping in ports:
|
||||
if isinstance(port_mapping, str):
|
||||
external_port = int(port_mapping.split(':')[0])
|
||||
used_ports.add(external_port)
|
||||
|
||||
# Find next available port starting from 8001
|
||||
port = 8001
|
||||
while port in used_ports:
|
||||
port += 1
|
||||
|
||||
return port
|
||||
except Exception as e:
|
||||
console.print(f"⚠️ [yellow]Could not determine available port, using 8001:[/yellow] {str(e)}")
|
||||
return 8001
|
||||
|
||||
def generate_service_config(app_name, port):
|
||||
"""Generate Docker Compose service configuration for new app"""
|
||||
return {
|
||||
'image': f'mayacontigo/{app_name}:latest',
|
||||
'build': {
|
||||
'context': '.',
|
||||
'dockerfile': '.containers/unit/Dockerfile',
|
||||
'args': {
|
||||
'PACKAGE': app_name
|
||||
}
|
||||
},
|
||||
'x-bake': {
|
||||
'tags': [f'mayacontigo/{app_name}:latest']
|
||||
},
|
||||
'ports': [f'{port}:80'],
|
||||
'labels': [
|
||||
'traefik.enable=true',
|
||||
f'traefik.http.routers.{app_name}.rule=PathPrefix(`/api/maya{app_name}`)',
|
||||
f'traefik.http.routers.{app_name}.entrypoints=web',
|
||||
f'traefik.http.routers.{app_name}.middlewares={app_name}-strip',
|
||||
f'traefik.http.middlewares.{app_name}-strip.stripprefix.prefixes=/api/maya{app_name}'
|
||||
]
|
||||
}
|
||||
|
||||
def add_service_to_compose(app_name):
|
||||
"""Add new service to compose.yaml file"""
|
||||
compose_path = Path('compose.yaml')
|
||||
|
||||
if not compose_path.exists():
|
||||
console.print(f"⚠️ [yellow]compose.yaml not found, skipping service addition.[/yellow]")
|
||||
return True
|
||||
|
||||
try:
|
||||
# Create backup
|
||||
backup_path = compose_path.with_suffix('.yaml.backup')
|
||||
shutil.copy2(compose_path, backup_path)
|
||||
|
||||
# Load existing compose file
|
||||
with open(compose_path, 'r') as f:
|
||||
compose_data = yaml.safe_load(f)
|
||||
|
||||
# Check if service already exists
|
||||
services = compose_data.get('services', {})
|
||||
if app_name in services:
|
||||
console.print(f"⚠️ [yellow]Service '{app_name}' already exists in compose.yaml[/yellow]")
|
||||
return True
|
||||
|
||||
# Find available port
|
||||
port = find_available_port(compose_path)
|
||||
|
||||
# Generate service configuration
|
||||
service_config = generate_service_config(app_name, port)
|
||||
|
||||
# Add service to compose data
|
||||
services[app_name] = service_config
|
||||
|
||||
# Write updated compose file with proper formatting
|
||||
with open(compose_path, 'w') as f:
|
||||
custom_yaml_dump(compose_data, f)
|
||||
|
||||
console.print(f"✅ [green]Added service '{app_name}' to compose.yaml (port {port})[/green]")
|
||||
|
||||
# Remove backup on success
|
||||
backup_path.unlink()
|
||||
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
console.print(f"❌ [red]Failed to add service to compose.yaml:[/red] {str(e)}")
|
||||
|
||||
# Restore backup if it exists
|
||||
if backup_path.exists():
|
||||
shutil.copy2(backup_path, compose_path)
|
||||
backup_path.unlink()
|
||||
console.print("🔄 [yellow]Restored original compose.yaml from backup[/yellow]")
|
||||
|
||||
return False
|
||||
|
||||
def show_summary(app_name):
|
||||
"""Show a summary of what was created"""
|
||||
table = Table(title="🎉 App Created Successfully!", box=box.ROUNDED)
|
||||
table.add_column("Component", style="cyan", no_wrap=True)
|
||||
table.add_column("Status", style="green")
|
||||
table.add_column("Location", style="yellow")
|
||||
|
||||
table.add_row("📁 Project Directory", "✅ Created", f"./apps/{app_name}")
|
||||
table.add_row("📦 Dependencies", "✅ Installed", "package.json + requirements.txt")
|
||||
table.add_row("🔧 Template", "✅ Applied", "RAG template")
|
||||
table.add_row("🐍 Python Environment", "✅ Synced", "uv environment")
|
||||
table.add_row("🐳 Docker Service", "✅ Added", "compose.yaml")
|
||||
|
||||
console.print(table)
|
||||
|
||||
# Next steps
|
||||
next_steps = Panel(
|
||||
f"[bold green]Next Steps:[/bold green]\n\n"
|
||||
f"1. [cyan]mise dev --app {app_name}[/cyan]\n"
|
||||
f"2. [cyan]Start building your RAG app! 🚀[/cyan]",
|
||||
title="🎯 What's Next?",
|
||||
border_style="green",
|
||||
box=box.ROUNDED
|
||||
)
|
||||
console.print(next_steps)
|
||||
|
||||
def main():
|
||||
"""Main function"""
|
||||
show_banner()
|
||||
|
||||
# Get app name from command line or prompt
|
||||
if len(sys.argv) < 2:
|
||||
console.print("[yellow]No app name provided as argument.[/yellow]")
|
||||
app_name = Prompt.ask("Enter your app name", default="my-rag-app")
|
||||
else:
|
||||
app_name = sys.argv[1]
|
||||
|
||||
# Validate app name
|
||||
is_valid, message = validate_app_name(app_name)
|
||||
if not is_valid:
|
||||
console.print(f"❌ [red]Invalid app name:[/red] {message}")
|
||||
sys.exit(1)
|
||||
|
||||
# Show what we're about to do
|
||||
console.print(f"[bold]Creating app:[/bold] [cyan]{app_name}[/cyan]")
|
||||
console.print()
|
||||
|
||||
# Check if directory already exists
|
||||
app_path = Path(f"apps/{app_name}")
|
||||
if app_path.exists():
|
||||
console.print(f"⚠️ [yellow]Directory 'apps/{app_name}' already exists![/yellow]")
|
||||
if not Prompt.ask("Do you want to continue?", choices=["y", "n"], default="n") == "y":
|
||||
console.print("Operation cancelled.")
|
||||
sys.exit(0)
|
||||
|
||||
# Step 1: Copy template
|
||||
console.print("📂 [bold]Step 1:[/bold] Copying RAG template...")
|
||||
if not run_command(
|
||||
f"uvx copier copy .templates/rag apps --data project_name={app_name} --trust",
|
||||
"Copying template files..."
|
||||
):
|
||||
console.print("❌ [red]Failed to copy template. Make sure you're in the right directory.[/red]")
|
||||
sys.exit(1)
|
||||
|
||||
# Step 2: Install npm dependencies
|
||||
console.print("📦 [bold]Step 2:[/bold] Installing npm dependencies...")
|
||||
if not run_command(
|
||||
"pnpm install",
|
||||
"Installing npm packages...",
|
||||
cwd=f"apps/{app_name}"
|
||||
):
|
||||
console.print("❌ [red]Failed to install npm dependencies.[/red]")
|
||||
sys.exit(1)
|
||||
|
||||
# Step 3: Sync Python environment
|
||||
console.print("🐍 [bold]Step 3:[/bold] Setting up Python environment...")
|
||||
if not run_command(
|
||||
"uv sync",
|
||||
"Syncing Python environment...",
|
||||
cwd=f"apps/{app_name}"
|
||||
):
|
||||
console.print("❌ [red]Failed to sync Python environment.[/red]")
|
||||
sys.exit(1)
|
||||
|
||||
# Step 4: Add service to compose.yaml
|
||||
console.print("🐳 [bold]Step 4:[/bold] Adding service to compose.yaml...")
|
||||
if not add_service_to_compose(app_name):
|
||||
console.print("❌ [red]Failed to add service to compose.yaml.[/red]")
|
||||
console.print("⚠️ [yellow]You can manually add the service later.[/yellow]")
|
||||
# Don't exit here, this is not critical
|
||||
|
||||
# Success!
|
||||
console.print()
|
||||
show_summary(app_name)
|
||||
|
||||
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"\n❌ [red]Unexpected error:[/red] {str(e)}")
|
||||
sys.exit(1)
|
||||
Reference in New Issue
Block a user