#!/usr/bin/env python3 """ Commonwealth Online Consumer Server CLI. Production-ready command-line interface for hosting Commonwealth Online servers in cloud and on-premises environments. Usage: commonwealth serve [--config CONFIG_PATH] [--host HOST] [--port PORT] commonwealth status commonwealth clients commonwealth world time HHmm commonwealth world weather FORM_ID commonwealth config init OUTPUT_PATH """ from __future__ import annotations import json import sys import time from datetime import datetime from pathlib import Path from typing import Any, Optional import typer from rich.console import Console from rich.table import Table from rich.panel import Panel from rich.text import Text from rich import box from server_service import ServerService, ServerConfig from config import Config, load_config, save_config, generate_default_config, validate_config # Rich console for beautiful output console = Console() app = typer.Typer( name="commonwealth", help="Commonwealth Online Server CLI", pretty_exceptions_enable=False, ) # Global service instance _service: Optional[ServerService] = None def get_service() -> ServerService: """Get or initialize the global service instance.""" global _service if _service is None: _service = ServerService() return _service def log_callback(message: str) -> None: """Callback for server logs from the service.""" timestamp = datetime.now().strftime("%H:%M:%S") console.print(f"[dim]{timestamp}[/dim] {message}") def print_startup_banner(config: ServerConfig) -> None: """Print a friendly startup banner.""" banner = f""" ================================================================================ {config.server_name} Commonwealth Online Consumer Server ================================================================================ Server Configuration: • Binding to {config.host}:{config.port} • Max players: {config.max_players} • Discovery port (UDP): 7778 Connection Instructions: • Local: 127.0.0.1:{config.port} • LAN: :{config.port} • Remote: Forward port {config.port}/TCP on your router Management Commands: • Check status: commonwealth status • List clients: commonwealth clients • Set time: commonwealth world time HHmm • Set weather: commonwealth world weather FORM_ID Shutdown: • Press Ctrl+C to stop the server gracefully ================================================================================ """ print(banner) @app.command() def serve( config: Optional[str] = typer.Option( None, "--config", "-c", help="Path to config.json file", ), host: Optional[str] = typer.Option( None, "--host", "-H", help="Server bind address (overrides config)", ), port: Optional[int] = typer.Option( None, "--port", "-p", help="Server port (overrides config)", ), ) -> None: """Start the Commonwealth Online relay server.""" try: # Load or create config if config: try: cfg = load_config(config) except FileNotFoundError: console.print(f"[red]Error: Config file not found: {config}[/red]") raise typer.Exit(code=1) except json.JSONDecodeError as e: console.print(f"[red]Error: Invalid JSON in config file: {e}[/red]") raise typer.Exit(code=1) else: cfg = Config() # Apply CLI overrides if host: cfg.host = host if port: cfg.port = port # Validate config is_valid, errors = validate_config(cfg) if not is_valid: console.print("[red]Configuration validation failed:[/red]") for error in errors: console.print(f" • {error}") raise typer.Exit(code=1) # Create and configure service server_config = ServerConfig( host=cfg.host, port=cfg.port, server_name=cfg.server_name, max_players=cfg.max_players, log_verbosity=cfg.log_verbosity, ) service = get_service() service.config = server_config service.add_log_listener(log_callback) # Print startup banner print_startup_banner(server_config) # Start server (blocks until shutdown) console.print("[yellow]Starting server...[/yellow]") service.serve_forever() except KeyboardInterrupt: console.print("\n[yellow]Shutdown signal received. Stopping server...[/yellow]") service = get_service() service.stop() console.print("[green]Server stopped gracefully.[/green]") except Exception as e: console.print(f"[red]Fatal error: {e}[/red]") raise typer.Exit(code=1) @app.command() def status( json_output: bool = typer.Option( False, "--json", "-j", help="Output as JSON", ), ) -> None: """Display server status and statistics.""" service = get_service() stats = service.get_stats() if json_output: print(service.stats_to_json(stats)) return # Pretty table output status_text = "[green]RUNNING[/green]" if stats.is_running else "[red]STOPPED[/red]" uptime_text = _format_uptime(stats.uptime_seconds) info_panel = f""" [bold]Server Status[/bold] Status: {status_text} Address: [bright_white]{stats.host}:{stats.port}[/bright_white] Uptime: [bold]{uptime_text}[/bold] Clients: [bold]{stats.connected_clients}[/bold] [bold]Packet Statistics[/bold] Transform Packets: Received {stats.transform_packets_received:,} | Broadcast {stats.transform_packets_broadcast:,} WorldState Packets: Received {stats.world_state_packets_received:,} | Broadcast {stats.world_state_packets_broadcast:,} Total Packets: Received {stats.packets_received:,} | Sent {stats.packets_sent:,} """ console.print(Panel(info_panel.strip(), border_style="cyan", box=box.ROUNDED)) @app.command() def clients( json_output: bool = typer.Option( False, "--json", "-j", help="Output as JSON", ), ) -> None: """List connected clients and their statistics.""" service = get_service() stats = service.get_stats() if json_output: data = { "total_clients": len(stats.clients), "clients": [ { "player_id": c.player_id, "address": c.address, "label": c.label, "connected_at": c.connected_at, "packets_sent": c.packets_sent, "packets_received": c.packets_received, } for c in stats.clients ], } print(json.dumps(data, indent=2)) return if not stats.clients: console.print("[dim]No clients currently connected.[/dim]") return # Create table table = Table(title="Connected Clients", box=box.ROUNDED) table.add_column("Player ID", style="cyan") table.add_column("Address", style="bright_white") table.add_column("Label", style="yellow") table.add_column("Connected", style="green") table.add_column("Packets Sent", justify="right") table.add_column("Packets Received", justify="right") for client in stats.clients: connected_time = datetime.fromtimestamp(client.connected_at).strftime("%H:%M:%S") table.add_row( str(client.player_id), client.address, client.label, connected_time, str(client.packets_sent), str(client.packets_received), ) console.print(table) world_app = typer.Typer(help="Manage world state (time, weather)") @world_app.command("time") def world_time( hhmm: str = typer.Argument(..., help="Time in HHmm format (e.g., 1430 for 14:30)"), ) -> None: """Set server time (broadcast to all clients).""" service = get_service() if not service.is_running(): console.print("[red]Error: Server is not running.[/red]") raise typer.Exit(code=1) # Validate format if not (len(hhmm) <= 4 and hhmm.isdigit()): console.print(f"[red]Error: Time must be in HHmm format (e.g., 1430). Got: {hhmm}[/red]") raise typer.Exit(code=1) success, message = service.set_server_time(hhmm) if success: console.print(f"[green]{message}[/green]") else: console.print(f"[red]{message}[/red]") raise typer.Exit(code=1) @world_app.command("weather") def world_weather( form_id: str = typer.Argument(..., help="8-digit hex form ID (e.g., 0002b52a)"), ) -> None: """Set server weather (broadcast to all clients).""" service = get_service() if not service.is_running(): console.print("[red]Error: Server is not running.[/red]") raise typer.Exit(code=1) success, message = service.set_server_weather(form_id) if success: console.print(f"[green]{message}[/green]") else: console.print(f"[red]{message}[/red]") raise typer.Exit(code=1) app.add_typer(world_app, name="world") config_app = typer.Typer(help="Manage server configuration") @config_app.command("init") def config_init( output_path: str = typer.Argument( "commonwealth-server.json", help="Path where config file will be created", ), ) -> None: """Generate a default configuration file.""" path = Path(output_path) if path.exists(): console.print(f"[yellow]File already exists: {output_path}[/yellow]") if typer.confirm("Overwrite?"): generate_default_config(str(path)) console.print(f"[green]Config written to {output_path}[/green]") else: console.print("[dim]Cancelled.[/dim]") else: generate_default_config(str(path)) console.print(f"[green]Config written to {output_path}[/green]") console.print(f"\n[yellow]To start the server with this config:[/yellow]") console.print(f" commonwealth serve --config {output_path}") app.add_typer(config_app, name="config") def _format_uptime(seconds: float) -> str: """Format uptime as human-readable string.""" if seconds < 60: return f"{int(seconds)}s" elif seconds < 3600: minutes = int(seconds / 60) secs = int(seconds % 60) return f"{minutes}m {secs}s" else: hours = int(seconds / 3600) minutes = int((seconds % 3600) / 60) return f"{hours}h {minutes}m" if __name__ == "__main__": app()