Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
Comment thread
ind4skylivey marked this conversation as resolved.

__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
2 changes: 1 addition & 1 deletion 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
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