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.
1147 lines
38 KiB
Python
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()
|