diff --git a/PKGBUILD b/PKGBUILD index 1bbb6f4..e9a4fe2 100644 --- a/PKGBUILD +++ b/PKGBUILD @@ -8,13 +8,14 @@ arch=('any') url="https://github.com/kevinveenbirkenbach/package-manager" license=('MIT') -# Nix is the only runtime dependency. depends=('nix') -makedepends=() +install=${pkgname}.install -source=() -sha256sums=() +source=('scripts/pkgmgr-wrapper.sh' + 'scripts/init-nix.sh') +sha256sums=('SKIP' + 'SKIP') build() { : @@ -22,19 +23,11 @@ build() { package() { install -d "$pkgdir/usr/bin" + install -d "$pkgdir/usr/lib/package-manager" - cat > "$pkgdir/usr/bin/pkgmgr" << 'EOF' -#!/usr/bin/env bash -set -euo pipefail + # Wrapper + install -m0755 "scripts/pkgmgr-wrapper.sh" "$pkgdir/usr/bin/pkgmgr" -# Enable flakes if not already configured. -if [[ -z "${NIX_CONFIG:-}" ]]; then - export NIX_CONFIG="experimental-features = nix-command flakes" -fi - -# Run package-manager via Nix flake -exec nix run "github:kevinveenbirkenbach/package-manager#pkgmgr" -- "$@" -EOF - - chmod 755 "$pkgdir/usr/bin/pkgmgr" + # Shared Nix init script + install -m0755 "scripts/init-nix.sh" "$pkgdir/usr/lib/package-manager/init-nix.sh" } diff --git a/debian/changelog b/debian/changelog new file mode 100644 index 0000000..46f287d --- /dev/null +++ b/debian/changelog @@ -0,0 +1,5 @@ +package-manager (0.1.1-1) unstable; urgency=medium + + * Initial release. + + -- Kevin Veen-Birkenbach Sat, 06 Dec 2025 16:30:00 +0100 diff --git a/debian/control b/debian/control new file mode 100644 index 0000000..43b8757 --- /dev/null +++ b/debian/control @@ -0,0 +1,18 @@ +Source: package-manager +Section: utils +Priority: optional +Maintainer: Kevin Veen-Birkenbach +Build-Depends: debhelper-compat (= 13) +Standards-Version: 4.6.2 +Rules-Requires-Root: no +Homepage: https://github.com/kevinveenbirkenbach/package-manager + +Package: package-manager +Architecture: any +Depends: nix, ${misc:Depends} +Description: Wrapper that runs Kevin's package-manager via Nix flake + This package provides the `pkgmgr` command, which runs Kevin's package + manager via a Nix flake: + nix run "github:kevinveenbirkenbach/package-manager#pkgmgr" -- ... + Nix is used as the only runtime dependency and must be initialized on + the system to work correctly. diff --git a/debian/postinst b/debian/postinst new file mode 100755 index 0000000..899b26c --- /dev/null +++ b/debian/postinst @@ -0,0 +1,14 @@ +#!/bin/sh +set -e + +case "$1" in + configure) + if [ -x /usr/lib/package-manager/init-nix.sh ]; then + /usr/lib/package-manager/init-nix.sh || true + else + echo ">>> Warning: /usr/lib/package-manager/init-nix.sh not found or not executable." + fi + ;; +esac + +exit 0 diff --git a/debian/rules b/debian/rules new file mode 100755 index 0000000..eeb72d1 --- /dev/null +++ b/debian/rules @@ -0,0 +1,10 @@ +#!/usr/bin/make -f + +%: + dh $@ + +override_dh_auto_install: + # Install wrapper + install -D -m0755 scripts/pkgmgr-wrapper.sh debian/package-manager/usr/bin/pkgmgr + # Install shared Nix init script + install -D -m0755 scripts/init-nix.sh debian/package-manager/usr/lib/package-manager/init-nix.sh diff --git a/package-manager.install b/package-manager.install new file mode 100644 index 0000000..0bdff27 --- /dev/null +++ b/package-manager.install @@ -0,0 +1,11 @@ +post_install() { + /usr/lib/package-manager/init-nix.sh || true +} + +post_upgrade() { + /usr/lib/package-manager/init-nix.sh || true +} + +post_remove() { + echo ">>> package-manager removed. Nix itself was not removed." +} diff --git a/package-manager.spec b/package-manager.spec new file mode 100644 index 0000000..7288dbe --- /dev/null +++ b/package-manager.spec @@ -0,0 +1,58 @@ +Name: package-manager +Version: 0.1.1 +Release: 1%{?dist} +Summary: Wrapper that runs Kevin's package-manager via Nix flake + +License: MIT +URL: https://github.com/kevinveenbirkenbach/package-manager +Source0: %{name}-%{version}.tar.gz + +BuildArch: noarch +Requires: nix + +%description +This package provides the `pkgmgr` command, which runs Kevin's package +manager via a Nix flake: + + nix run "github:kevinveenbirkenbach/package-manager#pkgmgr" -- ... + +Nix is the only runtime dependency and must be initialized on the +system to work correctly. + +%prep +%setup -q + +%build +# No build step required +: + +%install +rm -rf %{buildroot} +install -d %{buildroot}%{_bindir} +install -d %{buildroot}%{_libdir}/package-manager + +# Wrapper +install -m0755 scripts/pkgmgr-wrapper.sh %{buildroot}%{_bindir}/pkgmgr + +# Shared Nix init script +install -m0755 scripts/init-nix.sh %{buildroot}%{_libdir}/package-manager/init-nix.sh + +%post +if [ -x %{_libdir}/package-manager/init-nix.sh ]; then + %{_libdir}/package-manager/init-nix.sh || true +else + echo ">>> Warning: %{_libdir}/package-manager/init-nix.sh not found or not executable." +fi + +%postun +echo ">>> package-manager removed. Nix itself was not removed." + +%files +%doc README.md +%license LICENSE +%{_bindir}/pkgmgr +%{_libdir}/package-manager/init-nix.sh + +%changelog +* Sat Dec 06 2025 Kevin Veen-Birkenbach - 0.1.1-1 +- Initial RPM packaging for package-manager diff --git a/pkgmgr/install_repos.py b/pkgmgr/install_repos.py index 0c46669..10c45a9 100644 --- a/pkgmgr/install_repos.py +++ b/pkgmgr/install_repos.py @@ -30,23 +30,30 @@ from pkgmgr.context import RepoContext # Installer implementations from pkgmgr.installers.pkgmgr_manifest import PkgmgrManifestInstaller -from pkgmgr.installers.pkgbuild import PkgbuildInstaller +from pkgmgr.installers.os_packages import ( + ArchPkgbuildInstaller, + DebianControlInstaller, + RpmSpecInstaller, +) from pkgmgr.installers.nix_flake import NixFlakeInstaller -from pkgmgr.installers.ansible_requirements import AnsibleRequirementsInstaller from pkgmgr.installers.python import PythonInstaller from pkgmgr.installers.makefile import MakefileInstaller -from pkgmgr.installers.aur import AurInstaller -# Ordered list of installers to apply to each repository. +# Layering: +# 1) pkgmgr.yml (high-level repo dependencies) +# 2) OS packages: PKGBUILD / debian/control / RPM spec +# 3) Nix flakes (flake.nix) +# 4) Python (pyproject / requirements) +# 5) Makefile fallback INSTALLERS = [ - PkgmgrManifestInstaller(), - PkgbuildInstaller(), - NixFlakeInstaller(), - AnsibleRequirementsInstaller(), - PythonInstaller(), - MakefileInstaller(), - AurInstaller(), + PkgmgrManifestInstaller(), # meta/pkgmgr.yml deps + ArchPkgbuildInstaller(), # Arch + DebianControlInstaller(), # Debian/Ubuntu + RpmSpecInstaller(), # Fedora/RHEL/CentOS + NixFlakeInstaller(), # 2) flake.nix (Nix layer) + PythonInstaller(), # 3) pyproject / requirements (fallback if no flake+nix) + MakefileInstaller(), # generic 'make install' ] diff --git a/pkgmgr/installers/__init__.py b/pkgmgr/installers/__init__.py index 54fcb2a..e0126bc 100644 --- a/pkgmgr/installers/__init__.py +++ b/pkgmgr/installers/__init__.py @@ -4,16 +4,17 @@ """ Installer package for pkgmgr. -Each installer implements a small, focused step in the repository -installation pipeline (e.g. PKGBUILD dependencies, Nix flakes, Python, -Ansible requirements, pkgmgr.yml, Makefile, AUR). +This exposes all installer classes so users can import them directly from +pkgmgr.installers. """ from pkgmgr.installers.base import BaseInstaller # noqa: F401 from pkgmgr.installers.pkgmgr_manifest import PkgmgrManifestInstaller # noqa: F401 -from pkgmgr.installers.pkgbuild import PkgbuildInstaller # noqa: F401 from pkgmgr.installers.nix_flake import NixFlakeInstaller # noqa: F401 -from pkgmgr.installers.ansible_requirements import AnsibleRequirementsInstaller # noqa: F401 from pkgmgr.installers.python import PythonInstaller # noqa: F401 from pkgmgr.installers.makefile import MakefileInstaller # noqa: F401 -from pkgmgr.installers.aur import AurInstaller # noqa: F401 + +# OS-specific installers +from pkgmgr.installers.os_packages.arch_pkgbuild import ArchPkgbuildInstaller # noqa: F401 +from pkgmgr.installers.os_packages.debian_control import DebianControlInstaller # noqa: F401 +from pkgmgr.installers.os_packages.rpm_spec import RpmSpecInstaller # noqa: F401 diff --git a/pkgmgr/installers/ansible_requirements.py b/pkgmgr/installers/ansible_requirements.py deleted file mode 100644 index cfb23b1..0000000 --- a/pkgmgr/installers/ansible_requirements.py +++ /dev/null @@ -1,175 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- - -""" -Installer for Ansible dependencies defined in requirements.yml. - -This installer installs collections and roles via ansible-galaxy when found. -""" - -import os -import shutil -import tempfile -from typing import Any, Dict, List - -import yaml - -from pkgmgr.context import RepoContext -from pkgmgr.installers.base import BaseInstaller -from pkgmgr.run_command import run_command - - -class AnsibleRequirementsInstaller(BaseInstaller): - """Install Ansible collections and roles from requirements.yml.""" - - REQUIREMENTS_FILE = "requirements.yml" - - def supports(self, ctx: RepoContext) -> bool: - req_file = os.path.join(ctx.repo_dir, self.REQUIREMENTS_FILE) - return os.path.exists(req_file) - - def _get_ansible_galaxy_cmd(self) -> str: - """ - Resolve how to call ansible-galaxy: - - 1. If ansible-galaxy is on PATH, use it directly. - 2. Else, if nix is available, run it via Nix: - nix --extra-experimental-features 'nix-command flakes' \ - run nixpkgs#ansible-core -- ansible-galaxy - 3. If neither is available, return an empty string. - """ - if shutil.which("ansible-galaxy"): - return "ansible-galaxy" - - if shutil.which("nix"): - # Use Nix as the preferred provider - return ( - "nix --extra-experimental-features 'nix-command flakes' " - "run nixpkgs#ansible-core -- ansible-galaxy" - ) - - return "" - - def _load_requirements(self, req_path: str, identifier: str) -> Dict[str, Any]: - """ - Load requirements.yml. - - Any parsing error is treated as fatal (SystemExit). - """ - try: - with open(req_path, "r", encoding="utf-8") as f: - return yaml.safe_load(f) or {} - except Exception as exc: - print(f"Error loading {self.REQUIREMENTS_FILE} in {identifier}: {exc}") - raise SystemExit( - f"{self.REQUIREMENTS_FILE} parsing failed for {identifier}: {exc}" - ) - - def _validate_requirements(self, requirements: Dict[str, Any], identifier: str) -> None: - """ - Validate the requirements.yml structure. - - Raises SystemExit on any validation error. - """ - errors: List[str] = [] - - if not isinstance(requirements, dict): - errors.append("Top-level structure must be a mapping.") - else: - allowed_keys = {"collections", "roles"} - unknown_keys = set(requirements.keys()) - allowed_keys - if unknown_keys: - print( - f"Warning: requirements.yml in {identifier} contains unknown keys: " - f"{', '.join(sorted(unknown_keys))}" - ) - - for section in ("collections", "roles"): - if section not in requirements: - continue - - value = requirements[section] - if not isinstance(value, list): - errors.append(f"'{section}' must be a list.") - continue - - for idx, entry in enumerate(value): - if isinstance(entry, str): - # Short form "community.docker", etc. - continue - - if isinstance(entry, dict): - if section == "collections": - # Collections require 'name' - if not entry.get("name"): - errors.append( - f"Entry #{idx} in '{section}' is a mapping " - f"but has no 'name' key." - ) - else: - # Roles: 'name' OR 'src' are acceptable. - if not (entry.get("name") or entry.get("src")): - errors.append( - f"Entry #{idx} in '{section}' is a mapping but " - f"has neither 'name' nor 'src' key." - ) - continue - - errors.append( - f"Entry #{idx} in '{section}' has invalid type " - f"{type(entry).__name__}; expected string or mapping." - ) - - if errors: - print(f"Invalid requirements.yml in {identifier}:") - for err in errors: - print(f" - {err}") - raise SystemExit( - f"requirements.yml validation failed for {identifier}." - ) - - def run(self, ctx: RepoContext) -> None: - req_file = os.path.join(ctx.repo_dir, self.REQUIREMENTS_FILE) - requirements = self._load_requirements(req_file, ctx.identifier) - if not requirements: - return - - # Validate structure before doing anything dangerous. - self._validate_requirements(requirements, ctx.identifier) - - if "collections" not in requirements and "roles" not in requirements: - return - - print(f"Ansible dependencies found in {ctx.identifier}, installing...") - - ansible_requirements: Dict[str, Any] = {} - if "collections" in requirements: - ansible_requirements["collections"] = requirements["collections"] - if "roles" in requirements: - ansible_requirements["roles"] = requirements["roles"] - - with tempfile.NamedTemporaryFile( - mode="w", - suffix=".yml", - delete=False, - ) as tmp: - yaml.dump(ansible_requirements, tmp, default_flow_style=False) - tmp_filename = tmp.name - - galaxy_cmd = self._get_ansible_galaxy_cmd() - if not galaxy_cmd: - print( - "Warning: ansible-galaxy is not available and 'nix' is missing. " - "Skipping Ansible requirements installation." - ) - return - - if "collections" in ansible_requirements: - print(f"Ansible collections found in {ctx.identifier}, installing...") - cmd = f"{galaxy_cmd} collection install -r {tmp_filename}" - run_command(cmd, cwd=ctx.repo_dir, preview=ctx.preview) - - if "roles" in ansible_requirements: - print(f"Ansible roles found in {ctx.identifier}, installing...") - cmd = f"{galaxy_cmd} role install -r {tmp_filename}" - run_command(cmd, cwd=ctx.repo_dir, preview=ctx.preview) diff --git a/pkgmgr/installers/aur.py b/pkgmgr/installers/aur.py deleted file mode 100644 index a8cb362..0000000 --- a/pkgmgr/installers/aur.py +++ /dev/null @@ -1,150 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- - -""" -Installer for Arch AUR dependencies declared in an `aur.yml` file. - -This installer is: - - Arch-only (requires `pacman`) - - helper-driven (yay/paru/..) - - safe to ignore on non-Arch systems - -Config parsing errors are treated as fatal to avoid silently ignoring -broken configuration. -""" - -import os -import shutil -from typing import List - -import yaml - -from pkgmgr.installers.base import BaseInstaller -from pkgmgr.context import RepoContext -from pkgmgr.run_command import run_command - - -AUR_CONFIG_FILENAME = "aur.yml" - - -class AurInstaller(BaseInstaller): - """ - Installer for Arch AUR dependencies declared in an `aur.yml` file. - """ - - def _is_arch_like(self) -> bool: - return shutil.which("pacman") is not None - - def _config_path(self, ctx: RepoContext) -> str: - return os.path.join(ctx.repo_dir, AUR_CONFIG_FILENAME) - - def _load_config(self, ctx: RepoContext) -> dict: - """ - Load and validate aur.yml. - - Any parsing error or invalid top-level structure is treated as fatal - (SystemExit). - """ - path = self._config_path(ctx) - if not os.path.exists(path): - return {} - - try: - with open(path, "r", encoding="utf-8") as f: - data = yaml.safe_load(f) or {} - except Exception as exc: - print(f"[Error] Failed to load AUR config from '{path}': {exc}") - raise SystemExit(f"AUR config '{path}' could not be parsed: {exc}") - - if not isinstance(data, dict): - print(f"[Error] AUR config '{path}' is not a mapping.") - raise SystemExit(f"AUR config '{path}' must be a mapping at top level.") - - return data - - def _get_helper(self, cfg: dict) -> str: - # Priority: config.helper > $AUR_HELPER > "yay" - helper = cfg.get("helper") - if isinstance(helper, str) and helper.strip(): - return helper.strip() - - env_helper = os.environ.get("AUR_HELPER") - if env_helper: - return env_helper.strip() - - return "yay" - - def _get_packages(self, cfg: dict) -> List[str]: - raw = cfg.get("packages", []) - if not isinstance(raw, list): - return [] - - names: List[str] = [] - for entry in raw: - if isinstance(entry, str): - name = entry.strip() - if name: - names.append(name) - elif isinstance(entry, dict): - name = str(entry.get("name", "")).strip() - if name: - names.append(name) - - return names - - # --- BaseInstaller API ------------------------------------------------- - - def supports(self, ctx: RepoContext) -> bool: - """ - This installer is supported if: - - We are on an Arch-like system (pacman available), - - An aur.yml exists, - - That aur.yml declares at least one package. - - An invalid aur.yml will raise SystemExit during config loading. - """ - if not self._is_arch_like(): - return False - - cfg = self._load_config(ctx) - if not cfg: - return False - - packages = self._get_packages(cfg) - return len(packages) > 0 - - def run(self, ctx: RepoContext) -> None: - """ - Install AUR packages using the configured helper (default: yay). - - Missing helper is treated as non-fatal (warning), everything else - that fails in run_command() is fatal. - """ - if not self._is_arch_like(): - print("AUR installer skipped: not an Arch-like system.") - return - - cfg = self._load_config(ctx) - if not cfg: - print("AUR installer: no valid aur.yml found; skipping.") - return - - packages = self._get_packages(cfg) - if not packages: - print("AUR installer: no AUR packages defined; skipping.") - return - - helper = self._get_helper(cfg) - if shutil.which(helper) is None: - print( - f"[Warning] AUR helper '{helper}' is not available on PATH. " - f"Please install it (e.g. via your aur_builder setup). " - f"Skipping AUR installation." - ) - return - - pkg_list_str = " ".join(packages) - print(f"Installing AUR packages via '{helper}': {pkg_list_str}") - - cmd = f"{helper} -S --noconfirm {pkg_list_str}" - run_command(cmd, preview=ctx.preview) diff --git a/pkgmgr/installers/os_packages/__init__.py b/pkgmgr/installers/os_packages/__init__.py new file mode 100644 index 0000000..298a489 --- /dev/null +++ b/pkgmgr/installers/os_packages/__init__.py @@ -0,0 +1,9 @@ +from .arch_pkgbuild import ArchPkgbuildInstaller +from .debian_control import DebianControlInstaller +from .rpm_spec import RpmSpecInstaller + +__all__ = [ + "ArchPkgbuildInstaller", + "DebianControlInstaller", + "RpmSpecInstaller", +] diff --git a/pkgmgr/installers/pkgbuild.py b/pkgmgr/installers/os_packages/arch_pkgbuild.py similarity index 81% rename from pkgmgr/installers/pkgbuild.py rename to pkgmgr/installers/os_packages/arch_pkgbuild.py index 5166eb5..f4040fe 100644 --- a/pkgmgr/installers/pkgbuild.py +++ b/pkgmgr/installers/os_packages/arch_pkgbuild.py @@ -18,12 +18,17 @@ from pkgmgr.installers.base import BaseInstaller from pkgmgr.run_command import run_command -class PkgbuildInstaller(BaseInstaller): +class ArchPkgbuildInstaller(BaseInstaller): """Install Arch dependencies (depends/makedepends) from PKGBUILD.""" PKGBUILD_NAME = "PKGBUILD" def supports(self, ctx: RepoContext) -> bool: + """ + This installer is supported if: + - pacman is available, and + - a PKGBUILD file exists in the repository root. + """ if shutil.which("pacman") is None: return False pkgbuild_path = os.path.join(ctx.repo_dir, self.PKGBUILD_NAME) @@ -39,7 +44,10 @@ class PkgbuildInstaller(BaseInstaller): if not os.path.exists(pkgbuild_path): return [] - script = f'source {self.PKGBUILD_NAME} >/dev/null 2>&1; printf "%s\\n" "${{{var_name}[@]}}"' + script = ( + f'source {self.PKGBUILD_NAME} >/dev/null 2>&1; ' + f'printf "%s\\n" "${{{var_name}[@]}}"' + ) try: output = subprocess.check_output( ["bash", "--noprofile", "--norc", "-c", script], @@ -64,6 +72,9 @@ class PkgbuildInstaller(BaseInstaller): return packages def run(self, ctx: RepoContext) -> None: + """ + Install all packages from depends + makedepends via pacman. + """ depends = self._extract_pkgbuild_array(ctx, "depends") makedepends = self._extract_pkgbuild_array(ctx, "makedepends") all_pkgs = depends + makedepends @@ -72,4 +83,4 @@ class PkgbuildInstaller(BaseInstaller): return cmd = "sudo pacman -S --noconfirm " + " ".join(all_pkgs) - run_command(cmd, preview=ctx.preview) + run_command(cmd, cwd=ctx.repo_dir, preview=ctx.preview) diff --git a/pkgmgr/installers/os_packages/debian_control.py b/pkgmgr/installers/os_packages/debian_control.py new file mode 100644 index 0000000..49249b4 --- /dev/null +++ b/pkgmgr/installers/os_packages/debian_control.py @@ -0,0 +1,141 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- + +""" +Installer for Debian/Ubuntu system dependencies defined in debian/control. + +This installer parses the debian/control file and installs packages from +Build-Depends / Build-Depends-Indep / Depends via apt-get on Debian-based +systems. +""" + +import os +import shutil +from typing import List + +from pkgmgr.context import RepoContext +from pkgmgr.installers.base import BaseInstaller +from pkgmgr.run_command import run_command + + +class DebianControlInstaller(BaseInstaller): + """Install Debian/Ubuntu system packages from debian/control.""" + + CONTROL_DIR = "debian" + CONTROL_FILE = "control" + + def _is_debian_like(self) -> bool: + return shutil.which("apt-get") is not None + + def _control_path(self, ctx: RepoContext) -> str: + return os.path.join(ctx.repo_dir, self.CONTROL_DIR, self.CONTROL_FILE) + + def supports(self, ctx: RepoContext) -> bool: + """ + This installer is supported if: + - we are on a Debian-like system (apt-get available), and + - debian/control exists. + """ + if not self._is_debian_like(): + return False + + return os.path.exists(self._control_path(ctx)) + + def _parse_control_dependencies(self, control_path: str) -> List[str]: + """ + Parse Build-Depends, Build-Depends-Indep and Depends fields + from debian/control. + + This is a best-effort parser that: + - joins continuation lines starting with space, + - splits fields by comma, + - strips version constraints and alternatives (x | y → x), + - filters out variable placeholders like ${misc:Depends}. + """ + if not os.path.exists(control_path): + return [] + + with open(control_path, "r", encoding="utf-8") as f: + lines = f.readlines() + + deps: List[str] = [] + current_key = None + current_val_lines: List[str] = [] + + target_keys = { + "Build-Depends", + "Build-Depends-Indep", + "Depends", + } + + def flush_current(): + nonlocal current_key, current_val_lines, deps + if not current_key or not current_val_lines: + return + value = " ".join(l.strip() for l in current_val_lines) + # Split by comma into individual dependency expressions + for part in value.split(","): + part = part.strip() + if not part: + continue + # Take the first alternative: "foo | bar" → "foo" + if "|" in part: + part = part.split("|", 1)[0].strip() + # Strip version constraints: "pkg (>= 1.0)" → "pkg" + if " " in part: + part = part.split(" ", 1)[0].strip() + # Skip variable placeholders + if part.startswith("${") and part.endswith("}"): + continue + if part: + deps.append(part) + current_key = None + current_val_lines = [] + + for line in lines: + if line.startswith(" ") or line.startswith("\t"): + # Continuation of previous field + if current_key in target_keys: + current_val_lines.append(line) + continue + + # New field + flush_current() + + if ":" not in line: + continue + key, val = line.split(":", 1) + key = key.strip() + val = val.strip() + + if key in target_keys: + current_key = key + current_val_lines = [val] + + # Flush last field + flush_current() + + # De-duplicate while preserving order + seen = set() + unique_deps: List[str] = [] + for pkg in deps: + if pkg not in seen: + seen.add(pkg) + unique_deps.append(pkg) + + return unique_deps + + def run(self, ctx: RepoContext) -> None: + """ + Install Debian/Ubuntu system packages via apt-get. + """ + control_path = self._control_path(ctx) + packages = self._parse_control_dependencies(control_path) + if not packages: + return + + # Update and install in two separate commands for clarity. + run_command("sudo apt-get update", cwd=ctx.repo_dir, preview=ctx.preview) + + cmd = "sudo apt-get install -y " + " ".join(packages) + run_command(cmd, cwd=ctx.repo_dir, preview=ctx.preview) diff --git a/pkgmgr/installers/os_packages/rpm_spec.py b/pkgmgr/installers/os_packages/rpm_spec.py new file mode 100644 index 0000000..ef47575 --- /dev/null +++ b/pkgmgr/installers/os_packages/rpm_spec.py @@ -0,0 +1,152 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- + +""" +Installer for RPM-based system dependencies defined in *.spec files. + +This installer parses the first *.spec file it finds in the repository +and installs packages from BuildRequires / Requires via dnf or yum on +RPM-based systems (Fedora / RHEL / CentOS / Rocky / Alma, etc.). +""" + +import glob +import os +import shutil +from typing import List, Optional + +from pkgmgr.context import RepoContext +from pkgmgr.installers.base import BaseInstaller +from pkgmgr.run_command import run_command + + +class RpmSpecInstaller(BaseInstaller): + """Install RPM-based system packages from *.spec files.""" + + def _is_rpm_like(self) -> bool: + return shutil.which("dnf") is not None or shutil.which("yum") is not None + + def _spec_path(self, ctx: RepoContext) -> Optional[str]: + pattern = os.path.join(ctx.repo_dir, "*.spec") + matches = glob.glob(pattern) + if not matches: + return None + # Take the first match deterministically (sorted) + return sorted(matches)[0] + + def supports(self, ctx: RepoContext) -> bool: + """ + This installer is supported if: + - we are on an RPM-based system (dnf or yum available), and + - a *.spec file exists in the repository root. + """ + if not self._is_rpm_like(): + return False + + return self._spec_path(ctx) is not None + + def _parse_spec_dependencies(self, spec_path: str) -> List[str]: + """ + Parse BuildRequires and Requires from a .spec file. + + Best-effort parser that: + - joins continuation lines starting with space or tab, + - splits fields by comma, + - takes the first token of each entry as the package name, + - ignores macros and empty entries. + """ + if not os.path.exists(spec_path): + return [] + + with open(spec_path, "r", encoding="utf-8") as f: + lines = f.readlines() + + deps: List[str] = [] + current_key = None + current_val_lines: List[str] = [] + + target_keys = { + "BuildRequires", + "Requires", + } + + def flush_current(): + nonlocal current_key, current_val_lines, deps + if not current_key or not current_val_lines: + return + value = " ".join(l.strip() for l in current_val_lines) + # Split by comma into individual dependency expressions + for part in value.split(","): + part = part.strip() + if not part: + continue + # Take first token as package name: "pkg >= 1.0" → "pkg" + token = part.split()[0].strip() + if not token: + continue + # Ignore macros like %{?something} + if token.startswith("%"): + continue + deps.append(token) + current_key = None + current_val_lines = [] + + for line in lines: + stripped = line.lstrip() + if stripped.startswith("#"): + # Comment + continue + + if line.startswith(" ") or line.startswith("\t"): + # Continuation of previous field + if current_key in target_keys: + current_val_lines.append(line) + continue + + # New field + flush_current() + + if ":" not in line: + continue + key, val = line.split(":", 1) + key = key.strip() + val = val.strip() + + if key in target_keys: + current_key = key + current_val_lines = [val] + + # Flush last field + flush_current() + + # De-duplicate while preserving order + seen = set() + unique_deps: List[str] = [] + for pkg in deps: + if pkg not in seen: + seen.add(pkg) + unique_deps.append(pkg) + + return unique_deps + + def run(self, ctx: RepoContext) -> None: + """ + Install RPM-based system packages via dnf or yum. + """ + spec_path = self._spec_path(ctx) + if not spec_path: + return + + packages = self._parse_spec_dependencies(spec_path) + if not packages: + return + + pkg_mgr = shutil.which("dnf") or shutil.which("yum") + if not pkg_mgr: + print( + "[Warning] No suitable RPM package manager (dnf/yum) found on PATH. " + "Skipping RPM dependency installation." + ) + return + + cmd = f"sudo {pkg_mgr} install -y " + " ".join(packages) + run_command(cmd, cwd=ctx.repo_dir, preview=ctx.preview) diff --git a/scripts/init-nix.sh b/scripts/init-nix.sh new file mode 100644 index 0000000..b092054 --- /dev/null +++ b/scripts/init-nix.sh @@ -0,0 +1,48 @@ +#!/usr/bin/env bash +set -euo pipefail + +echo ">>> Initializing Nix environment for package-manager..." + +# 1. /nix Store +if [ ! -d /nix ]; then + echo ">>> Creating /nix store directory" + mkdir -m 0755 /nix + chown root:root /nix +fi + +# 2. nix-daemon aktivieren, falls vorhanden +if command -v systemctl >/dev/null 2>&1 && systemctl list-unit-files | grep -q nix-daemon.service; then + echo ">>> Enabling nix-daemon.service" + systemctl enable --now nix-daemon.service 2>/dev/null || true +else + echo ">>> Warning: nix-daemon.service not found or systemctl not available." +fi + +# 3. Gruppe nix-users sicherstellen +if ! getent group nix-users >/dev/null 2>&1; then + echo ">>> Creating nix-users group" + # Debian/RPM/Arch haben alle groupadd + groupadd -r nix-users 2>/dev/null || true +fi + +# 4. Benutzer zu nix-users hinzufügen (Best-Effort) + +# a) Wenn loginctl vorhanden ist → alle eingeloggten User +if command -v loginctl >/dev/null 2>&1; then + for user in $(loginctl list-users | awk 'NR>1 {print $2}'); do + if id "$user" >/dev/null 2>&1; then + echo ">>> Adding user '$user' to nix-users" + usermod -aG nix-users "$user" 2>/dev/null || true + fi + done +# b) Fallback: logname (typisch bei Debian) +elif command -v logname >/dev/null 2>&1; then + USERNAME="$(logname 2>/dev/null || true)" + if [ -n "$USERNAME" ] && id "$USERNAME" >/dev/null 2>&1; then + echo ">>> Adding user '$USERNAME' to nix-users" + usermod -aG nix-users "$USERNAME" 2>/dev/null || true + fi +fi + +echo ">>> Nix initialization for package-manager complete." +echo ">>> You may need to log out and log back in to activate group membership." diff --git a/scripts/pkgmgr-wrapper.sh b/scripts/pkgmgr-wrapper.sh new file mode 100644 index 0000000..bed6ef5 --- /dev/null +++ b/scripts/pkgmgr-wrapper.sh @@ -0,0 +1,10 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Enable flakes if not already configured. +if [[ -z "${NIX_CONFIG:-}" ]]; then + export NIX_CONFIG="experimental-features = nix-command flakes" +fi + +# Run Kevin’s package manager via Nix flake +exec nix run "github:kevinveenbirkenbach/package-manager#pkgmgr" -- "$@" diff --git a/tests/unit/pkgmgr/installers/os_packages/__init__.py b/tests/unit/pkgmgr/installers/os_packages/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/unit/pkgmgr/installers/test_pkgbuild.py b/tests/unit/pkgmgr/installers/os_packages/test_arch_pkgbuild.py similarity index 83% rename from tests/unit/pkgmgr/installers/test_pkgbuild.py rename to tests/unit/pkgmgr/installers/os_packages/test_arch_pkgbuild.py index 30cb13d..e0a586a 100644 --- a/tests/unit/pkgmgr/installers/test_pkgbuild.py +++ b/tests/unit/pkgmgr/installers/os_packages/test_arch_pkgbuild.py @@ -1,14 +1,14 @@ -# tests/unit/pkgmgr/installers/test_pkgbuild.py +# tests/unit/pkgmgr/installers/os_packages/test_arch_pkgbuild.py import os import unittest from unittest.mock import patch from pkgmgr.context import RepoContext -from pkgmgr.installers.pkgbuild import PkgbuildInstaller +from pkgmgr.installers.os_packages.arch_pkgbuild import ArchPkgbuildInstaller -class TestPkgbuildInstaller(unittest.TestCase): +class TestArchPkgbuildInstaller(unittest.TestCase): def setUp(self): self.repo = {"name": "test-repo"} self.ctx = RepoContext( @@ -24,7 +24,7 @@ class TestPkgbuildInstaller(unittest.TestCase): clone_mode="ssh", update_dependencies=False, ) - self.installer = PkgbuildInstaller() + self.installer = ArchPkgbuildInstaller() @patch("os.path.exists", return_value=True) @patch("shutil.which", return_value="/usr/bin/pacman") @@ -38,7 +38,7 @@ class TestPkgbuildInstaller(unittest.TestCase): def test_supports_false_when_pkgbuild_missing(self, mock_which, mock_exists): self.assertFalse(self.installer.supports(self.ctx)) - @patch("pkgmgr.installers.pkgbuild.run_command") + @patch("pkgmgr.installers.os_packages.arch_pkgbuild.run_command") @patch("subprocess.check_output", return_value="python\ngit\n") @patch("os.path.exists", return_value=True) @patch("shutil.which", return_value="/usr/bin/pacman") @@ -47,14 +47,14 @@ class TestPkgbuildInstaller(unittest.TestCase): ): self.installer.run(self.ctx) - # Check subprocess.check_output arguments (clean shell) + # subprocess.check_output call args, kwargs = mock_check_output.call_args cmd_list = args[0] self.assertEqual(cmd_list[0], "bash") self.assertIn("--noprofile", cmd_list) self.assertIn("--norc", cmd_list) - # Check that pacman is called with the extracted packages + # pacman install command cmd = mock_run_command.call_args[0][0] self.assertTrue(cmd.startswith("sudo pacman -S --noconfirm ")) self.assertIn("python", cmd) diff --git a/tests/unit/pkgmgr/installers/os_packages/test_debian_control.py b/tests/unit/pkgmgr/installers/os_packages/test_debian_control.py new file mode 100644 index 0000000..af079fe --- /dev/null +++ b/tests/unit/pkgmgr/installers/os_packages/test_debian_control.py @@ -0,0 +1,66 @@ +# tests/unit/pkgmgr/installers/os_packages/test_debian_control.py + +import unittest +from unittest.mock import patch, mock_open + +from pkgmgr.context import RepoContext +from pkgmgr.installers.os_packages.debian_control import DebianControlInstaller + + +class TestDebianControlInstaller(unittest.TestCase): + def setUp(self): + self.repo = {"name": "repo"} + self.ctx = RepoContext( + repo=self.repo, + identifier="id", + repo_dir="/tmp/repo", + repositories_base_dir="/tmp", + bin_dir="/bin", + all_repos=[self.repo], + no_verification=False, + preview=False, + quiet=False, + clone_mode="ssh", + update_dependencies=False, + ) + self.installer = DebianControlInstaller() + + @patch("os.path.exists", return_value=True) + @patch("shutil.which", return_value="/usr/bin/apt-get") + def test_supports_true(self, mock_which, mock_exists): + self.assertTrue(self.installer.supports(self.ctx)) + + @patch("os.path.exists", return_value=True) + @patch("shutil.which", return_value=None) + def test_supports_false_without_apt(self, mock_which, mock_exists): + self.assertFalse(self.installer.supports(self.ctx)) + + @patch("pkgmgr.installers.os_packages.debian_control.run_command") + @patch("builtins.open", new_callable=mock_open, read_data=""" +Build-Depends: python3, git (>= 2.0) +Depends: curl | wget +""") + @patch("os.path.exists", return_value=True) + @patch("shutil.which", return_value="/usr/bin/apt-get") + def test_run_installs_parsed_packages( + self, + mock_which, + mock_exists, + mock_file, + mock_run_command + ): + self.installer.run(self.ctx) + + # First call: apt-get update + self.assertIn("apt-get update", mock_run_command.call_args_list[0][0][0]) + + # Second call: install packages + install_cmd = mock_run_command.call_args_list[1][0][0] + self.assertIn("apt-get install -y", install_cmd) + self.assertIn("python3", install_cmd) + self.assertIn("git", install_cmd) + self.assertIn("curl", install_cmd) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/unit/pkgmgr/installers/os_packages/test_rpm_spec.py b/tests/unit/pkgmgr/installers/os_packages/test_rpm_spec.py new file mode 100644 index 0000000..467ce9b --- /dev/null +++ b/tests/unit/pkgmgr/installers/os_packages/test_rpm_spec.py @@ -0,0 +1,60 @@ +# tests/unit/pkgmgr/installers/os_packages/test_rpm_spec.py + +import unittest +from unittest.mock import patch, mock_open + +from pkgmgr.context import RepoContext +from pkgmgr.installers.os_packages.rpm_spec import RpmSpecInstaller + + +class TestRpmSpecInstaller(unittest.TestCase): + def setUp(self): + self.repo = {"name": "repo"} + self.ctx = RepoContext( + repo=self.repo, + identifier="id", + repo_dir="/tmp/repo", + repositories_base_dir="/tmp", + bin_dir="/bin", + all_repos=[self.repo], + no_verification=False, + preview=False, + quiet=False, + clone_mode="ssh", + update_dependencies=False, + ) + self.installer = RpmSpecInstaller() + + @patch("glob.glob", return_value=["/tmp/repo/test.spec"]) + @patch("shutil.which", return_value="/usr/bin/dnf") + def test_supports_true(self, mock_which, mock_glob): + self.assertTrue(self.installer.supports(self.ctx)) + + @patch("glob.glob", return_value=[]) + @patch("shutil.which", return_value="/usr/bin/dnf") + def test_supports_false_missing_spec(self, mock_which, mock_glob): + self.assertFalse(self.installer.supports(self.ctx)) + + @patch("pkgmgr.installers.os_packages.rpm_spec.run_command") + @patch("builtins.open", new_callable=mock_open, read_data=""" +BuildRequires: python3-devel, git >= 2.0 +Requires: curl +""") + @patch("glob.glob", return_value=["/tmp/repo/test.spec"]) + @patch("shutil.which", return_value="/usr/bin/dnf") + @patch("os.path.exists", return_value=True) + def test_run_installs_parsed_dependencies( + self, mock_exists, mock_which, mock_glob, mock_file, mock_run_command + ): + self.installer.run(self.ctx) + + install_cmd = mock_run_command.call_args_list[0][0][0] + + self.assertIn("dnf install -y", install_cmd) + self.assertIn("python3-devel", install_cmd) + self.assertIn("git", install_cmd) + self.assertIn("curl", install_cmd) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/unit/pkgmgr/installers/test_ansible_requirements.py b/tests/unit/pkgmgr/installers/test_ansible_requirements.py deleted file mode 100644 index 174329e..0000000 --- a/tests/unit/pkgmgr/installers/test_ansible_requirements.py +++ /dev/null @@ -1,168 +0,0 @@ -# tests/unit/pkgmgr/installers/test_ansible_requirements.py - -import os -import unittest -from unittest.mock import patch, mock_open - -from pkgmgr.context import RepoContext -from pkgmgr.installers.ansible_requirements import AnsibleRequirementsInstaller - - -class TestAnsibleRequirementsInstaller(unittest.TestCase): - def setUp(self): - self.repo = {"name": "test-repo"} - self.ctx = RepoContext( - repo=self.repo, - identifier="test-id", - repo_dir="/tmp/repo", - repositories_base_dir="/tmp", - bin_dir="/bin", - all_repos=[self.repo], - no_verification=False, - preview=False, - quiet=False, - clone_mode="ssh", - update_dependencies=False, - ) - self.installer = AnsibleRequirementsInstaller() - - @patch("os.path.exists", return_value=True) - def test_supports_true_when_requirements_exist(self, mock_exists): - self.assertTrue(self.installer.supports(self.ctx)) - mock_exists.assert_called_with(os.path.join(self.ctx.repo_dir, "requirements.yml")) - - @patch("os.path.exists", return_value=False) - def test_supports_false_when_requirements_missing(self, mock_exists): - self.assertFalse(self.installer.supports(self.ctx)) - - @patch("pkgmgr.installers.ansible_requirements.run_command") - @patch("tempfile.NamedTemporaryFile") - @patch( - "builtins.open", - new_callable=mock_open, - read_data=""" -collections: - - name: community.docker -roles: - - src: geerlingguy.docker -""", - ) - @patch("os.path.exists", return_value=True) - def test_run_installs_collections_and_roles( - self, mock_exists, mock_file, mock_tmp, mock_run_command - ): - # Fake temp file name - mock_tmp().__enter__().name = "/tmp/req.yml" - - self.installer.run(self.ctx) - - cmds = [call[0][0] for call in mock_run_command.call_args_list] - self.assertIn( - "ansible-galaxy collection install -r /tmp/req.yml", - cmds, - ) - self.assertIn( - "ansible-galaxy role install -r /tmp/req.yml", - cmds, - ) - - # --- Neue Tests für den Validator ------------------------------------- - - @patch("pkgmgr.installers.ansible_requirements.run_command") - @patch( - "builtins.open", - new_callable=mock_open, - read_data=""" -- not: - - a: mapping -""", - ) - @patch("os.path.exists", return_value=True) - def test_run_raises_when_top_level_is_not_mapping( - self, mock_exists, mock_file, mock_run_command - ): - # YAML ist eine Liste -> Validator soll fehlschlagen - with self.assertRaises(SystemExit): - self.installer.run(self.ctx) - - mock_run_command.assert_not_called() - - @patch("pkgmgr.installers.ansible_requirements.run_command") - @patch( - "builtins.open", - new_callable=mock_open, - read_data=""" -collections: community.docker -roles: - - src: geerlingguy.docker -""", - ) - @patch("os.path.exists", return_value=True) - def test_run_raises_when_collections_is_not_list( - self, mock_exists, mock_file, mock_run_command - ): - # collections ist ein String statt Liste -> invalid - with self.assertRaises(SystemExit): - self.installer.run(self.ctx) - - mock_run_command.assert_not_called() - - @patch("pkgmgr.installers.ansible_requirements.run_command") - @patch( - "builtins.open", - new_callable=mock_open, - read_data=""" -collections: - - name: community.docker -roles: - - version: "latest" -""", - ) - @patch("os.path.exists", return_value=True) - def test_run_raises_when_role_mapping_has_no_name( - self, mock_exists, mock_file, mock_run_command - ): - # roles-Eintrag ist Mapping ohne 'name' -> invalid - with self.assertRaises(SystemExit): - self.installer.run(self.ctx) - - mock_run_command.assert_not_called() - - @patch("pkgmgr.installers.ansible_requirements.run_command") - @patch("tempfile.NamedTemporaryFile") - @patch( - "builtins.open", - new_callable=mock_open, - read_data=""" -collections: - - name: community.docker -extra_key: should_be_ignored_but_warned -""", - ) - @patch("os.path.exists", return_value=True) - def test_run_accepts_unknown_top_level_keys( - self, mock_exists, mock_file, mock_tmp, mock_run_command - ): - """ - Unknown top-level keys (z.B. 'extra_key') sollen nur eine Warnung - auslösen, aber keine Validation-Exception. - """ - mock_tmp().__enter__().name = "/tmp/req.yml" - - # Erwartung: kein SystemExit, run_command wird für collections aufgerufen - self.installer.run(self.ctx) - - cmds = [call[0][0] for call in mock_run_command.call_args_list] - self.assertIn( - "ansible-galaxy collection install -r /tmp/req.yml", - cmds, - ) - # Keine roles definiert -> kein role-install - self.assertNotIn( - "ansible-galaxy role install -r /tmp/req.yml", - cmds, - ) - - -if __name__ == "__main__": - unittest.main() diff --git a/tests/unit/pkgmgr/installers/test_aur.py b/tests/unit/pkgmgr/installers/test_aur.py deleted file mode 100644 index 87a0a1b..0000000 --- a/tests/unit/pkgmgr/installers/test_aur.py +++ /dev/null @@ -1,97 +0,0 @@ -# tests/unit/pkgmgr/installers/test_aur.py - -import os -import unittest -from unittest.mock import patch, mock_open - -from pkgmgr.context import RepoContext -from pkgmgr.installers.aur import AurInstaller, AUR_CONFIG_FILENAME - - -class TestAurInstaller(unittest.TestCase): - def setUp(self): - self.repo = {"name": "test-repo"} - self.ctx = RepoContext( - repo=self.repo, - identifier="test-id", - repo_dir="/tmp/repo", - repositories_base_dir="/tmp", - bin_dir="/bin", - all_repos=[self.repo], - no_verification=False, - preview=False, - quiet=False, - clone_mode="ssh", - update_dependencies=False, - ) - self.installer = AurInstaller() - - @patch("shutil.which", return_value="/usr/bin/pacman") - @patch("os.path.exists", return_value=True) - @patch( - "builtins.open", - new_callable=mock_open, - read_data=""" -helper: yay -packages: - - aurutils - - name: some-aur-only-tool - reason: "Test tool" -""", - ) - def test_supports_true_when_arch_and_aur_config_present( - self, mock_file, mock_exists, mock_which - ): - self.assertTrue(self.installer.supports(self.ctx)) - mock_which.assert_called_with("pacman") - mock_exists.assert_called_with(os.path.join(self.ctx.repo_dir, AUR_CONFIG_FILENAME)) - - @patch("shutil.which", return_value=None) - def test_supports_false_when_not_arch(self, mock_which): - self.assertFalse(self.installer.supports(self.ctx)) - - @patch("shutil.which", return_value="/usr/bin/pacman") - @patch("os.path.exists", return_value=False) - def test_supports_false_when_no_config(self, mock_exists, mock_which): - self.assertFalse(self.installer.supports(self.ctx)) - - @patch("shutil.which", side_effect=lambda name: "/usr/bin/pacman" if name == "pacman" else "/usr/bin/yay") - @patch("pkgmgr.installers.aur.run_command") - @patch( - "builtins.open", - new_callable=mock_open, - read_data=""" -helper: yay -packages: - - aurutils - - some-aur-only-tool -""", - ) - @patch("os.path.exists", return_value=True) - def test_run_installs_packages_with_helper( - self, mock_exists, mock_file, mock_run_command, mock_which - ): - self.installer.run(self.ctx) - - cmd = mock_run_command.call_args[0][0] - self.assertTrue(cmd.startswith("yay -S --noconfirm ")) - self.assertIn("aurutils", cmd) - self.assertIn("some-aur-only-tool", cmd) - - @patch("shutil.which", return_value="/usr/bin/pacman") - @patch( - "builtins.open", - new_callable=mock_open, - read_data="packages: []", - ) - @patch("os.path.exists", return_value=True) - def test_run_skips_when_no_packages( - self, mock_exists, mock_file, mock_which - ): - with patch("pkgmgr.installers.aur.run_command") as mock_run_command: - self.installer.run(self.ctx) - mock_run_command.assert_not_called() - - -if __name__ == "__main__": - unittest.main()