Files
Jackify/jackify/backend/handlers/modlist_install_cli_discovery.py
2026-02-07 18:26:54 +00:00

452 lines
26 KiB
Python

"""Discovery phase methods for ModlistInstallCLI (Mixin)."""
import logging
import os
import subprocess
from pathlib import Path
from typing import Optional, Dict
from .config_handler import ConfigHandler
from .ui_colors import (
COLOR_PROMPT,
COLOR_RESET,
COLOR_INFO,
COLOR_ERROR,
COLOR_SUCCESS,
COLOR_WARNING,
COLOR_SELECTION,
)
logger = logging.getLogger(__name__)
class ModlistInstallCLIDiscoveryMixin:
"""Mixin providing discovery phase methods."""
def run_discovery_phase(self, context_override=None) -> Optional[Dict]:
"""
Run the discovery phase: prompt for all required info, and validate inputs.
Returns a context dict with all collected info, or None if cancelled.
Accepts context_override for pre-filled values (e.g., for Tuxborn/machineid flow).
"""
self.logger.info("Starting modlist discovery phase (restored logic).")
from .modlist_install_cli import get_jackify_engine_path
print(f"\n{COLOR_PROMPT}--- Wabbajack Modlist Install: Discovery Phase ---{COLOR_RESET}")
if context_override:
self.context.update(context_override)
if 'resolution' in context_override:
self.context['resolution'] = context_override['resolution']
else:
self.context = {}
is_gui_mode = os.environ.get('JACKIFY_GUI_MODE') == '1'
# Only require game_type for non-Tuxborn workflows
if self.context.get('machineid'):
required_keys = ['modlist_name', 'install_dir', 'download_dir', 'nexus_api_key']
else:
required_keys = ['modlist_name', 'install_dir', 'download_dir', 'nexus_api_key', 'game_type']
has_modlist = self.context.get('modlist_value') or self.context.get('machineid')
missing = [k for k in required_keys if not self.context.get(k)]
if is_gui_mode:
if missing or not has_modlist:
self.logger.error(f"Missing required arguments for GUI workflow: {', '.join(missing)}")
if not has_modlist:
self.logger.error("Missing modlist_value or machineid for GUI workflow.")
self.logger.error("This workflow must be fully non-interactive. Please report this as a bug if you see this message.")
return None
self.logger.info("All required context present in GUI mode, skipping prompts.")
return self.context
# Get engine path using the helper
engine_executable = get_jackify_engine_path()
self.logger.debug(f"Engine executable path: {engine_executable}")
if not os.path.exists(engine_executable):
print(f"{COLOR_ERROR}Error: jackify-install-engine not found at expected location.{COLOR_RESET}")
print(f"{COLOR_INFO}Expected: {engine_executable}{COLOR_RESET}")
return None
engine_dir = os.path.dirname(engine_executable)
# 1. Prompt for modlist source (unless using machineid from context_override)
if 'machineid' not in self.context:
print("\n" + "-" * 28) # Separator
print(f"{COLOR_PROMPT}How would you like to select your modlist?{COLOR_RESET}")
print(f"{COLOR_SELECTION}1.{COLOR_RESET} Select from a list of available modlists")
print(f"{COLOR_SELECTION}2.{COLOR_RESET} Provide the path to a .wabbajack file on disk")
print(f"{COLOR_SELECTION}0.{COLOR_RESET} Cancel and return to previous menu")
source_choice = input(f"{COLOR_PROMPT}Enter your selection (0-2): {COLOR_RESET}").strip()
self.logger.debug(f"User selected modlist source option: {source_choice}")
if source_choice == '1':
self.context['modlist_source_type'] = 'online_list'
print(f"\n{COLOR_INFO}Fetching available modlists... This may take a moment.{COLOR_RESET}")
try:
env = os.environ.copy()
env["DOTNET_SYSTEM_GLOBALIZATION_INVARIANT"] = "1"
self.logger.info("Setting DOTNET_SYSTEM_GLOBALIZATION_INVARIANT=1 for jackify-engine process.")
# Use the engine path from the helper function, but the command structure from restored.
engine_executable_path_for_subprocess = get_jackify_engine_path()
command = [engine_executable_path_for_subprocess, 'list-modlists', '--show-all-sizes', '--show-machine-url']
self.logger.info(f"Executing command: {' '.join(command)} in CWD: {engine_dir}")
# check=True as in restored logic
result = subprocess.run(
command,
capture_output=True, text=True, check=True,
env=env, cwd=engine_dir
)
# self.logger.debug(f"Engine stdout (raw):\n{result.stdout}") # COMMENTED OUT - too verbose
lines = result.stdout.splitlines()
# Parse new format: [STATUS] Modlist Name - Game - Download|Install|Total - MachineURL
# STATUS indicators: [DOWN], [NSFW], or both [DOWN] [NSFW]
raw_modlists_from_engine = []
for line in lines:
line = line.strip()
if not line or line.startswith('Loading') or line.startswith('Loaded'):
continue
# Extract status indicators
status_down = '[DOWN]' in line
status_nsfw = '[NSFW]' in line
# Remove status indicators to get clean line
clean_line = line.replace('[DOWN]', '').replace('[NSFW]', '').strip()
# Split on ' - ' to get: [Modlist Name, Game, Sizes, MachineURL]
parts = clean_line.split(' - ')
if len(parts) != 4:
continue # Skip malformed lines
modlist_name = parts[0].strip()
game_name = parts[1].strip()
sizes_str = parts[2].strip()
machine_url = parts[3].strip()
# Parse sizes: "Download|Install|Total" (e.g., "203GB|130GB|333GB")
size_parts = sizes_str.split('|')
if len(size_parts) != 3:
continue # Skip if sizes don't match expected format
download_size = size_parts[0].strip()
install_size = size_parts[1].strip()
total_size = size_parts[2].strip()
# Skip if any required data is missing
if not modlist_name or not game_name or not machine_url:
continue
raw_modlists_from_engine.append({
'id': modlist_name, # Use modlist name as ID for compatibility
'name': modlist_name,
'game': game_name,
'download_size': download_size,
'install_size': install_size,
'total_size': total_size,
'machine_url': machine_url, # Store machine URL for installation
'status_down': status_down,
'status_nsfw': status_nsfw
})
self.logger.info(f"Scraped {len(raw_modlists_from_engine)} modlists after revised regex and filtering logic.")
if not raw_modlists_from_engine:
print(f"{COLOR_WARNING}No modlists found after applying revised regex and filtering logic.{COLOR_RESET}")
return None
# EXACT game_type_map and grouping logic from restored file
game_type_map = {
'1': ('Skyrim', ['Skyrim', 'Skyrim Special Edition']),
'2': ('Fallout 4', ['Fallout 4']),
'3': ('Fallout New Vegas', ['Fallout New Vegas']),
'4': ('Oblivion', ['Oblivion']),
'5': ('Other Games', None) # Using None as in restored for keyword list
}
grouped_modlists = {k: [] for k in game_type_map}
for m_info in raw_modlists_from_engine: # m_info is like {'id': ..., 'game': ...}
found_category = False
for cat_key, (cat_label, cat_keywords) in game_type_map.items():
if cat_key == '5': # Skip 'Other Games' for direct matching initially
continue
if cat_keywords: # Ensure there are keywords to check (handles 'Other Games' with None)
for keyword in cat_keywords:
if keyword.lower() in m_info['game'].lower():
grouped_modlists[cat_key].append(m_info)
found_category = True
break # Found category for this modlist
if found_category:
break # Move to next modlist
if not found_category:
grouped_modlists['5'].append(m_info) # Add to 'Other Games'
selected_modlist_info = None # Will store {'id': ..., 'game': ...}
while not selected_modlist_info:
print(f"\n{COLOR_PROMPT}Select a game category:{COLOR_RESET}")
category_display_map = {} # Maps displayed number to actual game_type_map key
display_idx = 1
# Iterate in a defined order for consistent menu
for cat_key_ordered in ['1','2','3','4','5']:
if cat_key_ordered in grouped_modlists and grouped_modlists[cat_key_ordered]: # Only show if non-empty
cat_label = game_type_map[cat_key_ordered][0]
print(f" {COLOR_SELECTION}{display_idx}.{COLOR_RESET} {cat_label} ({len(grouped_modlists[cat_key_ordered])} modlists)")
category_display_map[str(display_idx)] = cat_key_ordered
display_idx += 1
if display_idx == 1: # No categories had any modlists
print(f"{COLOR_WARNING}No modlists found to display after grouping. Engine output might be empty or filtered entirely.{COLOR_RESET}")
return None
print(f" {COLOR_SELECTION}0.{COLOR_RESET} Cancel")
game_cat_choice = input(f"{COLOR_PROMPT}Enter selection: {COLOR_RESET}").strip()
if game_cat_choice == '0':
self.logger.info("User cancelled game category selection.")
return None
actual_cat_key = category_display_map.get(game_cat_choice)
if not actual_cat_key:
print(f"{COLOR_ERROR}Invalid selection. Please try again.{COLOR_RESET}")
continue
# modlist_group_for_game is a list of dicts like {'id': ..., 'game': ...}
modlist_group_for_game = sorted(grouped_modlists[actual_cat_key], key=lambda x: x['id'].lower())
print(f"\n{COLOR_SUCCESS}Available Modlists for {game_type_map[actual_cat_key][0]}:{COLOR_RESET}")
for idx, m_detail in enumerate(modlist_group_for_game, 1):
if actual_cat_key == '5': # 'Other Games' category
print(f" {COLOR_SELECTION}{idx}.{COLOR_RESET} {m_detail['id']} ({m_detail['game']})")
else:
print(f" {COLOR_SELECTION}{idx}.{COLOR_RESET} {m_detail['id']}")
print(f" {COLOR_SELECTION}0.{COLOR_RESET} Back to game categories")
while True:
mod_choice_idx_str = input(f"{COLOR_PROMPT}Select modlist (or 0): {COLOR_RESET}").strip()
if mod_choice_idx_str == '0':
break
if mod_choice_idx_str.isdigit():
mod_idx = int(mod_choice_idx_str) - 1
if 0 <= mod_idx < len(modlist_group_for_game):
selected_modlist_info = modlist_group_for_game[mod_idx]
self.context['modlist_source'] = 'identifier'
# Use machine_url for installation, display name for suggestions
self.context['modlist_value'] = selected_modlist_info.get('machine_url', selected_modlist_info['id'])
self.context['modlist_game'] = selected_modlist_info['game']
self.context['modlist_name_suggestion'] = selected_modlist_info['id'].split('/')[-1]
self.logger.info(f"User selected online modlist: {selected_modlist_info}")
break
else:
print(f"{COLOR_ERROR}Invalid modlist number.{COLOR_RESET}")
else:
print(f"{COLOR_ERROR}Invalid input. Please enter a number.{COLOR_RESET}")
if selected_modlist_info:
break
except subprocess.CalledProcessError as e:
self.logger.error(f"list-modlists failed. Code: {e.returncode}")
if e.stdout: self.logger.error(f"Engine stdout:\n{e.stdout}")
if e.stderr: self.logger.error(f"Engine stderr:\n{e.stderr}")
print(f"{COLOR_ERROR}Failed to fetch modlist list. Engine error (Code: {e.returncode}).{COLOR_RESET}")
return None
except FileNotFoundError:
self.logger.error(f"Engine not found: {engine_executable_path_for_subprocess}")
print(f"{COLOR_ERROR}Critical error: jackify-install-engine not found.{COLOR_RESET}")
return None
except Exception as e:
self.logger.error(f"Unexpected error fetching modlists: {e}", exc_info=True)
print(f"{COLOR_ERROR}Unexpected error fetching modlists: {e}{COLOR_RESET}")
return None
elif source_choice == '2':
self.context['modlist_source_type'] = 'local_file'
print(f"\n{COLOR_PROMPT}Please provide the path to your .wabbajack file (tab-completion supported).{COLOR_RESET}")
modlist_path = self.menu_handler.get_existing_file_path(
prompt_message="Enter the path to your .wabbajack file (or 'q' to cancel):",
extension_filter=".wabbajack", # Ensure this is the exact filter used by the method
no_header=True # To avoid re-printing a header if get_existing_file_path has one
)
if modlist_path is None: # Assumes get_existing_file_path returns None on cancel/'q'
self.logger.info("User cancelled .wabbajack file selection.")
print(f"{COLOR_INFO}Cancelled by user.{COLOR_RESET}")
return None
self.context['modlist_source'] = 'path' # For install command
self.context['modlist_value'] = str(modlist_path)
# Suggest a name based on the file
self.context['modlist_name_suggestion'] = Path(modlist_path).stem
self.logger.info(f"User selected local .wabbajack file: {modlist_path}")
elif source_choice == '0':
self.logger.info("User cancelled modlist source selection.")
print(f"{COLOR_INFO}Returning to previous menu.{COLOR_RESET}")
return None
else:
self.logger.warning(f"Invalid modlist source choice: {source_choice}")
print(f"{COLOR_ERROR}Invalid selection. Please try again.{COLOR_RESET}")
return self.run_discovery_phase() # Re-prompt
# --- Prompts for install_dir, download_dir, modlist_name, api_key ---
# It will use self.context['modlist_name_suggestion'] if available.
# 2. Prompt for modlist name (skip if 'modlist_name' already in context from override)
if 'modlist_name' not in self.context or not self.context['modlist_name']:
default_name = self.context.get('modlist_name_suggestion', 'MyModlist')
print("\n" + "-" * 28)
print(f"{COLOR_PROMPT}Enter a name for this modlist installation in Steam.{COLOR_RESET}")
print(f"{COLOR_INFO}(This will be the shortcut name. Default: {default_name}){COLOR_RESET}")
modlist_name_input = input(f"{COLOR_PROMPT}Modlist Name (or 'q' to cancel): {COLOR_RESET}").strip()
if not modlist_name_input: # User hit enter for default
modlist_name = default_name
elif modlist_name_input.lower() == 'q':
self.logger.info("User cancelled at modlist name prompt.")
return None
else:
modlist_name = modlist_name_input
self.context['modlist_name'] = modlist_name
self.logger.debug(f"Modlist name set to: {self.context['modlist_name']}")
# 3. Prompt for install directory
if 'install_dir' not in self.context:
# Use configurable base directory
config_handler = ConfigHandler()
base_install_dir = Path(config_handler.get_modlist_install_base_dir())
default_install_dir = base_install_dir / self.context['modlist_name']
print("\n" + "-" * 28)
print(f"{COLOR_PROMPT}Enter the main installation directory for '{self.context['modlist_name']}'.{COLOR_RESET}")
print(f"{COLOR_INFO}(Default: {default_install_dir}){COLOR_RESET}")
install_dir_path = self.menu_handler.get_directory_path(
prompt_message=f"{COLOR_PROMPT}Install directory (or 'q' to cancel, Enter for default): {COLOR_RESET}",
default_path=default_install_dir,
create_if_missing=True,
no_header=True
)
if install_dir_path is None:
self.logger.info("User cancelled at install directory prompt.")
return None
self.context['install_dir'] = install_dir_path
self.logger.debug(f"Install directory context set to: {self.context['install_dir']}")
# 4. Prompt for download directory
if 'download_dir' not in self.context:
# Use configurable base directory for downloads
config_handler = ConfigHandler()
base_download_dir = Path(config_handler.get_modlist_downloads_base_dir())
default_download_dir = base_download_dir / self.context['modlist_name']
print("\n" + "-" * 28)
print(f"{COLOR_PROMPT}Enter the downloads directory for modlist archives.{COLOR_RESET}")
print(f"{COLOR_INFO}(Default: {default_download_dir}){COLOR_RESET}")
download_dir_path = self.menu_handler.get_directory_path(
prompt_message=f"{COLOR_PROMPT}Download directory (or 'q' to cancel, Enter for default): {COLOR_RESET}",
default_path=default_download_dir,
create_if_missing=True,
no_header=True
)
if download_dir_path is None:
self.logger.info("User cancelled at download directory prompt.")
return None
self.context['download_dir'] = download_dir_path
self.logger.debug(f"Download directory context set to: {self.context['download_dir']}")
# 5. Get Nexus authentication (OAuth or API key)
if 'nexus_api_key' not in self.context:
from jackify.backend.services.nexus_auth_service import NexusAuthService
auth_service = NexusAuthService()
# Get current auth status
authenticated, method, username = auth_service.get_auth_status()
if authenticated:
# Already authenticated - use existing auth
if method == 'oauth':
print("\n" + "-" * 28)
print(f"{COLOR_SUCCESS}Nexus Authentication: Authorized via OAuth{COLOR_RESET}")
if username:
print(f"{COLOR_INFO}Logged in as: {username}{COLOR_RESET}")
elif method == 'api_key':
print("\n" + "-" * 28)
print(f"{COLOR_INFO}Nexus Authentication: Using API Key (Legacy){COLOR_RESET}")
# Get valid token/key and OAuth state for engine auto-refresh
api_key, oauth_info = auth_service.get_auth_for_engine()
if api_key:
self.context['nexus_api_key'] = api_key
self.context['nexus_oauth_info'] = oauth_info # For engine auto-refresh
else:
# Auth expired or invalid - prompt to set up
print(f"\n{COLOR_WARNING}Your authentication has expired or is invalid.{COLOR_RESET}")
authenticated = False
if not authenticated:
# Not authenticated - offer to set up OAuth
print("\n" + "-" * 28)
print(f"{COLOR_WARNING}Nexus Mods authentication is required for downloading mods.{COLOR_RESET}")
print(f"\n{COLOR_PROMPT}Would you like to authorize with Nexus now?{COLOR_RESET}")
print(f"{COLOR_INFO}This will open your browser for secure OAuth authorization.{COLOR_RESET}")
authorize = input(f"{COLOR_PROMPT}Authorize now? [Y/n]: {COLOR_RESET}").strip().lower()
if authorize in ('', 'y', 'yes'):
# Launch OAuth authorization
print(f"\n{COLOR_INFO}Starting OAuth authorization...{COLOR_RESET}")
print(f"{COLOR_WARNING}Your browser will open shortly.{COLOR_RESET}")
print(f"{COLOR_INFO}Note: Your browser may ask permission to open 'xdg-open' or{COLOR_RESET}")
print(f"{COLOR_INFO}Jackify's protocol handler - please click 'Open' or 'Allow'.{COLOR_RESET}")
def show_message(msg):
print(f"\n{COLOR_INFO}{msg}{COLOR_RESET}")
success = auth_service.authorize_oauth(show_browser_message_callback=show_message)
if success:
print(f"\n{COLOR_SUCCESS}OAuth authorization successful!{COLOR_RESET}")
_, _, username = auth_service.get_auth_status()
if username:
print(f"{COLOR_INFO}Authorized as: {username}{COLOR_RESET}")
api_key, oauth_info = auth_service.get_auth_for_engine()
if api_key:
self.context['nexus_api_key'] = api_key
self.context['nexus_oauth_info'] = oauth_info # For engine auto-refresh
else:
print(f"{COLOR_ERROR}Failed to retrieve auth token after authorization.{COLOR_RESET}")
return None
else:
print(f"\n{COLOR_ERROR}OAuth authorization failed.{COLOR_RESET}")
return None
else:
# User declined OAuth - cancelled
print(f"\n{COLOR_INFO}Authorization required to proceed. Installation cancelled.{COLOR_RESET}")
self.logger.info("User declined Nexus authorization.")
return None
self.logger.debug(f"Nexus authentication configured for engine.")
# Display summary and confirm
self._display_summary() # Ensure this method exists or implement it
if self.context.get('skip_confirmation'):
confirm = 'y'
else:
confirm = input(f"{COLOR_PROMPT}Proceed with installation using these settings? (y/N): {COLOR_RESET}").strip().lower()
if confirm != 'y':
self.logger.info("User cancelled at final confirmation.")
print(f"{COLOR_INFO}Installation cancelled by user.{COLOR_RESET}")
return None
self.logger.info("Discovery phase complete.") # Log completion first
# Create a copy of the context for logging, so we don't alter the original
context_for_logging = self.context.copy()
if 'nexus_api_key' in context_for_logging and context_for_logging['nexus_api_key'] is not None:
context_for_logging['nexus_api_key'] = "[REDACTED]" # Redact the API key for logging
self.logger.info(f"Context: {context_for_logging}") # Log the redacted context
return self.context