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/__about__.py +24 -0
- redfetch/__init__.py +0 -0
- redfetch/api.py +134 -0
- redfetch/auth.py +573 -0
- redfetch/config.py +483 -0
- redfetch/config_firstrun.py +455 -0
- redfetch/desktop_shortcut.py +81 -0
- redfetch/detecteq.py +116 -0
- redfetch/download.py +312 -0
- redfetch/listener.py +216 -0
- redfetch/main.py +744 -0
- redfetch/meta.py +561 -0
- redfetch/navmesh.py +371 -0
- redfetch/net.py +109 -0
- redfetch/processes.py +118 -0
- redfetch/push.py +246 -0
- redfetch/redfetch.ico +0 -0
- redfetch/runtime_errors.py +96 -0
- redfetch/settings.toml +303 -0
- redfetch/special.py +81 -0
- redfetch/store.py +505 -0
- redfetch/sync.py +261 -0
- redfetch/sync_discovery.py +352 -0
- redfetch/sync_executor.py +164 -0
- redfetch/sync_planner.py +196 -0
- redfetch/sync_remote.py +182 -0
- redfetch/sync_types.py +348 -0
- redfetch/terminal_ui.py +2318 -0
- redfetch/terminal_ui.tcss +569 -0
- redfetch/unloadmq.py +148 -0
- redfetch/update_status.py +62 -0
- redfetch/utils.py +256 -0
- redfetch-1.3.0.dist-info/METADATA +257 -0
- redfetch-1.3.0.dist-info/RECORD +37 -0
- redfetch-1.3.0.dist-info/WHEEL +4 -0
- redfetch-1.3.0.dist-info/entry_points.txt +2 -0
- redfetch-1.3.0.dist-info/licenses/LICENSE +674 -0
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()
|