Release v0.7 - Tools Hub, Engine Choice, NXM Link Handling, Native Component Install, NSF/CSF Support

This commit is contained in:
Omni
2026-06-21 21:47:48 +01:00
parent 33b3fbaed2
commit 7fff107389
483 changed files with 11150 additions and 6050 deletions
+145 -22
View File
@@ -29,33 +29,37 @@ class AdditionalMenuHandler:
self._clear_screen()
print_jackify_banner()
print_section_header("Additional Tasks & Tools")
print(f"{COLOR_INFO}Nexus Authentication, TTW Install & more{COLOR_RESET}\n")
print(f"{COLOR_INFO}Modlist tools, diagnostics, and more{COLOR_RESET}\n")
print(f"{COLOR_SELECTION}1.{COLOR_RESET} Nexus Mods Authorization")
print(f" {COLOR_ACTION}Authorize with Nexus using OAuth or manage API key{COLOR_RESET}")
print(f"{COLOR_SELECTION}2.{COLOR_RESET} Tale of Two Wastelands (TTW) Installation")
print(f" {COLOR_ACTION}→ Install TTW using TTW_Linux_Installer{COLOR_RESET}")
print(f"{COLOR_SELECTION}3.{COLOR_RESET} Install Wabbajack Application")
print(f" {COLOR_ACTION}→ Downloads and configures the Wabbajack app itself (via Proton){COLOR_RESET}")
print(f"{COLOR_SELECTION}4.{COLOR_RESET} Setup Mod Organizer 2")
print(f" {COLOR_ACTION}→ Download and configure a standalone MO2 instance{COLOR_RESET}")
print(f"{COLOR_SELECTION}5.{COLOR_RESET} Configure Tool Compatibility")
print(f"{COLOR_SELECTION}1.{COLOR_RESET} Run Install Verifier")
print(f" {COLOR_ACTION}Check an installed modlist for common configuration problems{COLOR_RESET}")
print(f"{COLOR_SELECTION}2.{COLOR_RESET} Configure Tool Compatibility")
print(f" {COLOR_ACTION}→ Apply Wine registry settings for xEdit, Synthesis, Pandora, Nemesis{COLOR_RESET}")
print(f"{COLOR_SELECTION}3.{COLOR_RESET} Setup Mod Organizer 2")
print(f" {COLOR_ACTION}→ Download and configure a standalone MO2 instance{COLOR_RESET}")
print(f"{COLOR_SELECTION}4.{COLOR_RESET} Install Wabbajack Application")
print(f" {COLOR_ACTION}→ Download the Wabbajack app under Proton — not needed for standard modlist installs{COLOR_RESET}")
print(f"{COLOR_SELECTION}5.{COLOR_RESET} Create Diagnostic Bundle")
print(f" {COLOR_ACTION}→ Package logs and system info for support{COLOR_RESET}")
print(f"{COLOR_SELECTION}6.{COLOR_RESET} Nexus Mods Authorization")
print(f" {COLOR_ACTION}→ Authorise with Nexus using OAuth or manage API key{COLOR_RESET}")
print(f"{COLOR_SELECTION}0.{COLOR_RESET} Return to Main Menu")
selection = input(f"\n{COLOR_PROMPT}Enter your selection (0-5): {COLOR_RESET}").strip()
selection = input(f"\n{COLOR_PROMPT}Enter your selection (0-6): {COLOR_RESET}").strip()
if selection.lower() == 'q': # Allow 'q' to re-display menu
continue
if selection == "1":
self._execute_nexus_authorization(cli_instance)
self._execute_run_verifier()
elif selection == "2":
self._execute_ttw_install(cli_instance)
elif selection == "3":
self._execute_install_wabbajack(cli_instance)
elif selection == "4":
self._execute_setup_mo2(cli_instance)
elif selection == "5":
self._execute_configure_tool_compat(cli_instance)
elif selection == "3":
self._execute_setup_mo2(cli_instance)
elif selection == "4":
self._execute_install_wabbajack(cli_instance)
elif selection == "5":
self._execute_diagnostic_bundle()
elif selection == "6":
self._execute_nexus_authorization(cli_instance)
elif selection == "0":
break
else:
@@ -271,17 +275,17 @@ class AdditionalMenuHandler:
if authenticated:
if method == 'oauth':
print(f"\n{COLOR_SUCCESS}Status: Authorized via OAuth{COLOR_RESET}")
print(f"\n{COLOR_SUCCESS}Status: Authorised via OAuth{COLOR_RESET}")
if username:
print(f"{COLOR_INFO}Logged in as: {username}{COLOR_RESET}")
elif method == 'api_key':
print(f"\n{COLOR_WARNING}Status: Using API Key (Legacy){COLOR_RESET}")
print(f"{COLOR_INFO}Consider switching to OAuth for better security{COLOR_RESET}")
else:
print(f"\n{COLOR_WARNING}Status: Not Authorized{COLOR_RESET}")
print(f"\n{COLOR_WARNING}Status: Not Authorised{COLOR_RESET}")
print(f"{COLOR_INFO}You need to authorize to download mods from Nexus{COLOR_RESET}")
print(f"\n{COLOR_SELECTION}1.{COLOR_RESET} Authorize with Nexus (OAuth)")
print(f"\n{COLOR_SELECTION}1.{COLOR_RESET} Authorise with Nexus (OAuth)")
print(f" {COLOR_ACTION}→ Opens browser for secure authorization{COLOR_RESET}")
if method == 'oauth':
@@ -320,7 +324,7 @@ class AdditionalMenuHandler:
# Get username
_, _, username = auth_service.get_auth_status()
if username:
print(f"{COLOR_INFO}Authorized as: {username}{COLOR_RESET}")
print(f"{COLOR_INFO}Authorised as: {username}{COLOR_RESET}")
else:
print(f"\n{COLOR_ERROR}OAuth authorization failed.{COLOR_RESET}")
print(f"{COLOR_INFO}You can try again or use API key as fallback.{COLOR_RESET}")
@@ -474,3 +478,122 @@ class AdditionalMenuHandler:
print(f"\n{COLOR_ERROR}Tool compatibility configuration failed. Check logs for details.{COLOR_RESET}")
input("\nPress Enter to return to menu...")
def _execute_diagnostic_bundle(self) -> None:
from jackify.backend.services.diagnostic_service import build_bundle
from jackify.shared.colors import COLOR_ERROR, COLOR_SUCCESS
self._clear_screen()
print_jackify_banner()
print_section_header("Create Diagnostic Bundle")
print(f"{COLOR_INFO}Collecting logs, system info, and prefix records...{COLOR_RESET}\n")
try:
bundle_path = build_bundle()
except Exception as exc:
print(f"{COLOR_ERROR}Failed to create bundle: {exc}{COLOR_RESET}")
input("Press Enter to return to menu...")
return
print(f"{COLOR_SUCCESS}Bundle created:{COLOR_RESET} {bundle_path}")
print(f"\n{COLOR_INFO}Attach this file when reporting an issue.{COLOR_RESET}")
input("\nPress Enter to return to menu...")
def _execute_run_verifier(self) -> None:
from jackify.backend.services.install_verifier_service import (
_load_verifier, run_install_verification, resolve_pfx_for_appid,
)
from jackify.shared.colors import COLOR_ERROR, COLOR_SUCCESS, COLOR_WARNING
import threading
self._clear_screen()
print_jackify_banner()
print_section_header("Run Install Verifier")
print(f"{COLOR_INFO}Discovering configured modlists...{COLOR_RESET}")
try:
vmod = _load_verifier()
modlists = vmod.discover_installed_modlists()
except Exception as e:
print(f"{COLOR_ERROR}Failed to discover modlists: {e}{COLOR_RESET}")
input("Press Enter to return to menu...")
return
if not modlists:
print(f"{COLOR_WARNING}No configured modlists found.{COLOR_RESET}")
print(f"{COLOR_INFO}Install and configure a modlist first.{COLOR_RESET}")
input("Press Enter to return to menu...")
return
print()
for i, m in enumerate(modlists, 1):
print(f"{COLOR_SELECTION}{i}.{COLOR_RESET} {m.get('name', 'Unknown')}")
print(f"{COLOR_SELECTION}0.{COLOR_RESET} Cancel")
selection = input(f"\n{COLOR_PROMPT}Select modlist (0-{len(modlists)}): {COLOR_RESET}").strip()
if selection == "0" or not selection:
return
try:
idx = int(selection) - 1
if idx < 0 or idx >= len(modlists):
raise ValueError()
except ValueError:
print(f"{COLOR_ERROR}Invalid selection.{COLOR_RESET}")
input("Press Enter to return to menu...")
return
chosen = modlists[idx]
appid = chosen.get("appid", "")
modlist_dir = chosen.get("modlist_dir")
game_type = chosen.get("game_type", "unknown")
name = chosen.get("name", "Unknown")
pfx = resolve_pfx_for_appid(appid) if appid else None
if not pfx or not pfx.is_dir():
print(f"{COLOR_WARNING}No Wine prefix found for {name}. Cannot run verifier.{COLOR_RESET}")
input("Press Enter to return to menu...")
return
print(f"\n{COLOR_INFO}Running install verification for: {name}{COLOR_RESET}")
print(f"{COLOR_INFO}This may take a moment...{COLOR_RESET}\n")
try:
result_holder = [None]
def _worker():
result_holder[0] = run_install_verification(pfx, modlist_dir, game_type, appid, name)
t = threading.Thread(target=_worker, daemon=True)
t.start()
t.join()
r = result_holder[0]
if r is None:
print(f"{COLOR_WARNING}Verifier returned no results.{COLOR_RESET}")
else:
passes = r.passes if hasattr(r, 'passes') else []
warnings = r.warnings if hasattr(r, 'warnings') else []
failures = r.failures if hasattr(r, 'failures') else []
total = len(passes) + len(warnings) + len(failures)
print(f"--- Install Verification: {name} ---")
print(f" {len(passes)} passed, {len(warnings)} warnings, {len(failures)} failed (of {total} checks)\n")
for msg in failures:
print(f"{COLOR_ERROR} [FAIL] {msg}{COLOR_RESET}")
for msg in warnings:
print(f"{COLOR_WARNING} [WARN] {msg}{COLOR_RESET}")
if not failures and not warnings:
print(f"{COLOR_SUCCESS} All checks passed.{COLOR_RESET}")
if passes:
show_all = input(f"\n{COLOR_PROMPT}Show all {len(passes)} passed checks? (y/N): {COLOR_RESET}").strip().lower()
if show_all in ('y', 'yes'):
print()
for msg in passes:
print(f"{COLOR_DISABLED} [OK] {msg}{COLOR_RESET}")
except Exception as e:
print(f"{COLOR_ERROR}Verifier error: {e}{COLOR_RESET}")
input("\nPress Enter to return to menu...")
def _execute_tools_hub(self, cli_instance) -> None:
from jackify.frontends.cli.menus.tools_hub_menu import ToolsHubMenuHandler
ToolsHubMenuHandler().show_tools_hub_menu(cli_instance)
+7 -3
View File
@@ -43,16 +43,20 @@ class MainMenuHandler:
print(f"{COLOR_SELECTION}1.{COLOR_RESET} Modlist Tasks")
print(f" {COLOR_ACTION}→ Install & Configure Modlists{COLOR_RESET}")
print(f"{COLOR_SELECTION}2.{COLOR_RESET} Additional Tasks & Tools")
print(f" {COLOR_ACTION}Nexus OAuth, TTW Installation, Install Wabbajack{COLOR_RESET}")
print(f" {COLOR_ACTION}Verifier, Diagnostics, Nexus OAuth & more{COLOR_RESET}")
print(f"{COLOR_SELECTION}3.{COLOR_RESET} Tools Hub")
print(f" {COLOR_ACTION}→ Install, update, or switch engines and tools{COLOR_RESET}")
print(f"{COLOR_SELECTION}0.{COLOR_RESET} Exit Jackify")
choice = input(f"\n{COLOR_PROMPT}Enter your selection (0-2): {COLOR_RESET}").strip()
choice = input(f"\n{COLOR_PROMPT}Enter your selection (0-3): {COLOR_RESET}").strip()
if choice.lower() == 'q': # Allow 'q' to re-display menu
continue
if choice == "1":
return "wabbajack"
elif choice == "2":
return "additional"
elif choice == "3":
return "tools_hub"
elif choice == "0":
return "exit"
else:
@@ -0,0 +1,249 @@
"""
Tools Hub Menu Handler for Jackify CLI Frontend
"""
import logging
import threading
import time
from typing import Optional, Tuple
from jackify.shared.colors import (
COLOR_SELECTION, COLOR_RESET, COLOR_ACTION, COLOR_PROMPT,
COLOR_INFO, COLOR_DISABLED, COLOR_WARNING, COLOR_ERROR, COLOR_SUCCESS
)
from jackify.shared.ui_utils import print_jackify_banner, print_section_header, clear_screen
from jackify.frontends.cli.ui.indeterminate_status import CliIndeterminateStatus
logger = logging.getLogger(__name__)
class ToolsHubMenuHandler:
"""CLI menu for managing third-party tools via ToolRegistry."""
def show_tools_hub_menu(self, cli_instance) -> None:
from jackify.backend.services.tool_registry import (
ToolRegistry, get_active_engine_id
)
registry = ToolRegistry()
while True:
clear_screen()
print_jackify_banner()
print_section_header("Third Party Tools Hub")
statuses = [s for s in registry.get_all_statuses() if not s.definition.hidden]
active_engine_id = get_active_engine_id()
print(f"{COLOR_INFO}Active engine: {active_engine_id}{COLOR_RESET}\n")
for i, status in enumerate(statuses, 1):
defn = status.definition
if status.installed:
ver = status.installed_version or "unknown"
active_tag = (
f" {COLOR_ACTION}[ACTIVE]{COLOR_RESET}"
if defn.is_engine and defn.tool_id == active_engine_id
else ""
)
print(f"{COLOR_SELECTION}{i}.{COLOR_RESET} {defn.display_name} "
f"{COLOR_INFO}v{ver}{COLOR_RESET}{active_tag}")
else:
print(f"{COLOR_SELECTION}{i}.{COLOR_RESET} "
f"{COLOR_DISABLED}{defn.display_name} [not installed]{COLOR_RESET}")
print(f" {COLOR_ACTION}{defn.description}{COLOR_RESET}")
print(f"\n{COLOR_SELECTION}0.{COLOR_RESET} Return to Main Menu")
selection = input(
f"\n{COLOR_PROMPT}Select tool (0-{len(statuses)}): {COLOR_RESET}"
).strip()
if selection == "0":
break
if selection.lower() == "q":
continue
try:
idx = int(selection) - 1
if idx < 0 or idx >= len(statuses):
raise ValueError()
except ValueError:
print(f"{COLOR_ERROR}Invalid selection.{COLOR_RESET}")
time.sleep(1)
continue
self._show_tool_detail(registry, statuses[idx], active_engine_id, cli_instance)
def _show_tool_detail(self, registry, status, active_engine_id: str, cli_instance=None) -> None:
from jackify.backend.services.tool_registry import (
get_active_engine_id, set_active_engine_id
)
defn = status.definition
while True:
clear_screen()
print_jackify_banner()
print_section_header(defn.display_name)
print(f"{COLOR_INFO}{defn.description}{COLOR_RESET}\n")
if status.installed:
ver = status.installed_version or "unknown"
print(f"Installed: {COLOR_INFO}{ver}{COLOR_RESET}")
if status.previous_version:
print(f"Previous: {COLOR_DISABLED}{status.previous_version}{COLOR_RESET}")
if defn.is_engine and defn.tool_id == active_engine_id:
print(f"Engine: {COLOR_ACTION}[ACTIVE]{COLOR_RESET}")
else:
print(f"Status: {COLOR_DISABLED}Not installed{COLOR_RESET}")
latest = self._fetch_latest_with_spinner(registry, defn.tool_id)
if latest:
latest_clean = latest.lstrip("v")
current_clean = (status.installed_version or "").lstrip("v")
update_available = status.installed and latest_clean != current_clean
update_tag = (
f" {COLOR_WARNING}[update available]{COLOR_RESET}" if update_available
else f" {COLOR_ACTION}[up to date]{COLOR_RESET}" if status.installed
else ""
)
print(f"Latest: {COLOR_INFO}{latest}{COLOR_RESET}{update_tag}")
else:
update_available = False
print(f"Latest: {COLOR_DISABLED}(could not check){COLOR_RESET}")
options: dict = {}
opt_num = 1
print()
if not status.installed:
print(f"{COLOR_SELECTION}{opt_num}.{COLOR_RESET} Install")
options[str(opt_num)] = "install"
opt_num += 1
else:
if latest and update_available:
print(f"{COLOR_SELECTION}{opt_num}.{COLOR_RESET} Update to {latest}")
options[str(opt_num)] = "update"
opt_num += 1
if status.previous_version and defn.can_uninstall:
print(f"{COLOR_SELECTION}{opt_num}.{COLOR_RESET} "
f"Downgrade to {status.previous_version}")
options[str(opt_num)] = "downgrade"
opt_num += 1
if defn.can_uninstall:
print(f"{COLOR_SELECTION}{opt_num}.{COLOR_RESET} Uninstall")
options[str(opt_num)] = "uninstall"
opt_num += 1
if defn.is_engine and defn.tool_id != active_engine_id:
print(f"{COLOR_SELECTION}{opt_num}.{COLOR_RESET} Set as active engine")
options[str(opt_num)] = "set_active"
opt_num += 1
if defn.tool_id == "ttw_installer":
print(f"{COLOR_SELECTION}{opt_num}.{COLOR_RESET} Run TTW Installation")
options[str(opt_num)] = "run_ttw"
opt_num += 1
print(f"{COLOR_SELECTION}0.{COLOR_RESET} Back")
if not options:
input(f"\n{COLOR_PROMPT}Press Enter to go back...{COLOR_RESET}")
break
selection = input(f"\n{COLOR_PROMPT}Enter selection: {COLOR_RESET}").strip()
if selection == "0":
break
action = options.get(selection)
if not action:
print(f"{COLOR_ERROR}Invalid selection.{COLOR_RESET}")
time.sleep(1)
continue
if action == "run_ttw":
from jackify.frontends.cli.menus.additional_menu import AdditionalMenuHandler
AdditionalMenuHandler()._execute_ttw_install(cli_instance)
status = registry.get_status(defn.tool_id) or status
continue
if action == "set_active":
try:
set_active_engine_id(defn.tool_id)
active_engine_id = defn.tool_id
print(f"\n{COLOR_SUCCESS}Active engine set to {defn.display_name}.{COLOR_RESET}")
except Exception as e:
print(f"\n{COLOR_ERROR}Failed to set active engine: {e}{COLOR_RESET}")
input(f"{COLOR_PROMPT}Press Enter to continue...{COLOR_RESET}")
status = registry.get_status(defn.tool_id) or status
continue
if action == "uninstall":
confirm = input(
f"\n{COLOR_WARNING}Uninstall {defn.display_name}? (y/N): {COLOR_RESET}"
).strip().lower()
if confirm not in ("y", "yes"):
print(f"{COLOR_INFO}Cancelled.{COLOR_RESET}")
time.sleep(0.8)
status = registry.get_status(defn.tool_id) or status
continue
ok, msg = self._run_with_spinner(registry, action, defn.tool_id, defn.display_name)
if ok:
print(f"\n{COLOR_SUCCESS}{msg}{COLOR_RESET}")
else:
print(f"\n{COLOR_ERROR}{msg}{COLOR_RESET}")
input(f"{COLOR_PROMPT}Press Enter to continue...{COLOR_RESET}")
status = registry.get_status(defn.tool_id) or status
active_engine_id = get_active_engine_id()
def _fetch_latest_with_spinner(self, registry, tool_id: str) -> Optional[str]:
result: list = [None]
spinner = CliIndeterminateStatus()
spinner.set("Checking latest version...")
def _worker():
try:
result[0] = registry.check_latest_version(tool_id)
except Exception:
result[0] = None
thread = threading.Thread(target=_worker, daemon=True)
thread.start()
thread.join()
spinner.stop()
return result[0]
def _run_with_spinner(
self, registry, action: str, tool_id: str, display_name: str
) -> Tuple[bool, str]:
labels = {
"install": f"Installing {display_name}",
"update": f"Updating {display_name}",
"downgrade": f"Downgrading {display_name}",
"uninstall": f"Uninstalling {display_name}",
}
label = labels.get(action, f"Working on {display_name}")
result: list = [False, ""]
spinner = CliIndeterminateStatus()
spinner.set(label)
def _worker():
try:
if action == "install":
result[0], result[1] = registry.install(tool_id)
elif action == "update":
result[0], result[1] = registry.update(tool_id)
elif action == "downgrade":
result[0], result[1] = registry.downgrade(tool_id)
elif action == "uninstall":
result[0], result[1] = registry.uninstall(tool_id)
except Exception as e:
result[0], result[1] = False, str(e)
thread = threading.Thread(target=_worker, daemon=True)
thread.start()
thread.join()
spinner.stop()
return result[0], result[1]
@@ -31,7 +31,7 @@ class WabbajackMenuHandler:
# Use print_section_header for consistency
print_section_header("Modlist and Wabbajack Tasks")
print(f"{COLOR_SELECTION}1.{COLOR_RESET} Install a Modlist (Automated)")
print(f"{COLOR_SELECTION}1.{COLOR_RESET} Install a Modlist")
print(f" {COLOR_ACTION}→ Install a modlist in full: Select from a list or provide a .wabbajack file{COLOR_RESET}")
print(f"{COLOR_SELECTION}2.{COLOR_RESET} Configure New Modlist (Post-Download)")
print(f" {COLOR_ACTION}→ Modlist already downloaded? Configure and add to Steam{COLOR_RESET}")