diff --git a/fastapps/cli/commands/cloud/__init__.py b/fastapps/cli/commands/cloud/__init__.py deleted file mode 100644 index 8f14443..0000000 --- a/fastapps/cli/commands/cloud/__init__.py +++ /dev/null @@ -1,35 +0,0 @@ -"""FastApps Cloud CLI commands.""" - -import click - -# Import subcommands -from .delete import delete -from .deploy import deploy -from .deployments import deployments -from .login import login -from .logout import logout -from .projects import projects -from .whoami import whoami - - -@click.group() -def cloud(): - """Manage FastApps Cloud deployments. - - Commands for authenticating, deploying, and managing your - FastApps projects on FastApps Cloud. - """ - pass - - -# Register subcommands -cloud.add_command(login) -cloud.add_command(logout) -cloud.add_command(whoami) -cloud.add_command(deploy) -cloud.add_command(deployments) -cloud.add_command(projects) # projects includes link, unlink, status as subcommands -cloud.add_command(delete) - - -__all__ = ["cloud"] diff --git a/fastapps/cli/commands/cloud/delete.py b/fastapps/cli/commands/cloud/delete.py deleted file mode 100644 index 8b6168d..0000000 --- a/fastapps/cli/commands/cloud/delete.py +++ /dev/null @@ -1,80 +0,0 @@ -"""Delete command for FastApps Cloud.""" - -import asyncio - -import click -from rich.console import Console - -from ....cloud.client import CloudClient -from ....cloud.config import CloudConfig - -console = Console() - - -@click.command() -@click.argument("deployment_id") -@click.option("--yes", "-y", is_flag=True, help="Skip confirmation prompt") -def delete(deployment_id, yes): - """Delete a deployment. - - Permanently removes a deployment from FastApps Cloud. - This action cannot be undone. - - Example: - fastapps cloud delete dep_abc123 - fastapps cloud delete dep_abc123 --yes - """ - asyncio.run(async_delete(deployment_id, yes)) - - -async def async_delete(deployment_id: str, skip_confirmation: bool): - """Async delete workflow.""" - if not CloudConfig.is_logged_in(): - console.print("[yellow]You are not logged in.[/yellow]") - console.print("[dim]Run 'fastapps cloud login' to authenticate.[/dim]") - return - - try: - async with CloudClient() as client: - # Get deployment details first - try: - deployment = await client.get_deployment(deployment_id) - except RuntimeError as e: - if "not found" in str(e): - console.print(f"[red]✗ Deployment {deployment_id} not found[/red]") - return - raise - - # Show warning - console.print(f"\n[bold red]⚠️ Warning[/bold red]") - console.print(f"This will permanently delete deployment: [cyan]{deployment.id}[/cyan]") - if deployment.projectId: - console.print(f"Project: [white]{deployment.projectId}[/white]") - if deployment.domain: - console.print(f"Domain: [green]{deployment.domain}[/green]") - console.print() - - # Confirmation - if not skip_confirmation: - confirm = console.input("[bold]Continue? (yes/no):[/bold] ") - if confirm.lower() not in ["yes", "y"]: - console.print("[dim]Deletion cancelled.[/dim]") - return - - # Delete - await client.delete_deployment(deployment_id) - console.print(f"[green]✓ Deployment {deployment_id} deleted successfully[/green]\n") - - except RuntimeError as e: - error_msg = str(e) - console.print(f"\n[red]✗ Error[/red]") - console.print(f"[yellow]{error_msg}[/yellow]\n") - - if "Authentication expired" in error_msg: - console.print("[dim]Run 'fastapps cloud login' to re-authenticate.[/dim]") - elif "Network error" in error_msg: - console.print("[dim]Please check your connection and server status:[/dim]") - console.print(f"[dim]Server: {CloudConfig.get_cloud_url()}[/dim]") - except Exception as e: - console.print(f"\n[red]✗ Unexpected Error[/red]") - console.print(f"[yellow]{type(e).__name__}: {e}[/yellow]\n") diff --git a/fastapps/cli/commands/cloud/deploy.py b/fastapps/cli/commands/cloud/deploy.py deleted file mode 100644 index 406fd70..0000000 --- a/fastapps/cli/commands/cloud/deploy.py +++ /dev/null @@ -1,436 +0,0 @@ -"""Deploy command for FastApps Cloud.""" - -import asyncio -import re -import secrets -import subprocess -from pathlib import Path - -import click -from rich.console import Console -from rich.panel import Panel -from rich.progress import Progress, SpinnerColumn, TextColumn -from rich.table import Table - -from ....cloud.client import CloudClient -from ....cloud.config import CloudConfig -from ....cloud.projects_manager import ProjectsManager -from ....deployer import ArtifactPackager - -console = Console() - - -def generate_random_suffix() -> str: - """Generate a random 4-character suffix for project names.""" - return secrets.token_hex(2) - - -def validate_project_slug(slug: str) -> bool: - """ - Validate project slug format. - - Rules: - - Only lowercase letters, numbers, and hyphens - - Must start with a letter or number - - Must end with a letter or number - - Length between 3 and 63 characters - - Args: - slug: Project slug to validate - - Returns: - True if valid, False otherwise - """ - if not slug or len(slug) < 3 or len(slug) > 63: - return False - - # Must match: lowercase letters, numbers, hyphens - # Must start and end with letter or number - pattern = r'^[a-z0-9][a-z0-9-]*[a-z0-9]$|^[a-z0-9]$' - return bool(re.match(pattern, slug)) - - -def sanitize_to_slug(name: str) -> str: - """ - Convert a name to a valid slug format. - - Args: - name: Input name - - Returns: - Sanitized slug - """ - # Convert to lowercase - slug = name.lower() - - # Replace spaces and underscores with hyphens - slug = re.sub(r'[\s_]+', '-', slug) - - # Remove any characters that aren't alphanumeric or hyphens - slug = re.sub(r'[^a-z0-9-]', '', slug) - - # Remove leading/trailing hyphens - slug = slug.strip('-') - - # Replace multiple consecutive hyphens with single hyphen - slug = re.sub(r'-+', '-', slug) - - return slug - - -async def handle_no_link() -> tuple[str, str, bool]: - """ - Handle deployment when no project is linked. - - Returns: - (project_id, project_name, should_link) - """ - console.print("What would you like to do?") - console.print(" [1] Create new project and link") - console.print(" [2] Link to existing project") - console.print() - - choice = console.input("Choose [1-2]: ") - - if choice == "1": - # Create new project - base_name = sanitize_to_slug(Path.cwd().name) - suffix = generate_random_suffix() - suggested_slug = f"{base_name}-{suffix}" - - console.print("\n[dim]Project slug requirements:[/dim]") - console.print("[dim] • Lowercase letters, numbers, and hyphens only[/dim]") - console.print("[dim] • 3-63 characters long[/dim]") - console.print("[dim] • Must start and end with letter or number[/dim]\n") - - # Loop until valid slug is provided - while True: - project_slug = console.input(f"Project slug [{suggested_slug}]: ") or suggested_slug - - if validate_project_slug(project_slug): - break - else: - console.print("[red]✗ Invalid slug format[/red]") - # Suggest sanitized version - sanitized = sanitize_to_slug(project_slug) - if sanitized and validate_project_slug(sanitized): - console.print(f"[yellow]Suggested: {sanitized}[/yellow]") - console.print() - - console.print(f"\n[cyan]Creating project '{project_slug}'...[/cyan]") - - async with CloudClient() as client: - project = await client.create_project(project_slug) - - project_id = project.get("id") - - if not project_id: - console.print("[red]✗ Failed to create project[/red]") - return None, None, False - - console.print(f"[green]✓ Project created: {project_id}[/green]") - return project_id, project_slug, True # Will link after deployment - - elif choice == "2": - # Link to existing project - console.print("\n[cyan]Fetching your projects...[/cyan]\n") - - async with CloudClient() as client: - response = await client.list_projects() - projects_list = response.get("projects", []) - - if not projects_list: - console.print("[yellow]No projects found.[/yellow]") - console.print("Please create a project first (option 1)") - return None, None, False - - # Show projects - from rich.table import Table as RichTable - - table = RichTable(show_header=True, header_style="bold cyan") - table.add_column("#", style="dim", width=4) - table.add_column("Project ID", style="cyan") - table.add_column("Deployments", justify="right") - - for idx, project in enumerate(projects_list, 1): - table.add_row( - str(idx), - project.get("project_id", ""), - str(project.get("deployment_count", 0)), - ) - - console.print(table) - console.print() - - # Select - selection = console.input(f"Select project [1-{len(projects_list)}]: ") - try: - idx = int(selection) - 1 - if 0 <= idx < len(projects_list): - selected = projects_list[idx] - project_id = selected.get("project_id") - project_name = selected.get("project_id") - return project_id, project_name, True - else: - console.print("[red]Invalid selection[/red]") - return None, None, False - except ValueError: - console.print("[red]Invalid selection[/red]") - return None, None, False - - else: - console.print("[red]Invalid choice[/red]") - return None, None, False - - -@click.command() -@click.option("--yes", "-y", is_flag=True, help="Skip confirmation prompt") -@click.option("--no-build", is_flag=True, help="Skip widget build step") -@click.option("--project-id", help="Override project ID (default: from linked project)") -def deploy(yes, no_build, project_id): - """Deploy your FastApps project to FastApps Cloud. - - This command will: - 1. Validate project structure - 2. Build widgets (unless --no-build) - 3. Authenticate (if not logged in) - 4. Package artifacts - 5. Upload and deploy to FastApps Cloud - - Examples: - fastapps cloud deploy - fastapps cloud deploy --yes - fastapps cloud deploy --no-build --project-id my-project - """ - asyncio.run(async_deploy(yes, no_build, project_id)) - - -async def async_deploy(skip_confirmation: bool, skip_build: bool, project_id_override: str): - """Async deployment workflow.""" - project_root = Path.cwd() - - # Step 1: Partial validation (without assets - will be built) - console.print("\n[cyan]Validating project structure...[/cyan]") - try: - # Check required files/directories (except assets which is built) - required_checks = { - "package.json": project_root / "package.json", - "requirements.txt": project_root / "requirements.txt", - "server": project_root / "server", - "server/main.py": project_root / "server" / "main.py", - "widgets": project_root / "widgets", - } - - for name, path in required_checks.items(): - if not path.exists(): - console.print(f"[red]✗ Required {'directory' if path.is_dir() or name in ['server', 'widgets'] else 'file'} '{name}' not found.[/red]") - console.print("[dim]Make sure you're in a FastApps project root.[/dim]") - return False - - console.print("[green]✓ Project structure valid[/green]") - except Exception as e: - console.print(f"[red]✗ Validation failed: {e}[/red]") - return False - - # Step 2: Check if build is needed and build widgets - assets_dir = project_root / "assets" - - if not skip_build: - # Check if assets exists - if not assets_dir.exists(): - # Assets not found - ask user if they want to build - console.print("\n[yellow]⚠️ Assets directory not found[/yellow]") - if not skip_confirmation: - console.print("[dim]Press Enter to confirm (default: yes)[/dim]") - confirm = console.input("[bold]Build widgets now? (Y/n):[/bold] ").strip().lower() - # Default to 'yes' on empty input - if confirm and confirm not in ["y", "yes"]: - console.print("[yellow]Build cancelled. Cannot deploy without assets.[/yellow]") - return False - - # Build widgets (always rebuild to ensure latest state) - console.print("\n[cyan]Building widgets...[/cyan]") - try: - result = subprocess.run( - ["npm", "run", "build"], - capture_output=True, - text=True, - check=True, - ) - console.print("[green]✓ Widgets built successfully[/green]") - except subprocess.CalledProcessError as e: - console.print(f"[red]✗ Build failed: {e.stderr}[/red]") - console.print("[yellow]Tip: Run 'npm install' if packages are not installed[/yellow]") - return False - except FileNotFoundError: - console.print("[red]✗ npm not found[/red]") - return False - else: - # --no-build flag specified - if not assets_dir.exists(): - console.print("\n[red]✗ Assets directory not found and --no-build specified[/red]") - console.print("[yellow]Run 'npm run build' first or remove --no-build flag[/yellow]") - return False - console.print("\n[dim]Skipping build (--no-build specified)[/dim]") - - # Step 3: Check authentication first - if not CloudConfig.is_logged_in(): - console.print("\n[yellow]You are not logged in.[/yellow]") - console.print("[dim]Please run 'fastapps cloud login' first.[/dim]") - return False - - # Step 4: Determine project ID - project_id = None - project_name = None - should_link = False - - # Check if directory is linked to a project - linked_project = ProjectsManager.get_linked_project(project_root) - - if project_id_override: - # User explicitly specified project_id via flag - project_id = project_id_override - project_name = project_id_override - console.print(f"\n[cyan]Using project:[/cyan] {project_id}") - - elif linked_project: - # Use linked project - project_id = linked_project["projectId"] - project_name = linked_project["projectName"] - console.print(f"\n[cyan]Deploying to:[/cyan] {project_name} ({project_id})") - - else: - # No link → Interactive selection - console.print("\n[yellow]⚠️ No project linked to this directory[/yellow]\n") - - project_id, project_name, should_link = await handle_no_link() - if not project_id: - return False - - # Count widgets - assets_dir = project_root / "assets" - console.print() - - # Step 5: Confirmation - if not skip_confirmation: - console.print("[dim]Press Enter to confirm (default: yes)[/dim]") - confirm = console.input("[bold]Deploy to FastApps Cloud? (Y/n):[/bold] ").strip().lower() - # Default to 'yes' on empty input - if confirm and confirm not in ["y", "yes"]: - console.print("[yellow]Deployment cancelled[/yellow]") - return False - - # Step 6: Final validation before packaging - try: - packager = ArtifactPackager(project_root) - packager._validate_project() - except FileNotFoundError as e: - console.print(f"\n[red]✗ Validation failed: {e}[/red]") - console.print("[yellow]Make sure widgets are built and all required files exist.[/yellow]") - return False - - # Step 7: Package artifacts - console.print("\n[cyan]Packaging deployment artifacts...[/cyan]") - try: - with Progress( - SpinnerColumn(), - TextColumn("[progress.description]{task.description}"), - console=console, - ) as progress: - progress.add_task("Creating tarball...", total=None) - tarball_path = packager.package() - - # Show tarball size - tarball_size_mb = tarball_path.stat().st_size / (1024 * 1024) - console.print(f"[green]✓ Package created ({tarball_size_mb:.2f} MB)[/green]") - except Exception as e: - console.print(f"[red]✗ Packaging failed: {e}[/red]") - return False - - # Step 8: Upload and deploy - console.print("\n[cyan]Deploying to FastApps Cloud...[/cyan]") - - try: - async with CloudClient() as client: - with Progress( - SpinnerColumn(), - TextColumn("[progress.description]{task.description}"), - console=console, - ) as progress: - deploy_task = progress.add_task("Deploying safely...", total=None) - - # Status update callback - def update_status(status: str, attempt: int, max_attempts: int): - status_messages = { - "pending": f"Queued... ({attempt}/{max_attempts})", - "building": f"Building application... ({attempt}/{max_attempts})", - "deploying": f"Deploying to production... ({attempt}/{max_attempts})", - "deployed": "Deployment complete!", - "failed": "Deployment failed", - } - message = status_messages.get( - status, f"Status: {status} ({attempt}/{max_attempts})" - ) - progress.update(deploy_task, description=message) - - # Deploy - use project_name as the slug since server auto-creates projects - deployment = await client.create_deployment( - tarball_path, project_name, status_callback=update_status - ) - - # Show success - console.print("[green]✓ Deployment successful![/green]\n") - - # Update/create link if needed - if should_link: - ProjectsManager.link_project(project_id, project_name, project_root) - console.print(f"[green]✓ Linked this directory to project[/green]\n") - elif linked_project: - # Update last deployment - ProjectsManager.update_last_deployment(deployment.id, project_root) - - # Display deployment information - if deployment.domain: - success_panel = Panel( - f"[bold green]Your app is live at:[/bold green]\n\n" - f"[link=https://{deployment.domain}]https://{deployment.domain}[/link]\n\n" - f"[dim]Deployment ID: {deployment.id}[/dim]\n" - f"[dim]Project: {project_name} ({project_id})[/dim]\n\n", - title="🚀 Deployment Complete", - border_style="green", - ) - else: - success_panel = Panel( - f"[bold green]Deployment Complete[/bold green]\n" - f"[dim]Deployment ID: {deployment.id}[/dim]\n" - f"[dim]Project: {project_name} ({project_id})[/dim]\n\n" - f"[yellow]Domain information not available yet.[/yellow]\n" - f"[dim]Check status: fastapps cloud deployments {deployment.id}[/dim]", - title="🚀 Deployment Complete", - border_style="green", - ) - - console.print(success_panel) - return True - - except KeyboardInterrupt: - console.print("\n\n[yellow]✗ Deployment cancelled by user[/yellow]") - return False - - except RuntimeError as e: - console.print(f"\n[red]✗ Deployment Failed[/red]\n") - console.print(f"[yellow]{e}[/yellow]\n") - - if "Authentication expired" in str(e): - console.print("[dim]Run 'fastapps cloud login' to re-authenticate.[/dim]") - - return False - - except Exception as e: - console.print(f"\n[red]✗ Unexpected Error[/red]\n") - console.print(f"[yellow]{type(e).__name__}: {e}[/yellow]\n") - return False - - finally: - # Always clean up tarball, regardless of success or failure - tarball_path.unlink(missing_ok=True) diff --git a/fastapps/cli/commands/cloud/deployments.py b/fastapps/cli/commands/cloud/deployments.py deleted file mode 100644 index 0c16567..0000000 --- a/fastapps/cli/commands/cloud/deployments.py +++ /dev/null @@ -1,181 +0,0 @@ -"""Deployments command for FastApps Cloud.""" - -import asyncio -from datetime import datetime - -import click -from rich.console import Console -from rich.panel import Panel -from rich.table import Table - -from ....cloud.client import CloudClient -from ....cloud.config import CloudConfig - -console = Console() - - -@click.command() -@click.argument("deployment_id", required=False) -@click.option("--limit", default=20, help="Maximum number of deployments to show") -def deployments(deployment_id, limit): - """List or view deployment details. - - Without arguments: List all deployments - With deployment_id: Show detailed information - - Examples: - fastapps cloud deployments - fastapps cloud deployments dep_abc123 - fastapps cloud deployments --limit 50 - """ - asyncio.run(async_deployments(deployment_id, limit)) - - -async def async_deployments(deployment_id: str, limit: int): - """Async deployments workflow.""" - if not CloudConfig.is_logged_in(): - console.print("[yellow]You are not logged in.[/yellow]") - console.print("[dim]Run 'fastapps cloud login' to authenticate.[/dim]") - return - - try: - async with CloudClient() as client: - if deployment_id: - # Show detailed deployment - await show_deployment_detail(client, deployment_id) - else: - # List deployments - await list_deployments(client, limit) - - except RuntimeError as e: - error_msg = str(e) - console.print(f"\n[red]✗ Error[/red]") - console.print(f"[yellow]{error_msg}[/yellow]\n") - - if "Authentication expired" in error_msg: - console.print("[dim]Run 'fastapps cloud login' to re-authenticate.[/dim]") - elif "Network error" in error_msg: - console.print("[dim]Please check your connection and server status:[/dim]") - console.print(f"[dim]Server: {CloudConfig.get_cloud_url()}[/dim]") - except Exception as e: - console.print(f"\n[red]✗ Unexpected Error[/red]") - console.print(f"[yellow]{type(e).__name__}: {e}[/yellow]\n") - - -async def list_deployments(client: CloudClient, limit: int): - """List all deployments.""" - deployments_list = await client.list_deployments(limit) - - if not deployments_list: - console.print("\n[yellow]No deployments found.[/yellow]") - console.print("[dim]Run 'fastapps cloud deploy' to create your first deployment.[/dim]\n") - return - - # Create table - table = Table(title="\nDeployments", show_lines=False) - table.add_column("ID", style="cyan", no_wrap=True) - table.add_column("Project", style="white") - table.add_column("Status", style="white") - table.add_column("Domain", style="green") - table.add_column("Created", style="dim") - - for dep in deployments_list: - # Format status with color - status_style = { - "deployed": "[green]", - "building": "[yellow]", - "deploying": "[yellow]", - "pending": "[dim]", - "failed": "[red]", - }.get(dep.status, "") - status_text = f"{status_style}{dep.status}[/]" - - # Format domain - domain_text = dep.domain if dep.domain else "[dim]-[/dim]" - - # Format date - created_text = format_relative_time(dep.createdAt) - - table.add_row( - dep.id[:12] + "...", - dep.projectId or "[dim]unknown[/dim]", - status_text, - domain_text, - created_text, - ) - - console.print(table) - console.print() - - -async def show_deployment_detail(client: CloudClient, deployment_id: str): - """Show detailed deployment information.""" - deployment = await client.get_deployment(deployment_id) - - # Create table for deployment details - table = Table(show_header=False, box=None, padding=(0, 2)) - table.add_column("Field", style="cyan") - table.add_column("Value", style="white") - - table.add_row("ID", deployment.id) - table.add_row("User ID", deployment.userId) - if deployment.projectId: - table.add_row("Project", deployment.projectId) - - # Status with color - status_style = { - "deployed": "[green]", - "building": "[yellow]", - "deploying": "[yellow]", - "pending": "[dim]", - "failed": "[red]", - }.get(deployment.status, "") - table.add_row("Status", f"{status_style}{deployment.status}[/]") - - if deployment.domain: - table.add_row("Domain", f"[green]{deployment.domain}[/green]") - table.add_row("URL", f"[link=https://{deployment.domain}]https://{deployment.domain}[/link]") - - if deployment.url: - table.add_row("Provider URL", deployment.url) - - if deployment.deploymentId: - table.add_row("Provider Deployment ID", deployment.deploymentId) - - if deployment.blobSize: - size_mb = deployment.blobSize / (1024 * 1024) - table.add_row("Size", f"{size_mb:.2f} MB") - - table.add_row("Created", deployment.createdAt) - table.add_row("Updated", deployment.updatedAt) - - # Display in panel - panel = Panel( - table, - title="[bold]Deployment Details[/bold]", - border_style="cyan", - ) - console.print() - console.print(panel) - console.print() - - -def format_relative_time(iso_timestamp: str) -> str: - """Format ISO timestamp as relative time.""" - try: - dt = datetime.fromisoformat(iso_timestamp.replace("Z", "+00:00")) - now = datetime.now(dt.tzinfo) - diff = now - dt - - if diff.days > 0: - return f"{diff.days}d ago" - elif diff.seconds > 3600: - hours = diff.seconds // 3600 - return f"{hours}h ago" - elif diff.seconds > 60: - minutes = diff.seconds // 60 - return f"{minutes}m ago" - else: - return "just now" - except Exception: - return iso_timestamp diff --git a/fastapps/cli/commands/cloud/link.py b/fastapps/cli/commands/cloud/link.py deleted file mode 100644 index 6a792bd..0000000 --- a/fastapps/cli/commands/cloud/link.py +++ /dev/null @@ -1,200 +0,0 @@ -"""Link command for FastApps Cloud.""" - -import asyncio -import secrets -from pathlib import Path - -import click -from rich.console import Console -from rich.prompt import Prompt -from rich.table import Table - -from ....cloud.client import CloudClient -from ....cloud.config import CloudConfig -from ....cloud.projects_manager import ProjectsManager - -console = Console() - - -def generate_random_suffix() -> str: - """Generate a random 4-character suffix for project names.""" - return secrets.token_hex(2) - - -@click.command() -@click.argument("project_id", required=False) -def link(project_id): - """Link current directory to a cloud project. - - Without project_id: Shows interactive project selector - With project_id: Links directly to specified project - - Examples: - fastapps cloud projects link - fastapps cloud projects link proj_abc123 - """ - asyncio.run(async_link(project_id)) - - -async def async_link(project_id: str): - """Async link workflow.""" - if not CloudConfig.is_logged_in(): - console.print("[yellow]You are not logged in.[/yellow]") - console.print("[dim]Run 'fastapps cloud login' to authenticate.[/dim]") - return - - cwd = Path.cwd() - - # Check if already linked - existing = ProjectsManager.get_linked_project(cwd) - if existing: - console.print( - f"\n[yellow]⚠️ This directory is already linked to:[/yellow]" - ) - console.print(f" {existing['projectName']} ({existing['projectId']})\n") - - confirm = Prompt.ask("Relink to a different project?", choices=["yes", "no"], default="no") - if confirm != "yes": - console.print("[dim]Link cancelled.[/dim]") - return - - try: - async with CloudClient() as client: - if project_id: - # Direct link with project_id - await link_to_project(client, project_id, cwd) - else: - # Interactive selector - await interactive_link(client, cwd) - - except RuntimeError as e: - error_msg = str(e) - console.print(f"\n[red]✗ Error[/red]") - console.print(f"[yellow]{error_msg}[/yellow]\n") - - if "Authentication expired" in error_msg: - console.print("[dim]Run 'fastapps cloud login' to re-authenticate.[/dim]") - elif "Network error" in error_msg: - console.print("[dim]Please check your connection and server status:[/dim]") - console.print(f"[dim]Server: {CloudConfig.get_cloud_url()}[/dim]") - except Exception as e: - console.print(f"\n[red]✗ Unexpected Error[/red]") - console.print(f"[yellow]{type(e).__name__}: {e}[/yellow]\n") - - -async def link_to_project(client: CloudClient, project_id: str, cwd: Path): - """Link to a specific project by ID.""" - # Fetch project details to verify it exists - try: - project_data = await client.get_project(project_id) - project_name = project_data.get("project_id", project_id) # Use ID as fallback - - # Link - ProjectsManager.link_project(project_id, project_name, cwd) - - console.print(f"\n[green]✓ Linked[/green]") - console.print(f"[dim]Directory:[/dim] {cwd}") - console.print(f"[dim]Project:[/dim] {project_name} ({project_id})\n") - - except RuntimeError as e: - if "not found" in str(e): - console.print(f"\n[red]✗ Project {project_id} not found[/red]\n") - else: - raise - - -async def interactive_link(client: CloudClient, cwd: Path): - """Interactive project selection.""" - console.print("\n[cyan]Select a project to link:[/cyan]\n") - - # Fetch projects - response = await client.list_projects() - projects_list = response.get("projects", []) - - if not projects_list: - console.print("[yellow]No projects found.[/yellow]") - console.print("\n[cyan]Would you like to create a new project?[/cyan]") - - create = Prompt.ask("Create new project?", choices=["yes", "no"], default="yes") - if create == "yes": - await create_and_link_project(client, cwd) - return - - # Show projects table - table = Table(show_header=True, header_style="bold cyan") - table.add_column("#", style="dim", width=4) - table.add_column("Project ID", style="cyan") - table.add_column("Name", style="white") - table.add_column("Deployments", justify="right") - - for idx, project in enumerate(projects_list, 1): - table.add_row( - str(idx), - project.get("project_id", ""), - project.get("project_id", ""), # Using ID as name for now - str(project.get("deployment_count", 0)), - ) - - console.print(table) - console.print() - - # Add "Create new" option - console.print(f"[cyan][{len(projects_list) + 1}][/cyan] Create new project") - console.print() - - # Get selection - max_choice = len(projects_list) + 1 - choice = Prompt.ask( - "Select project", - default="1", - ) - - try: - choice_num = int(choice) - if choice_num < 1 or choice_num > max_choice: - console.print("[red]Invalid selection[/red]") - return - except ValueError: - console.print("[red]Invalid selection[/red]") - return - - if choice_num == max_choice: - # Create new project - await create_and_link_project(client, cwd) - else: - # Link to selected project - selected_project = projects_list[choice_num - 1] - project_id = selected_project.get("project_id") - project_name = selected_project.get("project_id") # Using ID as name - - ProjectsManager.link_project(project_id, project_name, cwd) - - console.print(f"\n[green]✓ Linked[/green]") - console.print(f"[dim]Directory:[/dim] {cwd}") - console.print(f"[dim]Project:[/dim] {project_name} ({project_id})\n") - - -async def create_and_link_project(client: CloudClient, cwd: Path): - """Create a new project and link to it.""" - # Get project name with random suffix - base_name = cwd.name - suffix = generate_random_suffix() - suggested_name = f"{base_name}-{suffix}" - project_name = Prompt.ask("Project name", default=suggested_name) - - console.print(f"\n[cyan]Creating project '{project_name}'...[/cyan]") - - # Create project on server - project = await client.create_project(project_name) - project_id = project.get("id") - - if not project_id: - console.print("[red]✗ Failed to create project[/red]") - return - - # Link - ProjectsManager.link_project(project_id, project_name, cwd) - - console.print(f"\n[green]✓ Project created and linked[/green]") - console.print(f"[dim]Directory:[/dim] {cwd}") - console.print(f"[dim]Project:[/dim] {project_name} ({project_id})\n") diff --git a/fastapps/cli/commands/cloud/login.py b/fastapps/cli/commands/cloud/login.py deleted file mode 100644 index 56f84cb..0000000 --- a/fastapps/cli/commands/cloud/login.py +++ /dev/null @@ -1,98 +0,0 @@ -"""Login command for FastApps Cloud.""" - -import asyncio - -import click -from rich.console import Console - -from ....cloud.config import CloudConfig -from ....deployer.auth import Authenticator - -console = Console() - - -@click.command() -def login(): - """Login to FastApps Cloud. - - Opens your browser for OAuth authentication via Clerk. - The access token will be saved to ~/.fastapps/config.json - """ - asyncio.run(async_login()) - - -async def async_login(): - """Async login workflow.""" - console.print("\n[cyan]FastApps Cloud Login[/cyan]\n") - - # Check if already logged in - if CloudConfig.is_logged_in(): - # Verify token is still valid - from ....cloud.client import CloudClient - try: - async with CloudClient() as client: - await client.get_current_user() - # Token is valid - console.print("[yellow]You are already logged in.[/yellow]") - confirm = console.input("Login again? (yes/no): ") - if confirm.lower() not in ["yes", "y"]: - console.print("[dim]Login cancelled.[/dim]") - return - except RuntimeError: - # Token expired or invalid, proceed with login - console.print("[yellow]Your session has expired.[/yellow]") - console.print("[dim]Logging in again...[/dim]\n") - - cloud_url = CloudConfig.get_cloud_url() - - console.print(f"[dim]Server: {cloud_url}[/dim]") - console.print("[dim]Your browser will open for authentication...[/dim]\n") - - try: - # Callback to display auth URL after 1 second - async def show_auth_url(url): - await asyncio.sleep(1) - console.print("[cyan]If browser didn't open, visit:[/cyan]") - console.print(f"[link={url}]{url}[/link]\n") - - def url_callback(url): - asyncio.create_task(show_auth_url(url)) - - authenticator = Authenticator(cloud_url) - token = await authenticator.authenticate(url_callback=url_callback) - - # Save token - CloudConfig.set_token(token) - - console.print("[green]✓ Successfully logged in![/green]\n") - - except ConnectionError: - console.print(f"\n[red]✗ Connection Error[/red]\n") - console.print(f"[yellow]Cannot connect to FastApps Cloud:[/yellow]") - console.print(f"[white]{cloud_url}[/white]\n") - console.print("[dim]Please check your connection and server URL.[/dim]") - return - - except TimeoutError: - console.print(f"\n[red]✗ Authentication Timeout[/red]\n") - console.print("[yellow]Authentication took too long (5 minutes limit)[/yellow]\n") - console.print("[dim]Please try again.[/dim]") - return - - except RuntimeError as e: - error_msg = str(e) - if "OAuth error" in error_msg: - console.print(f"\n[red]✗ OAuth Error[/red]\n") - console.print(f"[yellow]{error_msg}[/yellow]\n") - elif "timed out" in error_msg.lower(): - console.print(f"\n[red]✗ Authentication Timeout[/red]\n") - console.print("[yellow]Authentication took too long. Please try again.[/yellow]") - else: - console.print(f"\n[red]✗ Authentication Failed[/red]\n") - console.print(f"[yellow]{error_msg}[/yellow]") - return - - except Exception as e: - console.print(f"\n[red]✗ Unexpected Error[/red]\n") - console.print(f"[yellow]{type(e).__name__}: {e}[/yellow]\n") - return diff --git a/fastapps/cli/commands/cloud/logout.py b/fastapps/cli/commands/cloud/logout.py deleted file mode 100644 index 56e9227..0000000 --- a/fastapps/cli/commands/cloud/logout.py +++ /dev/null @@ -1,22 +0,0 @@ -"""Logout command for FastApps Cloud.""" - -import click -from rich.console import Console - -from ....cloud.config import CloudConfig - -console = Console() - - -@click.command() -def logout(): - """Logout from FastApps Cloud. - - Removes the saved authentication token from ~/.fastapps/config.json - """ - if not CloudConfig.is_logged_in(): - console.print("[yellow]You are not logged in.[/yellow]") - return - - CloudConfig.clear_token() - console.print("[green]✓ Logged out successfully[/green]") diff --git a/fastapps/cli/commands/cloud/projects.py b/fastapps/cli/commands/cloud/projects.py deleted file mode 100644 index fadd431..0000000 --- a/fastapps/cli/commands/cloud/projects.py +++ /dev/null @@ -1,281 +0,0 @@ -"""Projects command for FastApps Cloud.""" - -import asyncio -from datetime import datetime -from pathlib import Path - -import click -from rich.console import Console -from rich.panel import Panel -from rich.table import Table - -from ....cloud.client import CloudClient -from ....cloud.config import CloudConfig -from ....cloud.projects_manager import ProjectsManager - -console = Console() - - -@click.group() -def projects(): - """Manage projects and project links. - - Commands: - list - List cloud projects or show project details - link - Link current directory to a project - unlink - Unlink current directory from a project - status - Show current directory's project status - - Examples: - fastapps cloud projects list - fastapps cloud projects list --linked - fastapps cloud projects list my-project - fastapps cloud projects link - fastapps cloud projects status - """ - pass - - -@click.command() -@click.argument("project_id", required=False) -@click.option("--linked", is_flag=True, help="Show locally linked projects") -def list(project_id, linked): - """List cloud projects or show project details. - - Without arguments: List all projects from cloud - With --linked: Show all locally linked directories - With project_id: Show detailed project information with all deployments - - Examples: - fastapps cloud projects list - fastapps cloud projects list --linked - fastapps cloud projects list my-project - """ - asyncio.run(async_projects(project_id, linked)) - - -async def async_projects(project_id: str, show_linked: bool): - """Async projects workflow.""" - # Handle --linked flag (doesn't require authentication) - if show_linked: - show_linked_projects() - return - - # Cloud operations require authentication - if not CloudConfig.is_logged_in(): - console.print("[yellow]You are not logged in.[/yellow]") - console.print("[dim]Run 'fastapps cloud login' to authenticate.[/dim]") - return - - try: - async with CloudClient() as client: - if project_id: - # Show detailed project - await show_project_detail(client, project_id) - else: - # List projects - await list_projects(client) - - except RuntimeError as e: - error_msg = str(e) - console.print(f"\n[red]✗ Error[/red]") - console.print(f"[yellow]{error_msg}[/yellow]\n") - - if "Authentication expired" in error_msg: - console.print("[dim]Run 'fastapps cloud login' to re-authenticate.[/dim]") - elif "Network error" in error_msg: - console.print("[dim]Please check your connection and server status:[/dim]") - console.print(f"[dim]Server: {CloudConfig.get_cloud_url()}[/dim]") - except Exception as e: - console.print(f"\n[red]✗ Unexpected Error[/red]") - console.print(f"[yellow]{type(e).__name__}: {e}[/yellow]\n") - - -def show_linked_projects(): - """Show all locally linked projects.""" - projects = ProjectsManager.load_projects() - - if not projects: - console.print("\n[yellow]No linked projects found.[/yellow]") - console.print("[dim]Run 'fastapps cloud link' to link a project to a directory.[/dim]\n") - return - - # Create table - table = Table(title="\nLinked Projects", show_lines=False) - table.add_column("Directory", style="cyan", no_wrap=False) - table.add_column("Project ID", style="white") - table.add_column("Project Name", style="white") - table.add_column("Linked", style="dim") - table.add_column("Last Deployment", style="green", no_wrap=True) - - for cwd_str, project_info in projects.items(): - directory = Path(cwd_str).name # Show just the directory name - project_id = project_info.get("projectId", "-") - project_name = project_info.get("projectName", "-") - linked_at = project_info.get("linkedAt", "") - last_deployment = project_info.get("lastDeployment") - - # Format linked time - linked_ago = "" - if linked_at: - try: - dt = datetime.fromisoformat(linked_at.replace("Z", "+00:00")) - now = datetime.now(dt.tzinfo) - diff = now - dt - if diff.days > 0: - linked_ago = f"{diff.days}d ago" - elif diff.seconds > 3600: - linked_ago = f"{diff.seconds // 3600}h ago" - elif diff.seconds > 60: - linked_ago = f"{diff.seconds // 60}m ago" - else: - linked_ago = "just now" - except Exception: - linked_ago = "-" - - # Format last deployment - last_dep_text = last_deployment[:12] + "..." if last_deployment else "[dim]-[/dim]" - - table.add_row( - f"[dim]{cwd_str}[/dim]", - project_id, - project_name, - linked_ago, - last_dep_text, - ) - - console.print(table) - console.print() - - -async def list_projects(client: CloudClient): - """List all projects.""" - response = await client.list_projects() - projects_list = response.get("projects", []) - - if not projects_list or len(projects_list) == 0: - console.print("\n[yellow]No projects found.[/yellow]") - console.print("[dim]Run 'fastapps cloud deploy' to create your first project.[/dim]\n") - return - - # Create table - table = Table(title="\nProjects", show_lines=False) - table.add_column("Project ID", style="cyan", no_wrap=True) - table.add_column("Deployments", style="white", justify="right") - table.add_column("Latest Status", style="white") - table.add_column("Latest Domain", style="green") - - for project_info in projects_list: - project_id = project_info.get("project_id", "unknown") - deployment_count = project_info.get("deployment_count", 0) - latest_status = project_info.get("latest_status") - latest_domain = project_info.get("latest_domain") - - # Format status with color - if latest_status: - status_style = { - "deployed": "[green]", - "building": "[yellow]", - "deploying": "[yellow]", - "pending": "[dim]", - "failed": "[red]", - }.get(latest_status, "") - status_text = f"{status_style}{latest_status}[/]" - else: - status_text = "[dim]-[/dim]" - - # Format domain - domain_text = latest_domain if latest_domain else "[dim]-[/dim]" - - table.add_row( - project_id, - str(deployment_count), - status_text, - domain_text, - ) - - console.print(table) - console.print() - - -async def show_project_detail(client: CloudClient, project_id: str): - """Show detailed project information.""" - project_data = await client.get_project(project_id) - - deployments = project_data.get("deployments", []) - - # Create summary panel - summary = Table(show_header=False, box=None, padding=(0, 2)) - summary.add_column("Field", style="cyan") - summary.add_column("Value", style="white") - - summary.add_row("Project ID", project_id) - summary.add_row("Total Deployments", str(len(deployments))) - - if deployments: - latest = deployments[0] - latest_status = latest.get("status", "unknown") - status_style = { - "deployed": "[green]", - "building": "[yellow]", - "deploying": "[yellow]", - "pending": "[dim]", - "failed": "[red]", - }.get(latest_status, "") - summary.add_row("Latest Status", f"{status_style}{latest_status}[/]") - - if latest.get("domain"): - summary.add_row("Latest Domain", f"[green]{latest['domain']}[/green]") - - panel = Panel( - summary, - title="[bold]Project Details[/bold]", - border_style="cyan", - ) - console.print() - console.print(panel) - - # Show deployments table - if deployments: - console.print("\n[bold]Deployments:[/bold]\n") - - table = Table(show_lines=False) - table.add_column("ID", style="cyan", no_wrap=True) - table.add_column("Status", style="white") - table.add_column("Domain", style="green") - table.add_column("Created", style="dim") - - for dep in deployments: - status = dep.get("status", "unknown") - status_style = { - "deployed": "[green]", - "building": "[yellow]", - "deploying": "[yellow]", - "pending": "[dim]", - "failed": "[red]", - }.get(status, "") - status_text = f"{status_style}{status}[/]" - - domain_text = dep.get("domain") or "[dim]-[/dim]" - - table.add_row( - dep.get("id", "")[:12] + "...", - status_text, - domain_text, - dep.get("createdAt", ""), - ) - - console.print(table) - - console.print() - - -# Import and register subcommands -from .link import link as link_cmd -from .status import status as status_cmd -from .unlink import unlink as unlink_cmd - -projects.add_command(list) -projects.add_command(link_cmd, name="link") -projects.add_command(unlink_cmd, name="unlink") -projects.add_command(status_cmd, name="status") diff --git a/fastapps/cli/commands/cloud/status.py b/fastapps/cli/commands/cloud/status.py deleted file mode 100644 index 9d68220..0000000 --- a/fastapps/cli/commands/cloud/status.py +++ /dev/null @@ -1,101 +0,0 @@ -"""Status command for FastApps Cloud.""" - -import asyncio -from datetime import datetime -from pathlib import Path - -import click -from rich.console import Console -from rich.panel import Panel -from rich.table import Table - -from ....cloud.client import CloudClient -from ....cloud.config import CloudConfig -from ....cloud.projects_manager import ProjectsManager - -console = Console() - - -@click.command() -def status(): - """Show current project status. - - Displays information about the project linked to current directory, - including last deployment status. - - Example: - fastapps cloud projects status - """ - asyncio.run(async_status()) - - -async def async_status(): - """Async status workflow.""" - cwd = Path.cwd() - linked_project = ProjectsManager.get_linked_project(cwd) - - if not linked_project: - console.print("\n[yellow]⚠️ No project linked to this directory[/yellow]") - console.print("[dim]Run 'fastapps cloud link' to connect to a project[/dim]\n") - return - - # Build status table - table = Table(show_header=False, box=None, padding=(0, 2)) - table.add_column("Field", style="cyan") - table.add_column("Value", style="white") - - table.add_row("Directory", str(cwd)) - table.add_row("Project ID", linked_project["projectId"]) - table.add_row("Project Name", linked_project["projectName"]) - - # Format linked time - linked_at = linked_project.get("linkedAt") - if linked_at: - try: - dt = datetime.fromisoformat(linked_at.replace("Z", "+00:00")) - now = datetime.now(dt.tzinfo) - diff = now - dt - if diff.days > 0: - linked_ago = f"{diff.days}d ago" - elif diff.seconds > 3600: - linked_ago = f"{diff.seconds // 3600}h ago" - elif diff.seconds > 60: - linked_ago = f"{diff.seconds // 60}m ago" - else: - linked_ago = "just now" - table.add_row("Linked", linked_ago) - except Exception: - pass - - # Fetch last deployment if authenticated - last_dep_id = linked_project.get("lastDeployment") - if last_dep_id and CloudConfig.is_logged_in(): - try: - async with CloudClient() as client: - deployment = await client.get_deployment(last_dep_id) - status_style = { - "deployed": "[green]", - "building": "[yellow]", - "deploying": "[yellow]", - "pending": "[dim]", - "failed": "[red]", - }.get(deployment.status, "") - table.add_row( - "Last Deployment", - f"{status_style}{deployment.status}[/] ({last_dep_id[:12]}...)" - ) - if deployment.domain: - table.add_row("Domain", f"[green]{deployment.domain}[/green]") - except Exception: - # Ignore errors fetching deployment - pass - - # Display panel - panel = Panel( - table, - title="[bold]Project Status[/bold]", - border_style="cyan", - ) - console.print() - console.print(panel) - console.print() diff --git a/fastapps/cli/commands/cloud/unlink.py b/fastapps/cli/commands/cloud/unlink.py deleted file mode 100644 index f6b897f..0000000 --- a/fastapps/cli/commands/cloud/unlink.py +++ /dev/null @@ -1,40 +0,0 @@ -"""Unlink command for FastApps Cloud.""" - -from pathlib import Path - -import click -from rich.console import Console - -from ....cloud.projects_manager import ProjectsManager - -console = Console() - - -@click.command() -def unlink(): - """Unlink current directory from cloud project. - - Removes the link between this directory and its cloud project. - This does NOT delete the project or deployments. - - Example: - fastapps cloud projects unlink - """ - cwd = Path.cwd() - linked_project = ProjectsManager.get_linked_project(cwd) - - if not linked_project: - console.print("\n[yellow]This directory is not linked to any project.[/yellow]\n") - return - - project_name = linked_project["projectName"] - project_id = linked_project["projectId"] - - # Unlink - ProjectsManager.unlink_project(cwd) - - console.print(f"\n[green]✓ Unlinked[/green]") - console.print(f"[dim]Directory:[/dim] {cwd}") - console.print(f"[dim]Project:[/dim] {project_name} ({project_id})") - console.print(f"\n[dim]The project and its deployments are still on the server.[/dim]") - console.print(f"[dim]Run 'fastapps cloud link {project_id}' to relink.[/dim]\n") diff --git a/fastapps/cli/commands/cloud/whoami.py b/fastapps/cli/commands/cloud/whoami.py deleted file mode 100644 index e7a32cc..0000000 --- a/fastapps/cli/commands/cloud/whoami.py +++ /dev/null @@ -1,71 +0,0 @@ -"""Whoami command for FastApps Cloud.""" - -import asyncio - -import click -from rich.console import Console -from rich.panel import Panel -from rich.table import Table - -from ....cloud.client import CloudClient -from ....cloud.config import CloudConfig - -console = Console() - - -@click.command() -def whoami(): - """Show current logged-in user information. - - Displays your FastApps Cloud user details including - ID, email, username, and GitHub username. - """ - asyncio.run(async_whoami()) - - -async def async_whoami(): - """Async whoami workflow.""" - if not CloudConfig.is_logged_in(): - console.print("[yellow]You are not logged in.[/yellow]") - console.print("[dim]Run 'fastapps cloud login' to authenticate.[/dim]") - return - - try: - async with CloudClient() as client: - user = await client.get_current_user() - - # Create table for user info - table = Table(show_header=False, box=None, padding=(0, 2)) - table.add_column("Field", style="cyan") - table.add_column("Value", style="white") - - if user.email: - table.add_row("Email", user.email) - if user.username: - table.add_row("Username", f"@{user.username}") - if user.github_username: - table.add_row("GitHub", user.github_username) - - # Display in panel - panel = Panel( - table, - title="[bold]User Information[/bold]", - border_style="green", - ) - console.print() - console.print(panel) - console.print() - - except RuntimeError as e: - error_msg = str(e) - console.print(f"\n[red]✗ Error[/red]") - console.print(f"[yellow]{error_msg}[/yellow]\n") - - if "Authentication expired" in error_msg: - console.print("[dim]Run 'fastapps cloud login' to re-authenticate.[/dim]") - elif "Network error" in error_msg: - console.print("[dim]Please check your connection and server status:[/dim]") - console.print(f"[dim]Server: {CloudConfig.get_cloud_url()}[/dim]") - except Exception as e: - console.print(f"\n[red]✗ Unexpected Error[/red]") - console.print(f"[yellow]{type(e).__name__}: {e}[/yellow]\n") diff --git a/fastapps/cli/main.py b/fastapps/cli/main.py index 715f47d..349f8db 100644 --- a/fastapps/cli/main.py +++ b/fastapps/cli/main.py @@ -7,7 +7,6 @@ from .commands.allow_csp import add_csp_domain, list_csp_domains, remove_csp_domain from .commands.build import build_command -from .commands.cloud import cloud from .commands.create import create_widget from .commands.dev import start_dev_server from .commands.init import init_project @@ -142,11 +141,6 @@ def build(): """Build widgets for production.""" build_command() - -# Register cloud command group -cli.add_command(cloud) - - @cli.command() def auth_info(): """Show authentication setup information.""" diff --git a/fastapps/cloud/__init__.py b/fastapps/cloud/__init__.py deleted file mode 100644 index e1507e2..0000000 --- a/fastapps/cloud/__init__.py +++ /dev/null @@ -1,24 +0,0 @@ -"""FastApps Cloud CLI module. - -Provides commands for interacting with FastApps Cloud deployment platform. -""" - -from .client import CloudClient -from .config import CloudConfig -from .models import ( - DeploymentListItem, - DeploymentResponse, - ProjectInfo, - UserInfo, -) -from .projects_manager import ProjectsManager - -__all__ = [ - "CloudClient", - "CloudConfig", - "DeploymentListItem", - "DeploymentResponse", - "ProjectInfo", - "ProjectsManager", - "UserInfo", -] diff --git a/fastapps/cloud/client.py b/fastapps/cloud/client.py deleted file mode 100644 index 9014d11..0000000 --- a/fastapps/cloud/client.py +++ /dev/null @@ -1,413 +0,0 @@ -"""HTTP client for FastApps Cloud API.""" - -import asyncio -from pathlib import Path -from typing import Callable, Optional - -import httpx - -from .config import CloudConfig -from .models import DeploymentListItem, DeploymentResponse, UserInfo - - -class CloudClient: - """Client for interacting with FastApps Cloud API.""" - - def __init__(self, base_url: Optional[str] = None, token: Optional[str] = None): - """ - Initialize Cloud client. - - Args: - base_url: Base URL of cloud server (default: from config) - token: Authentication token (default: from config) - """ - self.base_url = (base_url or CloudConfig.get_cloud_url()).rstrip("/") - self.token = token or CloudConfig.get_token() - self._client = None - - @property - def client(self) -> httpx.AsyncClient: - """Lazy-initialized HTTP client.""" - if self._client is None: - self._client = httpx.AsyncClient(timeout=300.0) - return self._client - - def _get_headers(self) -> dict: - """Get authorization headers.""" - if not self.token: - raise RuntimeError("Not authenticated. Please run 'fastapps cloud login' first.") - return {"Authorization": f"Bearer {self.token}"} - - # ==================== User API ==================== - - async def get_current_user(self) -> UserInfo: - """ - Get current authenticated user information. - - Returns: - UserInfo object - - Raises: - RuntimeError: If not authenticated or request fails - """ - try: - response = await self.client.get( - f"{self.base_url}/api/me", - headers=self._get_headers(), - ) - - if response.status_code == 401: - raise RuntimeError("Authentication expired. Please run 'fastapps cloud login' again.") - - response.raise_for_status() - data = response.json() - - return UserInfo( - id=data["id"], - email=data.get("email"), - username=data.get("username"), - github_username=data.get("github_username"), - ) - - except httpx.HTTPStatusError as e: - raise RuntimeError(f"API error: {e.response.status_code} - {e.response.text}") - except httpx.RequestError as e: - raise RuntimeError(f"Network error: {e}") - - # ==================== Deployment API ==================== - - async def create_deployment( - self, - tarball_path: Path, - project_slug: str, - status_callback: Optional[Callable[[str, int, int], None]] = None, - max_poll_attempts: int = 60, - poll_interval: int = 5, - ) -> DeploymentResponse: - """ - Create a new deployment and poll until completion. - - Args: - tarball_path: Path to deployment tarball - project_slug: Project slug identifier - status_callback: Optional callback(status, attempt, max_attempts) - max_poll_attempts: Maximum status checks - poll_interval: Seconds between checks - - Returns: - DeploymentResponse object - - Raises: - RuntimeError: If deployment fails - """ - try: - # Upload deployment - with open(tarball_path, "rb") as f: - file_content = f.read() - - files = {"file": ("deployment.tar.gz", file_content, "application/gzip")} - data = {"project_slug": project_slug} - - response = await self.client.post( - f"{self.base_url}/api/deployments", - files=files, - data=data, - headers=self._get_headers(), - ) - - if response.status_code == 401: - raise RuntimeError("Authentication expired. Please run 'fastapps cloud login' again.") - - response.raise_for_status() - response_data = response.json() - deployment = response_data.get("deployment", {}) - deployment_id = deployment.get("id") - - if not deployment_id: - raise RuntimeError("Invalid response: missing deployment ID") - - # Poll deployment status - return await self._poll_deployment_status( - deployment_id, max_poll_attempts, poll_interval, status_callback - ) - - except httpx.HTTPStatusError as e: - raise RuntimeError(f"Deployment error: {e.response.status_code} - {e.response.text}") - except httpx.RequestError as e: - raise RuntimeError(f"Network error: {e}") - - async def _poll_deployment_status( - self, - deployment_id: str, - max_attempts: int, - interval: int, - status_callback: Optional[Callable[[str, int, int], None]] = None, - ) -> DeploymentResponse: - """Poll deployment status until completion.""" - for attempt in range(max_attempts): - await asyncio.sleep(interval) - - try: - response = await self.client.get( - f"{self.base_url}/api/deployments/{deployment_id}", - headers=self._get_headers(), - ) - - if response.status_code != 200: - continue - - data = response.json() - status = data.get("status") - - if status_callback: - status_callback(status, attempt + 1, max_attempts) - - if status == "deployed": - return DeploymentResponse( - id=data["id"], - userId=data["userId"], - projectId=data.get("projectId"), - status=status, - domain=data.get("domain"), - url=data.get("url"), - deploymentId=data.get("deploymentId"), - blobSize=data.get("blobSize"), - createdAt=data["createdAt"], - updatedAt=data["updatedAt"], - ) - elif status == "failed": - error_msg = data.get("error", "Deployment failed") - raise RuntimeError(f"Deployment failed: {error_msg}") - - except Exception as e: - continue - - raise RuntimeError(f"Deployment timeout after {max_attempts * interval} seconds") - - async def list_deployments(self, limit: int = 20) -> list[DeploymentListItem]: - """ - List deployments for authenticated user. - - Args: - limit: Maximum number of deployments to return - - Returns: - List of DeploymentListItem objects - - Raises: - RuntimeError: If request fails - """ - try: - response = await self.client.get( - f"{self.base_url}/api/deployments", - params={"limit": limit}, - headers=self._get_headers(), - ) - - if response.status_code == 401: - raise RuntimeError("Authentication expired. Please run 'fastapps cloud login' again.") - - response.raise_for_status() - data = response.json() - - return [ - DeploymentListItem( - id=item["id"], - projectId=item.get("projectId"), - status=item["status"], - domain=item.get("domain"), - url=item.get("url"), - createdAt=item["createdAt"], - ) - for item in data - ] - - except httpx.HTTPStatusError as e: - raise RuntimeError(f"API error: {e.response.status_code} - {e.response.text}") - except httpx.RequestError as e: - raise RuntimeError(f"Network error: {e}") - - async def get_deployment(self, deployment_id: str) -> DeploymentResponse: - """ - Get detailed deployment information. - - Args: - deployment_id: Deployment ID - - Returns: - DeploymentResponse object - - Raises: - RuntimeError: If deployment not found or request fails - """ - try: - response = await self.client.get( - f"{self.base_url}/api/deployments/{deployment_id}", - headers=self._get_headers(), - ) - - if response.status_code == 401: - raise RuntimeError("Authentication expired. Please run 'fastapps cloud login' again.") - elif response.status_code == 404: - raise RuntimeError(f"Deployment {deployment_id} not found") - - response.raise_for_status() - data = response.json() - - return DeploymentResponse( - id=data["id"], - userId=data["userId"], - projectId=data.get("projectId"), - status=data["status"], - domain=data.get("domain"), - url=data.get("url"), - deploymentId=data.get("deploymentId"), - blobSize=data.get("blobSize"), - createdAt=data["createdAt"], - updatedAt=data["updatedAt"], - ) - - except httpx.HTTPStatusError as e: - raise RuntimeError(f"API error: {e.response.status_code} - {e.response.text}") - except httpx.RequestError as e: - raise RuntimeError(f"Network error: {e}") - - async def delete_deployment(self, deployment_id: str) -> bool: - """ - Delete a deployment. - - Args: - deployment_id: Deployment ID - - Returns: - True if deleted successfully - - Raises: - RuntimeError: If deletion fails - """ - try: - response = await self.client.delete( - f"{self.base_url}/api/deployments/{deployment_id}", - headers=self._get_headers(), - ) - - if response.status_code == 401: - raise RuntimeError("Authentication expired. Please run 'fastapps cloud login' again.") - elif response.status_code == 404: - raise RuntimeError(f"Deployment {deployment_id} not found") - - response.raise_for_status() - return True - - except httpx.HTTPStatusError as e: - raise RuntimeError(f"API error: {e.response.status_code} - {e.response.text}") - except httpx.RequestError as e: - raise RuntimeError(f"Network error: {e}") - - # ==================== Project API ==================== - - async def create_project(self, name: str) -> dict: - """ - Create a new project. - - Args: - name: Project name - - Returns: - Project dictionary with id, name, userId - - Raises: - RuntimeError: If request fails - """ - try: - response = await self.client.post( - f"{self.base_url}/api/projects", - json={"name": name}, - headers=self._get_headers(), - ) - - if response.status_code == 401: - raise RuntimeError("Authentication expired. Please run 'fastapps cloud login' again.") - - response.raise_for_status() - data = response.json() - return data - - except httpx.HTTPStatusError as e: - raise RuntimeError(f"API error: {e.response.status_code} - {e.response.text}") - except httpx.RequestError as e: - raise RuntimeError(f"Network error: {e}") - - async def list_projects(self) -> list[dict]: - """ - List all projects for authenticated user. - - Returns: - List of project dictionaries - - Raises: - RuntimeError: If request fails - """ - try: - response = await self.client.get( - f"{self.base_url}/api/projects", - headers=self._get_headers(), - ) - - if response.status_code == 401: - raise RuntimeError("Authentication expired. Please run 'fastapps cloud login' again.") - - response.raise_for_status() - return response.json() - - except httpx.HTTPStatusError as e: - raise RuntimeError(f"API error: {e.response.status_code} - {e.response.text}") - except httpx.RequestError as e: - raise RuntimeError(f"Network error: {e}") - - async def get_project(self, project_id: str) -> dict: - """ - Get detailed project information. - - Args: - project_id: Project ID - - Returns: - Project dictionary - - Raises: - RuntimeError: If project not found or request fails - """ - try: - response = await self.client.get( - f"{self.base_url}/api/projects/{project_id}", - headers=self._get_headers(), - ) - - if response.status_code == 401: - raise RuntimeError("Authentication expired. Please run 'fastapps cloud login' again.") - elif response.status_code == 404: - raise RuntimeError(f"Project {project_id} not found") - - response.raise_for_status() - return response.json() - - except httpx.HTTPStatusError as e: - raise RuntimeError(f"API error: {e.response.status_code} - {e.response.text}") - except httpx.RequestError as e: - raise RuntimeError(f"Network error: {e}") - - # ==================== Cleanup ==================== - - async def close(self): - """Close HTTP client.""" - if self._client is not None: - await self._client.aclose() - - async def __aenter__(self): - """Context manager entry.""" - return self - - async def __aexit__(self, exc_type, exc_val, exc_tb): - """Context manager exit.""" - await self.close() diff --git a/fastapps/cloud/config.py b/fastapps/cloud/config.py deleted file mode 100644 index b6c4885..0000000 --- a/fastapps/cloud/config.py +++ /dev/null @@ -1,90 +0,0 @@ -"""Configuration management for FastApps Cloud.""" - -import json -import os -from pathlib import Path -from typing import Optional - - -class CloudConfig: - """Manages FastApps Cloud configuration and authentication tokens.""" - - # Default cloud server URL - DEFAULT_CLOUD_URL = "https://cloud-api.dooi.app" - - @staticmethod - def get_config_dir() -> Path: - """Get FastApps config directory.""" - config_dir = Path.home() / ".fastapps" - config_dir.mkdir(exist_ok=True) - return config_dir - - @staticmethod - def get_config_file() -> Path: - """Get config file path.""" - return CloudConfig.get_config_dir() / "config.json" - - @staticmethod - def load_config() -> dict: - """Load configuration from file.""" - config_file = CloudConfig.get_config_file() - if config_file.exists(): - try: - with open(config_file, "r") as f: - return json.load(f) - except Exception: - return {} - return {} - - @staticmethod - def save_config(config: dict) -> bool: - """Save configuration to file.""" - config_file = CloudConfig.get_config_file() - try: - with open(config_file, "w") as f: - json.dump(config, f, indent=2) - - # Set restrictive permissions (owner read/write only) - os.chmod(config_file, 0o600) - return True - except Exception: - return False - - @staticmethod - def get_cloud_url() -> str: - """Get cloud server URL.""" - config = CloudConfig.load_config() - return config.get("cloud_url", CloudConfig.DEFAULT_CLOUD_URL) - - @staticmethod - def set_cloud_url(url: str): - """Set cloud server URL.""" - config = CloudConfig.load_config() - config["cloud_url"] = url - CloudConfig.save_config(config) - - @staticmethod - def get_token() -> Optional[str]: - """Get stored authentication token.""" - config = CloudConfig.load_config() - return config.get("cloud_token") - - @staticmethod - def set_token(token: str): - """Save authentication token.""" - config = CloudConfig.load_config() - config["cloud_token"] = token - CloudConfig.save_config(config) - - @staticmethod - def clear_token(): - """Remove authentication token.""" - config = CloudConfig.load_config() - if "cloud_token" in config: - del config["cloud_token"] - CloudConfig.save_config(config) - - @staticmethod - def is_logged_in() -> bool: - """Check if user is logged in.""" - return CloudConfig.get_token() is not None diff --git a/fastapps/cloud/models.py b/fastapps/cloud/models.py deleted file mode 100644 index 097da16..0000000 --- a/fastapps/cloud/models.py +++ /dev/null @@ -1,61 +0,0 @@ -"""Data models for FastApps Cloud API responses.""" - -from dataclasses import dataclass -from typing import Optional - - -@dataclass -class UserInfo: - """User information from /api/me.""" - - id: str - email: Optional[str] = None - username: Optional[str] = None - github_username: Optional[str] = None - - -@dataclass -class DeploymentListItem: - """Deployment list item from /api/deployments.""" - - id: str - projectId: Optional[str] - status: str - domain: Optional[str] - url: Optional[str] - createdAt: str - - -@dataclass -class DeploymentResponse: - """Detailed deployment information from /api/deployments/{id}.""" - - id: str - userId: str - projectId: Optional[str] - status: str - domain: Optional[str] - url: Optional[str] - deploymentId: Optional[str] - blobSize: Optional[int] - createdAt: str - updatedAt: str - - -@dataclass -class ProjectInfo: - """Project information from /api/projects.""" - - projectId: str - deployment_count: int - latest_status: Optional[str] - latest_domain: Optional[str] - last_deployed: Optional[str] - - -@dataclass -class ProjectDetail: - """Detailed project information from /api/projects/{id}.""" - - projectId: str - deployments: list[DeploymentListItem] diff --git a/fastapps/cloud/projects_manager.py b/fastapps/cloud/projects_manager.py deleted file mode 100644 index 2375d95..0000000 --- a/fastapps/cloud/projects_manager.py +++ /dev/null @@ -1,131 +0,0 @@ -"""Manages local directory to cloud project mappings.""" - -import json -from datetime import datetime -from pathlib import Path -from typing import Optional - -from .config import CloudConfig - - -class ProjectsManager: - """Manages mappings between local directories and cloud projects.""" - - @staticmethod - def get_projects_file() -> Path: - """Get projects mapping file path.""" - return CloudConfig.get_config_dir() / "projects.json" - - @staticmethod - def load_projects() -> dict: - """Load projects mapping from file.""" - projects_file = ProjectsManager.get_projects_file() - if projects_file.exists(): - try: - with open(projects_file, "r") as f: - return json.load(f) - except Exception: - return {} - return {} - - @staticmethod - def save_projects(projects: dict): - """Save projects mapping to file.""" - projects_file = ProjectsManager.get_projects_file() - try: - with open(projects_file, "w") as f: - json.dump(projects, f, indent=2) - except Exception: - pass - - @staticmethod - def get_linked_project(cwd: Optional[Path] = None) -> Optional[dict]: - """ - Get project linked to current directory. - - Args: - cwd: Directory path (defaults to current directory) - - Returns: - Project info dict or None if not linked - """ - if cwd is None: - cwd = Path.cwd() - - cwd_str = str(cwd.resolve()) - projects = ProjectsManager.load_projects() - return projects.get(cwd_str) - - @staticmethod - def link_project( - project_id: str, project_name: str, cwd: Optional[Path] = None - ): - """ - Link current directory to a project. - - Args: - project_id: Cloud project ID - project_name: Project name - cwd: Directory path (defaults to current directory) - """ - if cwd is None: - cwd = Path.cwd() - - cwd_str = str(cwd.resolve()) - projects = ProjectsManager.load_projects() - - projects[cwd_str] = { - "projectId": project_id, - "projectName": project_name, - "linkedAt": datetime.utcnow().isoformat() + "Z", - "lastDeployment": None, - } - - ProjectsManager.save_projects(projects) - - @staticmethod - def update_last_deployment(deployment_id: str, cwd: Optional[Path] = None): - """ - Update last deployment ID for linked project. - - Args: - deployment_id: Deployment ID - cwd: Directory path (defaults to current directory) - """ - if cwd is None: - cwd = Path.cwd() - - cwd_str = str(cwd.resolve()) - projects = ProjectsManager.load_projects() - - if cwd_str in projects: - projects[cwd_str]["lastDeployment"] = deployment_id - ProjectsManager.save_projects(projects) - - @staticmethod - def unlink_project(cwd: Optional[Path] = None): - """ - Unlink current directory from project. - - Args: - cwd: Directory path (defaults to current directory) - """ - if cwd is None: - cwd = Path.cwd() - - cwd_str = str(cwd.resolve()) - projects = ProjectsManager.load_projects() - - if cwd_str in projects: - del projects[cwd_str] - ProjectsManager.save_projects(projects) - - @staticmethod - def list_linked_projects() -> dict: - """ - List all linked projects. - - Returns: - Dict mapping directory paths to project info - """ - return ProjectsManager.load_projects() diff --git a/fastapps/deployer/__init__.py b/fastapps/deployer/__init__.py deleted file mode 100644 index fed2902..0000000 --- a/fastapps/deployer/__init__.py +++ /dev/null @@ -1,15 +0,0 @@ -"""FastApps Deployment Module - -Handles OAuth authentication, artifact packaging, and deployment to remote servers. -""" - -from .auth import Authenticator -from .client import DeployClient, DeploymentResult -from .packager import ArtifactPackager - -__all__ = [ - "Authenticator", - "DeployClient", - "DeploymentResult", - "ArtifactPackager", -] diff --git a/fastapps/deployer/auth.py b/fastapps/deployer/auth.py deleted file mode 100644 index 94ca784..0000000 --- a/fastapps/deployer/auth.py +++ /dev/null @@ -1,223 +0,0 @@ -"""OAuth 2.1 PKCE authentication for FastApps deployment.""" - -import asyncio -import hashlib -import secrets -import webbrowser -from typing import Optional, Tuple -from urllib.parse import parse_qs, urlencode, urlparse -import html -import httpx -from aiohttp import web - - -class Authenticator: - """Handles OAuth 2.1 PKCE flow for FastApps Cloud authentication.""" - - def __init__(self, deploy_url: str): - """ - Initialize OAuth authenticator. - - Args: - deploy_url: Base URL of deployment server (e.g., https://deploy.fastapps.org) - """ - self.deploy_url = deploy_url.rstrip("/") - self.auth_url = f"{self.deploy_url}/oauth/authorize" - self.token_url = f"{self.deploy_url}/oauth/token" - self.redirect_uri = "http://localhost:8765/callback" - self.client_id = "fastapps-cli" - - def _generate_pkce_pair(self) -> Tuple[str, str]: - """ - Generate PKCE code verifier and challenge. - - Returns: - Tuple of (code_verifier, code_challenge) - """ - import base64 - - # Generate random code verifier (43-128 characters) - code_verifier = secrets.token_urlsafe(64) - - # Generate SHA256 code challenge (base64url-encoded per RFC 7636) - code_challenge = ( - base64.urlsafe_b64encode( - hashlib.sha256(code_verifier.encode()).digest() - ) - .decode() - .rstrip("=") - ) - - return code_verifier, code_challenge - - async def authenticate(self, url_callback=None) -> str: - """ - Perform OAuth PKCE flow to get access token. - - Args: - url_callback: Optional callback function(url) to receive auth URL - - Returns: - Access token string - - Raises: - RuntimeError: If authentication fails - """ - # Generate PKCE pair - code_verifier, code_challenge = self._generate_pkce_pair() - - # Start local callback server - auth_code_future = asyncio.Future() - app = self._create_callback_app(auth_code_future) - runner = web.AppRunner(app) - await runner.setup() - site = web.TCPSite(runner, "localhost", 8765) - await site.start() - - try: - # Build authorization URL - auth_params = { - "client_id": self.client_id, - "response_type": "code", - "redirect_uri": self.redirect_uri, - "code_challenge": code_challenge, - "code_challenge_method": "S256", - "scope": "deploy", - } - auth_full_url = f"{self.auth_url}?{urlencode(auth_params)}" - - # Open browser for user authorization - webbrowser.open(auth_full_url) - - # Notify callback with auth URL - if url_callback: - url_callback(auth_full_url) - - # Wait for callback with timeout - try: - auth_code = await asyncio.wait_for(auth_code_future, timeout=300) - except asyncio.TimeoutError: - raise RuntimeError("Authentication timed out (5 minutes)") - - # Exchange authorization code for access token - access_token = await self._exchange_code_for_token( - auth_code, code_verifier - ) - - return access_token - - finally: - await runner.cleanup() - - def _create_callback_app(self, auth_code_future: asyncio.Future) -> web.Application: - """ - Create aiohttp app for OAuth callback. - - Args: - auth_code_future: Future to resolve with auth code - - Returns: - aiohttp Application - """ - - async def callback_handler(request: web.Request) -> web.Response: - """Handle OAuth callback.""" - # Parse query parameters - query_params = request.rel_url.query - - # Check for error - if "error" in query_params: - error = query_params.get("error", "unknown_error") - error_desc = query_params.get("error_description", "No description") - if not auth_code_future.done(): - auth_code_future.set_exception( - RuntimeError(f"OAuth error: {error} - {error_desc}") - ) - return web.Response( - text=f"

Authentication Failed

{html.escape(error_desc)}

", - content_type="text/html", - ) - - # Extract authorization code - auth_code = query_params.get("code") - if not auth_code: - if not auth_code_future.done(): - auth_code_future.set_exception( - RuntimeError("No authorization code received") - ) - return web.Response( - text="

Error

No authorization code received

", - content_type="text/html", - ) - - # Resolve future with auth code (avoid race condition) - if not auth_code_future.done(): - auth_code_future.set_result(auth_code) - - # Return success page - return web.Response( - text=""" - - FastApps - Authentication Success - -

✓ Authentication Successful

-

You can close this window and return to the terminal.

- - - """, - content_type="text/html", - ) - - app = web.Application() - app.router.add_get("/callback", callback_handler) - return app - - async def _exchange_code_for_token( - self, auth_code: str, code_verifier: str - ) -> str: - """ - Exchange authorization code for access token. - - Args: - auth_code: Authorization code from OAuth callback - code_verifier: PKCE code verifier - - Returns: - Access token - - Raises: - RuntimeError: If token exchange fails - """ - async with httpx.AsyncClient() as client: - try: - response = await client.post( - self.token_url, - data={ - "grant_type": "authorization_code", - "client_id": self.client_id, - "code": auth_code, - "redirect_uri": self.redirect_uri, - "code_verifier": code_verifier, - }, - headers={"Content-Type": "application/x-www-form-urlencoded"}, - ) - - if response.status_code != 200: - raise RuntimeError( - f"Token exchange failed: {response.status_code} - {response.text}" - ) - - token_data = response.json() - access_token = token_data.get("access_token") - - if not access_token: - raise RuntimeError("No access token in response") - - return access_token - - except httpx.ConnectError as e: - raise ConnectionError(f"Cannot connect to server: {e}") - except httpx.TimeoutException as e: - raise TimeoutError(f"Request timed out: {e}") - except httpx.RequestError as e: - raise RuntimeError(f"Token exchange request failed: {e}") diff --git a/fastapps/deployer/client.py b/fastapps/deployer/client.py deleted file mode 100644 index ea92f8e..0000000 --- a/fastapps/deployer/client.py +++ /dev/null @@ -1,224 +0,0 @@ -"""HTTP client for FastApps deployment API.""" - -import asyncio -from dataclasses import dataclass -from pathlib import Path -from typing import Callable, Optional - -import httpx - - -@dataclass -class DeploymentResult: - """Result of a deployment operation.""" - - success: bool - deployment_url: Optional[str] = None - deployment_id: Optional[str] = None - message: Optional[str] = None - error: Optional[str] = None - status: Optional[str] = None - domain: Optional[str] = None - - -class DeployClient: - """Client for interacting with FastApps deployment server.""" - - def __init__(self, base_url: str, access_token: str, project_id: str = "default"): - """ - Initialize deployment client. - - Args: - base_url: Base URL of deployment server - access_token: OAuth access token - project_id: Project identifier for deployment - """ - self.base_url = base_url.rstrip("/") - self.access_token = access_token - self.project_id = project_id - self._client = None - - @property - def client(self) -> httpx.AsyncClient: - """Lazy-initialized HTTP client.""" - if self._client is None: - self._client = httpx.AsyncClient(timeout=300.0) # 5 minute timeout - return self._client - - async def deploy( - self, - tarball_path: Path, - max_poll_attempts: int = 60, - poll_interval: int = 5, - status_callback: Optional[Callable[[str, int, int], None]] = None, - ) -> DeploymentResult: - """ - Deploy artifact to server and poll until completion. - - Args: - tarball_path: Path to deployment tarball - max_poll_attempts: Maximum number of status checks (default: 60) - poll_interval: Seconds between status checks (default: 5) - status_callback: Optional callback function(status, attempt, max_attempts) - - Returns: - DeploymentResult with deployment information - - Raises: - RuntimeError: If deployment fails - """ - try: - # Read tarball content to avoid file handle issues - with open(tarball_path, "rb") as f: - file_content = f.read() - - files = {"file": ("deployment.tar.gz", file_content, "application/gzip")} - data = {"project_id": self.project_id} - - # Send deployment request - response = await self.client.post( - f"{self.base_url}/api/deployments", - files=files, - data=data, - headers={"Authorization": f"Bearer {self.access_token}"}, - ) - - # Handle response - if response.status_code == 200: - response_data = response.json() - deployment = response_data.get("deployment", {}) - deployment_id = deployment.get("id") - - if not deployment_id: - return DeploymentResult( - success=False, - error="Invalid response: missing deployment ID", - ) - - # Poll deployment status - return await self._poll_deployment_status( - deployment_id, max_poll_attempts, poll_interval, status_callback - ) - - elif response.status_code == 401: - return DeploymentResult( - success=False, - error="Authentication failed. Please run 'fastapps deploy' again to re-authenticate.", - ) - elif response.status_code == 400: - # Fix: Check if content-type contains json (handles charset) - content_type = response.headers.get("content-type", "") - data = response.json() if "application/json" in content_type else {} - error_msg = data.get("error", response.text) - return DeploymentResult( - success=False, - error=f"Invalid deployment package: {error_msg}", - ) - else: - return DeploymentResult( - success=False, - error=f"Deployment failed: {response.status_code} - {response.text}", - ) - - except httpx.ConnectError as e: - return DeploymentResult( - success=False, - error=f"Connection error: Cannot reach deployment server", - ) - except httpx.TimeoutException as e: - return DeploymentResult( - success=False, - error=f"Network timeout: Request took too long", - ) - except httpx.RequestError as e: - return DeploymentResult( - success=False, - error=f"Network error during deployment: {e}", - ) - except Exception as e: - return DeploymentResult( - success=False, - error=f"Unexpected error: {e}", - ) - - async def _poll_deployment_status( - self, - deployment_id: str, - max_attempts: int, - interval: int, - status_callback: Optional[Callable[[str, int, int], None]] = None, - ) -> DeploymentResult: - """ - Poll deployment status until completion or failure. - - Args: - deployment_id: Deployment ID to check - max_attempts: Maximum polling attempts - interval: Seconds between attempts - status_callback: Optional callback function(status, attempt, max_attempts) - - Returns: - DeploymentResult with final status - """ - for attempt in range(max_attempts): - await asyncio.sleep(interval) - - try: - response = await self.client.get( - f"{self.base_url}/api/deployments/{deployment_id}", - headers={"Authorization": f"Bearer {self.access_token}"}, - ) - - if response.status_code != 200: - continue - - data = response.json() - status = data.get("status") - domain = data.get("domain") - - # Call status callback if provided - if status_callback: - status_callback(status, attempt + 1, max_attempts) - - if status == "deployed": - deployment_url = f"https://{domain}" if domain else None - return DeploymentResult( - success=True, - deployment_id=deployment_id, - deployment_url=deployment_url, - domain=domain, - status=status, - message="Deployment completed successfully", - ) - elif status == "failed": - error_msg = data.get("error", "Deployment failed") - return DeploymentResult( - success=False, - deployment_id=deployment_id, - status=status, - error=error_msg, - ) - - except Exception as e: - # Continue polling on transient errors - continue - - # Timeout - return DeploymentResult( - success=False, - deployment_id=deployment_id, - error=f"Deployment timeout: still in progress after {max_attempts * interval} seconds", - ) - - async def close(self): - """Close HTTP client.""" - if self._client is not None: - await self._client.aclose() - - async def __aenter__(self): - """Context manager entry.""" - return self - - async def __aexit__(self, exc_type, exc_val, exc_tb): - """Context manager exit.""" - await self.close() diff --git a/fastapps/deployer/packager.py b/fastapps/deployer/packager.py deleted file mode 100644 index faa6d1b..0000000 --- a/fastapps/deployer/packager.py +++ /dev/null @@ -1,257 +0,0 @@ -"""Artifact packaging for FastApps deployment.""" - -import json -import tarfile -import tempfile -from datetime import datetime -from pathlib import Path -from typing import Dict, List - -from fastapps.core.utils import get_cli_version - -class ArtifactPackager: - """Packages FastApps project for deployment.""" - - def __init__(self, project_root: Path): - """ - Initialize artifact packager. - - Args: - project_root: Root directory of FastApps project - """ - self.project_root = project_root - - def package(self) -> Path: - """ - Create deployment package as tar.gz. - - Returns: - Path to created tarball - - Raises: - FileNotFoundError: If required files/directories are missing - RuntimeError: If packaging fails - """ - import shutil - - # Validate project structure - self._validate_project() - - # Use TemporaryDirectory context manager for automatic cleanup - with tempfile.TemporaryDirectory(prefix="fastapps-deploy-") as temp_dir_str: - temp_dir = Path(temp_dir_str) - - try: - # Create manifest - manifest = self._create_manifest() - manifest_path = temp_dir / ".fastapps-manifest.json" - manifest_path.write_text(json.dumps(manifest, indent=2)) - - # Create tarball in temp directory - tarball_temp_path = temp_dir / "deployment.tar.gz" - - with tarfile.open(tarball_temp_path, "w:gz") as tar: - # Add manifest - tar.add( - manifest_path, - arcname=".fastapps-manifest.json", - ) - - # Add required directories and files - self._add_directory(tar, "assets") - self._add_directory(tar, "server") - - # Add configuration files - self._add_file(tar, "package.json") - self._add_file(tar, "requirements.txt") - - # Add optional files if they exist - for optional_file in ["README.md", ".env.example"]: - optional_path = self.project_root / optional_file - if optional_path.exists(): - tar.add(optional_path, arcname=optional_file) - - # Move tarball to project root with timestamp - from datetime import datetime - - timestamp = datetime.utcnow().strftime("%Y%m%d-%H%M%S") - final_path = self.project_root / f".fastapps-deploy-{timestamp}.tar.gz" - shutil.move(str(tarball_temp_path), str(final_path)) - - return final_path - - except Exception as e: - # TemporaryDirectory cleanup is automatic via context manager - raise RuntimeError(f"Failed to create deployment package: {e}") - - def _validate_project(self): - """ - Validate that project has required structure. - - Raises: - FileNotFoundError: If required files are missing - """ - required_dirs = ["assets", "server"] - required_files = ["package.json", "requirements.txt", "server/main.py"] - - for dir_name in required_dirs: - dir_path = self.project_root / dir_name - if not dir_path.exists(): - raise FileNotFoundError( - f"Required directory '{dir_name}' not found. " - f"Make sure you're in a FastApps project root." - ) - - for file_name in required_files: - file_path = self.project_root / file_name - if not file_path.exists(): - raise FileNotFoundError( - f"Required file '{file_name}' not found. " - f"Make sure you're in a FastApps project root." - ) - - def _create_manifest(self) -> Dict: - """ - Create deployment manifest with project metadata. - - Returns: - Manifest dictionary - """ - return { - "fastapps_version": get_cli_version(), - "timestamp": datetime.utcnow().isoformat() + "Z", - "project_name": self._get_project_name(), - "widgets": self._list_widgets(), - "dependencies": { - "python": self._parse_python_dependencies(), - "node": self._parse_node_dependencies(), - }, - } - - def _get_project_name(self) -> str: - """ - Extract project name from package.json. - - Returns: - Project name or 'unknown' - """ - try: - package_json_path = self.project_root / "package.json" - package_data = json.loads(package_json_path.read_text()) - return package_data.get("name", "unknown") - except Exception: - return "unknown" - - def _list_widgets(self) -> List[str]: - """ - List all built widget identifiers. - - Returns: - List of widget identifiers - """ - widgets = [] - assets_dir = self.project_root / "assets" - - if assets_dir.exists(): - for html_file in assets_dir.glob("*.html"): - # Extract widget identifier from filename - # Format: widgetid-hash.html - filename = html_file.stem - if "-" in filename: - widget_id = filename.rsplit("-", 1)[0] - widgets.append(widget_id) - - return sorted(set(widgets)) - - def _parse_python_dependencies(self) -> List[str]: - """ - Parse Python dependencies from requirements.txt. - - Returns: - List of package specifications - """ - try: - requirements_path = self.project_root / "requirements.txt" - requirements_text = requirements_path.read_text() - - dependencies = [] - for line in requirements_text.splitlines(): - line = line.strip() - # Skip comments and empty lines - if line and not line.startswith("#"): - dependencies.append(line) - - return dependencies - except Exception: - return [] - - def _parse_node_dependencies(self) -> Dict[str, str]: - """ - Parse Node.js dependencies from package.json. - - Returns: - Dictionary of package name to version - """ - try: - package_json_path = self.project_root / "package.json" - package_data = json.loads(package_json_path.read_text()) - return package_data.get("dependencies", {}) - except Exception: - return {} - - def _add_directory(self, tar: tarfile.TarFile, dir_name: str): - """ - Add directory to tarball with exclusions. - - Args: - tar: TarFile object - dir_name: Directory name relative to project root - """ - dir_path = self.project_root / dir_name - - if not dir_path.exists(): - return - - # Exclusion patterns - exclude_patterns = [ - "__pycache__", - "*.pyc", - ".DS_Store", - "*.swp", - ".venv", - "venv", - "env", - "node_modules", - ".git", - ] - - def should_exclude(path: Path) -> bool: - """Check if path matches exclusion patterns.""" - path_str = str(path) - for pattern in exclude_patterns: - if pattern in path_str: - return True - return False - - # Add directory recursively with exclusions - for item in dir_path.rglob("*"): - if should_exclude(item): - continue - - # Calculate relative path for archive - rel_path = item.relative_to(self.project_root) - - tar.add(item, arcname=str(rel_path)) - - def _add_file(self, tar: tarfile.TarFile, file_name: str): - """ - Add single file to tarball. - - Args: - tar: TarFile object - file_name: File name relative to project root - """ - file_path = self.project_root / file_name - - if file_path.exists(): - tar.add(file_path, arcname=file_name)