redfetch 1.4.3__tar.gz → 1.5.0__tar.gz

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.
Files changed (67) hide show
  1. {redfetch-1.4.3 → redfetch-1.5.0}/.gitignore +1 -0
  2. {redfetch-1.4.3 → redfetch-1.5.0}/CHANGELOG.md +16 -0
  3. {redfetch-1.4.3 → redfetch-1.5.0}/PKG-INFO +2 -2
  4. {redfetch-1.4.3 → redfetch-1.5.0}/src/redfetch/__about__.py +2 -2
  5. redfetch-1.5.0/src/redfetch/__init__.py +4 -0
  6. {redfetch-1.4.3 → redfetch-1.5.0}/src/redfetch/auth.py +11 -41
  7. {redfetch-1.4.3 → redfetch-1.5.0}/src/redfetch/config.py +37 -2
  8. {redfetch-1.4.3 → redfetch-1.5.0}/src/redfetch/config_firstrun.py +1 -2
  9. {redfetch-1.4.3 → redfetch-1.5.0}/src/redfetch/detecteq.py +0 -20
  10. {redfetch-1.4.3 → redfetch-1.5.0}/src/redfetch/download.py +20 -15
  11. {redfetch-1.4.3 → redfetch-1.5.0}/src/redfetch/listener.py +4 -4
  12. {redfetch-1.4.3 → redfetch-1.5.0}/src/redfetch/main.py +125 -71
  13. {redfetch-1.4.3 → redfetch-1.5.0}/src/redfetch/meta.py +80 -75
  14. {redfetch-1.4.3 → redfetch-1.5.0}/src/redfetch/navmesh.py +70 -85
  15. {redfetch-1.4.3 → redfetch-1.5.0}/src/redfetch/net.py +10 -1
  16. redfetch-1.5.0/src/redfetch/runtime_errors.py +57 -0
  17. {redfetch-1.4.3 → redfetch-1.5.0}/src/redfetch/sync.py +22 -25
  18. {redfetch-1.4.3 → redfetch-1.5.0}/src/redfetch/sync_discovery.py +7 -0
  19. {redfetch-1.4.3 → redfetch-1.5.0}/src/redfetch/sync_planner.py +1 -0
  20. {redfetch-1.4.3 → redfetch-1.5.0}/src/redfetch/sync_remote.py +9 -3
  21. {redfetch-1.4.3 → redfetch-1.5.0}/src/redfetch/sync_types.py +9 -19
  22. {redfetch-1.4.3 → redfetch-1.5.0}/src/redfetch/terminal_ui.py +79 -119
  23. {redfetch-1.4.3 → redfetch-1.5.0}/src/redfetch/terminal_ui.tcss +9 -17
  24. redfetch-1.5.0/src/redfetch/update_status.py +102 -0
  25. {redfetch-1.4.3 → redfetch-1.5.0}/src/redfetch/utils.py +8 -8
  26. {redfetch-1.4.3 → redfetch-1.5.0}/tests/cli_reference.py +1 -1
  27. {redfetch-1.4.3 → redfetch-1.5.0}/tests/test_autologin_eqpath.py +0 -26
  28. {redfetch-1.4.3 → redfetch-1.5.0}/tests/test_check.py +109 -26
  29. {redfetch-1.4.3 → redfetch-1.5.0}/tests/test_close_gating.py +18 -36
  30. {redfetch-1.4.3 → redfetch-1.5.0}/tests/test_env_selection.py +5 -8
  31. redfetch-1.5.0/tests/test_headless_update.py +327 -0
  32. redfetch-1.5.0/tests/test_listener.py +39 -0
  33. redfetch-1.5.0/tests/test_oauth_401_recovery.py +80 -0
  34. {redfetch-1.4.3 → redfetch-1.5.0}/tests/test_path_reset.py +0 -60
  35. {redfetch-1.4.3 → redfetch-1.5.0}/tests/test_process_lock.py +20 -0
  36. {redfetch-1.4.3 → redfetch-1.5.0}/tests/test_remote_snapshot.py +0 -8
  37. {redfetch-1.4.3 → redfetch-1.5.0}/tests/test_self_heal_eqpath.py +0 -12
  38. redfetch-1.5.0/tests/test_self_update_spawn.py +102 -0
  39. {redfetch-1.4.3 → redfetch-1.5.0}/tests/test_settings_local_deltas.py +16 -8
  40. {redfetch-1.4.3 → redfetch-1.5.0}/tests/test_shortcuts.py +0 -19
  41. {redfetch-1.4.3 → redfetch-1.5.0}/tests/test_staged_extraction.py +49 -0
  42. {redfetch-1.4.3 → redfetch-1.5.0}/tests/test_sync_pipeline_regressions.py +24 -15
  43. {redfetch-1.4.3 → redfetch-1.5.0}/tests/test_sync_queue_rules.py +0 -2
  44. redfetch-1.4.3/src/redfetch/__init__.py +0 -0
  45. redfetch-1.4.3/src/redfetch/runtime_errors.py +0 -94
  46. redfetch-1.4.3/src/redfetch/special.py +0 -81
  47. redfetch-1.4.3/src/redfetch/update_status.py +0 -62
  48. redfetch-1.4.3/tests/test_is_special_or_dependency.py +0 -91
  49. {redfetch-1.4.3 → redfetch-1.5.0}/.github/workflows/pipeline.yml +0 -0
  50. {redfetch-1.4.3 → redfetch-1.5.0}/AGENTS.md +0 -0
  51. {redfetch-1.4.3 → redfetch-1.5.0}/LICENSE +0 -0
  52. {redfetch-1.4.3 → redfetch-1.5.0}/README.md +0 -0
  53. {redfetch-1.4.3 → redfetch-1.5.0}/pyapp_launcher.py +0 -0
  54. {redfetch-1.4.3 → redfetch-1.5.0}/pyproject.toml +0 -0
  55. {redfetch-1.4.3 → redfetch-1.5.0}/redfetch.ico +0 -0
  56. {redfetch-1.4.3 → redfetch-1.5.0}/src/redfetch/api.py +0 -0
  57. {redfetch-1.4.3 → redfetch-1.5.0}/src/redfetch/desktop_shortcut.py +0 -0
  58. {redfetch-1.4.3 → redfetch-1.5.0}/src/redfetch/post_update.py +0 -0
  59. {redfetch-1.4.3 → redfetch-1.5.0}/src/redfetch/processes.py +0 -0
  60. {redfetch-1.4.3 → redfetch-1.5.0}/src/redfetch/push.py +0 -0
  61. {redfetch-1.4.3 → redfetch-1.5.0}/src/redfetch/settings.toml +0 -0
  62. {redfetch-1.4.3 → redfetch-1.5.0}/src/redfetch/shortcuts.py +0 -0
  63. {redfetch-1.4.3 → redfetch-1.5.0}/src/redfetch/store.py +0 -0
  64. {redfetch-1.4.3 → redfetch-1.5.0}/src/redfetch/sync_executor.py +0 -0
  65. {redfetch-1.4.3 → redfetch-1.5.0}/tests/test_fetch_sync_info.py +0 -0
  66. {redfetch-1.4.3 → redfetch-1.5.0}/tests/test_first_run_setup.py +0 -0
  67. {redfetch-1.4.3 → redfetch-1.5.0}/tests/test_licensed_resources_filtering.py +0 -0
@@ -33,6 +33,7 @@ src/redfetch/__about__.py
33
33
 
34
34
  # Database files
35
35
  *.db
36
+ .cache/
36
37
 
37
38
  # Environment file
38
39
  .env
@@ -5,6 +5,22 @@ All notable changes to this project will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [1.5.0] - 2026-07-21
9
+
10
+ ### Added
11
+ - Background updates triggered on each launch of "Very Vanilla MQ", so you no longer have to see redfetch. Run MacroQuest directly. Credit: all the TUI haters
12
+
13
+ ### Changed
14
+ - Navigation meshes now download by default instead of prompting. Opt out in settings. (`PROTECTED_FILES_BY_RESOURCE` still protects your custom meshes)
15
+
16
+ ### Fixed
17
+ - pip/pipx in-app updates fixed on windows
18
+
19
+ ## [1.4.4] - 2026-07-18
20
+
21
+ ### Fixed
22
+ - A rejected OAuth token will no longer assume you're level 1.
23
+
8
24
  ## [1.4.3] - 2026-07-06
9
25
 
10
26
  ### Added
@@ -1,13 +1,13 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: redfetch
3
- Version: 1.4.3
3
+ Version: 1.5.0
4
4
  Summary: Download and publish EverQuest scripts and software using the RedGuides API
5
5
  Project-URL: Homepage, https://www.redguides.com
6
6
  Project-URL: Documentation, https://www.redguides.com/community/resources/redfetch.3177/
7
7
  Project-URL: Repository, https://github.com/RedGuides/redfetch
8
8
  Project-URL: Issues, https://github.com/RedGuides/redfetch/issues
9
9
  Project-URL: Changelog, https://github.com/RedGuides/redfetch/blob/main/CHANGELOG.md
10
- Project-URL: Source_archive, https://github.com/RedGuides/redfetch/archive/9161a88b101d6df6ce9a697736e3fcc57b1c3122.zip
10
+ Project-URL: Source_archive, https://github.com/RedGuides/redfetch/archive/9825ba1eaf7b60758f68420040e877f5bee93ddb.zip
11
11
  Author-email: Redbot <ask@redguides.com>
12
12
  License-Expression: GPL-3.0-or-later
13
13
  License-File: LICENSE
@@ -18,7 +18,7 @@ version_tuple: tuple[int | str, ...]
18
18
  commit_id: str | None
19
19
  __commit_id__: str | None
20
20
 
21
- __version__ = version = '1.4.3'
22
- __version_tuple__ = version_tuple = (1, 4, 3)
21
+ __version__ = version = '1.5.0'
22
+ __version_tuple__ = version_tuple = (1, 5, 0)
23
23
 
24
24
  __commit_id__ = commit_id = None
@@ -0,0 +1,4 @@
1
+ import sys
2
+
3
+ # sys.path entries can get held open, and Windows can't update an open .exe.
4
+ sys.path[:] = [p for p in sys.path if not p.lower().endswith('.exe')]
@@ -55,7 +55,7 @@ def _get_setting(key: str, default=None):
55
55
 
56
56
 
57
57
  # ---------------------------------------------------------------------------
58
- # Disk cache for non-secret identity data (user_id, username, token_expiry)
58
+ # Disk cache for non-secret identity data (username, token_expiry)
59
59
  # ---------------------------------------------------------------------------
60
60
 
61
61
  _disk_cache = None
@@ -76,21 +76,6 @@ def _ensure_cache():
76
76
  return _disk_cache
77
77
 
78
78
 
79
- def get_disk_cache():
80
- """Return the shared disk cache (for non-identity caching by other modules)."""
81
- return _ensure_cache()
82
-
83
-
84
- def set_user_id(user_id: str) -> None:
85
- """Store user_id in disk cache (non-sensitive public identifier)."""
86
- _ensure_cache().set('user_id', str(user_id))
87
-
88
-
89
- def get_user_id() -> Optional[str]:
90
- """Retrieve user_id from disk cache."""
91
- return _ensure_cache().get('user_id')
92
-
93
-
94
79
  def set_username(username: str) -> None:
95
80
  """Store username in disk cache (non-sensitive public display name)."""
96
81
  _ensure_cache().set('username', username)
@@ -241,7 +226,7 @@ def first_authorization(client_id: str, client_secret: str | None, *, scope: str
241
226
 
242
227
 
243
228
  def _cache_user_info(access_token: str | None) -> None:
244
- """Fetch /api/me and cache username/user_id (best-effort)."""
229
+ """Fetch /api/me and cache username (best-effort)."""
245
230
  if not access_token:
246
231
  return
247
232
  headers = {"Authorization": f"Bearer {access_token}"}
@@ -251,8 +236,6 @@ def _cache_user_info(access_token: str | None) -> None:
251
236
  me = data.get("me") or {}
252
237
  if me.get("username"):
253
238
  set_username(str(me["username"]))
254
- if me.get("user_id"):
255
- set_user_id(str(me["user_id"]))
256
239
 
257
240
 
258
241
  def authorize():
@@ -381,7 +364,6 @@ def get_cached_tokens():
381
364
  data["access_token"] = keyring.get_password(KEYRING_SERVICE_NAME, "access_token")
382
365
  data["refresh_token"] = keyring.get_password(KEYRING_SERVICE_NAME, "refresh_token")
383
366
  data["username"] = get_username_from_cache()
384
- data["user_id"] = get_user_id()
385
367
  return data
386
368
 
387
369
 
@@ -391,28 +373,19 @@ def logout():
391
373
 
392
374
  keyring_credentials = ["access_token", "refresh_token", "api_key"]
393
375
  legacy_credentials = ["user_id", "username", "expires_at"]
394
-
395
- credentials_deleted = False
396
376
 
397
377
  for credential in keyring_credentials + legacy_credentials:
398
378
  try:
399
379
  keyring.delete_password(KEYRING_SERVICE_NAME, credential)
400
- credentials_deleted = True
401
380
  except keyring.errors.PasswordDeleteError:
402
381
  pass
403
382
 
404
- try:
405
- clear_disk_cache()
406
- meta.clear_pypi_cache()
407
- net.clear_manifest_cache()
408
- credentials_deleted = True
409
- except Exception:
410
- pass
411
-
412
- if credentials_deleted:
413
- print("You have been logged out successfully.")
414
- else:
415
- print("No active session found. You were not logged in.")
383
+ # isolated so a locked cache can't block the remaining clears
384
+ for clear in (clear_disk_cache, meta.clear_pypi_cache, net.clear_manifest_cache):
385
+ try:
386
+ clear()
387
+ except Exception:
388
+ pass
416
389
 
417
390
 
418
391
  # ---------------------------------------------------------------------------
@@ -434,23 +407,21 @@ async def fetch_me(client: httpx.AsyncClient) -> Optional[dict]:
434
407
 
435
408
 
436
409
  async def fetch_user_id_from_api(api_key):
437
- """Fetch user_id using the API key; caches it."""
410
+ """Fetch user_id using the API key."""
438
411
  async with httpx.AsyncClient(headers={'XF-Api-Key': api_key}, http2=True) as client:
439
412
  me = await fetch_me(client)
440
413
  if me:
441
- set_user_id(me['user_id'])
442
414
  return me['user_id']
443
415
  return None
444
416
 
445
417
 
446
418
  async def fetch_username(api_key, cache=True):
447
- """Fetch username via API key; caches username and user_id."""
419
+ """Fetch username via API key; caches username."""
448
420
  async with httpx.AsyncClient(headers={'XF-Api-Key': api_key}, http2=True) as client:
449
421
  me = await fetch_me(client)
450
422
  if me:
451
423
  if cache:
452
424
  set_username(me['username'])
453
- set_user_id(me['user_id'])
454
425
  return me['username']
455
426
  return "Unknown"
456
427
 
@@ -499,7 +470,7 @@ async def get_api_headers():
499
470
  if access_token:
500
471
  return {"Authorization": f"Bearer {access_token}"}
501
472
 
502
- raise RuntimeError("OAuth token refresh failed. Please run `redfetch logout` and authorize again.")
473
+ raise RuntimeError("OAuth token refresh failed. Restart redfetch to authorize again.")
503
474
 
504
475
  raise RuntimeError("OAuth access token is expired and no refresh token is available. Please authorize again.")
505
476
 
@@ -534,7 +505,6 @@ async def get_username():
534
505
  me = await fetch_me(client)
535
506
  if me and me.get("username"):
536
507
  set_username(me["username"])
537
- set_user_id(me["user_id"])
538
508
  return me["username"]
539
509
  raise RuntimeError("Unable to retrieve username using the stored OAuth token.")
540
510
 
@@ -4,12 +4,14 @@ import os
4
4
  import platform
5
5
  import re
6
6
  import shutil
7
+ import tomllib
7
8
 
8
9
  # third-party
9
10
  import tomlkit
10
11
  from dynaconf import Dynaconf, Validator, ValidationError
11
12
  from dynaconf.loaders import env_loader
12
13
  from platformdirs import user_config_dir, user_data_dir
14
+ from tenacity import retry, retry_if_exception_type, stop_after_attempt, wait_fixed
13
15
 
14
16
  # Parent Category to folder
15
17
  CATEGORY_MAP = {
@@ -58,7 +60,6 @@ BREADCRUMB_FILENAME = "last_command.json"
58
60
  DEFAULT_CONFIG_DIR = user_config_dir("redfetch", "RedGuides")
59
61
 
60
62
  script_dir = os.path.dirname(os.path.abspath(__file__))
61
- os.environ['REDFETCH_SCRIPT_DIR'] = script_dir
62
63
 
63
64
  # Populated by initialize_config()
64
65
  config_dir = None
@@ -139,6 +140,9 @@ def initialize_config():
139
140
  atomic_write_text(env_file_path, 'REDFETCH_ENV=LIVE\n')
140
141
  print(f".env file created with default environment set to 'LIVE' at {env_file_path}")
141
142
 
143
+ # Migrate any settings.local.toml written by older versions
144
+ _migrate_local_settings(config_dir)
145
+
142
146
  # Initialize Dynaconf settings
143
147
  settings = Dynaconf(
144
148
  envvar_prefix="REDFETCH",
@@ -210,6 +214,17 @@ def _resolve_redfetch_executable():
210
214
  return None
211
215
 
212
216
 
217
+ @retry(
218
+ retry=retry_if_exception_type(PermissionError),
219
+ stop=stop_after_attempt(5),
220
+ wait=wait_fixed(0.1),
221
+ reraise=True,
222
+ )
223
+ def _replace_with_retry(src: str, dst: str) -> None:
224
+ # MQ reads update_status.json
225
+ os.replace(src, dst)
226
+
227
+
213
228
  def atomic_write_text(path: str, text: str) -> None:
214
229
  """Write UTF-8 text to `path` via a temp file + os.replace() so readers never see a partial write."""
215
230
  directory = os.path.dirname(path)
@@ -218,7 +233,7 @@ def atomic_write_text(path: str, text: str) -> None:
218
233
  tmp_path = f"{path}.tmp"
219
234
  with open(tmp_path, "w", encoding="utf-8") as f:
220
235
  f.write(text)
221
- os.replace(tmp_path, path)
236
+ _replace_with_retry(tmp_path, path)
222
237
 
223
238
 
224
239
  def atomic_write_json(path: str, data) -> None:
@@ -435,6 +450,26 @@ def save_config(file_path, config_data):
435
450
  atomic_write_text(file_path, toml_text)
436
451
 
437
452
 
453
+ def _migrate_local_settings(config_dir):
454
+ """Carry renamed settings in an older settings.local.toml"""
455
+ config_file = os.path.join(config_dir, 'settings.local.toml')
456
+ if not os.path.exists(config_file):
457
+ return
458
+ try:
459
+ with open(config_file, "rb") as f:
460
+ data = tomllib.load(f)
461
+ except Exception:
462
+ return
463
+ # NAVMESH_OPT_IN became NAVMESH_DOWNLOADS
464
+ changed = False
465
+ for env_table in data.values():
466
+ if isinstance(env_table, dict) and "NAVMESH_OPT_IN" in env_table:
467
+ env_table.setdefault("NAVMESH_DOWNLOADS", env_table.pop("NAVMESH_OPT_IN"))
468
+ changed = True
469
+ if changed:
470
+ save_config(config_file, data)
471
+
472
+
438
473
  def update_setting(setting_path, setting_value, env=None):
439
474
  """Update a specific setting in the settings.local.toml file and in memory,
440
475
  optionally within a specific environment."""
@@ -460,8 +460,7 @@ def first_run_setup():
460
460
  default_choice = bool(os.environ.get("PYAPP"))
461
461
 
462
462
  console.print(
463
- "\n[bold cyan][italic]\"Wilt thou we set a signe upon thy Desktop, whereby redfetch may be call'd forth "
464
- "when thou list?\"[/italic][/bold cyan]"
463
+ "\n[bold cyan][italic]\"Shall we set a signe upon thy fair anime walpaper?\"[/italic][/bold cyan]"
465
464
  )
466
465
  wants_shortcut = CustomConfirm.ask(
467
466
  "Create Desktop shortcut?",
@@ -91,26 +91,6 @@ def find_everquest_uninstall_location():
91
91
  except OSError:
92
92
  pass
93
93
 
94
- # 3) HKU: enumerate SIDs as final fallback
95
- try:
96
- with reg.ConnectRegistry(None, reg.HKEY_USERS) as hku:
97
- i = 0
98
- while True:
99
- try:
100
- sid = reg.EnumKey(hku, i)
101
- i += 1
102
- subkey = rf"{sid}\{base_uninstall}"
103
- for valname in ("InstallLocation", "DisplayIcon", "UninstallString"):
104
- val = _read_reg_value(hku, subkey, valname)
105
- if val:
106
- candidate = _extract_dir_from_value(str(val))
107
- if _is_valid_eq_dir(candidate):
108
- return candidate
109
- except OSError:
110
- break
111
- except OSError:
112
- pass
113
-
114
94
  return None
115
95
 
116
96
 
@@ -7,7 +7,7 @@ import sys
7
7
  import time
8
8
  import zlib
9
9
  from pathlib import Path
10
- from zipfile import ZipFile, is_zipfile
10
+ from zipfile import BadZipFile, ZipFile, is_zipfile
11
11
  import asyncio
12
12
 
13
13
  # third-party
@@ -104,7 +104,17 @@ async def download_file_async(
104
104
  if tmp_path is None:
105
105
  return False
106
106
 
107
- os.replace(tmp_path, file_path)
107
+ try:
108
+ displaced = _swap_into_place(tmp_path, file_path)
109
+ except PermissionError:
110
+ _report_locked_target(file_path)
111
+ return False
112
+ except OSError as e:
113
+ print(f"Could not update {os.path.basename(file_path)}: {e}")
114
+ _remove_if_exists(tmp_path)
115
+ return False
116
+ if displaced:
117
+ print(f"Staged update for {os.path.basename(file_path)}; applies on next launch.")
108
118
  print(f"Downloaded file {file_path}")
109
119
  return True
110
120
  finally:
@@ -181,15 +191,6 @@ def extract_and_discard_zip(zip_path, extract_to, resource_id, should_flatten=Fa
181
191
  return False
182
192
 
183
193
  with ZipFile(zip_path, 'r') as zip_ref:
184
- try:
185
- bad_member = zip_ref.testzip()
186
- if bad_member is not None:
187
- print(f"ZIP integrity check failed at member: {bad_member}. Extraction aborted.")
188
- return False
189
- except Exception as e:
190
- print(f"ZIP integrity check failed: {e}. Extraction aborted.")
191
- return False
192
-
193
194
  infos = zip_ref.infolist()
194
195
  if len(infos) > MAX_FILES_PER_ZIP:
195
196
  print(f"ZIP has too many files ({len(infos)} > {MAX_FILES_PER_ZIP}). Extraction aborted.")
@@ -211,10 +212,14 @@ def extract_and_discard_zip(zip_path, extract_to, resource_id, should_flatten=Fa
211
212
  if protected_files is None:
212
213
  protected_files = config.settings.from_env(config.settings.ENV).PROTECTED_FILES_BY_RESOURCE.get(resource_id, [])
213
214
  sweep_stale_swap_files(extract_to)
214
- if should_flatten:
215
- extract_flattened(zip_ref, extract_to, protected_files)
216
- else:
217
- extract_with_structure(zip_ref, extract_to, protected_files)
215
+ try:
216
+ if should_flatten:
217
+ extract_flattened(zip_ref, extract_to, protected_files)
218
+ else:
219
+ extract_with_structure(zip_ref, extract_to, protected_files)
220
+ except (BadZipFile, zlib.error) as e: # bad CRC raises BadZipFile; truncated deflate raises zlib.error
221
+ print(f"ZIP integrity check failed: {e}. Extraction aborted.")
222
+ return False
218
223
  return True
219
224
  finally:
220
225
  delete_zip_file(zip_path)
@@ -7,9 +7,9 @@ from typing import Any, Dict, Optional
7
7
 
8
8
  from aiohttp import web
9
9
 
10
+ from redfetch import config
10
11
  from redfetch import store
11
12
  from redfetch import sync
12
- from redfetch.special import compute_special_status
13
13
 
14
14
  log = logging.getLogger(__name__)
15
15
 
@@ -169,9 +169,9 @@ async def handle_category_map(request: web.Request) -> web.Response:
169
169
 
170
170
 
171
171
  async def handle_special_resource_ids(request: web.Request) -> web.Response:
172
- status = compute_special_status(None)
173
- special_resource_ids = [int(rid) for rid, info in status.items() if info["is_special"]]
174
- return web.json_response(special_resource_ids)
172
+ # from_env, not bare settings: --server swaps settings.ENV at runtime
173
+ special_resources = config.settings.from_env(config.settings.ENV).SPECIAL_RESOURCES
174
+ return web.json_response([int(rid) for rid, d in special_resources.items() if d.get('opt_in', False)])
175
175
 
176
176
 
177
177
  async def create_app(
@@ -1,13 +1,14 @@
1
1
  # standard imports
2
2
  import sys
3
3
  import os
4
+ from contextlib import contextmanager
4
5
  from enum import Enum
5
6
  from pathlib import Path
6
7
  from typing import Optional
7
8
  import asyncio
8
9
 
9
10
  # third-party imports
10
- from rich.prompt import Confirm, Prompt
11
+ from rich.prompt import Prompt
11
12
  from rich.console import Console
12
13
  import typer
13
14
 
@@ -25,6 +26,7 @@ from redfetch import sync
25
26
  from redfetch import store
26
27
  from redfetch import shortcuts
27
28
  from redfetch.runtime_errors import exit_with_fatal_error
29
+ from redfetch.sync_types import SyncOutcome
28
30
 
29
31
 
30
32
  app = typer.Typer(
@@ -43,13 +45,10 @@ class Env(str, Enum):
43
45
 
44
46
  def parse_resource_id_or_fail(value: str) -> str:
45
47
  """Accept either an integer ID or a URL that includes a recognizable ID."""
46
- value_stripped = value.strip()
47
- if value_stripped.isdigit():
48
- return value_stripped
49
- parsed = utils.parse_resource_id(value_stripped)
50
- if parsed is None:
51
- raise typer.BadParameter("Provide a resource ID or a recognized RedGuides URL.")
52
- return parsed
48
+ try:
49
+ return utils.parse_resource_id(value.strip())
50
+ except ValueError as exc:
51
+ raise typer.BadParameter(str(exc)) from exc
53
52
 
54
53
 
55
54
  def _apply_server_override(server: "Optional[Env]" = None) -> None:
@@ -78,41 +77,6 @@ def initialize_db_only(server: "Optional[Env]" = None):
78
77
 
79
78
  # ===== CLI prompt helpers =====
80
79
 
81
- def prompt_navmesh_opt_in() -> bool | None:
82
- """Prompt user about navmesh downloads if not configured."""
83
- from redfetch import navmesh
84
-
85
- # Check if VVMQ path exists (navmesh requires it)
86
- vvmq_path = utils.get_vvmq_path()
87
- if not vvmq_path:
88
- return None # Can't do navmesh without VVMQ
89
-
90
- opt_in = navmesh.get_navmesh_opt_in()
91
-
92
- if opt_in is not None:
93
- return None # Already configured, use existing setting
94
-
95
- # Prompt user
96
- user_choice = Prompt.ask(
97
- "🧭 Download navigation meshes? (will overwrite, protect your custom meshes in settings.local.toml)",
98
- choices=["yes", "no", "always", "never"],
99
- default="yes",
100
- )
101
-
102
- if user_choice == "yes":
103
- return True # One-time yes
104
- elif user_choice == "always":
105
- config.update_setting(["NAVMESH_OPT_IN"], True)
106
- console.print("Updated settings to always download navmeshes.")
107
- return None # Config is now set, use it
108
- elif user_choice == "never":
109
- config.update_setting(["NAVMESH_OPT_IN"], False)
110
- console.print("Updated settings to never download navmeshes.")
111
- return None # Config is now set, use it
112
- else: # "no"
113
- return False # One-time no
114
-
115
-
116
80
  class _CliPostUpdate:
117
81
  """CLI adapter for post_update.execute: rich prompts, console output."""
118
82
 
@@ -162,19 +126,8 @@ async def handle_download_watched_async(db_path: str, headers: dict) -> bool:
162
126
  console.print(
163
127
  "[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."
164
128
  )
165
- continue_download = Confirm.ask(
166
- "Do you want to continue with the download?", default=False
167
- )
168
- if not continue_download:
169
- console.print("Download cancelled by user.")
170
- return False
171
129
 
172
- navmesh_override = prompt_navmesh_opt_in()
173
-
174
- outcome = await sync.run_sync(
175
- db_path, headers,
176
- navmesh_override=navmesh_override,
177
- )
130
+ outcome = await sync.run_sync(db_path, headers)
178
131
  # offer regardless of overall success:
179
132
  await post_update.offer(outcome, _CliPostUpdate())
180
133
  return outcome.success
@@ -216,11 +169,113 @@ async def download_command_async(db_name: str, db_path: str, id_or_url: str, for
216
169
  def update_command(
217
170
  force: bool = typer.Option(False, "--force", "-f", help="Force re-download of all watched resources."),
218
171
  server: Optional[Env] = typer.Option(None, "--server", "-s", case_sensitive=False, help="Update this server for this run only, without changing your current server ([green]LIVE[/green], [yellow]TEST[/yellow], [cyan]EMU[/cyan])."),
172
+ headless: bool = typer.Option(False, "--headless", hidden=True, help="MQ silent update: no prompts, no browser, no dialogs. writes update_status.json."),
219
173
  ):
174
+ if headless:
175
+ _headless_update(server=server, force=force)
176
+ return
220
177
  db_name, db_path = initialize_db_only(server=server)
221
178
  asyncio.run(update_command_async(db_name=db_name, db_path=db_path, force=force))
222
179
 
223
180
 
181
+ @contextmanager
182
+ def _exit_silently_on_error():
183
+ """Anything unexpected for headless commands becomes a silent exit 1."""
184
+ try:
185
+ yield
186
+ except typer.Exit:
187
+ raise
188
+ except Exception:
189
+ raise typer.Exit(1)
190
+
191
+
192
+ def _headless_update(server: "Optional[Env]", force: bool) -> None:
193
+ from redfetch.config_firstrun import is_configured
194
+ from redfetch import update_status
195
+
196
+ requested_env = server.value if server else None
197
+
198
+ with _exit_silently_on_error():
199
+ if not is_configured():
200
+ update_status.write_update_status(
201
+ env=requested_env or Env.LIVE.value,
202
+ auth_state="not_configured",
203
+ )
204
+ raise typer.Exit(0)
205
+
206
+ config.initialize_config()
207
+ if requested_env:
208
+ config.select_environment_in_memory(requested_env)
209
+ env = config.settings.ENV
210
+ auth.initialize_keyring()
211
+
212
+ auto_update = utils.is_auto_update_enabled()
213
+ if not auto_update:
214
+ raise typer.Exit(1)
215
+
216
+ managed_path = utils.get_vvmq_path()
217
+
218
+ if not _has_auth_credentials():
219
+ update_status.write_update_status(
220
+ env=env, auth_state="needs_login", managed_path=managed_path, auto_update=auto_update,
221
+ )
222
+ raise typer.Exit(0)
223
+
224
+ db_name = f"{env}_resources.db"
225
+ store.initialize_db(db_name)
226
+ db_path = store.get_db_path(db_name)
227
+
228
+ if force:
229
+ with store.get_db_connection(db_name) as conn:
230
+ store.reset_download_dates(conn.cursor())
231
+
232
+ outcome = asyncio.run(_headless_update_async(db_path))
233
+
234
+ if outcome is None:
235
+ # Mid-run silent-refresh failure handling
236
+ update_status.write_update_status(
237
+ env=env, auth_state="needs_login", managed_path=managed_path, auto_update=auto_update,
238
+ )
239
+ raise typer.Exit(0)
240
+
241
+ if outcome.execution_plan is None or outcome.execution_result is None:
242
+ raise typer.Exit(1) # busy or cancelled: nothing ran, nothing fresh
243
+
244
+ installed, remaining = update_status.split_items_by_outcome(
245
+ outcome.execution_plan, outcome.execution_result
246
+ )
247
+ vvmq_version = None
248
+ if outcome.vvmq_updated:
249
+ vvmq_id = utils.get_current_vvmq_id(env)
250
+ vvmq_version = next(
251
+ (item["version"] for item in installed if item["resource_id"] == vvmq_id), None
252
+ )
253
+ update_status.write_update_status(
254
+ env=env,
255
+ auth_state="ok",
256
+ items=remaining,
257
+ managed_path=managed_path,
258
+ auto_update=auto_update,
259
+ installed=installed,
260
+ pending_restart=outcome.vvmq_updated,
261
+ pending_restart_version=vvmq_version,
262
+ )
263
+
264
+ # the swap starts only when this run has nothing left to do.
265
+ meta.spawn_silent_self_update()
266
+ raise typer.Exit(0)
267
+
268
+
269
+ async def _headless_update_async(db_path: str) -> SyncOutcome | None:
270
+ """Returns the SyncOutcome or needs_login."""
271
+ await asyncio.to_thread(utils.sweep_stale_update_debris)
272
+ try:
273
+ headers = await auth.get_api_headers()
274
+ except RuntimeError:
275
+ return None
276
+ return await sync.run_sync(db_path, headers)
277
+
278
+
224
279
  @app.command(
225
280
  "download",
226
281
  help="Download a specific resource by ID or URL.",
@@ -265,15 +320,14 @@ def check_command(
265
320
 
266
321
  requested_env = server.value if server else None
267
322
 
268
- # Pre-flight: not configured at all -> no init possible, but we can still record the verdict.
269
- if not is_configured():
270
- update_status.write_update_status(
271
- env=requested_env or Env.LIVE.value,
272
- auth_state="not_configured",
273
- )
274
- raise typer.Exit(0)
323
+ with _exit_silently_on_error():
324
+ if not is_configured():
325
+ update_status.write_update_status(
326
+ env=requested_env or Env.LIVE.value,
327
+ auth_state="not_configured",
328
+ )
329
+ raise typer.Exit(0)
275
330
 
276
- try:
277
331
  config.initialize_config()
278
332
  # Honor --server for this run only; never persist (a "check" must not change the user's env).
279
333
  if requested_env:
@@ -284,8 +338,12 @@ def check_command(
284
338
  # MQ matches this against its own root to ignore stray copies.
285
339
  managed_path = utils.get_vvmq_path()
286
340
 
341
+ auto_update = utils.is_auto_update_enabled()
342
+
287
343
  if not _has_auth_credentials():
288
- update_status.write_update_status(env=env, auth_state="needs_login", managed_path=managed_path)
344
+ update_status.write_update_status(
345
+ env=env, auth_state="needs_login", managed_path=managed_path, auto_update=auto_update,
346
+ )
289
347
  raise typer.Exit(0)
290
348
 
291
349
  db_name = f"{env}_resources.db"
@@ -293,15 +351,11 @@ def check_command(
293
351
  db_path = store.get_db_path(db_name)
294
352
 
295
353
  auth_state, items = asyncio.run(_check_command_async(db_path))
296
- update_status.write_update_status(env=env, auth_state=auth_state, items=items, managed_path=managed_path)
354
+ update_status.write_update_status(
355
+ env=env, auth_state=auth_state, items=items, managed_path=managed_path, auto_update=auto_update,
356
+ )
297
357
  raise typer.Exit(0)
298
358
 
299
- except typer.Exit:
300
- raise
301
- except Exception:
302
- # Transient failure, no touch to update_status.json so we know checked_at didn't advance this cycle.
303
- raise typer.Exit(1)
304
-
305
359
 
306
360
  async def _check_command_async(db_path: str) -> tuple[str, list[dict] | None]:
307
361
  from redfetch import update_status