Add cross-distribution OS package installers (Arch PKGBUILD, Debian control, RPM spec) and restructure tests.

Remove deprecated AUR and Ansible requirements installers.
Introduce Nix init + wrapper scripts and full packaging (Arch/DEB/RPM).
Associated conversation: https://chatgpt.com/share/693476a8-b9f0-800f-8e0c-ea5151295ce2
This commit is contained in:
Kevin Veen-Birkenbach
2025-12-06 19:32:31 +01:00
parent d6a7ce0aa0
commit aaf20da0a0
23 changed files with 658 additions and 634 deletions

View File

@@ -8,13 +8,14 @@ arch=('any')
url="https://github.com/kevinveenbirkenbach/package-manager" url="https://github.com/kevinveenbirkenbach/package-manager"
license=('MIT') license=('MIT')
# Nix is the only runtime dependency.
depends=('nix') depends=('nix')
makedepends=() install=${pkgname}.install
source=() source=('scripts/pkgmgr-wrapper.sh'
sha256sums=() 'scripts/init-nix.sh')
sha256sums=('SKIP'
'SKIP')
build() { build() {
: :
@@ -22,19 +23,11 @@ build() {
package() { package() {
install -d "$pkgdir/usr/bin" install -d "$pkgdir/usr/bin"
install -d "$pkgdir/usr/lib/package-manager"
cat > "$pkgdir/usr/bin/pkgmgr" << 'EOF' # Wrapper
#!/usr/bin/env bash install -m0755 "scripts/pkgmgr-wrapper.sh" "$pkgdir/usr/bin/pkgmgr"
set -euo pipefail
# Enable flakes if not already configured. # Shared Nix init script
if [[ -z "${NIX_CONFIG:-}" ]]; then install -m0755 "scripts/init-nix.sh" "$pkgdir/usr/lib/package-manager/init-nix.sh"
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"
} }

5
debian/changelog vendored Normal file
View File

@@ -0,0 +1,5 @@
package-manager (0.1.1-1) unstable; urgency=medium
* Initial release.
-- Kevin Veen-Birkenbach <info@veen.world> Sat, 06 Dec 2025 16:30:00 +0100

18
debian/control vendored Normal file
View File

@@ -0,0 +1,18 @@
Source: package-manager
Section: utils
Priority: optional
Maintainer: Kevin Veen-Birkenbach <info@veen.world>
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.

14
debian/postinst vendored Executable file
View File

@@ -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

10
debian/rules vendored Executable file
View File

@@ -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

11
package-manager.install Normal file
View File

@@ -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."
}

58
package-manager.spec Normal file
View File

@@ -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 <info@veen.world> - 0.1.1-1
- Initial RPM packaging for package-manager

View File

@@ -30,23 +30,30 @@ from pkgmgr.context import RepoContext
# Installer implementations # Installer implementations
from pkgmgr.installers.pkgmgr_manifest import PkgmgrManifestInstaller 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.nix_flake import NixFlakeInstaller
from pkgmgr.installers.ansible_requirements import AnsibleRequirementsInstaller
from pkgmgr.installers.python import PythonInstaller from pkgmgr.installers.python import PythonInstaller
from pkgmgr.installers.makefile import MakefileInstaller 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 = [ INSTALLERS = [
PkgmgrManifestInstaller(), PkgmgrManifestInstaller(), # meta/pkgmgr.yml deps
PkgbuildInstaller(), ArchPkgbuildInstaller(), # Arch
NixFlakeInstaller(), DebianControlInstaller(), # Debian/Ubuntu
AnsibleRequirementsInstaller(), RpmSpecInstaller(), # Fedora/RHEL/CentOS
PythonInstaller(), NixFlakeInstaller(), # 2) flake.nix (Nix layer)
MakefileInstaller(), PythonInstaller(), # 3) pyproject / requirements (fallback if no flake+nix)
AurInstaller(), MakefileInstaller(), # generic 'make install'
] ]

View File

@@ -4,16 +4,17 @@
""" """
Installer package for pkgmgr. Installer package for pkgmgr.
Each installer implements a small, focused step in the repository This exposes all installer classes so users can import them directly from
installation pipeline (e.g. PKGBUILD dependencies, Nix flakes, Python, pkgmgr.installers.
Ansible requirements, pkgmgr.yml, Makefile, AUR).
""" """
from pkgmgr.installers.base import BaseInstaller # noqa: F401 from pkgmgr.installers.base import BaseInstaller # noqa: F401
from pkgmgr.installers.pkgmgr_manifest import PkgmgrManifestInstaller # 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.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.python import PythonInstaller # noqa: F401
from pkgmgr.installers.makefile import MakefileInstaller # 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

View File

@@ -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)

View File

@@ -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)

View File

@@ -0,0 +1,9 @@
from .arch_pkgbuild import ArchPkgbuildInstaller
from .debian_control import DebianControlInstaller
from .rpm_spec import RpmSpecInstaller
__all__ = [
"ArchPkgbuildInstaller",
"DebianControlInstaller",
"RpmSpecInstaller",
]

View File

@@ -18,12 +18,17 @@ from pkgmgr.installers.base import BaseInstaller
from pkgmgr.run_command import run_command from pkgmgr.run_command import run_command
class PkgbuildInstaller(BaseInstaller): class ArchPkgbuildInstaller(BaseInstaller):
"""Install Arch dependencies (depends/makedepends) from PKGBUILD.""" """Install Arch dependencies (depends/makedepends) from PKGBUILD."""
PKGBUILD_NAME = "PKGBUILD" PKGBUILD_NAME = "PKGBUILD"
def supports(self, ctx: RepoContext) -> bool: 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: if shutil.which("pacman") is None:
return False return False
pkgbuild_path = os.path.join(ctx.repo_dir, self.PKGBUILD_NAME) 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): if not os.path.exists(pkgbuild_path):
return [] 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: try:
output = subprocess.check_output( output = subprocess.check_output(
["bash", "--noprofile", "--norc", "-c", script], ["bash", "--noprofile", "--norc", "-c", script],
@@ -64,6 +72,9 @@ class PkgbuildInstaller(BaseInstaller):
return packages return packages
def run(self, ctx: RepoContext) -> None: def run(self, ctx: RepoContext) -> None:
"""
Install all packages from depends + makedepends via pacman.
"""
depends = self._extract_pkgbuild_array(ctx, "depends") depends = self._extract_pkgbuild_array(ctx, "depends")
makedepends = self._extract_pkgbuild_array(ctx, "makedepends") makedepends = self._extract_pkgbuild_array(ctx, "makedepends")
all_pkgs = depends + makedepends all_pkgs = depends + makedepends
@@ -72,4 +83,4 @@ class PkgbuildInstaller(BaseInstaller):
return return
cmd = "sudo pacman -S --noconfirm " + " ".join(all_pkgs) cmd = "sudo pacman -S --noconfirm " + " ".join(all_pkgs)
run_command(cmd, preview=ctx.preview) run_command(cmd, cwd=ctx.repo_dir, preview=ctx.preview)

View File

@@ -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)

View File

@@ -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)

48
scripts/init-nix.sh Normal file
View File

@@ -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."

10
scripts/pkgmgr-wrapper.sh Normal file
View File

@@ -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 Kevins package manager via Nix flake
exec nix run "github:kevinveenbirkenbach/package-manager#pkgmgr" -- "$@"

View File

@@ -1,14 +1,14 @@
# tests/unit/pkgmgr/installers/test_pkgbuild.py # tests/unit/pkgmgr/installers/os_packages/test_arch_pkgbuild.py
import os import os
import unittest import unittest
from unittest.mock import patch from unittest.mock import patch
from pkgmgr.context import RepoContext 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): def setUp(self):
self.repo = {"name": "test-repo"} self.repo = {"name": "test-repo"}
self.ctx = RepoContext( self.ctx = RepoContext(
@@ -24,7 +24,7 @@ class TestPkgbuildInstaller(unittest.TestCase):
clone_mode="ssh", clone_mode="ssh",
update_dependencies=False, update_dependencies=False,
) )
self.installer = PkgbuildInstaller() self.installer = ArchPkgbuildInstaller()
@patch("os.path.exists", return_value=True) @patch("os.path.exists", return_value=True)
@patch("shutil.which", return_value="/usr/bin/pacman") @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): def test_supports_false_when_pkgbuild_missing(self, mock_which, mock_exists):
self.assertFalse(self.installer.supports(self.ctx)) 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("subprocess.check_output", return_value="python\ngit\n")
@patch("os.path.exists", return_value=True) @patch("os.path.exists", return_value=True)
@patch("shutil.which", return_value="/usr/bin/pacman") @patch("shutil.which", return_value="/usr/bin/pacman")
@@ -47,14 +47,14 @@ class TestPkgbuildInstaller(unittest.TestCase):
): ):
self.installer.run(self.ctx) self.installer.run(self.ctx)
# Check subprocess.check_output arguments (clean shell) # subprocess.check_output call
args, kwargs = mock_check_output.call_args args, kwargs = mock_check_output.call_args
cmd_list = args[0] cmd_list = args[0]
self.assertEqual(cmd_list[0], "bash") self.assertEqual(cmd_list[0], "bash")
self.assertIn("--noprofile", cmd_list) self.assertIn("--noprofile", cmd_list)
self.assertIn("--norc", 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] cmd = mock_run_command.call_args[0][0]
self.assertTrue(cmd.startswith("sudo pacman -S --noconfirm ")) self.assertTrue(cmd.startswith("sudo pacman -S --noconfirm "))
self.assertIn("python", cmd) self.assertIn("python", cmd)

View File

@@ -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()

View File

@@ -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()

View File

@@ -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()

View File

@@ -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()