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/server.py ADDED
@@ -0,0 +1,471 @@
1
+ import asyncio
2
+ import os
3
+
4
+ import click
5
+ import questionary
6
+ from bsm_cli.allowlist import interactive_allowlist_workflow
7
+ from bsm_cli.decorators import monitor_task, pass_async_context
8
+ from bsm_cli.permissions import interactive_permissions_workflow
9
+ from bsm_cli.properties import interactive_properties_workflow
10
+
11
+ from bsm_api_client.exceptions import AuthError
12
+ from bsm_api_client.models import CommandPayload, InstallServerPayload
13
+
14
+
15
+ def _print_server_table(servers):
16
+ """Prints a formatted table of server information to the console."""
17
+ header = f"{'SERVER NAME':<25} {'STATUS':<15} {'VERSION'}"
18
+ click.secho(header, bold=True)
19
+ click.echo("-" * 65)
20
+
21
+ if not servers:
22
+ click.echo(" No servers found.")
23
+ else:
24
+ for server_data in servers:
25
+ name = getattr(server_data, "name", "N/A")
26
+ status = getattr(server_data, "status", "UNKNOWN").upper()
27
+ version = getattr(server_data, "version", "UNKNOWN")
28
+
29
+ color_map = {
30
+ "RUNNING": "green",
31
+ "STOPPED": "red",
32
+ "STARTING": "yellow",
33
+ "STOPPING": "yellow",
34
+ "INSTALLING": "bright_cyan",
35
+ "UPDATING": "bright_cyan",
36
+ "INSTALLED": "bright_magenta",
37
+ "UPDATED": "bright_magenta",
38
+ "UNKNOWN": "bright_black",
39
+ }
40
+ status_color = color_map.get(status, "red")
41
+
42
+ status_styled = click.style(f"{status:<10}", fg=status_color)
43
+ name_styled = click.style(name, fg="cyan")
44
+ version_styled = click.style(version, fg="bright_white")
45
+
46
+ click.echo(f" {name_styled:<38} {status_styled:<20} {version_styled}")
47
+ click.echo("-" * 65)
48
+
49
+
50
+ @click.group()
51
+ def server():
52
+ """Manages servers."""
53
+ pass
54
+
55
+
56
+ @server.command("list")
57
+ @click.option(
58
+ "--loop", is_flag=True, help="Continuously refresh server statuses every 5 seconds."
59
+ )
60
+ @click.option("--server-name", help="Display status for only a specific server.")
61
+ @pass_async_context
62
+ async def list_servers(ctx, loop, server_name): # noqa: C901
63
+ """Lists all configured Bedrock servers and their current operational status."""
64
+ client = ctx.obj.get("client")
65
+ if not client:
66
+ click.secho("You are not logged in.", fg="red")
67
+ return
68
+
69
+ async def _display_status():
70
+ response = await client.async_get_servers()
71
+ all_servers = response.servers or []
72
+
73
+ if server_name:
74
+ servers_to_show = [
75
+ s for s in all_servers if getattr(s, "name", "") == server_name
76
+ ]
77
+ else:
78
+ servers_to_show = all_servers
79
+
80
+ _print_server_table(servers_to_show)
81
+
82
+ try:
83
+ if loop:
84
+ # Initial display
85
+ click.clear()
86
+ click.secho(
87
+ "--- Bedrock Servers Status (Press CTRL+C to exit) ---",
88
+ fg="magenta",
89
+ bold=True,
90
+ )
91
+ await _display_status()
92
+
93
+ # Try to use WebSocket for updates
94
+ try:
95
+ ws_client = await client.websocket_connect()
96
+
97
+ async with ws_client:
98
+ # Subscribe to multiple topics for comprehensive status updates
99
+ await ws_client.subscribe("event:after_server_statuses_updated")
100
+ await ws_client.subscribe("event:after_server_start")
101
+ await ws_client.subscribe("event:after_server_stop")
102
+ await ws_client.subscribe("event:after_server_updated")
103
+ await ws_client.subscribe("event:after_delete_server_data")
104
+
105
+ # Listen for updates
106
+ async for _ in ws_client.listen():
107
+ click.clear()
108
+ click.secho(
109
+ "--- Bedrock Servers Status (Press CTRL+C to exit) ---",
110
+ fg="magenta",
111
+ bold=True,
112
+ )
113
+ await _display_status()
114
+
115
+ except (KeyboardInterrupt, click.Abort):
116
+ raise
117
+ except AuthError:
118
+ click.secho(
119
+ "WebSocket authentication failed. Attempting to refresh token...",
120
+ fg="yellow",
121
+ )
122
+ try:
123
+ await client.authenticate()
124
+ # Retry WebSocket once
125
+ ws_client = await client.websocket_connect()
126
+ async with ws_client:
127
+ await ws_client.subscribe("event:after_server_statuses_updated")
128
+ async for _ in ws_client.listen():
129
+ click.clear()
130
+ click.secho(
131
+ "--- Bedrock Servers Status (Press CTRL+C to exit) ---",
132
+ fg="magenta",
133
+ bold=True,
134
+ )
135
+ await _display_status()
136
+ except Exception as e:
137
+ click.secho(
138
+ f"WebSocket retry failed ({e}), falling back to polling...",
139
+ fg="yellow",
140
+ )
141
+ await asyncio.sleep(2)
142
+ while True:
143
+ click.clear()
144
+ click.secho(
145
+ "--- Bedrock Servers Status (Press CTRL+C to exit) ---",
146
+ fg="magenta",
147
+ bold=True,
148
+ )
149
+ await _display_status()
150
+ await asyncio.sleep(5)
151
+ except Exception as e:
152
+ # Fallback to polling if WebSocket fails
153
+ click.secho(
154
+ f"WebSocket connection failed ({e}), falling back to polling...",
155
+ fg="yellow",
156
+ )
157
+
158
+ # If we are here, WebSocket failed or closed. Fallback to polling.
159
+ await asyncio.sleep(2)
160
+ while True:
161
+ click.clear()
162
+ click.secho(
163
+ "--- Bedrock Servers Status (Press CTRL+C to exit) ---",
164
+ fg="magenta",
165
+ bold=True,
166
+ )
167
+ try:
168
+ await _display_status()
169
+ except Exception as e:
170
+ click.secho(f"Error refreshing status: {e}", fg="red")
171
+ await asyncio.sleep(5)
172
+ else:
173
+ if not server_name:
174
+ click.secho("--- Bedrock Servers Status ---", fg="magenta", bold=True)
175
+ await _display_status()
176
+
177
+ except (KeyboardInterrupt, click.Abort):
178
+ click.secho("\nExiting status monitor.", fg="green")
179
+ except Exception as e:
180
+ click.secho(f"An error occurred: {e}", fg="red")
181
+
182
+
183
+ @server.command("start")
184
+ @click.option(
185
+ "-s", "--server", "server_name", required=True, help="Name of the server to start."
186
+ )
187
+ @pass_async_context
188
+ async def start_server(ctx, server_name: str):
189
+ """Starts a specific Bedrock server instance."""
190
+ client = ctx.obj.get("client")
191
+ if not client:
192
+ click.secho("You are not logged in.", fg="red")
193
+ return
194
+
195
+ click.echo(f"Attempting to start server '{server_name}'...")
196
+ try:
197
+ response = await client.async_start_server(server_name)
198
+ if response.task_id:
199
+ await monitor_task(
200
+ client,
201
+ response.task_id,
202
+ "Server started successfully",
203
+ "Failed to start server",
204
+ )
205
+ elif response.status == "success":
206
+ click.secho(f"Server '{server_name}' started successfully.", fg="green")
207
+ else:
208
+ click.secho(f"Failed to start server: {response.message}", fg="red")
209
+ except Exception as e:
210
+ click.secho(f"Failed to start server: {e}", fg="red")
211
+
212
+
213
+ @server.command("stop")
214
+ @click.option(
215
+ "-s", "--server", "server_name", required=True, help="Name of the server to stop."
216
+ )
217
+ @pass_async_context
218
+ async def stop_server(ctx, server_name: str):
219
+ """Sends a graceful stop command to a running Bedrock server."""
220
+ client = ctx.obj.get("client")
221
+ if not client:
222
+ click.secho("You are not logged in.", fg="red")
223
+ return
224
+
225
+ click.echo(f"Attempting to stop server '{server_name}'...")
226
+ try:
227
+ response = await client.async_stop_server(server_name)
228
+ if response.task_id:
229
+ await monitor_task(
230
+ client,
231
+ response.task_id,
232
+ "Server stopped successfully",
233
+ "Failed to stop server",
234
+ )
235
+ elif response.status == "success":
236
+ click.secho(f"Stop signal sent to server '{server_name}'.", fg="green")
237
+ else:
238
+ click.secho(f"Failed to stop server: {response.message}", fg="red")
239
+ except Exception as e:
240
+ click.secho(f"Failed to stop server: {e}", fg="red")
241
+
242
+
243
+ @server.command("restart")
244
+ @click.option(
245
+ "-s",
246
+ "--server",
247
+ "server_name",
248
+ required=True,
249
+ help="Name of the server to restart.",
250
+ )
251
+ @pass_async_context
252
+ async def restart_server(ctx, server_name: str):
253
+ """Gracefully restarts a specific Bedrock server."""
254
+ client = ctx.obj.get("client")
255
+ if not client:
256
+ click.secho("You are not logged in.", fg="red")
257
+ return
258
+
259
+ click.echo(f"Attempting to restart server '{server_name}'...")
260
+ try:
261
+ response = await client.async_restart_server(server_name)
262
+ if response.task_id:
263
+ await monitor_task(
264
+ client,
265
+ response.task_id,
266
+ "Server restarted successfully",
267
+ "Failed to restart server",
268
+ )
269
+ elif response.status == "success":
270
+ click.secho(f"Restart signal sent to server '{server_name}'.", fg="green")
271
+ else:
272
+ click.secho(f"Failed to restart server: {response.message}", fg="red")
273
+ except Exception as e:
274
+ click.secho(f"Failed to restart server: {e}", fg="red")
275
+
276
+
277
+ @server.command("install")
278
+ @click.pass_context
279
+ async def install(ctx): # noqa: C901
280
+ """Guides you through installing and configuring a new Bedrock server instance."""
281
+ client = ctx.obj.get("client")
282
+ if not client:
283
+ click.secho("You are not logged in.", fg="red")
284
+ return
285
+
286
+ try:
287
+ click.secho("--- New Bedrock Server Installation ---", bold=True)
288
+ server_name = await questionary.text(
289
+ "Enter a name for the new server:"
290
+ ).ask_async()
291
+ if not server_name:
292
+ raise click.Abort()
293
+
294
+ target_version = await questionary.text(
295
+ "Enter server version (e.g., LATEST, PREVIEW, CUSTOM, 1.20.81.01):",
296
+ default="LATEST",
297
+ ).ask_async()
298
+ if not target_version:
299
+ raise click.Abort()
300
+
301
+ server_zip_path = None
302
+ if target_version.upper() == "CUSTOM":
303
+ response = await client.async_get_custom_zips()
304
+ available_files = response.custom_zips
305
+
306
+ if not available_files:
307
+ click.secho(
308
+ "No custom server ZIP files found in the content/custom directory.",
309
+ fg="red",
310
+ )
311
+ return
312
+
313
+ file_map = {os.path.basename(f): f for f in available_files}
314
+ choices = sorted(list(file_map.keys())) + ["Cancel"]
315
+ selection = await questionary.select(
316
+ "Select a custom server ZIP to install:", choices=choices
317
+ ).ask_async()
318
+
319
+ if not selection or selection == "Cancel":
320
+ raise click.Abort()
321
+ server_zip_path = file_map[selection]
322
+
323
+ overwrite = await questionary.confirm(
324
+ "Overwrite existing server if it exists?", default=False
325
+ ).ask_async()
326
+
327
+ click.echo(f"\nInstalling server '{server_name}' version '{target_version}'...")
328
+
329
+ payload = InstallServerPayload(
330
+ server_name=server_name,
331
+ server_version=target_version,
332
+ overwrite=overwrite,
333
+ server_zip_path=server_zip_path,
334
+ )
335
+ install_result = await client.async_install_new_server(payload)
336
+
337
+ if install_result.task_id:
338
+ await monitor_task(
339
+ client,
340
+ install_result.task_id,
341
+ "Server installation completed successfully",
342
+ "Installation failed",
343
+ )
344
+ elif install_result.status == "success":
345
+ click.secho("Server files installed successfully.", fg="green")
346
+ else:
347
+ click.secho(f"Failed to install server: {install_result.message}", fg="red")
348
+ return
349
+
350
+ await interactive_properties_workflow(client, server_name)
351
+ if await questionary.confirm(
352
+ "\nConfigure the allowlist now?", default=False
353
+ ).ask_async():
354
+ await interactive_allowlist_workflow(client, server_name)
355
+ if await questionary.confirm(
356
+ "\nConfigure player permissions now?", default=False
357
+ ).ask_async():
358
+ await interactive_permissions_workflow(client, server_name)
359
+
360
+ click.secho(
361
+ "\nInstallation and initial configuration complete!", fg="green", bold=True
362
+ )
363
+
364
+ if await questionary.confirm(
365
+ f"Start server '{server_name}' now?", default=True
366
+ ).ask_async():
367
+ await ctx.invoke(start_server, server_name=server_name)
368
+
369
+ except Exception as e:
370
+ click.secho(f"An application error occurred: {e}", fg="red")
371
+
372
+
373
+ @server.command("update")
374
+ @click.option(
375
+ "-s", "--server", "server_name", required=True, help="Name of the server to update."
376
+ )
377
+ @pass_async_context
378
+ async def update(ctx, server_name: str):
379
+ """Checks for and applies updates to an existing Bedrock server."""
380
+ client = ctx.obj.get("client")
381
+ if not client:
382
+ click.secho("You are not logged in.", fg="red")
383
+ return
384
+
385
+ click.echo(f"Checking for updates for server '{server_name}'...")
386
+ try:
387
+ response = await client.async_update_server(server_name)
388
+ if response.task_id:
389
+ await monitor_task(
390
+ client,
391
+ response.task_id,
392
+ "Server update completed successfully",
393
+ "Failed to update server",
394
+ )
395
+ elif response.status == "success":
396
+ click.secho("Update check complete.", fg="green")
397
+ else:
398
+ click.secho(f"Failed to update server: {response.message}", fg="red")
399
+ except Exception as e:
400
+ click.secho(f"A server update error occurred: {e}", fg="red")
401
+
402
+
403
+ @server.command("delete")
404
+ @click.option(
405
+ "-s", "--server", "server_name", required=True, help="Name of the server to delete."
406
+ )
407
+ @click.option("-y", "--yes", is_flag=True, help="Bypass the confirmation prompt.")
408
+ @pass_async_context
409
+ async def delete_server(ctx, server_name: str, yes: bool):
410
+ """Deletes all data for a server, including world, configs, and backups."""
411
+ client = ctx.obj.get("client")
412
+ if not client:
413
+ click.secho("You are not logged in.", fg="red")
414
+ return
415
+
416
+ if not yes:
417
+ click.secho(
418
+ f"WARNING: This will permanently delete all data for server '{server_name}',\n"
419
+ "including the installation, worlds, and all associated backups.",
420
+ fg="red",
421
+ bold=True,
422
+ )
423
+ click.confirm(
424
+ f"\nAre you absolutely sure you want to delete '{server_name}'?", abort=True
425
+ )
426
+
427
+ click.echo(f"Proceeding with deletion of server '{server_name}'...")
428
+ try:
429
+ response = await client.async_delete_server(server_name)
430
+ if response.task_id:
431
+ await monitor_task(
432
+ client,
433
+ response.task_id,
434
+ "Server deleted successfully",
435
+ "Failed to delete server",
436
+ )
437
+ elif response.status == "success":
438
+ click.secho(
439
+ f"Server '{server_name}' and all its data have been deleted.",
440
+ fg="green",
441
+ )
442
+ else:
443
+ click.secho(f"Failed to delete server: {response.message}", fg="red")
444
+ except Exception as e:
445
+ click.secho(f"Failed to delete server: {e}", fg="red")
446
+
447
+
448
+ @server.command("send-command")
449
+ @click.option(
450
+ "-s", "--server", "server_name", required=True, help="Name of the target server."
451
+ )
452
+ @click.argument("command_parts", nargs=-1, required=True)
453
+ @click.pass_context
454
+ async def send_command(ctx, server_name: str, command_parts: str):
455
+ """Sends a command to a running Bedrock server's console."""
456
+ client = ctx.obj.get("client")
457
+ if not client:
458
+ click.secho("You are not logged in.", fg="red")
459
+ return
460
+
461
+ command_string = " ".join(command_parts)
462
+ click.echo(f"Sending command to '{server_name}': {command_string}")
463
+ try:
464
+ payload = CommandPayload(command=command_string)
465
+ response = await client.async_send_server_command(server_name, payload)
466
+ if response.status == "success":
467
+ click.secho("Command sent successfully.", fg="green")
468
+ else:
469
+ click.secho(f"Failed to send command: {response.message}", fg="red")
470
+ except Exception as e:
471
+ click.secho(f"Failed to send command: {e}", fg="red")
bsm_cli/system.py ADDED
@@ -0,0 +1,142 @@
1
+ import time
2
+
3
+ import click
4
+ import questionary
5
+
6
+ from bsm_api_client.models import ServerSettingItemPayload
7
+
8
+
9
+ @click.group()
10
+ def system():
11
+ """Manages server OS-level resource monitoring and settings."""
12
+ pass
13
+
14
+
15
+ @system.command("settings")
16
+ @click.option(
17
+ "-s",
18
+ "--server",
19
+ "server_name",
20
+ required=True,
21
+ help="Name of the server to configure.",
22
+ )
23
+ @click.pass_context
24
+ async def server_settings(ctx, server_name: str):
25
+ """Configures autostart and autoupdate settings for a Bedrock server."""
26
+ client = ctx.obj.get("client")
27
+ if not client:
28
+ click.secho("You are not logged in.", fg="red")
29
+ return
30
+
31
+ click.secho(
32
+ f"Starting interactive settings configuration for '{server_name}'...",
33
+ fg="yellow",
34
+ )
35
+
36
+ settings_response = await client.async_get_server_settings(server_name)
37
+ if settings_response.status != "success" or not settings_response.settings:
38
+ click.secho(
39
+ f"Failed to fetch server settings: {settings_response.message}", fg="red"
40
+ )
41
+ return
42
+
43
+ settings = settings_response.settings
44
+
45
+ current_autoupdate = settings.get("settings", {}).get("autoupdate", False)
46
+ current_autostart = settings.get("settings", {}).get("autostart", False)
47
+
48
+ click.secho(
49
+ f"\n--- Interactive Settings Configuration for '{server_name}' ---", bold=True
50
+ )
51
+
52
+ autoupdate_choice = await questionary.confirm(
53
+ "Enable check for updates when the server starts?", default=current_autoupdate
54
+ ).ask_async()
55
+
56
+ if autoupdate_choice is not None and autoupdate_choice != current_autoupdate:
57
+ payload = ServerSettingItemPayload(
58
+ key="settings.autoupdate", value=autoupdate_choice
59
+ )
60
+ response = await client.async_set_server_setting(server_name, payload)
61
+ if response.status == "success":
62
+ click.secho(
63
+ f"Autoupdate setting configured to '{autoupdate_choice}'.", fg="green"
64
+ )
65
+ else:
66
+ click.secho(f"Failed to set autoupdate: {response.message}", fg="red")
67
+
68
+ autostart_choice = await questionary.confirm(
69
+ "Enable the server to start automatically when the manager starts?",
70
+ default=current_autostart,
71
+ ).ask_async()
72
+
73
+ if autostart_choice is not None and autostart_choice != current_autostart:
74
+ payload = ServerSettingItemPayload(
75
+ key="settings.autostart", value=autostart_choice
76
+ )
77
+ response = await client.async_set_server_setting(server_name, payload)
78
+ if response.status == "success":
79
+ click.secho(
80
+ f"Autostart setting configured to '{autostart_choice}'.", fg="green"
81
+ )
82
+ else:
83
+ click.secho(f"Failed to set autostart: {response.message}", fg="red")
84
+
85
+ click.secho("\nSettings configuration complete.", fg="green", bold=True)
86
+
87
+
88
+ @system.command("monitor")
89
+ @click.option(
90
+ "-s",
91
+ "--server",
92
+ "server_name",
93
+ required=True,
94
+ help="Name of the server to monitor.",
95
+ )
96
+ @click.pass_context
97
+ async def monitor_usage(ctx, server_name: str):
98
+ """Continuously monitors CPU and memory usage of a specific server process."""
99
+ client = ctx.obj.get("client")
100
+ if not client:
101
+ click.secho("You are not logged in.", fg="red")
102
+ return
103
+
104
+ click.secho(
105
+ f"Starting resource monitoring for server '{server_name}'. Press CTRL+C to exit.",
106
+ fg="cyan",
107
+ )
108
+ time.sleep(1)
109
+
110
+ try:
111
+ while True:
112
+ response = await client.async_get_server_process_info(server_name)
113
+
114
+ click.clear()
115
+ click.secho(
116
+ f"--- Monitoring Server: {server_name} ---", fg="magenta", bold=True
117
+ )
118
+ click.echo(
119
+ f"(Last updated: {time.strftime('%H:%M:%S')}, Press CTRL+C to exit)\n"
120
+ )
121
+
122
+ if response.status == "error":
123
+ click.secho(f"Error: {response.message}", fg="red")
124
+ elif response.process_info is None:
125
+ click.secho("Server process not found (is it running?).", fg="yellow")
126
+ else:
127
+ info = response.process_info
128
+ pid_str = info.get("pid", "N/A")
129
+ cpu_str = f"{info.get('cpu_percent', 0.0):.1f}%"
130
+ mem_str = f"{info.get('memory_mb', 0.0):.1f} MB"
131
+ uptime_str = info.get("uptime", "N/A")
132
+
133
+ click.echo(f" {'PID':<15}: {click.style(str(pid_str), fg='cyan')}")
134
+ click.echo(f" {'CPU Usage':<15}: {click.style(cpu_str, fg='green')}")
135
+ click.echo(
136
+ f" {'Memory Usage':<15}: {click.style(mem_str, fg='green')}"
137
+ )
138
+ click.echo(f" {'Uptime':<15}: {click.style(uptime_str, fg='white')}")
139
+
140
+ time.sleep(2)
141
+ except (KeyboardInterrupt, click.Abort):
142
+ click.secho("\nMonitoring stopped.", fg="green")