#!/usr/bin/env python3 """OTA CLI - Over-The-Air update automation for BrowserOS""" from pathlib import Path from typing import Optional import typer from ..common.context import Context from ..common.env import EnvConfig from ..common.module import ValidationError from ..common.sparkle import sparkle_sign_file from ..common.utils import log_info, log_error, log_success from ..modules.ota import ServerOTAModule from ..modules.ota.common import ( get_appcast_path, SERVER_PLATFORMS, ) from ..modules.storage import get_r2_client, upload_file_to_r2 app = typer.Typer( help="OTA (Over-The-Air) update automation", pretty_exceptions_enable=False, pretty_exceptions_show_locals=False, ) server_app = typer.Typer( help="BrowserOS Server OTA commands", pretty_exceptions_enable=False, pretty_exceptions_show_locals=False, ) app.add_typer(server_app, name="server") def create_ota_context() -> Context: """Create Context for OTA operations""" return Context( chromium_src=Path(), architecture="", build_type="release", ) def execute_module(ctx: Context, module) -> None: """Execute a single module with validation""" try: module.validate(ctx) module.execute(ctx) except ValidationError as e: log_error(f"Validation failed: {e}") raise typer.Exit(1) except Exception as e: log_error(f"Module failed: {e}") raise typer.Exit(1) @server_app.command("release") def server_release( version: str = typer.Option( ..., "--version", "-v", help="Version to release (e.g., 0.0.69)" ), channel: str = typer.Option( "alpha", "--channel", "-c", help="Release channel: alpha or prod" ), platform: Optional[str] = typer.Option( None, "--platform", "-p", help="Platform(s) to process, comma-separated (darwin_arm64, darwin_x64, linux_arm64, linux_x64, windows_x64)" ), ): """Release BrowserOS Server OTA update Downloads server binaries from R2 (artifacts/server/latest/), signs them, creates Sparkle update packages, and uploads to R2. \b Full Release (all platforms): browseros ota server release --version 0.0.69 --channel alpha \b Single Platform: browseros ota server release --version 0.0.69 --platform darwin_arm64 \b Multiple Platforms: browseros ota server release --version 0.0.69 --platform darwin_arm64,darwin_x64 """ log_info(f"šŸš€ BrowserOS Server OTA v{version}") log_info("=" * 70) ctx = create_ota_context() module = ServerOTAModule( version=version, channel=channel, platform_filter=platform, ) execute_module(ctx, module) @server_app.command("release-appcast") def server_release_appcast( channel: str = typer.Option( "alpha", "--channel", "-c", help="Release channel: alpha or prod" ), appcast_file: Optional[Path] = typer.Option( None, "--file", "-f", help="Custom appcast file to upload" ), ): """Publish appcast XML to make the release live This is the final step after 'release' uploads artifacts. Publishing the appcast makes the update available to clients. \b Release alpha appcast: browseros ota server release-appcast --channel alpha \b Release production appcast: browseros ota server release-appcast --channel prod \b Release custom appcast file: browseros ota server release-appcast --file /path/to/appcast.xml """ if appcast_file: if not appcast_file.exists(): log_error(f"Appcast file not found: {appcast_file}") raise typer.Exit(1) source_path = appcast_file else: source_path = get_appcast_path(channel) if not source_path.exists(): log_error(f"Appcast file not found: {source_path}") log_error("Run 'browseros ota server release' first to generate the appcast") raise typer.Exit(1) if channel == "alpha": r2_key = "appcast-server.alpha.xml" else: r2_key = "appcast-server.xml" log_info(f"šŸ“¤ Uploading {source_path.name} to {r2_key}...") env = EnvConfig() if not env.has_r2_config(): log_error("R2 configuration not set. Required env vars: R2_ACCOUNT_ID, R2_ACCESS_KEY_ID, R2_SECRET_ACCESS_KEY") raise typer.Exit(1) r2_client = get_r2_client(env) if not r2_client: log_error("Failed to create R2 client") raise typer.Exit(1) if upload_file_to_r2(r2_client, source_path, r2_key, env.r2_bucket): cdn_url = f"https://cdn.browseros.com/{r2_key}" log_success(f"āœ… Published: {cdn_url}") else: log_error("Upload failed") raise typer.Exit(1) @server_app.command("list-platforms") def server_list_platforms(): """List available server platforms""" log_info("\nšŸ“¦ Available Server Platforms:") log_info("-" * 50) for p in SERVER_PLATFORMS: log_info(f" {p['name']:<15} {p['os']:<10} {p['arch']}") log_info("-" * 50) @app.command("test-signing") def test_signing( file_path: Path = typer.Argument(..., help="File to sign for testing"), ): """Test Sparkle Ed25519 signing on a file \b Example: browseros ota test-signing /path/to/file.zip """ if not file_path.exists(): log_error(f"File not found: {file_path}") raise typer.Exit(1) env = EnvConfig() if not env.has_sparkle_key(): log_error("SPARKLE_PRIVATE_KEY not set") raise typer.Exit(1) log_info(f"\nšŸ” Testing Sparkle Ed25519 signing") log_info(f"File: {file_path}") log_info("-" * 60) sig, length = sparkle_sign_file(file_path, env) if not sig: log_error("Signing failed") raise typer.Exit(1) log_success(f"āœ… Signed successfully") log_info(f" Signature: {sig[:50]}...") log_info(f" Length: {length}") @server_app.callback(invoke_without_command=True) def server_main(ctx: typer.Context): """BrowserOS Server OTA commands \b Release (upload artifacts): browseros ota server release --version 0.0.36 \b Release Appcast (make live): browseros ota server release-appcast --channel alpha \b List Platforms: browseros ota server list-platforms """ if ctx.invoked_subcommand is None: typer.echo("Use --help for usage information") typer.echo("Available commands: release, release-appcast, list-platforms") raise typer.Exit(0) @app.callback(invoke_without_command=True) def main(ctx: typer.Context): """OTA update automation for BrowserOS \b Server OTA: browseros ota server release --version 0.0.36 browseros ota server release-appcast --channel alpha browseros ota server list-platforms """ if ctx.invoked_subcommand is None: typer.echo("Use --help for usage information") typer.echo("Available subcommands: server") raise typer.Exit(0) if __name__ == "__main__": app()