Files
Commonwealth-Online-Public/server/consumer_server_cli.py
T
andrewandCursor 7a3e0855b0 Add consumer server CLI with Typer + Rich and Windows launcher
Introduce production-ready CLI for hosting Commonwealth Online servers in
cloud and on-premises environments.

Features:
- Server orchestration service (server_service.py) wrapping relay lifecycle
- JSON configuration system (config.py) for hosted deployments
- Typer+Rich CLI (consumer_server_cli.py) with serve/status/clients/world commands
- Windows launcher (start.bat) for one-click server startup
- Auto-detection of LAN addresses and dependency installation
- Machine-readable JSON output for monitoring and automation

Cli commands:
  serve - Start server with optional config overrides
  status - Display server stats and packet counters
  clients - List connected players
  world time - Set in-game time for all clients
  world weather - Set weather for all clients
  config init - Generate default configuration file

Documentation:
  - Updated docs/setup.md with CLI quick-start guide
  - Added server/README.md with usage instructions
  - Updated changelog and dev-log with test results

Testing: - Verified config generation and loading
  - Verified server startup banner and LAN detection
  - Verified fake client connection and welcome packet
  - Verified CLI help and command routing
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-09 20:44:40 +12:00

369 lines
10 KiB
Python

#!/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: <your-ip>:{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()