redfetch 1.3.0__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.
redfetch/main.py ADDED
@@ -0,0 +1,744 @@
1
+ # standard imports
2
+ import sys
3
+ import os
4
+ from enum import Enum
5
+ from pathlib import Path
6
+ from typing import Optional
7
+ import asyncio
8
+
9
+ # third-party imports
10
+ from rich.prompt import Confirm, Prompt
11
+ from rich.console import Console
12
+ import typer
13
+
14
+ # local imports
15
+ from redfetch import api
16
+ from redfetch import auth
17
+ from redfetch import config
18
+ from redfetch import meta
19
+ from redfetch import net
20
+ from redfetch import processes
21
+ from redfetch import utils
22
+ from redfetch import push
23
+ from redfetch import sync
24
+ from redfetch import store
25
+ from redfetch.runtime_errors import exit_with_fatal_error
26
+
27
+
28
+ app = typer.Typer(
29
+ help="[bold red]redfetch[/bold red] - RedGuides resource management tool. Run without arguments to launch the [italic]Terminal User Interface[/italic].",
30
+ rich_markup_mode="rich"
31
+ )
32
+
33
+ console = Console()
34
+
35
+
36
+ class Env(str, Enum):
37
+ LIVE = "LIVE"
38
+ TEST = "TEST"
39
+ EMU = "EMU"
40
+
41
+
42
+ def parse_resource_id_or_fail(value: str) -> str:
43
+ """Accept either an integer ID or a URL that includes a recognizable ID."""
44
+ value_stripped = value.strip()
45
+ if value_stripped.isdigit():
46
+ return value_stripped
47
+ parsed = utils.parse_resource_id(value_stripped)
48
+ if parsed is None:
49
+ raise typer.BadParameter("Provide a resource ID or a recognized RedGuides URL.")
50
+ return parsed
51
+
52
+
53
+ def _apply_server_override(server: "Optional[Env]" = None) -> None:
54
+ if server is not None and server.value != config.settings.ENV:
55
+ config.switch_environment(server.value)
56
+
57
+
58
+ def _initialize_auth():
59
+ """Initialize configuration, update check, and auth (no DB, no network)."""
60
+ config.initialize_config()
61
+ if os.environ.get('CI') != 'true':
62
+ _ = meta.check_for_update()
63
+ auth.initialize_keyring()
64
+ auth.authorize()
65
+
66
+
67
+ def initialize_db_only(server: "Optional[Env]" = None):
68
+ """Initialize configuration, auth, and local cache database (no network)."""
69
+ _initialize_auth()
70
+ _apply_server_override(server)
71
+ db_name = f"{config.settings.ENV}_resources.db"
72
+ store.initialize_db(db_name)
73
+ db_path = store.get_db_path(db_name)
74
+ return db_name, db_path
75
+
76
+
77
+ # ===== CLI prompt helpers =====
78
+
79
+ def prompt_terminate_processes(mq_folder: str) -> None:
80
+ """Handle the 'AUTO_TERMINATE_PROCESSES' prompt and action."""
81
+ # Check if MQ or any other executable is running
82
+ if not processes.are_executables_running_in_folder(mq_folder):
83
+ return
84
+
85
+ auto_terminate = config.settings.from_env(config.settings.ENV).get(
86
+ "AUTO_TERMINATE_PROCESSES", None
87
+ )
88
+
89
+ if auto_terminate is True:
90
+ processes.terminate_executables_in_folder(mq_folder)
91
+ return
92
+
93
+ if auto_terminate is False:
94
+ console.print("Continuing update without closing processes...")
95
+ return
96
+
97
+ # auto_terminate is None -> ask the user
98
+ user_choice = Prompt.ask(
99
+ "Processes are running from the folder. Attempt to close them?",
100
+ choices=["yes", "no", "always", "never"],
101
+ default="yes",
102
+ )
103
+ if user_choice == "yes":
104
+ processes.terminate_executables_in_folder(mq_folder)
105
+ elif user_choice == "always":
106
+ processes.terminate_executables_in_folder(mq_folder)
107
+ config.update_setting(["AUTO_TERMINATE_PROCESSES"], True)
108
+ console.print("Updated settings to always terminate processes.")
109
+ elif user_choice == "never":
110
+ console.print("Continuing update without closing processes...")
111
+ config.update_setting(["AUTO_TERMINATE_PROCESSES"], False)
112
+ console.print("Updated settings to never terminate processes.")
113
+ else: # "no"
114
+ console.print("Continuing update without closing processes...")
115
+
116
+
117
+ def prompt_navmesh_opt_in() -> bool | None:
118
+ """Prompt user about navmesh downloads if not configured."""
119
+ from redfetch import navmesh
120
+
121
+ # Check if VVMQ path exists (navmesh requires it)
122
+ vvmq_path = utils.get_vvmq_path()
123
+ if not vvmq_path:
124
+ return None # Can't do navmesh without VVMQ
125
+
126
+ opt_in = navmesh.get_navmesh_opt_in()
127
+
128
+ if opt_in is not None:
129
+ return None # Already configured, use existing setting
130
+
131
+ # Prompt user
132
+ user_choice = Prompt.ask(
133
+ "🧭 Download navigation meshes? (will overwrite, protect your custom meshes in settings.local.toml)",
134
+ choices=["yes", "no", "always", "never"],
135
+ default="yes",
136
+ )
137
+
138
+ if user_choice == "yes":
139
+ return True # One-time yes
140
+ elif user_choice == "always":
141
+ config.update_setting(["NAVMESH_OPT_IN"], True)
142
+ console.print("Updated settings to always download navmeshes.")
143
+ return None # Config is now set, use it
144
+ elif user_choice == "never":
145
+ config.update_setting(["NAVMESH_OPT_IN"], False)
146
+ console.print("Updated settings to never download navmeshes.")
147
+ return None # Config is now set, use it
148
+ else: # "no"
149
+ return False # One-time no
150
+
151
+
152
+ def prompt_auto_run_macroquest() -> None:
153
+ """Prompt to start MacroQuest after a successful update, if not configured."""
154
+ if os.environ.get("CI") == "true" or sys.platform != "win32":
155
+ return
156
+
157
+ auto_run = config.settings.from_env(config.settings.ENV).get("AUTO_RUN_VVMQ", None)
158
+
159
+ if auto_run is False:
160
+ return
161
+
162
+ if auto_run is None:
163
+ user_choice = Prompt.ask(
164
+ "Do you want to start MacroQuest now?",
165
+ choices=["yes", "no", "always", "never"],
166
+ default="yes",
167
+ )
168
+ if user_choice == "always":
169
+ config.update_setting(["AUTO_RUN_VVMQ"], True)
170
+ console.print("Updated settings to always run MacroQuest after updates.")
171
+ elif user_choice == "never":
172
+ config.update_setting(["AUTO_RUN_VVMQ"], False)
173
+ console.print("Updated settings to never run MacroQuest after updates.")
174
+ return
175
+ elif user_choice == "no":
176
+ console.print("Not starting MacroQuest.")
177
+ return
178
+
179
+ mq_path = utils.get_vvmq_path()
180
+ if mq_path:
181
+ processes.run_executable(mq_path, "MacroQuest.exe")
182
+ else:
183
+ console.print("MacroQuest path not found. Please check your configuration.")
184
+
185
+
186
+ def run_post_update_launch() -> None:
187
+ """Launch the configured post-update program."""
188
+ if os.environ.get("CI") == "true":
189
+ return
190
+
191
+ for command, cwd in utils.resolve_post_update_launch(config.settings.ENV):
192
+ processes.run_command(command, cwd)
193
+
194
+
195
+ async def handle_download_watched_async(db_path: str, headers: dict) -> bool:
196
+ """Run the main 'update watched' flow using async network calls."""
197
+ if await net.is_mq_down():
198
+ console.print(
199
+ "[bold yellow]Warning:[/bold yellow] [blink bold red]MQ appears to be down[/blink bold red] for a patch, so it's not likely to work."
200
+ )
201
+ continue_download = Confirm.ask(
202
+ "Do you want to continue with the download?", default=False
203
+ )
204
+ if not continue_download:
205
+ console.print("Download cancelled by user.")
206
+ return False
207
+
208
+ mq_folder = utils.get_base_path()
209
+ prompt_terminate_processes(mq_folder)
210
+
211
+ # Check navmesh preference (prompt if not configured)
212
+ navmesh_override = prompt_navmesh_opt_in()
213
+
214
+ # Perform the download via async pipeline
215
+ success = await sync.run_sync(
216
+ db_path, headers, navmesh_override=navmesh_override
217
+ )
218
+ if success:
219
+ prompt_auto_run_macroquest()
220
+ run_post_update_launch()
221
+ return True
222
+ return False
223
+
224
+
225
+ async def update_command_async(db_name: str, db_path: str, force: bool) -> None:
226
+ headers = await auth.get_api_headers()
227
+ # Only check KISS access for bulk operations (not single resource downloads)
228
+ if not await api.is_kiss_downloadable(headers):
229
+ console.print(
230
+ "[bold yellow]Warning:[/bold yellow] You're not level 2 on RedGuides, so some resources will not be downloadable."
231
+ )
232
+ if force:
233
+ with store.get_db_connection(db_name) as conn:
234
+ cursor = conn.cursor()
235
+ console.print(
236
+ "Force download requested. All watched resources will be re-downloaded."
237
+ )
238
+ store.reset_download_dates(cursor)
239
+ await handle_download_watched_async(db_path, headers)
240
+
241
+
242
+ async def download_command_async(db_name: str, db_path: str, id_or_url: str, force: bool) -> None:
243
+ headers = await auth.get_api_headers()
244
+ rid = parse_resource_id_or_fail(id_or_url)
245
+ if force:
246
+ with store.get_db_connection(db_name) as conn:
247
+ cursor = conn.cursor()
248
+ store.reset_versions_for_resource(cursor, rid)
249
+ console.print(f"Downloading resource {rid}.")
250
+ await sync.run_sync(db_path, headers, [rid])
251
+
252
+
253
+ @app.command(
254
+ "update",
255
+ help="Update all [italic]watched[/italic] and special resources.",
256
+ rich_help_panel="📦 Resource Management"
257
+ )
258
+ def update_command(
259
+ force: bool = typer.Option(False, "--force", "-f", help="Force re-download of all watched resources."),
260
+ server: Optional[Env] = typer.Option(None, "--server", "-s", case_sensitive=False, help="Switch to this server before updating ([green]LIVE[/green], [yellow]TEST[/yellow], [cyan]EMU[/cyan])."),
261
+ ):
262
+ db_name, db_path = initialize_db_only(server=server)
263
+ asyncio.run(update_command_async(db_name=db_name, db_path=db_path, force=force))
264
+
265
+
266
+ @app.command(
267
+ "download",
268
+ help="Download a specific resource by ID or URL.",
269
+ rich_help_panel="📦 Resource Management"
270
+ )
271
+ def download(
272
+ id_or_url: str = typer.Argument(..., metavar="ID_OR_URL", help="RedGuides resource ID or URL"),
273
+ force: bool = typer.Option(False, "--force", "-f", help="Force re-download by resetting this resource's download date."),
274
+ server: Optional[Env] = typer.Option(None, "--server", "-s", case_sensitive=False, help="Switch to this server type before downloading ([green]LIVE[/green], [yellow]TEST[/yellow], [cyan]EMU[/cyan])."),
275
+ ):
276
+ db_name, db_path = initialize_db_only(server=server)
277
+ asyncio.run(download_command_async(db_name=db_name, db_path=db_path, id_or_url=id_or_url, force=force))
278
+
279
+
280
+ def _has_auth_credentials() -> bool:
281
+ """Peek at env / keyring for stored credentials (no network, no init)."""
282
+ if os.environ.get("REDGUIDES_API_KEY"):
283
+ return True
284
+ try:
285
+ import keyring
286
+ token = keyring.get_password(auth.KEYRING_SERVICE_NAME, "access_token")
287
+ return token is not None
288
+ except Exception:
289
+ return False
290
+
291
+
292
+ @app.command(
293
+ "check",
294
+ help=(
295
+ "Non-interactive update check. Writes [bold]update_status.json[/bold] for the env "
296
+ "(exit 0=wrote a verdict, 1=transient failure)."
297
+ ),
298
+ rich_help_panel="📦 Resource Management",
299
+ )
300
+ def check_command(
301
+ server: Optional[Env] = typer.Option(
302
+ None, "--server", "-s", case_sensitive=False,
303
+ help="Check this server's env for this run only, without persisting it ([green]LIVE[/green], [yellow]TEST[/yellow], [cyan]EMU[/cyan]).",
304
+ ),
305
+ ):
306
+ from redfetch.config_firstrun import is_configured
307
+ from redfetch import update_status
308
+
309
+ requested_env = server.value if server else None
310
+
311
+ # Pre-flight: not configured at all -> no init possible, but we can still record the verdict.
312
+ if not is_configured():
313
+ update_status.write_update_status(
314
+ env=requested_env or Env.LIVE.value,
315
+ auth_state="not_configured",
316
+ )
317
+ raise typer.Exit(0)
318
+
319
+ try:
320
+ config.initialize_config()
321
+ # Honor --server for this run only; never persist (a "check" must not change the user's env).
322
+ if requested_env:
323
+ config.select_environment_in_memory(requested_env)
324
+ env = config.settings.ENV
325
+ auth.initialize_keyring()
326
+
327
+ # MQ matches this against its own root to ignore stray copies.
328
+ managed_path = utils.get_vvmq_path()
329
+
330
+ if not _has_auth_credentials():
331
+ update_status.write_update_status(env=env, auth_state="needs_login", managed_path=managed_path)
332
+ raise typer.Exit(0)
333
+
334
+ db_name = f"{env}_resources.db"
335
+ store.initialize_db(db_name)
336
+ db_path = store.get_db_path(db_name)
337
+
338
+ auth_state, items = asyncio.run(_check_command_async(db_path))
339
+ update_status.write_update_status(env=env, auth_state=auth_state, items=items, managed_path=managed_path)
340
+ raise typer.Exit(0)
341
+
342
+ except typer.Exit:
343
+ raise
344
+ except Exception:
345
+ # Transient failure, no touch to update_status.json so we know checked_at didn't advance this cycle.
346
+ raise typer.Exit(1)
347
+
348
+
349
+ async def _check_command_async(db_path: str) -> tuple[str, list[dict] | None]:
350
+ from redfetch import update_status
351
+
352
+ try:
353
+ headers = await auth.get_api_headers()
354
+ except RuntimeError:
355
+ # Silent refresh failed / expired / logged out -> a gentle re-login nudge
356
+ return "needs_login", None
357
+
358
+ prepared = await sync.prepare_sync(db_path, headers)
359
+ return "ok", update_status.build_items_from_plan(prepared.execution_plan)
360
+
361
+
362
+ @app.command(
363
+ "ui",
364
+ help="Launch the [italic]Terminal User Interface[/italic].",
365
+ rich_help_panel="🔧 System & Utilities"
366
+ )
367
+ def run_tui():
368
+ """Initialize configuration and launch the Terminal User Interface."""
369
+ _initialize_auth()
370
+ from redfetch.terminal_ui import run_textual_ui
371
+ run_textual_ui()
372
+
373
+
374
+ @app.command(
375
+ "web",
376
+ help="Launch the [bold]RedGuides.com[/bold] web interface.",
377
+ rich_help_panel="🔧 System & Utilities"
378
+ )
379
+ def web_command():
380
+ db_name, _db_path = initialize_db_only()
381
+ try:
382
+ asyncio.run(web_command_async(db_name=db_name))
383
+ except KeyboardInterrupt:
384
+ console.print("\nServer stopped by user (Ctrl+C).")
385
+
386
+
387
+ async def web_command_async(db_name: str) -> None:
388
+ headers = await auth.get_api_headers()
389
+ from .listener import run_server_async
390
+ await run_server_async(
391
+ config.settings, db_name, headers, config.CATEGORY_MAP
392
+ )
393
+
394
+
395
+ @app.command(
396
+ "list",
397
+ help="List resources and dependencies currently in the cache database.",
398
+ rich_help_panel="📦 Resource Management"
399
+ )
400
+ def resources_list_command():
401
+ db_name, _db_path = initialize_db_only()
402
+ with store.get_db_connection(db_name) as conn:
403
+ cursor = conn.cursor()
404
+ resources = store.list_resources(cursor)
405
+ console.print("Resources:")
406
+ for resource_id, title in resources:
407
+ console.print(f"ID: {resource_id}, Title: {title}")
408
+ dependencies = store.list_dependencies(cursor)
409
+ console.print("Dependencies:")
410
+ for resource_id, title in dependencies:
411
+ console.print(f"ID: {resource_id}, Title: {title}")
412
+
413
+
414
+ @app.command(
415
+ "reset",
416
+ help="Reset download dates for [italic]watched resources[/italic] in the database.",
417
+ rich_help_panel="📦 Resource Management"
418
+ )
419
+ def resources_reset_command():
420
+ db_name, _db_path = initialize_db_only()
421
+ with store.get_db_connection(db_name) as conn:
422
+ cursor = conn.cursor()
423
+ store.reset_download_dates(cursor)
424
+ console.print("Reset download dates for watched resources.")
425
+
426
+
427
+ @app.command(
428
+ "config",
429
+ help="Update a setting by path and value.",
430
+ rich_help_panel="🍔 Configuration"
431
+ )
432
+ def config_command(
433
+ path: str = typer.Argument(..., metavar="SETTING_PATH", help="Dot-separated setting path (e.g., SPECIAL_RESOURCES.1974.opt_in)"),
434
+ value: str = typer.Argument(..., metavar="VALUE", help="New value for the setting"),
435
+ server: Optional[Env] = typer.Option(None, "--server", "-s", case_sensitive=False, help="Server to apply the change in ([green]LIVE[/green], [yellow]TEST[/yellow], [cyan]EMU[/cyan])"),
436
+ ):
437
+ config.initialize_config()
438
+ setting_path_list = path.split('.')
439
+ config.update_setting(setting_path_list, value, server.value if server else None)
440
+ settings_env = server.value if server else config.settings.ENV
441
+ db_name = f"{settings_env}_resources.db"
442
+ store.initialize_db(db_name)
443
+ console.print(f"Updated setting {path} to {value}{' for server ' + server.value if server else ''}.")
444
+
445
+
446
+ @app.command(
447
+ "server",
448
+ help="Switch the current server/environment to [green]LIVE[/green], [yellow]TEST[/yellow], or [cyan]EMU[/cyan].",
449
+ rich_help_panel="🍔 Configuration"
450
+ )
451
+ def server_command(
452
+ env: Env = typer.Argument(..., metavar="SERVER", case_sensitive=False, help="Server to use: [green]LIVE[/green], [yellow]TEST[/yellow], [cyan]EMU[/cyan]"),
453
+ ):
454
+ config.initialize_config()
455
+ config.switch_environment(env.value)
456
+ console.print(f"Environment updated to {env.value}.")
457
+ console.print("New complete configuration:")
458
+ typer.echo(config.settings.from_env(env.value).as_dict())
459
+
460
+
461
+ @app.command("show", hidden=True)
462
+ @app.command(
463
+ "status",
464
+ help="Show the configuration for the current or specified server.",
465
+ rich_help_panel="🍔 Configuration"
466
+ )
467
+ def config_show_command(server: Optional[Env] = typer.Option(None, "--server", "-s", case_sensitive=False, help="Server to show (defaults to current)")):
468
+ from rich.panel import Panel
469
+
470
+ config.initialize_config()
471
+ current_env = server.value if server else getattr(config.settings, "ENV", "UNKNOWN")
472
+ env_settings = config.settings.from_env(current_env)
473
+
474
+ # Core paths for this environment
475
+ download_folder = env_settings.get("DOWNLOAD_FOLDER") or ""
476
+ eq_path = env_settings.get("EQPATH") or ""
477
+
478
+ panel_lines: list[str] = []
479
+
480
+ # Only show top-level paths that are actually set
481
+ if download_folder:
482
+ panel_lines.append(f"[bold yellow]DOWNLOAD_FOLDER:[/bold yellow] {download_folder}")
483
+ if eq_path:
484
+ panel_lines.append(f"[bold yellow]EQPATH:[/bold yellow] {eq_path}")
485
+
486
+ # Opted-in special resources with resolved paths, keyed by resource ID
487
+ special_resources = env_settings.get("SPECIAL_RESOURCES", {})
488
+
489
+ for resource_id, resource_info in special_resources.items():
490
+ # Only show if opted in
491
+ if not resource_info.get("opt_in"):
492
+ continue
493
+
494
+ # Get the resolved path
495
+ custom_path = resource_info.get("custom_path", "")
496
+ default_path = resource_info.get("default_path", "")
497
+
498
+ if custom_path:
499
+ resource_path = custom_path
500
+ elif default_path:
501
+ # If default_path is absolute, use it as-is
502
+ if download_folder and not os.path.isabs(default_path):
503
+ resource_path = os.path.join(download_folder, default_path)
504
+ else:
505
+ resource_path = default_path
506
+ else:
507
+ continue
508
+
509
+ # Label only by resource ID
510
+ panel_lines.append(f"[bold yellow]Resource {resource_id}:[/bold yellow] {resource_path}")
511
+
512
+ # If nothing to show, still render an empty-but-clear panel
513
+ if not panel_lines:
514
+ panel_lines.append("[dim]No paths are currently configured or opted in for this environment.[/dim]")
515
+
516
+ console.print(Panel("\n".join(panel_lines), expand=False))
517
+
518
+ # Optional: Show full settings dict with a label
519
+ console.print("\n[dim][italic]Full configuration (for debugging):[/italic][/dim]")
520
+ typer.echo(env_settings.as_dict())
521
+
522
+
523
+ @app.command(
524
+ "publish",
525
+ help="Publish updates to a [bold]RedGuides[/bold] resource.",
526
+ rich_help_panel="📤 Publishing"
527
+ )
528
+ def publish_command(
529
+ resource_id: int = typer.Argument(..., help="Existing RedGuides resource ID"),
530
+ description: Optional[Path] = typer.Option(None, "--description", "-d", metavar="README.md", help="Path to a description file (e.g. README.md) to become the overview description.", exists=True, file_okay=True, dir_okay=False, readable=True, resolve_path=True),
531
+ version: Optional[str] = typer.Option(None, "--version", "-v", help="New version string (e.g., v1.0.1)"),
532
+ message: Optional[Path] = typer.Option(None, "--message", "-m", metavar="CHANGELOG.md | MESSAGE", help="Path to [italic]CHANGELOG.md[/italic] (keep a changelog), other message file, or a direct message string.", exists=False),
533
+ file: Optional[Path] = typer.Option(None, "--file", "-f", metavar="FILE.zip", help="Path to your zipped release file", exists=True, file_okay=True, dir_okay=False, readable=True, resolve_path=True),
534
+ domain: Optional[str] = typer.Option(None, "--domain", help="If description or message is a .md file with relative URLs, resolve them to this domain (e.g., https://raw.githubusercontent.com/your/repo/main/)")
535
+ ):
536
+ from types import SimpleNamespace
537
+ args = SimpleNamespace(
538
+ resource_id=resource_id,
539
+ description=str(description) if isinstance(description, Path) else description,
540
+ version=version,
541
+ message=str(message) if isinstance(message, Path) else message,
542
+ file=str(file) if isinstance(file, Path) else file,
543
+ domain=domain,
544
+ )
545
+ push.handle_cli(args)
546
+
547
+
548
+ @app.command(
549
+ "version",
550
+ help="Show version and exit.",
551
+ rich_help_panel="🔧 System & Utilities"
552
+ )
553
+ def version_command():
554
+ console.print(f"redfetch {meta.get_current_version()}")
555
+
556
+
557
+ @app.command(
558
+ "uninstall",
559
+ help="Uninstall [bold]redfetch[/bold] and clean up data.",
560
+ rich_help_panel="🔧 System & Utilities"
561
+ )
562
+ def uninstall_command():
563
+ meta.uninstall()
564
+
565
+
566
+ @app.command(
567
+ "logout",
568
+ help="Log out and clear cached token and API cache.",
569
+ rich_help_panel="🔧 System & Utilities"
570
+ )
571
+ def auth_logout():
572
+ config.initialize_config()
573
+ API_KEY = os.environ.get('REDGUIDES_API_KEY')
574
+ if not API_KEY:
575
+ auth.initialize_keyring()
576
+ auth.logout()
577
+ console.print("Logged out successfully.")
578
+ else:
579
+ console.print("Cannot logout when using API key from environment variable.")
580
+
581
+
582
+ # ============================================================================
583
+ # LEGACY/DEPRECATED COMMAND ALIASES
584
+ # ============================================================================
585
+
586
+
587
+ @app.command(
588
+ "push",
589
+ help="[DEPRECATED] Use 'publish' instead.",
590
+ rich_help_panel="📤 Publishing",
591
+ hidden=True,
592
+ )
593
+ def push_command(
594
+ resource_id: int = typer.Argument(..., help="Existing RedGuides resource ID"),
595
+ description: Optional[Path] = typer.Option(
596
+ None,
597
+ "--description",
598
+ "-d",
599
+ metavar="README.md",
600
+ help="Path to a description file (e.g. README.md) to become the overview description.",
601
+ exists=True,
602
+ file_okay=True,
603
+ dir_okay=False,
604
+ readable=True,
605
+ resolve_path=True,
606
+ ),
607
+ version: Optional[str] = typer.Option(
608
+ None,
609
+ "--version",
610
+ "-v",
611
+ help="New version string (e.g., v1.0.1)",
612
+ ),
613
+ message: Optional[Path] = typer.Option(
614
+ None,
615
+ "--message",
616
+ "-m",
617
+ metavar="CHANGELOG.md | MESSAGE",
618
+ help="Path to [italic]CHANGELOG.md[/italic] (keep a changelog), other message file, or a direct message string.",
619
+ exists=False,
620
+ ),
621
+ file: Optional[Path] = typer.Option(
622
+ None,
623
+ "--file",
624
+ "-f",
625
+ metavar="FILE.zip",
626
+ help="Path to your zipped release file",
627
+ exists=True,
628
+ file_okay=True,
629
+ dir_okay=False,
630
+ readable=True,
631
+ resolve_path=True,
632
+ ),
633
+ domain: Optional[str] = typer.Option(
634
+ None,
635
+ "--domain",
636
+ help="If description or message is a .md file with relative URLs, resolve them to this domain (e.g., https://raw.githubusercontent.com/your/repo/main/)",
637
+ ),
638
+ ):
639
+ """Legacy alias for the old 'push' command; forwards to 'publish'."""
640
+ console.print(
641
+ "[yellow]Warning:[/yellow] 'push' is deprecated. "
642
+ "Use 'redfetch publish' instead."
643
+ )
644
+ publish_command(
645
+ resource_id=resource_id,
646
+ description=description,
647
+ version=version,
648
+ message=message,
649
+ file=file,
650
+ domain=domain,
651
+ )
652
+
653
+ def legacy_callback_factory(new_command: str, invoke_func=None, **invoke_kwargs):
654
+ """Factory to create deprecation callbacks that forward to new commands."""
655
+ def callback(ctx: typer.Context, value):
656
+ if ctx.resilient_parsing or not value:
657
+ return value
658
+ console.print(f"[bold yellow blink]Warning:[/bold yellow blink] This flag is deprecated! Use 'redfetch {new_command}' instead.")
659
+ if invoke_func:
660
+ ctx.invoke(invoke_func, **invoke_kwargs)
661
+ raise typer.Exit()
662
+ return callback
663
+
664
+
665
+ def legacy_switch_env_callback(ctx: typer.Context, value: Optional[Env]):
666
+ """Deprecated --switch-env handler that forwards to the 'server' subcommand."""
667
+ if ctx.resilient_parsing or value is None:
668
+ return value
669
+ console.print("[yellow]Warning:[/yellow] --switch-env is deprecated. Use 'redfetch server ENV' instead.")
670
+ ctx.invoke(server_command, env=value)
671
+ raise typer.Exit()
672
+
673
+
674
+ @app.callback()
675
+ def root(
676
+ ctx: typer.Context,
677
+ # Legacy: --switch-env ENV
678
+ switch_env: Optional[Env] = typer.Option(
679
+ None, "--switch-env", is_eager=True, case_sensitive=False, hidden=True,
680
+ callback=legacy_switch_env_callback,
681
+ metavar="ENV", help="(Deprecated) Use 'server' subcommand instead.",
682
+ ),
683
+ # Legacy: --download-watched
684
+ download_watched: bool = typer.Option(
685
+ False, "--download-watched", is_eager=True, hidden=True,
686
+ callback=legacy_callback_factory("update", update_command, force=False),
687
+ help="(Deprecated) Use 'update' subcommand instead.",
688
+ ),
689
+ # Legacy: --force-download
690
+ force_download: bool = typer.Option(
691
+ False, "--force-download", is_eager=True, hidden=True,
692
+ callback=legacy_callback_factory("update --force", update_command, force=True),
693
+ help="(Deprecated) Use 'update --force' instead.",
694
+ ),
695
+ # Legacy: --serve
696
+ serve: bool = typer.Option(
697
+ False, "--serve", is_eager=True, hidden=True,
698
+ callback=legacy_callback_factory("web", web_command),
699
+ help="(Deprecated) Use 'web' subcommand instead.",
700
+ ),
701
+ # Legacy: --version
702
+ show_version: bool = typer.Option(
703
+ False, "--version", is_eager=True, hidden=True,
704
+ callback=legacy_callback_factory("version", version_command),
705
+ help="(Deprecated) Use 'version' subcommand instead.",
706
+ ),
707
+ # Legacy: --logout
708
+ do_logout: bool = typer.Option(
709
+ False, "--logout", is_eager=True, hidden=True,
710
+ callback=legacy_callback_factory("logout", auth_logout),
711
+ help="(Deprecated) Use 'logout' subcommand instead.",
712
+ ),
713
+ # Legacy: --uninstall
714
+ do_uninstall: bool = typer.Option(
715
+ False, "--uninstall", is_eager=True, hidden=True,
716
+ callback=legacy_callback_factory("uninstall", uninstall_command),
717
+ help="(Deprecated) Use 'uninstall' subcommand instead.",
718
+ ),
719
+ ):
720
+ """redfetch - RedGuides resource management tool."""
721
+ pass
722
+
723
+
724
+ # ============================================================================
725
+ # END LEGACY/DEPRECATED COMMANDS
726
+ # ============================================================================
727
+
728
+ def main():
729
+ try:
730
+ # Launch TUI when no arguments are provided
731
+ if len(sys.argv) == 1:
732
+ run_tui()
733
+ return
734
+ app()
735
+ except typer.Exit:
736
+ raise
737
+ except KeyboardInterrupt:
738
+ raise
739
+ except Exception as exc:
740
+ exit_with_fatal_error(exc)
741
+
742
+
743
+ if __name__ == "__main__":
744
+ main()