#!/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 help commonwealth serve [--config CONFIG_PATH] [--host HOST] [--port PORT] commonwealth status commonwealth clients commonwealth users commonwealth kick PLAYER_ID [--reason REASON] commonwealth ban PLAYER_ID_OR_IP [--reason REASON] commonwealth unban IP commonwealth bans commonwealth world time HHmm commonwealth world weather FORM_ID commonwealth config init OUTPUT_PATH """ from __future__ import annotations import json import logging import os import shlex import signal import sys import threading import time from datetime import datetime from pathlib import Path from typing import Any, Optional import typer from rich.console import Console, Group, RenderableType from rich.live import Live from rich.table import Table from rich.panel import Panel from rich.text import Text from rich import box from admin_server import send_admin_command from server_service import ServerService, ServerConfig, looks_like_ipv4 from config import ( Config, load_config, generate_default_config, validate_config, ensure_writable_directory, DEFAULT_ADMIN_PORT as CONFIG_DEFAULT_ADMIN_PORT, ) # Rich console for beautiful output when attached to a TTY. _FORCE_COLOR = os.environ.get("FORCE_COLOR", "").strip() not in ("", "0", "false", "False") _USE_COLOR = _FORCE_COLOR or (sys.stdout.isatty() and os.environ.get("NO_COLOR") is None) console = Console(force_terminal=_USE_COLOR, color_system="auto" if _USE_COLOR else None) app = typer.Typer( name="commonwealth", help="Commonwealth Online Server CLI", pretty_exceptions_enable=False, ) # Global service instance (used by the serve process only) _service: Optional[ServerService] = None _shutdown_requested = threading.Event() _logger = logging.getLogger("commonwealth.server") def get_service() -> ServerService: """Get or initialize the global service instance.""" global _service if _service is None: _service = ServerService() return _service def _configure_logging() -> None: if _logger.handlers: return handler = logging.StreamHandler(sys.stdout) handler.setFormatter( logging.Formatter( fmt="%(asctime)s %(levelname)s %(message)s", datefmt="%Y-%m-%d %H:%M:%S", ) ) handler.flush = sys.stdout.flush # type: ignore[method-assign] _logger.setLevel(logging.INFO) _logger.addHandler(handler) _logger.propagate = False def log_callback(message: str, *, level: str = "info") -> None: """Callback for server logs from the service.""" severity = str(level or "info").strip().lower() if _USE_COLOR: timestamp = datetime.now().strftime("%H:%M:%S") console.print(f"[dim]{timestamp}[/dim] [{severity}] {message}") return _configure_logging() log_level = { "debug": logging.DEBUG, "info": logging.INFO, "warning": logging.WARNING, "error": logging.ERROR, }.get(severity, logging.INFO) _logger.log(log_level, message) for handler in _logger.handlers: handler.flush() def _require_python_version() -> None: if sys.version_info < (3, 9): console.print( f"[red]ERROR: Python 3.9+ is required (found {sys.version.split()[0]}).[/red]" ) raise typer.Exit(code=1) def _request_shutdown(_signum: int, _frame: Any) -> None: """Handle SIGINT/SIGTERM by requesting a clean shutdown.""" _shutdown_requested.set() service = _service if service is not None: try: service.stop() except Exception: pass # Interrupt blocking main-thread waits (accept/input) so shutdown completes. raise KeyboardInterrupt def admin_request( request: dict[str, Any], *, admin_port: int = CONFIG_DEFAULT_ADMIN_PORT, ) -> dict[str, Any]: """Send an admin command to the running server, or exit with an error.""" try: return send_admin_command(request, port=admin_port) except OSError: console.print( f"[red]Error: Could not reach admin port 127.0.0.1:{admin_port}. " "Is the server running?[/red]" ) raise typer.Exit(code=1) except (ConnectionError, ValueError, json.JSONDecodeError) as error: console.print(f"[red]Error talking to admin port: {error}[/red]") raise typer.Exit(code=1) def require_ok(response: dict[str, Any]) -> dict[str, Any]: if not response.get("ok"): console.print(f"[red]{response.get('error', 'Admin command failed.')}[/red]") raise typer.Exit(code=1) return response def print_startup_banner(config: ServerConfig) -> None: """Print a friendly startup banner.""" description_line = "" if config.server_description: description_line = f" • Description: {config.server_description}\n" banner = f""" ================================================================================ {config.server_name} Commonwealth Online Consumer Server ================================================================================ Server Configuration: • Binding to {config.host}:{config.port} {description_line} • Max players: {config.max_players} • Discovery port (UDP): 7778 • Admin port (localhost): {config.admin_port} Connection Instructions: • Local: 127.0.0.1:{config.port} • LAN: :{config.port} • Remote: Forward port {config.port}/TCP on your router Interactive Commands (type at the commonwealth> prompt): • help List commands • status Server status • users Live player table (Enter to stop) • users once Single snapshot • kick PLAYER_ID Disconnect a player • ban PLAYER_ID_OR_IP Ban a player or IP • unban IP Remove an IP ban • bans List bans • world time HHmm Set time • world weather ID Set weather • quit Stop the server Note: • Live position/state spam is off by default (log_verbosity=info). • Use "users" for a live table; "users once" for a single snapshot. • Set log_verbosity to "debug" in config for per-packet logs. Shutdown: • Type quit / exit, or press Ctrl+C ================================================================================ """ print(banner) @app.command("help") def help_command() -> None: """List available CLI commands.""" _print_help_table() def _print_help_table(*, interactive: bool = False) -> None: title = ( "Interactive Server Commands" if interactive else "Commonwealth Online Server Commands" ) table = Table(title=title, box=box.ROUNDED) table.add_column("Command", style="cyan", no_wrap=True) table.add_column("Description", style="bright_white") if interactive: commands = [ ("help", "List available commands"), ("status", "Show server status and packet stats"), ("users", "Live player table (positions/state); Enter to stop"), ("users once", "Single snapshot of connected users"), ("clients", "Same as users"), ("kick PLAYER_ID [--reason TEXT]", "Disconnect a player without banning"), ("ban PLAYER_ID_OR_IP [--reason TEXT]", "Ban a player id or IP address"), ("unban IP", "Remove an IP from the ban list"), ("bans", "List banned IP addresses"), ("world time HHmm", "Set server time (e.g. 1430)"), ("world weather FORM_ID", "Set weather (8-digit hex form id)"), ("quit / exit / stop", "Stop the server and close this window session"), ] footer = ( "Type commands at the commonwealth> prompt in this window. " "Example: users | ban 2 --reason griefing" ) else: commands = [ ("help", "List available CLI commands"), ("serve [--config PATH] [--interactive]", "Start the relay server"), ("status", "Show server status and packet stats"), ("users [--watch]", "List users; --watch for live updates"), ("clients [--watch]", "Same as users"), ("kick PLAYER_ID [--reason TEXT]", "Disconnect a player without banning"), ("ban PLAYER_ID_OR_IP [--reason TEXT]", "Ban a player id or IP address"), ("unban IP", "Remove an IP from the ban list"), ("bans", "List banned IP addresses"), ("world time HHmm", "Set server time (e.g. 1430)"), ("world weather FORM_ID", "Set weather (8-digit hex form id)"), ("config init [OUTPUT_PATH]", "Create a default config file"), ] footer = ( "start.bat / start.sh launch an interactive prompt in the same window. " "You can also run management commands from another terminal against admin_port." ) for command, description in commands: table.add_row(command, description) console.print(table) console.print(f"\n[dim]{footer}[/dim]") @app.command() def serve( config_path: Optional[str] = typer.Argument( None, help="Optional path to config.json (same as --config)", ), 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)", ), interactive: bool = typer.Option( False, "--interactive", "-i", help="Start an interactive command prompt in this window (used by start.bat / start.sh)", ), ) -> None: """Start the Commonwealth Online relay server.""" _require_python_version() service: Optional[ServerService] = None _shutdown_requested.clear() # Accept either `serve --config FILE` or `serve FILE` (file managers may pass FILE). if config and config_path and Path(config).resolve() != Path(config_path).resolve(): console.print( "[red]Error: Conflicting config paths from positional argument and --config.[/red]" ) raise typer.Exit(code=1) config = config or config_path previous_sigint = signal.getsignal(signal.SIGINT) previous_sigterm = signal.getsignal(signal.SIGTERM) if hasattr(signal, "SIGTERM") else None try: signal.signal(signal.SIGINT, _request_shutdown) if hasattr(signal, "SIGTERM"): signal.signal(signal.SIGTERM, _request_shutdown) # 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) except ValueError as e: console.print(f"[red]Error: Invalid config values: {e}[/red]") raise typer.Exit(code=1) bans_path = Path(config).resolve().parent / "bans.json" else: cfg = Config() bans_path = Path(__file__).resolve().parent / "bans.json" # 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) try: ensure_writable_directory(bans_path.parent) except OSError as error: console.print( f"[red]ERROR: Cannot write server state in {bans_path.parent}: {error}[/red]" ) raise typer.Exit(code=1) # Create and configure service server_config = ServerConfig( host=cfg.host, port=cfg.port, server_name=cfg.server_name, server_description=cfg.server_description, max_players=cfg.max_players, log_verbosity=cfg.log_verbosity, admin_port=cfg.admin_port, bans_path=str(bans_path), ) service = get_service() service.config = server_config service.add_log_listener(log_callback) # Print startup banner print_startup_banner(server_config) use_interactive = bool(interactive and sys.stdin.isatty() and sys.stdout.isatty()) if interactive and not use_interactive: console.print( "[yellow]stdin/stdout are not a terminal; starting non-interactive mode. " "Use the admin CLI against 127.0.0.1 to manage the server.[/yellow]" ) console.print("[yellow]Starting server...[/yellow]") if use_interactive: service.start() if not _wait_for_server_ready(service, timeout_seconds=5.0): console.print("[red]Server failed to become ready.[/red]") raise typer.Exit(code=1) console.print( "[green]Server running. Type [bold]help[/bold] for commands, " "[bold]quit[/bold] to stop.[/green]\n" ) _run_interactive_shell(admin_port=server_config.admin_port) console.print("\n[yellow]Stopping server...[/yellow]") service.stop() console.print("[green]Server stopped gracefully.[/green]") else: # Non-interactive mode for Host GUI / systemd / headless hosting. service.serve_forever() if _shutdown_requested.is_set(): console.print("[green]Server stopped gracefully.[/green]") except KeyboardInterrupt: console.print("\n[yellow]Shutdown signal received. Stopping server...[/yellow]") if service is None: service = get_service() service.stop() console.print("[green]Server stopped gracefully.[/green]") except OSError as e: console.print(f"[red]ERROR: {e}[/red]") if service is not None: try: service.stop() except Exception: pass raise typer.Exit(code=1) except Exception as e: console.print(f"[red]Fatal error: {e}[/red]") if service is not None: try: service.stop() except Exception: pass raise typer.Exit(code=1) finally: try: signal.signal(signal.SIGINT, previous_sigint) if previous_sigterm is not None and hasattr(signal, "SIGTERM"): signal.signal(signal.SIGTERM, previous_sigterm) except Exception: pass @app.command() def status( json_output: bool = typer.Option( False, "--json", "-j", help="Output as JSON", ), admin_port: int = typer.Option( CONFIG_DEFAULT_ADMIN_PORT, "--admin-port", help="Localhost admin control port of the running server", ), ) -> None: """Display server status and statistics.""" response = require_ok(admin_request({"cmd": "stats"}, admin_port=admin_port)) stats = response.get("data") or {} if json_output: print(json.dumps(stats, indent=2)) return _print_status_panel(stats) def _format_bool_flag(value: Any) -> str: if value is True: return "Y" if value is False: return "N" return "-" def _format_coord(value: Any) -> str: try: return f"{float(value):.1f}" except (TypeError, ValueError): return "-" def _build_users_table(client_rows: list[dict[str, Any]], *, title: str) -> RenderableType: if not client_rows: return Text("No clients currently connected.", style="dim") table = Table(title=title, box=box.ROUNDED) table.add_column("ID", style="cyan", justify="right") table.add_column("Address", style="bright_white") table.add_column("X", justify="right") table.add_column("Y", justify="right") table.add_column("Z", justify="right") table.add_column("Angle", justify="right") table.add_column("Cell") table.add_column("World") table.add_column("Moving", justify="center") table.add_column("Speed", justify="right") table.add_column("Sprint", justify="center") table.add_column("Sneak", justify="center") table.add_column("Jump", justify="center") table.add_column("Drawn", justify="center") table.add_column("Type") table.add_column("Connected", style="green") for client in client_rows: connected_at = client.get("connected_at") connected_time = ( datetime.fromtimestamp(connected_at).strftime("%H:%M:%S") if isinstance(connected_at, (int, float)) else "?" ) transform = client.get("last_transform") if not isinstance(transform, dict): transform = {} speed = transform.get("movementSpeed") try: speed_text = f"{float(speed):.1f}" if speed is not None else "-" except (TypeError, ValueError): speed_text = "-" table.add_row( str(client.get("player_id", "?")), str(client.get("address", "?")), _format_coord(transform.get("x")), _format_coord(transform.get("y")), _format_coord(transform.get("z")), _format_coord(transform.get("angleZ")), str(transform.get("cellId") or "-"), str(transform.get("worldspaceId") or "-"), _format_bool_flag(transform.get("isMoving")), speed_text, _format_bool_flag(transform.get("isSprinting")), _format_bool_flag(transform.get("isSneaking")), _format_bool_flag(transform.get("isJumping")), _format_bool_flag(transform.get("weaponDrawn")), str(transform.get("movementType") or "-"), connected_time, ) return table def _print_users_table(client_rows: list[dict[str, Any]], *, title: str) -> None: console.print(_build_users_table(client_rows, title=title)) def _fetch_users_rows(*, admin_port: int, command: str = "users") -> list[dict[str, Any]] | None: response = _shell_admin_request({"cmd": command}, admin_port=admin_port) if response is None: return None data = response.get("data") or {} rows = data.get("clients") or [] return rows if isinstance(rows, list) else [] def _watch_users_table( *, admin_port: int, title: str, command: str = "users", mute_server_logs: bool = False, ) -> None: """Refresh the users table until Enter or Ctrl+C.""" stop = threading.Event() service = get_service() if mute_server_logs else None muted = False if service is not None: try: service.remove_log_listener(log_callback) muted = True except Exception: muted = False def wait_for_stop() -> None: try: input() except (EOFError, KeyboardInterrupt): pass stop.set() waiter = threading.Thread(target=wait_for_stop, daemon=True) waiter.start() def render() -> RenderableType: rows = _fetch_users_rows(admin_port=admin_port, command=command) if rows is None: body: RenderableType = Text("Could not refresh users.", style="red") else: body = _build_users_table(rows, title=title) footer = Text("Live updating every 1s — press Enter to stop.", style="dim") return Group(body, Text(""), footer) try: with Live( render(), console=console, refresh_per_second=4, vertical_overflow="visible", ) as live: while not stop.is_set(): live.update(render()) stop.wait(1.0) finally: if muted and service is not None: service.add_log_listener(log_callback) @app.command() def clients( json_output: bool = typer.Option( False, "--json", "-j", help="Output as JSON", ), watch: bool = typer.Option( False, "--watch", "-w", help="Live-update the table until Enter is pressed", ), admin_port: int = typer.Option( CONFIG_DEFAULT_ADMIN_PORT, "--admin-port", help="Localhost admin control port of the running server", ), ) -> None: """List connected clients with positions and movement state.""" if json_output and watch: console.print("[red]Error: --json cannot be combined with --watch.[/red]") raise typer.Exit(code=1) if watch: _watch_users_table( admin_port=admin_port, title="Connected Clients", command="clients", ) return response = require_ok(admin_request({"cmd": "clients"}, admin_port=admin_port)) data = response.get("data") or {} client_rows = data.get("clients") or [] if json_output: print(json.dumps(data, indent=2)) return _print_users_table(client_rows, title="Connected Clients") @app.command() def users( json_output: bool = typer.Option( False, "--json", "-j", help="Output as JSON", ), watch: bool = typer.Option( False, "--watch", "-w", help="Live-update the table until Enter is pressed", ), admin_port: int = typer.Option( CONFIG_DEFAULT_ADMIN_PORT, "--admin-port", help="Localhost admin control port of the running server", ), ) -> None: """List connected users with positions and movement state (same as clients).""" if json_output and watch: console.print("[red]Error: --json cannot be combined with --watch.[/red]") raise typer.Exit(code=1) if watch: _watch_users_table( admin_port=admin_port, title="Connected Users", command="users", ) return response = require_ok(admin_request({"cmd": "users"}, admin_port=admin_port)) data = response.get("data") or {} client_rows = data.get("clients") or [] if json_output: print(json.dumps(data, indent=2)) return _print_users_table(client_rows, title="Connected Users") @app.command() def kick( player_id: int = typer.Argument(..., help="Connected player id to kick"), reason: str = typer.Option("", "--reason", "-r", help="Optional kick reason"), admin_port: int = typer.Option( CONFIG_DEFAULT_ADMIN_PORT, "--admin-port", help="Localhost admin control port of the running server", ), ) -> None: """Disconnect a connected player without banning their IP.""" response = require_ok( admin_request( {"cmd": "kick", "playerId": player_id, "reason": reason}, admin_port=admin_port, ) ) console.print(f"[green]{response.get('message', 'Player kicked.')}[/green]") @app.command() def ban( target: str = typer.Argument(..., help="Player id or IPv4 address to ban"), reason: str = typer.Option("", "--reason", "-r", help="Optional ban reason"), admin_port: int = typer.Option( CONFIG_DEFAULT_ADMIN_PORT, "--admin-port", help="Localhost admin control port of the running server", ), ) -> None: """Ban a connected player (by id) or an IP address, disconnecting matching sessions.""" request: dict[str, Any] = {"cmd": "ban", "reason": reason} if looks_like_ipv4(target): request["ip"] = target.strip() else: try: request["playerId"] = int(target) except ValueError: console.print( f"[red]Error: ban target must be a player id or IPv4 address. Got: {target}[/red]" ) raise typer.Exit(code=1) response = require_ok(admin_request(request, admin_port=admin_port)) console.print(f"[green]{response.get('message', 'Ban applied.')}[/green]") @app.command() def unban( ip: str = typer.Argument(..., help="IPv4 address to remove from the ban list"), admin_port: int = typer.Option( CONFIG_DEFAULT_ADMIN_PORT, "--admin-port", help="Localhost admin control port of the running server", ), ) -> None: """Remove an IP address from the ban list.""" response = require_ok( admin_request({"cmd": "unban", "ip": ip}, admin_port=admin_port) ) console.print(f"[green]{response.get('message', 'IP unbanned.')}[/green]") @app.command() def bans( json_output: bool = typer.Option( False, "--json", "-j", help="Output as JSON", ), admin_port: int = typer.Option( CONFIG_DEFAULT_ADMIN_PORT, "--admin-port", help="Localhost admin control port of the running server", ), ) -> None: """List currently banned IP addresses.""" response = require_ok(admin_request({"cmd": "bans"}, admin_port=admin_port)) data = response.get("data") or {} ban_rows = data.get("bans") or [] if json_output: print(json.dumps(data, indent=2)) return _print_bans_table(ban_rows) 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)"), admin_port: int = typer.Option( CONFIG_DEFAULT_ADMIN_PORT, "--admin-port", help="Localhost admin control port of the running server", ), ) -> None: """Set server time (broadcast to all clients).""" 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) response = require_ok( admin_request({"cmd": "world_time", "hhmm": hhmm}, admin_port=admin_port) ) console.print(f"[green]{response.get('message', 'Server time updated.')}[/green]") @world_app.command("weather") def world_weather( form_id: str = typer.Argument(..., help="8-digit hex form ID (e.g., 0002b52a)"), admin_port: int = typer.Option( CONFIG_DEFAULT_ADMIN_PORT, "--admin-port", help="Localhost admin control port of the running server", ), ) -> None: """Set server weather (broadcast to all clients).""" response = require_ok( admin_request({"cmd": "world_weather", "weather": form_id}, admin_port=admin_port) ) console.print(f"[green]{response.get('message', 'Server weather updated.')}[/green]") 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" def _wait_for_server_ready(service: ServerService, *, timeout_seconds: float) -> bool: deadline = time.time() + timeout_seconds while time.time() < deadline: if service.is_running(): return True time.sleep(0.05) return service.is_running() def _shell_admin_request( request: dict[str, Any], *, admin_port: int, ) -> dict[str, Any] | None: """Admin request for interactive mode; prints errors and returns None on failure.""" try: response = send_admin_command(request, port=admin_port) except OSError: console.print( f"[red]Error: Could not reach admin port 127.0.0.1:{admin_port}. " "Is the server still running?[/red]" ) return None except (ConnectionError, ValueError, json.JSONDecodeError) as error: console.print(f"[red]Error talking to admin port: {error}[/red]") return None if not response.get("ok"): console.print(f"[red]{response.get('error', 'Admin command failed.')}[/red]") return None return response def _parse_reason_option(args: list[str]) -> tuple[list[str], str]: """Pull --reason/-r from args. Returns (remaining_args, reason).""" remaining: list[str] = [] reason = "" index = 0 while index < len(args): token = args[index] if token in ("--reason", "-r"): if index + 1 >= len(args): console.print("[red]Error: --reason requires a value.[/red]") return [], "" reason = args[index + 1] index += 2 continue if token.startswith("--reason="): reason = token.split("=", 1)[1] index += 1 continue remaining.append(token) index += 1 return remaining, reason def _print_status_panel(stats: dict[str, Any]) -> None: status_text = "[green]RUNNING[/green]" if stats.get("is_running") else "[red]STOPPED[/red]" uptime_text = _format_uptime(float(stats.get("uptime_seconds") or 0.0)) info_panel = f""" [bold]Server Status[/bold] Status: {status_text} Address: [bright_white]{stats.get('host')}:{stats.get('port')}[/bright_white] Uptime: [bold]{uptime_text}[/bold] Clients: [bold]{stats.get('connected_clients', 0)}[/bold] [bold]Packet Statistics[/bold] Transform Packets: Received {stats.get('transform_packets_received', 0):,} | Broadcast {stats.get('transform_packets_broadcast', 0):,} WorldState Packets: Received {stats.get('world_state_packets_received', 0):,} | Broadcast {stats.get('world_state_packets_broadcast', 0):,} Total Packets: Received {stats.get('packets_received', 0):,} | Sent {stats.get('packets_sent', 0):,} """ console.print(Panel(info_panel.strip(), border_style="cyan", box=box.ROUNDED)) def _print_bans_table(ban_rows: list[dict[str, Any]]) -> None: if not ban_rows: console.print("[dim]No banned IPs.[/dim]") return table = Table(title="Banned IPs", box=box.ROUNDED) table.add_column("IP", style="bright_white") table.add_column("Reason", style="yellow") table.add_column("Banned At", style="green") for entry in ban_rows: banned_at = entry.get("bannedAt") banned_text = ( datetime.fromtimestamp(banned_at).strftime("%Y-%m-%d %H:%M:%S") if banned_at else "?" ) table.add_row( str(entry.get("ip")), str(entry.get("reason") or ""), banned_text, ) console.print(table) def _dispatch_interactive_command(args: list[str], *, admin_port: int) -> bool: """ Handle one interactive command. Returns False when the shell should exit. """ if not args: return True command = args[0].lower() rest = args[1:] if command in ("quit", "exit", "stop", "q"): return False if command == "help": _print_help_table(interactive=True) return True if command == "status": response = _shell_admin_request({"cmd": "stats"}, admin_port=admin_port) if response is not None: _print_status_panel(response.get("data") or {}) return True if command in ("users", "clients"): title = "Connected Users" if command == "users" else "Connected Clients" once = bool(rest) and rest[0].lower() in ("once", "snapshot", "--once") if once: response = _shell_admin_request({"cmd": command}, admin_port=admin_port) if response is not None: data = response.get("data") or {} _print_users_table(data.get("clients") or [], title=title) return True _watch_users_table( admin_port=admin_port, title=title, command=command, mute_server_logs=True, ) return True if command == "kick": rest, reason = _parse_reason_option(rest) if len(rest) != 1: console.print("[red]Usage: kick PLAYER_ID [--reason TEXT][/red]") return True try: player_id = int(rest[0]) except ValueError: console.print(f"[red]Error: player id must be an integer. Got: {rest[0]}[/red]") return True response = _shell_admin_request( {"cmd": "kick", "playerId": player_id, "reason": reason}, admin_port=admin_port, ) if response is not None: console.print(f"[green]{response.get('message', 'Player kicked.')}[/green]") return True if command == "ban": rest, reason = _parse_reason_option(rest) if len(rest) != 1: console.print("[red]Usage: ban PLAYER_ID_OR_IP [--reason TEXT][/red]") return True target = rest[0] request: dict[str, Any] = {"cmd": "ban", "reason": reason} if looks_like_ipv4(target): request["ip"] = target.strip() else: try: request["playerId"] = int(target) except ValueError: console.print( f"[red]Error: ban target must be a player id or IPv4 address. Got: {target}[/red]" ) return True response = _shell_admin_request(request, admin_port=admin_port) if response is not None: console.print(f"[green]{response.get('message', 'Ban applied.')}[/green]") return True if command == "unban": if len(rest) != 1: console.print("[red]Usage: unban IP[/red]") return True response = _shell_admin_request({"cmd": "unban", "ip": rest[0]}, admin_port=admin_port) if response is not None: console.print(f"[green]{response.get('message', 'IP unbanned.')}[/green]") return True if command == "bans": response = _shell_admin_request({"cmd": "bans"}, admin_port=admin_port) if response is not None: data = response.get("data") or {} _print_bans_table(data.get("bans") or []) return True if command == "world": if len(rest) < 2: console.print("[red]Usage: world time HHmm | world weather FORM_ID[/red]") return True subcommand = rest[0].lower() if subcommand == "time": hhmm = rest[1] 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]" ) return True response = _shell_admin_request( {"cmd": "world_time", "hhmm": hhmm}, admin_port=admin_port, ) if response is not None: console.print( f"[green]{response.get('message', 'Server time updated.')}[/green]" ) return True if subcommand == "weather": response = _shell_admin_request( {"cmd": "world_weather", "weather": rest[1]}, admin_port=admin_port, ) if response is not None: console.print( f"[green]{response.get('message', 'Server weather updated.')}[/green]" ) return True console.print("[red]Usage: world time HHmm | world weather FORM_ID[/red]") return True console.print(f"[red]Unknown command: {command}[/red]") console.print("[dim]Type help to list commands.[/dim]") return True def _run_interactive_shell(*, admin_port: int) -> None: """Read management commands from stdin until quit/exit/Ctrl+C.""" if not sys.stdin.isatty(): console.print( "[yellow]Interactive prompt requires a terminal. " "Server remains available via the admin port.[/yellow]" ) while not _shutdown_requested.is_set(): time.sleep(0.5) return while not _shutdown_requested.is_set(): try: line = input("commonwealth> ") except EOFError: console.print() break except KeyboardInterrupt: console.print() break line = line.strip() if not line: continue try: args = shlex.split(line, posix=(os.name != "nt")) except ValueError as error: console.print(f"[red]Could not parse command: {error}[/red]") continue if not _dispatch_interactive_command(args, admin_port=admin_port): break if __name__ == "__main__": app()