|
3 | 3 | import os |
4 | 4 | from enum import Enum |
5 | 5 | from pathlib import Path |
6 | | -from typing import Optional |
| 6 | +from typing import NoReturn, Optional |
7 | 7 | import asyncio |
8 | 8 |
|
9 | 9 | # third-party imports |
@@ -39,6 +39,12 @@ class Env(str, Enum): |
39 | 39 | EMU = "EMU" |
40 | 40 |
|
41 | 41 |
|
| 42 | +EXIT_CHECK_ERROR = 1 |
| 43 | +EXIT_CALLER_UPDATE = 2 |
| 44 | +EXIT_AUTH_REQUIRED = 3 |
| 45 | +EXIT_NOT_CONFIGURED = 4 |
| 46 | + |
| 47 | + |
42 | 48 | def parse_resource_id_or_fail(value: str) -> str: |
43 | 49 | """Accept either an integer ID or a URL that includes a recognizable ID.""" |
44 | 50 | value_stripped = value.strip() |
@@ -259,6 +265,83 @@ def download( |
259 | 265 | asyncio.run(download_command_async(db_name=db_name, db_path=db_path, id_or_url=id_or_url, force=force)) |
260 | 266 |
|
261 | 267 |
|
| 268 | +def _check_exit(real_stdout, exit_code: int, stdout_line: str = "") -> NoReturn: |
| 269 | + """Write one line to real stdout and exit (never returns).""" |
| 270 | + if stdout_line: |
| 271 | + real_stdout.write(stdout_line + "\n") |
| 272 | + real_stdout.flush() |
| 273 | + raise typer.Exit(exit_code) |
| 274 | + |
| 275 | + |
| 276 | +def _has_auth_credentials() -> bool: |
| 277 | + """Peek at env / keyring for stored credentials (no network, no init).""" |
| 278 | + if os.environ.get("REDGUIDES_API_KEY"): |
| 279 | + return True |
| 280 | + try: |
| 281 | + import keyring |
| 282 | + token = keyring.get_password(auth.KEYRING_SERVICE_NAME, "access_token") |
| 283 | + return token is not None |
| 284 | + except Exception: |
| 285 | + return False |
| 286 | + |
| 287 | + |
| 288 | +@app.command( |
| 289 | + "check", |
| 290 | + help="Machine-readable check for available updates. Stdout: update count. Exit code: 0=ok, 2=caller needs update, 3=auth required, 4=not configured.", |
| 291 | + rich_help_panel="📦 Resource Management", |
| 292 | +) |
| 293 | +def check_command( |
| 294 | + caller_resource_id: str | None = typer.Option( |
| 295 | + None, "--caller-resource-id", |
| 296 | + help="Resource ID of the calling program (e.g. 1974 for Very Vanilla MQ Live).", |
| 297 | + ), |
| 298 | +): |
| 299 | + real_stdout = sys.stdout |
| 300 | + sys.stdout = sys.stderr |
| 301 | + |
| 302 | + try: |
| 303 | + # Phase 1 — pre-flight (no init, instant) |
| 304 | + from redfetch.config_firstrun import is_configured |
| 305 | + |
| 306 | + if not is_configured(): |
| 307 | + _check_exit(real_stdout, EXIT_NOT_CONFIGURED) |
| 308 | + |
| 309 | + if not _has_auth_credentials(): |
| 310 | + _check_exit(real_stdout, EXIT_AUTH_REQUIRED) |
| 311 | + |
| 312 | + # Phase 2 — init + check |
| 313 | + config.initialize_config() |
| 314 | + auth.initialize_keyring() |
| 315 | + db_name = f"{config.settings.ENV}_resources.db" |
| 316 | + store.initialize_db(db_name) |
| 317 | + db_path = store.get_db_path(db_name) |
| 318 | + |
| 319 | + result = asyncio.run(_check_command_async(db_path, caller_resource_id)) |
| 320 | + |
| 321 | + sys.stdout = real_stdout |
| 322 | + |
| 323 | + if result is None: |
| 324 | + _check_exit(real_stdout, EXIT_AUTH_REQUIRED) |
| 325 | + |
| 326 | + exit_code = EXIT_CALLER_UPDATE if result.caller_update_available is True else 0 |
| 327 | + _check_exit(real_stdout, exit_code, str(result.updates_available)) |
| 328 | + |
| 329 | + except typer.Exit: |
| 330 | + raise |
| 331 | + except Exception: |
| 332 | + sys.stdout = real_stdout |
| 333 | + _check_exit(real_stdout, EXIT_CHECK_ERROR) |
| 334 | + |
| 335 | + |
| 336 | +async def _check_command_async(db_path: str, caller_resource_id: str | None): |
| 337 | + try: |
| 338 | + headers = await auth.get_api_headers() |
| 339 | + except RuntimeError: |
| 340 | + return None |
| 341 | + from redfetch.update_check import check_for_updates |
| 342 | + return await check_for_updates(db_path, headers, caller_resource_id) |
| 343 | + |
| 344 | + |
262 | 345 | @app.command( |
263 | 346 | "ui", |
264 | 347 | help="Launch the [italic]Terminal User Interface[/italic].", |
|
0 commit comments