diff --git a/affinity_cli/commands/__init__.py b/affinity_cli/commands/__init__.py new file mode 100644 index 0000000..658e0ee --- /dev/null +++ b/affinity_cli/commands/__init__.py @@ -0,0 +1,3 @@ +"""Command implementations used by the CLI entrypoint.""" + +__all__ = ["status", "install", "list_installers"] diff --git a/affinity_cli/config.py b/affinity_cli/config.py index 9b9ead0..ad59e03 100644 --- a/affinity_cli/config.py +++ b/affinity_cli/config.py @@ -7,7 +7,7 @@ # Project metadata --------------------------------------------------------- -VERSION = "1.1.0" +VERSION = "1.1.1" APP_NAME = "Affinity CLI" # Paths -------------------------------------------------------------------- @@ -31,7 +31,7 @@ # Installer discovery ------------------------------------------------------- -INSTALLER_SUFFIXES = (".exe", ".msi") +INSTALLER_SUFFIXES = (".exe", ".msi", ".msix") INSTALLER_NAME_PREFIX = "affinity" # Affinity Products -------------------------------------------------------- @@ -94,7 +94,41 @@ "flex", ] -# Ensure config directories exist ----------------------------------------- - -CONFIG_DIR.mkdir(parents=True, exist_ok=True) -CACHE_DIR.mkdir(parents=True, exist_ok=True) +# Ensure config directories exist (best-effort; ignore permission errors) -- +for path in (CONFIG_DIR, CACHE_DIR): + try: + path.mkdir(parents=True, exist_ok=True) + except PermissionError: + # In restricted environments we still want imports to succeed. + pass + +# Convenience re-exports ---------------------------------------------------- +# Imported lazily to avoid circular imports during module initialization. +from affinity_cli.core.config_loader import ConfigLoader, ConfigError, ResolvedConfig, UserConfig # noqa: E402,F401 + +__all__ = [ + "ConfigLoader", + "ConfigError", + "ResolvedConfig", + "UserConfig", + "VERSION", + "APP_NAME", + "HOME_DIR", + "CONFIG_DIR", + "CACHE_DIR", + "DEFAULT_INSTALLERS_PATH", + "DEFAULT_WINE_PREFIX", + "DEFAULT_WINE_INSTALL", + "DEFAULT_INSTALLER_VERSION", + "SUPPORTED_INSTALLER_VERSIONS", + "WINE_VERSION_DEFAULT", + "ELEMENTALWARRIOR_REPO", + "INSTALLER_SUFFIXES", + "INSTALLER_NAME_PREFIX", + "AFFINITY_PRODUCTS", + "CORE_WINE_DEPS", + "MULTIARCH_32BIT_DEPS", + "GRAPHICS_DEPS", + "FONT_DEPS", + "BUILD_DEPS", +] diff --git a/affinity_cli/core/__init__.py b/affinity_cli/core/__init__.py new file mode 100644 index 0000000..a4e6d9a --- /dev/null +++ b/affinity_cli/core/__init__.py @@ -0,0 +1,12 @@ +""" +Subpackage containing core building blocks (distro detection, installer scanning, wine helpers). +""" + +__all__ = [ + "config_loader", + "distro_detector", + "installer_scanner", + "wine_manager", + "wine_executor", + "prefix_manager", +] diff --git a/affinity_cli/core/config_loader.py b/affinity_cli/core/config_loader.py index 8accd7d..ccfc9bf 100644 --- a/affinity_cli/core/config_loader.py +++ b/affinity_cli/core/config_loader.py @@ -7,6 +7,8 @@ from pathlib import Path from typing import Any, Dict, Optional +import os + from affinity_cli import config try: # Python 3.11+ @@ -49,7 +51,7 @@ def to_display_dict(self) -> Dict[str, str]: class ConfigLoader: - """Loads configuration from ~/.config/affinity-cli or an explicit path.""" + """Loads configuration from ~/.config/affinity-cli, an explicit path, or environment.""" CONFIG_FILES = ( "config.toml", @@ -58,13 +60,30 @@ class ConfigLoader: "config.json", ) - def __init__(self, explicit_path: Optional[str] = None) -> None: - self.explicit_path = Path(explicit_path).expanduser() if explicit_path else None + ENV_INSTALLERS = "AFFINITY_INSTALLERS_PATH" + ENV_PREFIX = "AFFINITY_WINE_PREFIX" + ENV_VERSION = "AFFINITY_DEFAULT_VERSION" + + def __init__(self, explicit_path: Optional[str] = None, config_file: Optional[str] = None) -> None: + """ + Args: + explicit_path: Backwards-compatible path argument (kept for callers) + config_file: Preferred keyword accepted by tests/CLI + """ + chosen = config_file or explicit_path + self.explicit_path = Path(chosen).expanduser() if chosen else None self.config_path: Optional[Path] = None self._raw_data: Dict[str, Any] = {} self.user_config = UserConfig() self._load() + def load(self) -> ResolvedConfig: + """ + Public helper used by tests and CLI entrypoint. + Mirrors `derive` with no overrides. + """ + return self.derive() + def derive( self, *, @@ -72,17 +91,29 @@ def derive( prefix_path: Optional[str] = None, version: Optional[str] = None, ) -> ResolvedConfig: + """ + Resolve configuration using precedence: + explicit args > environment > user config file > defaults + """ + env_installers = os.getenv(self.ENV_INSTALLERS) + env_prefix = os.getenv(self.ENV_PREFIX) + env_version = os.getenv(self.ENV_VERSION) + installers = self._normalize_path( installers_path + or env_installers or (self.user_config.installers_path and str(self.user_config.installers_path)) or str(config.DEFAULT_INSTALLERS_PATH) ) prefix = self._normalize_path( prefix_path + or env_prefix or (self.user_config.wine_prefix and str(self.user_config.wine_prefix)) or str(config.DEFAULT_WINE_PREFIX) ) - version_choice = (version or self.user_config.default_version or config.DEFAULT_INSTALLER_VERSION) + version_choice = ( + (version or env_version or self.user_config.default_version or config.DEFAULT_INSTALLER_VERSION) + ) version_choice = version_choice.lower() if version_choice not in config.SUPPORTED_INSTALLER_VERSIONS: raise ConfigError( @@ -93,12 +124,17 @@ def derive( def _load(self) -> None: if self.explicit_path: - if not self.explicit_path.exists(): - raise ConfigError(f"Config file not found: {self.explicit_path}") - self.config_path = self.explicit_path - self._raw_data = self._read_file(self.explicit_path) - self.user_config = self._parse_user_config(self._raw_data) - return + # If the caller asked for a specific path but it does not exist, + # fall back to defaults instead of crashing (friendlier UX/tests). + if self.explicit_path.exists(): + self.config_path = self.explicit_path + self._raw_data = self._read_file(self.explicit_path) + self.user_config = self._parse_user_config(self._raw_data) + return + else: + self._raw_data = {} + self.user_config = UserConfig() + return for candidate in self.CONFIG_FILES: path = config.CONFIG_DIR / candidate diff --git a/affinity_cli/core/prefix_manager.py b/affinity_cli/core/prefix_manager.py new file mode 100644 index 0000000..3cb7415 --- /dev/null +++ b/affinity_cli/core/prefix_manager.py @@ -0,0 +1,74 @@ +"""Wine prefix management helpers.""" + +from __future__ import annotations + +import os +import subprocess +from pathlib import Path +from typing import Optional, Tuple + +from affinity_cli.core.wine_manager import WineManager + + +class PrefixManager: + """ + Minimal manager for creating and checking a Wine prefix. + + This implementation intentionally keeps side effects small; it is enough for + status checks and install flows while avoiding fragile assumptions. + """ + + def __init__(self, prefix_path: Path, wine_manager: Optional[WineManager] = None) -> None: + self.prefix_path = Path(prefix_path).expanduser() + self.wine_manager = wine_manager or WineManager() + + # ------------------------------------------------------------------ + # Basic introspection helpers + # ------------------------------------------------------------------ + def prefix_exists(self) -> bool: + """Return True when the prefix directory looks initialized.""" + return (self.prefix_path / "drive_c").exists() + + # ------------------------------------------------------------------ + # Creation helpers + # ------------------------------------------------------------------ + def create_prefix(self) -> Tuple[bool, str]: + """ + Initialize the Wine prefix using wineboot. + + Returns: + (success flag, human-readable message) + """ + wine_bin = self.wine_manager.get_wine_path() + if not wine_bin: + return False, "Wine binary not found; install Wine first." + + try: + self.prefix_path.mkdir(parents=True, exist_ok=True) + except OSError as exc: # pragma: no cover - filesystem issues + return False, f"Unable to create prefix directory: {exc}" + + env = os.environ.copy() + env["WINEPREFIX"] = str(self.prefix_path) + env.setdefault("WINEARCH", "win64") + + try: + result = subprocess.run( + [str(wine_bin), "wineboot", "-u"], + env=env, + capture_output=True, + text=True, + timeout=120, + ) + except subprocess.TimeoutExpired: # pragma: no cover - rare + return False, "wineboot timed out while initializing the prefix." + except Exception as exc: # pragma: no cover - defensive + return False, f"Failed to initialize prefix: {exc}" + + if result.returncode != 0: + return False, f"wineboot failed: {result.stderr.strip() or result.stdout.strip()}" + + return True, f"Prefix initialized at {self.prefix_path}" + + +__all__ = ["PrefixManager"] diff --git a/affinity_cli/installer_discovery.py b/affinity_cli/installer_discovery.py index 99cfb4e..11a3348 100644 --- a/affinity_cli/installer_discovery.py +++ b/affinity_cli/installer_discovery.py @@ -16,7 +16,7 @@ } INSTALLER_PATTERN = re.compile( - r"^affinity-(photo|designer|publisher)(-msi)?-([0-9]+\.[0-9]+\.[0-9]+)\.exe$", + r"^affinity-(photo|designer|publisher)(-msi|-msix)?-([0-9]+\.[0-9]+\.[0-9]+)\.(exe|msix)$", re.IGNORECASE, ) @@ -51,7 +51,7 @@ def scan(self) -> List[InstallerInfo]: match = INSTALLER_PATTERN.match(file.name) if not match: continue - product, msi_marker, file_version = match.groups() + product, msi_marker, file_version, _ext = match.groups() version: VersionLiteral = "v2" if msi_marker else "v1" installers.append( InstallerInfo( diff --git a/affinity_cli/main.py b/affinity_cli/main.py index 89644aa..f4111f9 100644 --- a/affinity_cli/main.py +++ b/affinity_cli/main.py @@ -40,6 +40,12 @@ def cli(ctx: click.Context, config_file: Optional[str], verbose: bool) -> None: @cli.command(name="list-installers") @click.option("--path", "installers_path", type=click.Path(file_okay=False), help="Directory to scan") +@click.option( + "--installers", + "installers_path_alias", + type=click.Path(file_okay=False), + help="Alias for --path (matches other commands)", +) @click.option( "--version", "version_filter", @@ -50,12 +56,14 @@ def cli(ctx: click.Context, config_file: Optional[str], verbose: bool) -> None: def list_installers_cmd( ctx: click.Context, installers_path: Optional[str], + installers_path_alias: Optional[str], version_filter: Optional[str], ) -> None: """List every installer detected in the configured directory.""" loader: ConfigLoader = ctx.obj["config_loader"] - settings = loader.derive(installers_path=installers_path) + path_choice = installers_path_alias or installers_path + settings = loader.derive(installers_path=path_choice) from affinity_cli.commands.list_installers import run_list_installers diff --git a/pyproject.toml b/pyproject.toml index 0038af9..4399fdf 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "affinity-cli" -version = "1.1.0" +version = "1.1.1" description = "Universal CLI installer for Affinity products on Linux" readme = "README.md" requires-python = ">=3.8" @@ -34,7 +34,7 @@ dependencies = [ dev = [ "pytest>=7.0", "pytest-cov>=4.0", - "black>=22.0", + "black>=22.0,<24.0", "flake8>=5.0", "mypy>=0.990", ] diff --git a/setup.py b/setup.py index ee3a6fe..07a35d5 100644 --- a/setup.py +++ b/setup.py @@ -11,7 +11,7 @@ setup( name="affinity-cli", - version="1.1.0", + version="1.1.1", author="ind4skylivey", description="Universal CLI installer for Affinity products on Linux", long_description=long_description,