bsm-cli 1.6.0b3__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- bsm_cli/__init__.py +0 -0
- bsm_cli/__main__.py +88 -0
- bsm_cli/account.py +75 -0
- bsm_cli/addon.py +362 -0
- bsm_cli/allowlist.py +229 -0
- bsm_cli/auth.py +106 -0
- bsm_cli/backup.py +263 -0
- bsm_cli/bans.py +190 -0
- bsm_cli/config.py +89 -0
- bsm_cli/content.py +21 -0
- bsm_cli/decorators.py +138 -0
- bsm_cli/main_menus.py +293 -0
- bsm_cli/permissions.py +191 -0
- bsm_cli/player.py +59 -0
- bsm_cli/plugins.py +296 -0
- bsm_cli/properties.py +197 -0
- bsm_cli/server.py +471 -0
- bsm_cli/system.py +142 -0
- bsm_cli/users.py +245 -0
- bsm_cli/world.py +171 -0
- bsm_cli-1.6.0b3.dist-info/METADATA +77 -0
- bsm_cli-1.6.0b3.dist-info/RECORD +26 -0
- bsm_cli-1.6.0b3.dist-info/WHEEL +5 -0
- bsm_cli-1.6.0b3.dist-info/entry_points.txt +2 -0
- bsm_cli-1.6.0b3.dist-info/licenses/LICENSE +21 -0
- bsm_cli-1.6.0b3.dist-info/top_level.txt +1 -0
bsm_cli/bans.py
ADDED
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
import click
|
|
2
|
+
import questionary
|
|
3
|
+
|
|
4
|
+
from bsm_api_client.models import BanAddRequest, BanRemoveRequest
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
@click.group()
|
|
8
|
+
def bans():
|
|
9
|
+
"""Manages the server ban list."""
|
|
10
|
+
pass
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@bans.command("list")
|
|
14
|
+
@click.option(
|
|
15
|
+
"-s",
|
|
16
|
+
"--server",
|
|
17
|
+
"server_name",
|
|
18
|
+
required=True,
|
|
19
|
+
help="Name of the server.",
|
|
20
|
+
)
|
|
21
|
+
@click.pass_context
|
|
22
|
+
async def list_bans(ctx, server_name: str):
|
|
23
|
+
"""Lists all players on the server's ban list."""
|
|
24
|
+
client = ctx.obj.get("client")
|
|
25
|
+
if not client:
|
|
26
|
+
click.secho("You are not logged in.", fg="red")
|
|
27
|
+
return
|
|
28
|
+
|
|
29
|
+
click.echo(f"Fetching ban list for server '{server_name}'...")
|
|
30
|
+
response = await client.async_get_server_bans(server_name)
|
|
31
|
+
|
|
32
|
+
if response.get("status") == "success":
|
|
33
|
+
bans_list = response.get("bans", [])
|
|
34
|
+
if not bans_list:
|
|
35
|
+
click.secho("The ban list is empty.", fg="green")
|
|
36
|
+
else:
|
|
37
|
+
click.secho(f"\n--- Ban List for '{server_name}' ---", bold=True)
|
|
38
|
+
for entry in bans_list:
|
|
39
|
+
click.echo(
|
|
40
|
+
f"- {click.style(entry.get('player_name', 'Unknown'), fg='cyan')} (XUID: {entry.get('xuid')}) - Reason: {entry.get('reason', 'N/A')}"
|
|
41
|
+
)
|
|
42
|
+
else:
|
|
43
|
+
click.secho(
|
|
44
|
+
f"Failed to fetch ban list: {response.get('message', 'Unknown Error')}",
|
|
45
|
+
fg="red",
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@bans.command("add")
|
|
50
|
+
@click.option(
|
|
51
|
+
"-s",
|
|
52
|
+
"--server",
|
|
53
|
+
"server_name",
|
|
54
|
+
required=True,
|
|
55
|
+
help="Name of the server.",
|
|
56
|
+
)
|
|
57
|
+
@click.pass_context
|
|
58
|
+
async def add_ban(ctx, server_name: str):
|
|
59
|
+
"""Adds a player to the server ban list interactively."""
|
|
60
|
+
client = ctx.obj.get("client")
|
|
61
|
+
if not client:
|
|
62
|
+
click.secho("You are not logged in.", fg="red")
|
|
63
|
+
return
|
|
64
|
+
|
|
65
|
+
await interactive_ban_workflow(client, server_name)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
@bans.command("remove")
|
|
69
|
+
@click.option(
|
|
70
|
+
"-s",
|
|
71
|
+
"--server",
|
|
72
|
+
"server_name",
|
|
73
|
+
required=True,
|
|
74
|
+
help="Name of the server.",
|
|
75
|
+
)
|
|
76
|
+
@click.pass_context
|
|
77
|
+
async def remove_ban(ctx, server_name: str):
|
|
78
|
+
"""Removes a player from the server ban list interactively."""
|
|
79
|
+
client = ctx.obj.get("client")
|
|
80
|
+
if not client:
|
|
81
|
+
click.secho("You are not logged in.", fg="red")
|
|
82
|
+
return
|
|
83
|
+
|
|
84
|
+
response = await client.async_get_server_bans(server_name)
|
|
85
|
+
if response.get("status") != "success":
|
|
86
|
+
click.secho(
|
|
87
|
+
f"Failed to fetch ban list: {response.get('message', 'Unknown Error')}",
|
|
88
|
+
fg="red",
|
|
89
|
+
)
|
|
90
|
+
return
|
|
91
|
+
|
|
92
|
+
bans_list = response.get("bans", [])
|
|
93
|
+
if not bans_list:
|
|
94
|
+
click.secho("The ban list is currently empty.", fg="yellow")
|
|
95
|
+
return
|
|
96
|
+
|
|
97
|
+
choices = [
|
|
98
|
+
questionary.Choice(
|
|
99
|
+
title=f"{entry.get('player_name', 'Unknown')} ({entry.get('xuid')})",
|
|
100
|
+
value=entry.get("xuid"),
|
|
101
|
+
)
|
|
102
|
+
for entry in bans_list
|
|
103
|
+
]
|
|
104
|
+
|
|
105
|
+
selected_xuid = await questionary.select(
|
|
106
|
+
"Select a player to remove from the ban list:", choices=choices
|
|
107
|
+
).ask_async()
|
|
108
|
+
|
|
109
|
+
if not selected_xuid:
|
|
110
|
+
return
|
|
111
|
+
|
|
112
|
+
confirm = await questionary.confirm(
|
|
113
|
+
f"Are you sure you want to remove XUID '{selected_xuid}' from the ban list?"
|
|
114
|
+
).ask_async()
|
|
115
|
+
if not confirm:
|
|
116
|
+
click.secho("Operation cancelled.", fg="yellow")
|
|
117
|
+
return
|
|
118
|
+
|
|
119
|
+
payload = BanRemoveRequest(xuid=selected_xuid)
|
|
120
|
+
|
|
121
|
+
click.echo(
|
|
122
|
+
f"Removing XUID '{selected_xuid}' from the ban list for '{server_name}'..."
|
|
123
|
+
)
|
|
124
|
+
rem_response = await client.async_remove_server_ban(server_name, payload)
|
|
125
|
+
|
|
126
|
+
if rem_response.get("status") == "success":
|
|
127
|
+
click.secho("Player successfully removed from the ban list.", fg="green")
|
|
128
|
+
else:
|
|
129
|
+
click.secho(
|
|
130
|
+
f"Failed to remove player from ban list: {rem_response.get('message', 'Unknown Error')}",
|
|
131
|
+
fg="red",
|
|
132
|
+
)
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
async def interactive_ban_workflow(client, server_name: str):
|
|
136
|
+
"""Guides the user through an interactive session to view and add players to the ban list."""
|
|
137
|
+
response = await client.async_get_server_bans(server_name)
|
|
138
|
+
existing_bans = response.get("bans", [])
|
|
139
|
+
|
|
140
|
+
click.secho("\n--- Interactive Ban List Configuration ---", bold=True)
|
|
141
|
+
if existing_bans:
|
|
142
|
+
click.echo("Current players in ban list:")
|
|
143
|
+
for b in existing_bans:
|
|
144
|
+
click.echo(
|
|
145
|
+
f" - {b.get('player_name')} (XUID: {b.get('xuid')}) - Reason: {b.get('reason', 'N/A')}"
|
|
146
|
+
)
|
|
147
|
+
else:
|
|
148
|
+
click.secho("Ban list is currently empty.", fg="yellow")
|
|
149
|
+
|
|
150
|
+
click.echo("\nEnter a new player to ban. Leave Gamertag empty to finish.")
|
|
151
|
+
while True:
|
|
152
|
+
player_name = await questionary.text("Player gamertag:").ask_async()
|
|
153
|
+
if not player_name or not player_name.strip():
|
|
154
|
+
break
|
|
155
|
+
|
|
156
|
+
xuid = await questionary.text("Player XUID:").ask_async()
|
|
157
|
+
if not xuid or not xuid.strip():
|
|
158
|
+
click.secho("XUID is required to ban a player.", fg="red")
|
|
159
|
+
continue
|
|
160
|
+
|
|
161
|
+
reason = await questionary.text("Reason (optional):").ask_async()
|
|
162
|
+
|
|
163
|
+
if any(b.get("xuid") == xuid.strip() for b in existing_bans):
|
|
164
|
+
click.secho(
|
|
165
|
+
f"Player with XUID '{xuid}' is already in the list. Skipping.",
|
|
166
|
+
fg="yellow",
|
|
167
|
+
)
|
|
168
|
+
continue
|
|
169
|
+
|
|
170
|
+
payload = BanAddRequest(
|
|
171
|
+
player_name=player_name.strip(),
|
|
172
|
+
xuid=xuid.strip(),
|
|
173
|
+
reason=reason.strip() if reason.strip() else None,
|
|
174
|
+
)
|
|
175
|
+
res = await client.async_add_server_ban(server_name, payload)
|
|
176
|
+
if res.get("status") == "success":
|
|
177
|
+
click.secho(f"Successfully banned {player_name}.", fg="green")
|
|
178
|
+
existing_bans.append(
|
|
179
|
+
{
|
|
180
|
+
"player_name": player_name.strip(),
|
|
181
|
+
"xuid": xuid.strip(),
|
|
182
|
+
"reason": reason.strip() if reason.strip() else None,
|
|
183
|
+
}
|
|
184
|
+
)
|
|
185
|
+
else:
|
|
186
|
+
click.secho(
|
|
187
|
+
f"Failed to ban player: {res.get('message', 'Unknown Error')}", fg="red"
|
|
188
|
+
)
|
|
189
|
+
|
|
190
|
+
click.secho("Ban list interactive configuration finished.", fg="green")
|
bsm_cli/config.py
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import os
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from typing import Any, Dict, Optional
|
|
5
|
+
|
|
6
|
+
CONFIG_FILE_NAME = ".bsm_cli_config.json"
|
|
7
|
+
DEFAULT_BASE_URL = "http://127.0.0.1:11325"
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def get_config_path() -> Path:
|
|
11
|
+
"""Gets the path to the configuration file."""
|
|
12
|
+
return Path.home() / CONFIG_FILE_NAME
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def load_config() -> Dict[str, Any]:
|
|
16
|
+
"""Loads the configuration from the config file."""
|
|
17
|
+
config_path = get_config_path()
|
|
18
|
+
if not config_path.exists():
|
|
19
|
+
return {}
|
|
20
|
+
with open(config_path, "r") as f:
|
|
21
|
+
data = json.load(f)
|
|
22
|
+
if isinstance(data, dict):
|
|
23
|
+
return data
|
|
24
|
+
return {}
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def save_config(config: Dict[str, Any]):
|
|
28
|
+
"""Saves the configuration to the config file."""
|
|
29
|
+
config_path = get_config_path()
|
|
30
|
+
with open(config_path, "w") as f:
|
|
31
|
+
json.dump(config, f, indent=4)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class Config:
|
|
35
|
+
"""Manages CLI configuration."""
|
|
36
|
+
|
|
37
|
+
def __init__(self):
|
|
38
|
+
self._config = load_config()
|
|
39
|
+
|
|
40
|
+
def get(self, key: str, default: Any = None) -> Any:
|
|
41
|
+
"""Gets a configuration value."""
|
|
42
|
+
# Prioritize environment variables
|
|
43
|
+
env_var = f"BSM_{key.upper()}"
|
|
44
|
+
value = os.environ.get(env_var)
|
|
45
|
+
if value:
|
|
46
|
+
if isinstance(default, bool):
|
|
47
|
+
return value.lower() in ("true", "1", "yes")
|
|
48
|
+
return value
|
|
49
|
+
|
|
50
|
+
# Fallback to config file
|
|
51
|
+
return self._config.get(key, default)
|
|
52
|
+
|
|
53
|
+
def set(self, key: str, value: Any):
|
|
54
|
+
"""Sets a configuration value."""
|
|
55
|
+
self._config[key] = value
|
|
56
|
+
save_config(self._config)
|
|
57
|
+
|
|
58
|
+
@property
|
|
59
|
+
def base_url(self) -> str:
|
|
60
|
+
"""The API base URL."""
|
|
61
|
+
return str(self.get("base_url", DEFAULT_BASE_URL))
|
|
62
|
+
|
|
63
|
+
@property
|
|
64
|
+
def verify_ssl(self) -> bool:
|
|
65
|
+
"""Whether to verify SSL."""
|
|
66
|
+
return bool(self.get("verify_ssl", True))
|
|
67
|
+
|
|
68
|
+
@property
|
|
69
|
+
def username(self) -> Optional[str]:
|
|
70
|
+
"""The username for authentication."""
|
|
71
|
+
val = self.get("username")
|
|
72
|
+
return str(val) if val is not None else None
|
|
73
|
+
|
|
74
|
+
@property
|
|
75
|
+
def password(self) -> Optional[str]:
|
|
76
|
+
"""The password for authentication."""
|
|
77
|
+
val = self.get("password")
|
|
78
|
+
return str(val) if val is not None else None
|
|
79
|
+
|
|
80
|
+
@property
|
|
81
|
+
def jwt_token(self) -> Optional[str]:
|
|
82
|
+
"""The JWT for authentication."""
|
|
83
|
+
val = self.get("jwt_token")
|
|
84
|
+
return str(val) if val is not None else None
|
|
85
|
+
|
|
86
|
+
@jwt_token.setter
|
|
87
|
+
def jwt_token(self, value: Optional[str]):
|
|
88
|
+
"""Sets the JWT."""
|
|
89
|
+
self.set("jwt_token", value)
|
bsm_cli/content.py
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# src/bsm_cli/content.py
|
|
2
|
+
"""CLI commands for content management."""
|
|
3
|
+
|
|
4
|
+
import click
|
|
5
|
+
from bsm_cli.decorators import pass_async_context
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@click.group()
|
|
9
|
+
def content():
|
|
10
|
+
"""Commands for managing content."""
|
|
11
|
+
pass
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@content.command()
|
|
15
|
+
@click.argument("file_path", type=click.Path(exists=True, dir_okay=False))
|
|
16
|
+
@pass_async_context
|
|
17
|
+
async def upload(ctx, file_path):
|
|
18
|
+
"""Upload a content file."""
|
|
19
|
+
client = ctx.obj["client"]
|
|
20
|
+
response = await client.async_upload_content(file_path)
|
|
21
|
+
click.echo(response)
|
bsm_cli/decorators.py
ADDED
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import functools
|
|
3
|
+
|
|
4
|
+
import click
|
|
5
|
+
|
|
6
|
+
from bsm_api_client.exceptions import AuthError
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class AsyncGroup(click.Group):
|
|
10
|
+
def __init__(self, *args, **kwargs):
|
|
11
|
+
super().__init__(*args, **kwargs)
|
|
12
|
+
self.async_context_settings = {}
|
|
13
|
+
|
|
14
|
+
def context(self, f):
|
|
15
|
+
self.async_context_settings["context"] = f
|
|
16
|
+
return f
|
|
17
|
+
|
|
18
|
+
def invoke(self, ctx):
|
|
19
|
+
ctx.obj = ctx.obj or {}
|
|
20
|
+
if self.async_context_settings.get("context"):
|
|
21
|
+
|
|
22
|
+
async def runner():
|
|
23
|
+
async with self.async_context_settings["context"](ctx):
|
|
24
|
+
result = super(AsyncGroup, self).invoke(ctx)
|
|
25
|
+
if asyncio.iscoroutine(result):
|
|
26
|
+
await result
|
|
27
|
+
|
|
28
|
+
return asyncio.run(runner())
|
|
29
|
+
|
|
30
|
+
result = super().invoke(ctx)
|
|
31
|
+
if asyncio.iscoroutine(result):
|
|
32
|
+
return asyncio.run(result)
|
|
33
|
+
return result
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def pass_async_context(f):
|
|
37
|
+
@functools.wraps(f)
|
|
38
|
+
def wrapper(*args, **kwargs):
|
|
39
|
+
ctx = click.get_current_context()
|
|
40
|
+
return f(ctx, *args, **kwargs)
|
|
41
|
+
|
|
42
|
+
return wrapper
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
async def monitor_task( # noqa: C901
|
|
46
|
+
client, task_id: str, success_message: str, failure_message: str
|
|
47
|
+
):
|
|
48
|
+
"""Polls the status of a background task until it completes."""
|
|
49
|
+
click.echo("Task started in the background. Monitoring for completion...")
|
|
50
|
+
|
|
51
|
+
# Try WebSocket first
|
|
52
|
+
try:
|
|
53
|
+
ws_client = await client.websocket_connect()
|
|
54
|
+
async with ws_client:
|
|
55
|
+
# No subscription needed for task updates as per documentation
|
|
56
|
+
async for msg in ws_client.listen():
|
|
57
|
+
# Expected format: {"type": "task_update", "topic": "task:{task_id}", "data": {...}}
|
|
58
|
+
if (
|
|
59
|
+
msg.get("topic") == f"task:{task_id}"
|
|
60
|
+
and msg.get("type") == "task_update"
|
|
61
|
+
):
|
|
62
|
+
data = msg.get("data", {})
|
|
63
|
+
status = data.get("status")
|
|
64
|
+
message = data.get("message", "No message provided.")
|
|
65
|
+
|
|
66
|
+
if status == "success":
|
|
67
|
+
click.secho(f"{success_message}: {message}", fg="green")
|
|
68
|
+
return
|
|
69
|
+
elif status == "error":
|
|
70
|
+
click.secho(f"{failure_message}: {message}", fg="red")
|
|
71
|
+
return
|
|
72
|
+
elif status == "in_progress":
|
|
73
|
+
# Maybe print progress if available, or just wait
|
|
74
|
+
pass
|
|
75
|
+
except AuthError:
|
|
76
|
+
click.secho(
|
|
77
|
+
"WebSocket authentication failed. Attempting to refresh token...",
|
|
78
|
+
fg="yellow",
|
|
79
|
+
)
|
|
80
|
+
try:
|
|
81
|
+
await client.authenticate()
|
|
82
|
+
# Retry WebSocket once
|
|
83
|
+
ws_client = await client.websocket_connect()
|
|
84
|
+
async with ws_client:
|
|
85
|
+
async for msg in ws_client.listen():
|
|
86
|
+
if (
|
|
87
|
+
msg.get("topic") == f"task:{task_id}"
|
|
88
|
+
and msg.get("type") == "task_update"
|
|
89
|
+
):
|
|
90
|
+
data = msg.get("data", {})
|
|
91
|
+
status = data.get("status")
|
|
92
|
+
message = data.get("message", "No message provided.")
|
|
93
|
+
|
|
94
|
+
if status == "success":
|
|
95
|
+
click.secho(f"{success_message}: {message}", fg="green")
|
|
96
|
+
return
|
|
97
|
+
elif status == "error":
|
|
98
|
+
click.secho(f"{failure_message}: {message}", fg="red")
|
|
99
|
+
return
|
|
100
|
+
elif status == "in_progress":
|
|
101
|
+
pass
|
|
102
|
+
except Exception as e:
|
|
103
|
+
click.secho(
|
|
104
|
+
f"WebSocket retry failed ({e}), falling back to polling...", fg="yellow"
|
|
105
|
+
)
|
|
106
|
+
except Exception as e:
|
|
107
|
+
click.secho(
|
|
108
|
+
f"WebSocket monitoring failed ({e}), falling back to polling...",
|
|
109
|
+
fg="yellow",
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
# Fallback to polling
|
|
113
|
+
while True:
|
|
114
|
+
try:
|
|
115
|
+
status_response = await client.async_get_task_status(task_id)
|
|
116
|
+
status = status_response.get("status")
|
|
117
|
+
message = status_response.get("message", "No message provided.")
|
|
118
|
+
|
|
119
|
+
if status == "success":
|
|
120
|
+
click.secho(f"{success_message}: {message}", fg="green")
|
|
121
|
+
break
|
|
122
|
+
elif status == "error":
|
|
123
|
+
click.secho(
|
|
124
|
+
f"{failure_message}: {message}",
|
|
125
|
+
fg="red",
|
|
126
|
+
)
|
|
127
|
+
break
|
|
128
|
+
elif status == "pending" or status == "in_progress":
|
|
129
|
+
# Still waiting, continue loop
|
|
130
|
+
pass
|
|
131
|
+
else:
|
|
132
|
+
# Handle unexpected status
|
|
133
|
+
click.secho(f"Unknown task status received: {status}", fg="yellow")
|
|
134
|
+
|
|
135
|
+
await asyncio.sleep(2)
|
|
136
|
+
except Exception as e:
|
|
137
|
+
click.secho(f"An error occurred while monitoring task: {e}", fg="red")
|
|
138
|
+
await asyncio.sleep(2) # Retry polling on error
|