Files
Commonwealth-Online-Server/server/consumer_server_cli.py
T
andrew dab76146cf
Linux Compatibility / Ubuntu dedicated server (push) Canceled after 0s
Linux Compatibility / Arch Linux container (push) Canceled after 0s
Handle config path args in server startup
Updated server launch behavior to reliably treat JSON file arguments as config paths, including file-manager “Open with” cases. The CLI `serve` command now accepts an optional positional config path (equivalent to `--config`) and errors on conflicting values. `start.sh` and `start.bat` now parse config-related arguments more explicitly, validate required values, and always pass `--config` to avoid accidental positional forwarding. Added tests covering both positional and `--config` forms, plus README documentation for the new startup behavior.
2026-08-01 21:49:48 +12:00

1147 lines
38 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 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: <your-ip>:{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()