Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions affinity_cli/commands/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
"""Command implementations used by the CLI entrypoint."""

__all__ = ["status", "install", "list_installers"]
46 changes: 40 additions & 6 deletions affinity_cli/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@

# Project metadata ---------------------------------------------------------

VERSION = "1.1.0"
VERSION = "1.1.1"
APP_NAME = "Affinity CLI"

# Paths --------------------------------------------------------------------
Expand All @@ -31,7 +31,7 @@

# Installer discovery -------------------------------------------------------

INSTALLER_SUFFIXES = (".exe", ".msi")
INSTALLER_SUFFIXES = (".exe", ".msi", ".msix")
INSTALLER_NAME_PREFIX = "affinity"

# Affinity Products --------------------------------------------------------
Expand Down Expand Up @@ -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",
]
12 changes: 12 additions & 0 deletions affinity_cli/core/__init__.py
Original file line number Diff line number Diff line change
@@ -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",
]
56 changes: 46 additions & 10 deletions affinity_cli/core/config_loader.py
Original file line number Diff line number Diff line change
Expand Up @@ -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+
Expand Down Expand Up @@ -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",
Expand All @@ -58,31 +60,60 @@ 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,
*,
installers_path: Optional[str] = None,
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(
Expand All @@ -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
Expand Down
74 changes: 74 additions & 0 deletions affinity_cli/core/prefix_manager.py
Original file line number Diff line number Diff line change
@@ -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"]
4 changes: 2 additions & 2 deletions affinity_cli/installer_discovery.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
)

Expand Down Expand Up @@ -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(
Expand Down
10 changes: 9 additions & 1 deletion affinity_cli/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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

Expand Down
4 changes: 2 additions & 2 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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",
]
Expand Down
2 changes: 1 addition & 1 deletion setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Loading