Refactor pkgmgr installers, introduce capability-based execution, and replace manifest layer

References:
- Current ChatGPT conversation: https://chatgpt.com/share/6935d6d7-0ae4-800f-988a-44a50c17ba48
- Extended discussion: https://chatgpt.com/share/6935d734-fd84-800f-9755-290902b8cee8

Summary:
This commit performs a major cleanup and modernization of the installation pipeline:

1. Introduced a new capability-detection subsystem:
   - Capabilities (python-runtime, make-install, nix-flake) are detected per installer/layer.
   - Installers run only when they add new capabilities.
   - Prevents duplicated work such as Python installers running when Nix already provides the runtime.

2. Removed deprecated pkgmgr.yml manifest installer:
   - Dependency resolution is now delegated entirely to real package managers (Nix, pip, make, distro build tools).
   - Simplifies layering and avoids unnecessary recursion.

3. Reworked OS-specific installers:
   - Arch PKGBUILD now uses 'makepkg --syncdeps --cleanbuild --install --noconfirm'.
   - Debian installer now builds proper .deb packages via dpkg-buildpackage + installs them.
   - RPM installer now builds packages using rpmbuild and installs them via rpm.

4. Switched from remote GitHub flakes to local-flake execution:
   - Wrapper now executes: nix run /usr/lib/package-manager#pkgmgr
   - Avoids lock-file write attempts and improves reliability in CI.

5. Added bash -i based integration test:
   - Correctly sources ~/.bashrc and evaluates alias + venv activation.
   - ‘pkgmgr --help’ is now printed for debugging without failing tests.

6. Updated unit tests across all installers:
   - Removed references to manifest installer.
   - Adjusted expectations for new behaviors (makepkg, dpkg-buildpackage, rpmbuild).
   - Added capability subsystem tests.

7. Improved flake.nix packaging logic:
   - The entire project source tree is copied into the runtime closure.
   - pkgmgr wrapper now executes runpy inside the packaged directory.

Together, these changes create a predictable, layered, capability-driven installer pipeline with consistent behavior across Arch, Debian, RPM, Nix, and Python layers.
This commit is contained in:
Kevin Veen-Birkenbach
2025-12-07 20:36:39 +01:00
parent 5134fd5273
commit 16a9d55d4f
28 changed files with 984 additions and 632 deletions

View File

@@ -8,8 +8,6 @@ RUN pacman -Syu --noconfirm \
nix \
&& pacman -Scc --noconfirm
ENV NIX_CONFIG="experimental-features = nix-command flakes"
# 2) Unprivileged user for building Arch packages
RUN useradd -m builder
WORKDIR /build

View File

@@ -3,31 +3,72 @@
pkgname=package-manager
pkgver=0.1.1
pkgrel=1
pkgdesc="Wrapper that runs Kevin's package-manager via Nix flake."
pkgdesc="Local-flake wrapper for Kevin's package-manager (Nix-based)."
arch=('any')
url="https://github.com/kevinveenbirkenbach/package-manager"
license=('MIT')
# Nix is the only runtime dependency; Python is provided by the Nix closure.
depends=('nix')
makedepends=('rsync')
install=${pkgname}.install
source=('scripts/pkgmgr-wrapper.sh'
'scripts/init-nix.sh')
sha256sums=('SKIP'
'SKIP')
# Local source checkout — avoids the tarball requirement.
# This assumes you build the package from inside the main project repository.
source=(
"scripts/pkgmgr-wrapper.sh"
"scripts/init-nix.sh"
)
sha256sums=('SKIP' 'SKIP')
# Local source directory name under $srcdir
_srcdir_name="source"
prepare() {
mkdir -p "$srcdir/$_srcdir_name"
# Copy the full local tree into $srcdir/source,
# but avoid makepkg's own directories and the VCS metadata.
rsync -a \
--exclude="src" \
--exclude="pkg" \
--exclude=".git" \
"$startdir/" "$srcdir/$_srcdir_name/"
}
build() {
cd "$srcdir/$_srcdir_name"
:
}
package() {
install -d "$pkgdir/usr/bin"
install -d "$pkgdir/usr/lib/package-manager"
cd "$srcdir/$_srcdir_name"
# Wrapper
install -m0755 "scripts/pkgmgr-wrapper.sh" "$pkgdir/usr/bin/pkgmgr"
# Install the wrapper into /usr/bin
install -Dm0755 "scripts/pkgmgr-wrapper.sh" \
"$pkgdir/usr/bin/pkgmgr"
# Shared Nix init script
install -m0755 "scripts/init-nix.sh" "$pkgdir/usr/lib/package-manager/init-nix.sh"
# Install Nix init helper
install -Dm0755 "scripts/init-nix.sh" \
"$pkgdir/usr/lib/package-manager/init-nix.sh"
# Install the full repository into /usr/lib/package-manager
mkdir -p "$pkgdir/usr/lib/package-manager"
# Copy entire project tree from our local source checkout
cp -a . "$pkgdir/usr/lib/package-manager/"
# Remove packaging-only and development artefacts from the installed tree
rm -rf \
"$pkgdir/usr/lib/package-manager/.git" \
"$pkgdir/usr/lib/package-manager/.github" \
"$pkgdir/usr/lib/package-manager/tests" \
"$pkgdir/usr/lib/package-manager/PKGBUILD" \
"$pkgdir/usr/lib/package-manager/Dockerfile" \
"$pkgdir/usr/lib/package-manager/debian" \
"$pkgdir/usr/lib/package-manager/.gitignore" \
"$pkgdir/usr/lib/package-manager/__pycache__" \
"$pkgdir/usr/lib/package-manager/.gitkeep"
}

6
debian/control vendored
View File

@@ -12,7 +12,7 @@ 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
manager via a local Nix flake
nix run /usr/lib/package-manager#pkgmgr -- ...
Nix is the only runtime dependency and must be initialized on
the system to work correctly.

30
debian/rules vendored
View File

@@ -4,7 +4,33 @@
dh $@
override_dh_auto_install:
# Create target directories
install -d debian/package-manager/usr/bin
install -d debian/package-manager/usr/lib/package-manager
# Install wrapper
install -D -m0755 scripts/pkgmgr-wrapper.sh debian/package-manager/usr/bin/pkgmgr
install -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
install -m0755 scripts/init-nix.sh \
debian/package-manager/usr/lib/package-manager/init-nix.sh
# Copy full project source into /usr/lib/package-manager,
# but do not include the debian/ directory itself.
find . -mindepth 1 -maxdepth 1 \
! -name debian \
! -name .git \
-exec cp -a {} debian/package-manager/usr/lib/package-manager/ \;
# Remove packaging-only and development artefacts from the installed tree
rm -rf \
debian/package-manager/usr/lib/package-manager/debian \
debian/package-manager/usr/lib/package-manager/PKGBUILD \
debian/package-manager/usr/lib/package-manager/Dockerfile \
debian/package-manager/usr/lib/package-manager/.git \
debian/package-manager/usr/lib/package-manager/.github \
debian/package-manager/usr/lib/package-manager/tests \
debian/package-manager/usr/lib/package-manager/.gitignore \
debian/package-manager/usr/lib/package-manager/__pycache__ \
debian/package-manager/usr/lib/package-manager/.gitkeep || true

View File

@@ -13,14 +13,14 @@
let
systems = [ "x86_64-linux" "aarch64-linux" ];
# Small helper: build an attrset for all systems
# Helper to build an attribute set for all target systems
forAllSystems = f:
builtins.listToAttrs (map (system: {
name = system;
value = f system;
}) systems);
in {
# Dev shells: nix develop .#default (on both architectures)
# Development shells: `nix develop .#default`
devShells = forAllSystems (system:
let
pkgs = nixpkgs.legacyPackages.${system};
@@ -28,13 +28,13 @@
# Base Python interpreter
python = pkgs.python311;
# Python env with pip + pyyaml available, so `python -m pip` works
# Python environment with pip + PyYAML so `python -m pip` works
pythonEnv = python.withPackages (ps: with ps; [
pip
pyyaml
]);
# Be robust: ansible-core if available, otherwise ansible.
# Be robust: use ansible-core if available, otherwise ansible
ansiblePkg =
if pkgs ? ansible-core then pkgs.ansible-core
else pkgs.ansible;
@@ -52,12 +52,13 @@
}
);
# Packages: `nix build .#pkgmgr` or `nix build .#default`
packages = forAllSystems (system:
let
pkgs = nixpkgs.legacyPackages.${system};
python = pkgs.python311;
# Runtime Python for pkgmgr (with pip + pyyaml)
# Runtime Python for pkgmgr (with pip + PyYAML)
pythonEnv = python.withPackages (ps: with ps; [
pip
pyyaml
@@ -73,40 +74,49 @@
pname = "package-manager";
version = "0.1.1";
# Use the current repository as the source
src = ./.;
# Nix should not run configure / build (no make)
# No traditional configure/build steps
dontConfigure = true;
dontBuild = true;
# Runtime deps: Python (with pip) + Ansible
# Runtime dependencies: Python (with pip + PyYAML) + Ansible
buildInputs = [
pythonEnv
ansiblePkg
];
installPhase = ''
mkdir -p "$out/bin"
mkdir -p "$out/bin" "$out/lib/package-manager"
# Wrapper that always uses the pythonEnv interpreter, so
# sys.executable -m pip has a working pip.
cat > "$out/bin/pkgmgr" << EOF
# Copy the full project tree into the runtime closure
cp -a . "$out/lib/package-manager/"
# Wrapper that runs main.py from the copied tree,
# using the pythonEnv interpreter.
cat > "$out/bin/pkgmgr" << 'EOF'
#!${pythonEnv}/bin/python3
import os
import runpy
if __name__ == "__main__":
runpy.run_module("main", run_name="__main__")
base_dir = os.path.join(os.path.dirname(__file__), "..", "lib", "package-manager")
main_path = os.path.join(base_dir, "main.py")
os.chdir(base_dir)
runpy.run_path(main_path, run_name="__main__")
EOF
chmod +x "$out/bin/pkgmgr"
'';
};
# default package just points to pkgmgr
# Default package points to pkgmgr
default = pkgmgr;
}
);
# Apps: nix run .#pkgmgr / .#default
# Apps: `nix run .#pkgmgr` or `nix run .#default`
apps = forAllSystems (system:
let
pkgmgrPkg = self.packages.${system}.pkgmgr;

View File

@@ -12,9 +12,9 @@ Requires: nix
%description
This package provides the `pkgmgr` command, which runs Kevin's package
manager via a Nix flake:
manager via a local Nix flake:
nix run "github:kevinveenbirkenbach/package-manager#pkgmgr" -- ...
nix run /usr/lib/package-manager#pkgmgr -- ...
Nix is the only runtime dependency and must be initialized on the
system to work correctly.
@@ -31,12 +31,27 @@ rm -rf %{buildroot}
install -d %{buildroot}%{_bindir}
install -d %{buildroot}%{_libdir}/package-manager
# Copy full project source into /usr/lib/package-manager
cp -a . %{buildroot}%{_libdir}/package-manager/
# Wrapper
install -m0755 scripts/pkgmgr-wrapper.sh %{buildroot}%{_bindir}/pkgmgr
# Shared Nix init script
# Shared Nix init script (ensure it is executable in the installed tree)
install -m0755 scripts/init-nix.sh %{buildroot}%{_libdir}/package-manager/init-nix.sh
# Remove packaging-only and development artefacts from the installed tree
rm -rf \
%{buildroot}%{_libdir}/package-manager/PKGBUILD \
%{buildroot}%{_libdir}/package-manager/Dockerfile \
%{buildroot}%{_libdir}/package-manager/debian \
%{buildroot}%{_libdir}/package-manager/.git \
%{buildroot}%{_libdir}/package-manager/.github \
%{buildroot}%{_libdir}/package-manager/tests \
%{buildroot}%{_libdir}/package-manager/.gitignore \
%{buildroot}%{_libdir}/package-manager/__pycache__ \
%{buildroot}%{_libdir}/package-manager/.gitkeep || true
%post
if [ -x %{_libdir}/package-manager/init-nix.sh ]; then
%{_libdir}/package-manager/init-nix.sh || true
@@ -51,7 +66,7 @@ echo ">>> package-manager removed. Nix itself was not removed."
%doc README.md
%license LICENSE
%{_bindir}/pkgmgr
%{_libdir}/package-manager/init-nix.sh
%{_libdir}/package-manager/
%changelog
* Sat Dec 06 2025 Kevin Veen-Birkenbach <info@veen.world> - 0.1.1-1

297
pkgmgr/capabilities.py Normal file
View File

@@ -0,0 +1,297 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
Capability detection for pkgmgr.
Each capability is represented by a class that:
- defines a logical name (e.g. "python-runtime", "make-install", "nix-flake")
- knows for which installer layer(s) it applies (e.g. "nix", "python",
"makefile", "os-packages")
- searches the repository config/build files for specific strings
to determine whether that capability is provided by that layer.
This allows pkgmgr to dynamically decide if a higher layer already covers
work a lower layer would otherwise do (e.g. Nix calling pyproject/make,
or distro packages wrapping Nix or Makefile logic).
"""
from __future__ import annotations
import glob
import os
from abc import ABC, abstractmethod
from typing import Iterable, TYPE_CHECKING
if TYPE_CHECKING:
from pkgmgr.context import RepoContext
# ---------------------------------------------------------------------------
# Helper functions
# ---------------------------------------------------------------------------
def _read_text_if_exists(path: str) -> str | None:
"""Read a file as UTF-8 text, returning None if it does not exist or fails."""
if not os.path.exists(path):
return None
try:
with open(path, "r", encoding="utf-8") as f:
return f.read()
except OSError:
return None
def _scan_files_for_patterns(files: Iterable[str], patterns: Iterable[str]) -> bool:
"""
Return True if any of the given files exists and contains at least one of
the given patterns (case-insensitive).
"""
lower_patterns = [p.lower() for p in patterns]
for path in files:
content = _read_text_if_exists(path)
if not content:
continue
lower_content = content.lower()
if any(p in lower_content for p in lower_patterns):
return True
return False
def _first_spec_file(repo_dir: str) -> str | None:
"""Return the first *.spec file in repo_dir, if any."""
matches = glob.glob(os.path.join(repo_dir, "*.spec"))
if not matches:
return None
return sorted(matches)[0]
# ---------------------------------------------------------------------------
# Base matcher
# ---------------------------------------------------------------------------
class CapabilityMatcher(ABC):
"""Base class for all capability detectors."""
#: Logical capability name (e.g. "python-runtime", "make-install").
name: str
@abstractmethod
def applies_to_layer(self, layer: str) -> bool:
"""Return True if this capability can be provided by the given layer."""
raise NotImplementedError
@abstractmethod
def is_provided(self, ctx: "RepoContext", layer: str) -> bool:
"""
Return True if this capability is actually provided by the given layer
for this repository.
This is where we search for specific strings in build/config files
(flake.nix, pyproject.toml, Makefile, PKGBUILD, debian/rules, *.spec, ...).
"""
raise NotImplementedError
# ---------------------------------------------------------------------------
# Capability: python-runtime
#
# Provided when:
# - Layer "python":
# pyproject.toml exists → Python runtime via pip for this project
# - Layer "nix":
# flake.nix contains hints that it builds a Python app
# (buildPythonApplication, python3Packages., poetry2nix, pip install, ...)
# - Layer "os-packages":
# distro build scripts (PKGBUILD, debian/rules, *.spec) clearly call
# pip/python to install THIS Python project (heuristic).
# ---------------------------------------------------------------------------
class PythonRuntimeCapability(CapabilityMatcher):
name = "python-runtime"
def applies_to_layer(self, layer: str) -> bool:
# OS packages may wrap Python builds, but must explicitly prove it
return layer in {"python", "nix", "os-packages"}
def is_provided(self, ctx: "RepoContext", layer: str) -> bool:
repo_dir = ctx.repo_dir
if layer == "python":
# For pkgmgr, a pyproject.toml is enough to say:
# "This layer provides the Python runtime for this project."
pyproject = os.path.join(repo_dir, "pyproject.toml")
return os.path.exists(pyproject)
if layer == "nix":
flake = os.path.join(repo_dir, "flake.nix")
content = _read_text_if_exists(flake)
if not content:
return False
content = content.lower()
patterns = [
"buildpythonapplication",
"python3packages.",
"poetry2nix",
"pip install",
"python -m pip",
]
return any(p in content for p in patterns)
if layer == "os-packages":
# Heuristic:
# - repo looks like a Python project (pyproject.toml or setup.py)
# - and OS build scripts call pip / python -m pip / setup.py install
pyproject = os.path.join(repo_dir, "pyproject.toml")
setup_py = os.path.join(repo_dir, "setup.py")
if not (os.path.exists(pyproject) or os.path.exists(setup_py)):
return False
pkgbuild = os.path.join(repo_dir, "PKGBUILD")
debian_rules = os.path.join(repo_dir, "debian", "rules")
spec = _first_spec_file(repo_dir)
scripts = [pkgbuild, debian_rules]
if spec:
scripts.append(spec)
patterns = [
"pip install .",
"python -m pip install",
"python3 -m pip install",
"setup.py install",
]
return _scan_files_for_patterns(scripts, patterns)
return False
# ---------------------------------------------------------------------------
# Capability: make-install
#
# Provided when:
# - Layer "makefile":
# Makefile has an "install:" target
# - Layer "python":
# pyproject.toml mentions "make install"
# - Layer "nix":
# flake.nix mentions "make install"
# - Layer "os-packages":
# distro build scripts call "make install" (they already consume the
# Makefile installation step).
# ---------------------------------------------------------------------------
class MakeInstallCapability(CapabilityMatcher):
name = "make-install"
def applies_to_layer(self, layer: str) -> bool:
return layer in {"makefile", "python", "nix", "os-packages"}
def is_provided(self, ctx: "RepoContext", layer: str) -> bool:
repo_dir = ctx.repo_dir
if layer == "makefile":
makefile = os.path.join(repo_dir, "Makefile")
if not os.path.exists(makefile):
return False
try:
with open(makefile, "r", encoding="utf-8") as f:
for line in f:
if line.strip().startswith("install:"):
return True
except OSError:
return False
return False
if layer == "python":
pyproject = os.path.join(repo_dir, "pyproject.toml")
content = _read_text_if_exists(pyproject)
if not content:
return False
return "make install" in content.lower()
if layer == "nix":
flake = os.path.join(repo_dir, "flake.nix")
content = _read_text_if_exists(flake)
if not content:
return False
return "make install" in content.lower()
if layer == "os-packages":
pkgbuild = os.path.join(repo_dir, "PKGBUILD")
debian_rules = os.path.join(repo_dir, "debian", "rules")
spec = _first_spec_file(repo_dir)
scripts = [pkgbuild, debian_rules]
if spec:
scripts.append(spec)
# If any OS build script calls "make install", we assume it is
# already consuming the Makefile installation and thus provides
# the make-install capability.
return _scan_files_for_patterns(scripts, ["make install"])
return False
# ---------------------------------------------------------------------------
# Capability: nix-flake
#
# Provided when:
# - Layer "nix":
# flake.nix exists → Nix flake installer can install this project
# - Layer "os-packages":
# distro build scripts clearly call Nix (nix build/run/develop/profile),
# i.e. they already use Nix as part of building/installing.
# ---------------------------------------------------------------------------
class NixFlakeCapability(CapabilityMatcher):
name = "nix-flake"
def applies_to_layer(self, layer: str) -> bool:
# Only Nix itself and OS packages that explicitly wrap Nix
return layer in {"nix", "os-packages"}
def is_provided(self, ctx: "RepoContext", layer: str) -> bool:
repo_dir = ctx.repo_dir
if layer == "nix":
flake = os.path.join(repo_dir, "flake.nix")
return os.path.exists(flake)
if layer == "os-packages":
pkgbuild = os.path.join(repo_dir, "PKGBUILD")
debian_rules = os.path.join(repo_dir, "debian", "rules")
spec = _first_spec_file(repo_dir)
scripts = [pkgbuild, debian_rules]
if spec:
scripts.append(spec)
patterns = [
"nix build",
"nix run",
"nix-shell",
"nix develop",
"nix profile",
]
return _scan_files_for_patterns(scripts, patterns)
return False
# ---------------------------------------------------------------------------
# Registry of all capability matchers currently supported.
# ---------------------------------------------------------------------------
CAPABILITY_MATCHERS: list[CapabilityMatcher] = [
PythonRuntimeCapability(),
MakeInstallCapability(),
NixFlakeCapability(),
]

View File

@@ -10,8 +10,8 @@ This module orchestrates the installation of repositories by:
2. Verifying the repository according to the configured policies.
3. Creating executable links using create_ink().
4. Running a sequence of modular installer components that handle
specific technologies or manifests (pkgmgr.yml, PKGBUILD, Nix,
Ansible requirements, Python, Makefile, AUR).
specific technologies or manifests (PKGBUILD, Nix flakes, Python
via pyproject.toml, Makefile, OS-specific package metadata).
The goal is to keep this file thin and delegate most logic to small,
focused installer classes.
@@ -29,7 +29,6 @@ from pkgmgr.clone_repos import clone_repos
from pkgmgr.context import RepoContext
# Installer implementations
from pkgmgr.installers.pkgmgr_manifest import PkgmgrManifestInstaller
from pkgmgr.installers.os_packages import (
ArchPkgbuildInstaller,
DebianControlInstaller,
@@ -41,18 +40,16 @@ from pkgmgr.installers.makefile import MakefileInstaller
# 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
# 1) OS packages: PKGBUILD / debian/control / RPM spec → os-deps.*
# 2) Nix flakes (flake.nix) → e.g. python-runtime, make-install
# 3) Python (pyproject.toml) → e.g. python-runtime, make-install
# 4) Makefile fallback → e.g. make-install
INSTALLERS = [
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)
NixFlakeInstaller(), # flake.nix (Nix layer)
PythonInstaller(), # pyproject.toml
MakefileInstaller(), # generic 'make install'
]
@@ -165,8 +162,8 @@ def install_repos(
) -> None:
"""
Install repositories by creating symbolic links and processing standard
manifest files (pkgmgr.yml, PKGBUILD, flake.nix, Ansible requirements,
Python manifests, Makefile, AUR) via dedicated installer components.
manifest files (PKGBUILD, flake.nix, Python manifests, Makefile, etc.)
via dedicated installer components.
Any installer failure (SystemExit) is treated as fatal and will abort
the current installation.
@@ -217,7 +214,29 @@ def install_repos(
preview=preview,
)
# Run all installers that support this repository.
# Track which logical capabilities have already been provided by
# earlier installers for this repository. This allows us to skip
# installers that would only duplicate work (e.g. Python runtime
# already provided by Nix flake → skip pyproject/Makefile).
provided_capabilities: set[str] = set()
# Run all installers that support this repository, but only if they
# provide at least one capability that is not yet satisfied.
for installer in INSTALLERS:
if installer.supports(ctx):
installer.run(ctx)
if not installer.supports(ctx):
continue
caps = installer.discover_capabilities(ctx)
# If the installer declares capabilities and *all* of them are
# already provided, we can safely skip it.
if caps and caps.issubset(provided_capabilities):
if not quiet:
print(
f"Skipping installer {installer.__class__.__name__} "
f"for {identifier} capabilities {caps} already provided."
)
continue
installer.run(ctx)
provided_capabilities.update(caps)

View File

@@ -9,7 +9,6 @@ pkgmgr.installers.
"""
from pkgmgr.installers.base import BaseInstaller # noqa: F401
from pkgmgr.installers.pkgmgr_manifest import PkgmgrManifestInstaller # noqa: F401
from pkgmgr.installers.nix_flake import NixFlakeInstaller # noqa: F401
from pkgmgr.installers.python import PythonInstaller # noqa: F401
from pkgmgr.installers.makefile import MakefileInstaller # noqa: F401

View File

@@ -6,8 +6,10 @@ Base interface for all installer components in the pkgmgr installation pipeline.
"""
from abc import ABC, abstractmethod
from typing import Set
from pkgmgr.context import RepoContext
from pkgmgr.capabilities import CAPABILITY_MATCHERS
class BaseInstaller(ABC):
@@ -15,9 +17,35 @@ class BaseInstaller(ABC):
A single step in the installation pipeline for a repository.
Implementations should be small and focused on one technology or manifest
type (e.g. PKGBUILD, Nix, Python, Ansible, pkgmgr.yml).
type (e.g. PKGBUILD, Nix, Python, Makefile, etc.).
"""
#: Logical layer name for this installer.
# Examples: "nix", "python", "makefile".
# This is used by capability matchers to decide which patterns to
# search for in the repository.
layer: str | None = None
def discover_capabilities(self, ctx: RepoContext) -> Set[str]:
"""
Determine which logical capabilities this installer will provide
for this specific repository instance.
This method delegates to the global capability matchers, which
inspect build/configuration files (flake.nix, pyproject.toml,
Makefile, etc.) and decide, via string matching, whether a given
capability is actually provided by this layer.
"""
caps: Set[str] = set()
if not self.layer:
return caps
for matcher in CAPABILITY_MATCHERS:
if matcher.applies_to_layer(self.layer) and matcher.is_provided(ctx, self.layer):
caps.add(matcher.name)
return caps
@abstractmethod
def supports(self, ctx: RepoContext) -> bool:
"""

View File

@@ -18,6 +18,9 @@ from pkgmgr.run_command import run_command
class MakefileInstaller(BaseInstaller):
"""Run `make install` if a Makefile exists in the repository."""
# Logical layer name, used by capability matchers.
layer = "makefile"
MAKEFILE_NAME = "Makefile"
def supports(self, ctx: RepoContext) -> bool:

View File

@@ -30,6 +30,9 @@ if TYPE_CHECKING:
class NixFlakeInstaller(BaseInstaller):
"""Install Nix flake profiles for repositories that define flake.nix."""
# Logical layer name, used by capability matchers.
layer = "nix"
FLAKE_FILE = "flake.nix"
PROFILE_NAME = "package-manager"

View File

@@ -1,17 +1,7 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
Installer for Arch Linux dependencies defined in PKGBUILD files.
This installer extracts depends/makedepends from PKGBUILD and installs them
via pacman on Arch-based systems.
"""
# pkgmgr/installers/os_packages/arch_pkgbuild.py
import os
import shutil
import subprocess
from typing import List
from pkgmgr.context import RepoContext
from pkgmgr.installers.base import BaseInstaller
@@ -19,68 +9,51 @@ from pkgmgr.run_command import run_command
class ArchPkgbuildInstaller(BaseInstaller):
"""Install Arch dependencies (depends/makedepends) from PKGBUILD."""
"""
Build and install an Arch package from PKGBUILD via makepkg.
This installer is responsible for the full build + install of the
application on Arch-based systems. System dependencies are resolved
by makepkg itself (--syncdeps).
Note: makepkg must not be run as root, so this installer refuses
to run when the current user is UID 0.
"""
# Logical layer name, used by capability matchers.
layer = "os-packages"
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.
- pacman and makepkg are available,
- a PKGBUILD file exists in the repository root,
- the current user is NOT root (makepkg forbids root).
"""
if shutil.which("pacman") is None:
# Do not run makepkg as root it is explicitly forbidden.
try:
if hasattr(os, "geteuid") and os.geteuid() == 0:
return False
except Exception:
# On non-POSIX platforms just ignore this check.
pass
if shutil.which("pacman") is None or shutil.which("makepkg") is None:
return False
pkgbuild_path = os.path.join(ctx.repo_dir, self.PKGBUILD_NAME)
return os.path.exists(pkgbuild_path)
def _extract_pkgbuild_array(self, ctx: RepoContext, var_name: str) -> List[str]:
"""
Extract a Bash array (depends/makedepends) from PKGBUILD using bash itself.
Any failure in sourcing or extracting the variable is treated as fatal.
"""
pkgbuild_path = os.path.join(ctx.repo_dir, self.PKGBUILD_NAME)
if not os.path.exists(pkgbuild_path):
return []
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],
cwd=ctx.repo_dir,
text=True,
)
except Exception as exc:
print(
f"[Error] Failed to extract '{var_name}' from PKGBUILD in "
f"{ctx.identifier}: {exc}"
)
raise SystemExit(
f"PKGBUILD parsing failed for '{var_name}' in {ctx.identifier}: {exc}"
)
packages: List[str] = []
for line in output.splitlines():
line = line.strip()
if not line:
continue
packages.append(line)
return packages
def run(self, ctx: RepoContext) -> None:
"""
Install all packages from depends + makedepends via pacman.
Build and install the package using makepkg.
This uses:
makepkg --syncdeps --cleanbuild --install --noconfirm
Any failure is treated as fatal (SystemExit).
"""
depends = self._extract_pkgbuild_array(ctx, "depends")
makedepends = self._extract_pkgbuild_array(ctx, "makedepends")
all_pkgs = depends + makedepends
if not all_pkgs:
return
cmd = "sudo pacman -S --noconfirm " + " ".join(all_pkgs)
cmd = "makepkg --syncdeps --cleanbuild --install --noconfirm"
run_command(cmd, cwd=ctx.repo_dir, preview=ctx.preview)

View File

@@ -2,15 +2,22 @@
# -*- coding: utf-8 -*-
"""
Installer for Debian/Ubuntu system dependencies defined in debian/control.
Installer for Debian/Ubuntu packages defined via 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.
This installer:
1. Installs build dependencies via `apt-get build-dep ./`
2. Uses dpkg-buildpackage to build .deb packages from debian/*
3. Installs the resulting .deb files via `dpkg -i`
It is intended for Debian-based systems where dpkg-buildpackage and
apt/dpkg tooling are available.
"""
import glob
import os
import shutil
from typing import List
from pkgmgr.context import RepoContext
@@ -19,13 +26,22 @@ from pkgmgr.run_command import run_command
class DebianControlInstaller(BaseInstaller):
"""Install Debian/Ubuntu system packages from debian/control."""
"""
Build and install a Debian/Ubuntu package from debian/control.
This installer is responsible for the full build + install of the
application on Debian-like systems.
"""
# Logical layer name, used by capability matchers.
layer = "os-packages"
CONTROL_DIR = "debian"
CONTROL_FILE = "control"
def _is_debian_like(self) -> bool:
return shutil.which("apt-get") is not None
"""Return True if this looks like a Debian-based system."""
return shutil.which("dpkg-buildpackage") is not None
def _control_path(self, ctx: RepoContext) -> str:
return os.path.join(ctx.repo_dir, self.CONTROL_DIR, self.CONTROL_FILE)
@@ -33,7 +49,7 @@ class DebianControlInstaller(BaseInstaller):
def supports(self, ctx: RepoContext) -> bool:
"""
This installer is supported if:
- we are on a Debian-like system (apt-get available), and
- we are on a Debian-like system (dpkg-buildpackage available), and
- debian/control exists.
"""
if not self._is_debian_like():
@@ -41,101 +57,73 @@ class DebianControlInstaller(BaseInstaller):
return os.path.exists(self._control_path(ctx))
def _parse_control_dependencies(self, control_path: str) -> List[str]:
def _find_built_debs(self, repo_dir: str) -> List[str]:
"""
Parse Build-Depends, Build-Depends-Indep and Depends fields
from debian/control.
Find .deb files built by dpkg-buildpackage.
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}.
By default, dpkg-buildpackage creates .deb files in the parent
directory of the source tree.
"""
if not os.path.exists(control_path):
return []
parent = os.path.dirname(repo_dir)
pattern = os.path.join(parent, "*.deb")
return sorted(glob.glob(pattern))
with open(control_path, "r", encoding="utf-8") as f:
lines = f.readlines()
def _install_build_dependencies(self, ctx: RepoContext) -> None:
"""
Install build dependencies using `apt-get build-dep ./`.
deps: List[str] = []
current_key = None
current_val_lines: List[str] = []
This is a best-effort implementation that assumes:
- deb-src entries are configured in /etc/apt/sources.list*,
- apt-get is available on PATH.
target_keys = {
"Build-Depends",
"Build-Depends-Indep",
"Depends",
}
Any failure is treated as fatal (SystemExit), just like other
installer steps.
"""
if shutil.which("apt-get") is None:
print(
"[Warning] apt-get not found on PATH. "
"Skipping automatic build-dep installation for Debian."
)
return
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 = []
# Update package lists first for reliable build-dep resolution.
run_command("sudo apt-get update", cwd=ctx.repo_dir, preview=ctx.preview)
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
# Install build dependencies based on debian/control in the current tree.
# `apt-get build-dep ./` uses the source in the current directory.
builddep_cmd = "sudo apt-get build-dep -y ./"
run_command(builddep_cmd, cwd=ctx.repo_dir, preview=ctx.preview)
def run(self, ctx: RepoContext) -> None:
"""
Install Debian/Ubuntu system packages via apt-get.
Build and install Debian/Ubuntu packages from debian/*.
Steps:
1. apt-get build-dep ./ (automatic build dependency installation)
2. dpkg-buildpackage -b -us -uc
3. sudo dpkg -i ../*.deb
"""
control_path = self._control_path(ctx)
packages = self._parse_control_dependencies(control_path)
if not packages:
if not os.path.exists(control_path):
return
# Update and install in two separate commands for clarity.
run_command("sudo apt-get update", cwd=ctx.repo_dir, preview=ctx.preview)
# 1) Install build dependencies
self._install_build_dependencies(ctx)
cmd = "sudo apt-get install -y " + " ".join(packages)
run_command(cmd, cwd=ctx.repo_dir, preview=ctx.preview)
# 2) Build the package
build_cmd = "dpkg-buildpackage -b -us -uc"
run_command(build_cmd, cwd=ctx.repo_dir, preview=ctx.preview)
# 3) Locate built .deb files
debs = self._find_built_debs(ctx.repo_dir)
if not debs:
print(
"[Warning] No .deb files found after dpkg-buildpackage. "
"Skipping Debian package installation."
)
return
# 4) Install .deb files
install_cmd = "sudo dpkg -i " + " ".join(os.path.basename(d) for d in debs)
parent = os.path.dirname(ctx.repo_dir)
run_command(install_cmd, cwd=parent, preview=ctx.preview)

View File

@@ -2,16 +2,21 @@
# -*- coding: utf-8 -*-
"""
Installer for RPM-based system dependencies defined in *.spec files.
Installer for RPM-based packages 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.).
This installer:
1. Installs build dependencies via dnf/yum builddep (where available)
2. Uses rpmbuild to build RPMs from the provided .spec file
3. Installs the resulting RPMs via `rpm -i`
It targets 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
@@ -20,23 +25,44 @@ from pkgmgr.run_command import run_command
class RpmSpecInstaller(BaseInstaller):
"""Install RPM-based system packages from *.spec files."""
"""
Build and install RPM-based packages from *.spec files.
This installer is responsible for the full build + install of the
application on RPM-like systems.
"""
# Logical layer name, used by capability matchers.
layer = "os-packages"
def _is_rpm_like(self) -> bool:
return shutil.which("dnf") is not None or shutil.which("yum") is not None
"""
Basic RPM-like detection:
- rpmbuild must be available
- at least one of dnf / yum / yum-builddep must be present
"""
if shutil.which("rpmbuild") is None:
return False
has_dnf = shutil.which("dnf") is not None
has_yum = shutil.which("yum") is not None
has_yum_builddep = shutil.which("yum-builddep") is not None
return has_dnf or has_yum or has_yum_builddep
def _spec_path(self, ctx: RepoContext) -> Optional[str]:
"""Return the first *.spec file in the repository root, if any."""
pattern = os.path.join(ctx.repo_dir, "*.spec")
matches = glob.glob(pattern)
matches = sorted(glob.glob(pattern))
if not matches:
return None
# Take the first match deterministically (sorted)
return sorted(matches)[0]
return 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
- we are on an RPM-based system (rpmbuild + dnf/yum/yum-builddep available), and
- a *.spec file exists in the repository root.
"""
if not self._is_rpm_like():
@@ -44,109 +70,91 @@ class RpmSpecInstaller(BaseInstaller):
return self._spec_path(ctx) is not None
def _parse_spec_dependencies(self, spec_path: str) -> List[str]:
def _find_built_rpms(self) -> List[str]:
"""
Parse BuildRequires and Requires from a .spec file.
Find RPMs built by rpmbuild.
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.
By default, rpmbuild outputs RPMs into:
~/rpmbuild/RPMS/*/*.rpm
"""
if not os.path.exists(spec_path):
return []
home = os.path.expanduser("~")
pattern = os.path.join(home, "rpmbuild", "RPMS", "**", "*.rpm")
return sorted(glob.glob(pattern, recursive=True))
with open(spec_path, "r", encoding="utf-8") as f:
lines = f.readlines()
def _install_build_dependencies(self, ctx: RepoContext, spec_path: str) -> None:
"""
Install build dependencies for the given .spec file.
deps: List[str] = []
current_key = None
current_val_lines: List[str] = []
Strategy (best-effort):
target_keys = {
"BuildRequires",
"Requires",
}
1. If dnf is available:
sudo dnf builddep -y <spec>
2. Else if yum-builddep is available:
sudo yum-builddep -y <spec>
3. Else if yum is available:
sudo yum-builddep -y <spec> # Some systems provide it via yum plugin
4. Otherwise: print a warning and skip automatic builddep install.
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 = []
Any failure in builddep installation is treated as fatal (SystemExit),
consistent with other installer steps.
"""
spec_basename = os.path.basename(spec_path)
for line in lines:
stripped = line.lstrip()
if stripped.startswith("#"):
# Comment
continue
if shutil.which("dnf") is not None:
cmd = f"sudo dnf builddep -y {spec_basename}"
elif shutil.which("yum-builddep") is not None:
cmd = f"sudo yum-builddep -y {spec_basename}"
elif shutil.which("yum") is not None:
# Some distributions ship yum-builddep as a plugin.
cmd = f"sudo yum-builddep -y {spec_basename}"
else:
print(
"[Warning] No suitable RPM builddep tool (dnf/yum-builddep/yum) found. "
"Skipping automatic build dependency installation for RPM."
)
return
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
# Run builddep in the repository directory so relative spec paths work.
run_command(cmd, cwd=ctx.repo_dir, preview=ctx.preview)
def run(self, ctx: RepoContext) -> None:
"""
Install RPM-based system packages via dnf or yum.
Build and install RPM-based packages.
Steps:
1. dnf/yum builddep <spec> (automatic build dependency installation)
2. rpmbuild -ba path/to/spec
3. sudo rpm -i ~/rpmbuild/RPMS/*/*.rpm
"""
spec_path = self._spec_path(ctx)
if not spec_path:
return
packages = self._parse_spec_dependencies(spec_path)
if not packages:
return
# 1) Install build dependencies
self._install_build_dependencies(ctx, spec_path)
pkg_mgr = shutil.which("dnf") or shutil.which("yum")
if not pkg_mgr:
# 2) Build RPMs
# Use the full spec path, but run in the repo directory.
spec_basename = os.path.basename(spec_path)
build_cmd = f"rpmbuild -ba {spec_basename}"
run_command(build_cmd, cwd=ctx.repo_dir, preview=ctx.preview)
# 3) Find built RPMs
rpms = self._find_built_rpms()
if not rpms:
print(
"[Warning] No suitable RPM package manager (dnf/yum) found on PATH. "
"Skipping RPM dependency installation."
"[Warning] No RPM files found after rpmbuild. "
"Skipping RPM package installation."
)
return
cmd = f"sudo {pkg_mgr} install -y " + " ".join(packages)
run_command(cmd, cwd=ctx.repo_dir, preview=ctx.preview)
# 4) Install RPMs
if shutil.which("rpm") is None:
print(
"[Warning] rpm binary not found on PATH. "
"Cannot install built RPMs."
)
return
install_cmd = "sudo rpm -i " + " ".join(rpms)
run_command(install_cmd, cwd=ctx.repo_dir, preview=ctx.preview)

View File

@@ -1,117 +0,0 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
Installer for pkgmgr.yml manifest dependencies.
This installer reads pkgmgr.yml (if present) and installs referenced pkgmgr
repository dependencies via pkgmgr itself.
"""
import os
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 PkgmgrManifestInstaller(BaseInstaller):
"""Install pkgmgr-defined repository dependencies from pkgmgr.yml."""
MANIFEST_NAME = "pkgmgr.yml"
def supports(self, ctx: RepoContext) -> bool:
manifest_path = os.path.join(ctx.repo_dir, self.MANIFEST_NAME)
return os.path.exists(manifest_path)
def _load_manifest(self, manifest_path: str) -> Dict[str, Any]:
"""
Load the pkgmgr.yml manifest.
Any parsing error is treated as a fatal error (SystemExit).
"""
try:
with open(manifest_path, "r", encoding="utf-8") as f:
return yaml.safe_load(f) or {}
except Exception as exc:
print(f"Error loading {self.MANIFEST_NAME} in '{manifest_path}': {exc}")
raise SystemExit(
f"{self.MANIFEST_NAME} parsing failed for '{manifest_path}': {exc}"
)
def _collect_dependency_ids(self, dependencies: List[Dict[str, Any]]) -> List[str]:
ids: List[str] = []
for dep in dependencies:
if not isinstance(dep, dict):
continue
repo_id = dep.get("repository")
if repo_id:
ids.append(str(repo_id))
return ids
def run(self, ctx: RepoContext) -> None:
manifest_path = os.path.join(ctx.repo_dir, self.MANIFEST_NAME)
manifest = self._load_manifest(manifest_path)
if not manifest:
return
dependencies = manifest.get("dependencies", []) or []
if not isinstance(dependencies, list) or not dependencies:
return
author = manifest.get("author")
url = manifest.get("url")
description = manifest.get("description")
if not ctx.preview:
print("pkgmgr manifest detected:")
if author:
print(f" author: {author}")
if url:
print(f" url: {url}")
if description:
print(f" description: {description}")
dep_repo_ids = self._collect_dependency_ids(dependencies)
# Optionally pull dependencies if requested.
if ctx.update_dependencies and dep_repo_ids:
cmd_pull = "pkgmgr pull " + " ".join(dep_repo_ids)
run_command(cmd_pull, preview=ctx.preview)
# Install dependencies one by one.
for dep in dependencies:
if not isinstance(dep, dict):
continue
repo_id = dep.get("repository")
if not repo_id:
continue
version = dep.get("version")
reason = dep.get("reason")
if reason and not ctx.preview:
print(f"Installing dependency {repo_id}: {reason}")
else:
print(f"Installing dependency {repo_id}...")
cmd = f"pkgmgr install {repo_id}"
if version:
cmd += f" --version {version}"
if ctx.no_verification:
cmd += " --no-verification"
if ctx.update_dependencies:
cmd += " --dependencies"
if ctx.clone_mode:
cmd += f" --clone-mode {ctx.clone_mode}"
# Dependency installation failures are fatal.
run_command(cmd, preview=ctx.preview)

View File

@@ -2,7 +2,7 @@
# -*- coding: utf-8 -*-
"""
Installer for Python projects based on pyproject.toml and/or requirements.txt.
Installer for Python projects based on pyproject.toml.
Strategy:
- Determine a pip command in this order:
@@ -10,7 +10,6 @@ Strategy:
2. sys.executable -m pip (current interpreter)
3. "pip" from PATH as last resort
- If pyproject.toml exists: pip install .
- If requirements.txt exists: pip install -r requirements.txt
All installation failures are treated as fatal errors (SystemExit).
"""
@@ -25,17 +24,18 @@ from pkgmgr.run_command import run_command
class PythonInstaller(BaseInstaller):
"""Install Python projects and dependencies via pip."""
name = "python"
# Logical layer name, used by capability matchers.
layer = "python"
def supports(self, ctx) -> bool:
"""
Return True if this installer should handle the given repository.
Only pyproject.toml is supported as the single source of truth
for Python dependencies and packaging metadata.
"""
repo_dir = ctx.repo_dir
return (
os.path.exists(os.path.join(repo_dir, "pyproject.toml"))
or os.path.exists(os.path.join(repo_dir, "requirements.txt"))
)
return os.path.exists(os.path.join(repo_dir, "pyproject.toml"))
def _pip_cmd(self) -> str:
"""
@@ -52,7 +52,7 @@ class PythonInstaller(BaseInstaller):
def run(self, ctx) -> None:
"""
Install Python project (pyproject.toml) and/or requirements.txt.
Install Python project defined via pyproject.toml.
Any pip failure is propagated as SystemExit.
"""
@@ -66,12 +66,3 @@ class PythonInstaller(BaseInstaller):
)
cmd = f"{pip_cmd} install ."
run_command(cmd, cwd=ctx.repo_dir, preview=ctx.preview)
req_txt = os.path.join(ctx.repo_dir, "requirements.txt")
if os.path.exists(req_txt):
print(
f"requirements.txt found in {ctx.identifier}, "
f"installing Python dependencies..."
)
cmd = f"{pip_cmd} install -r requirements.txt"
run_command(cmd, cwd=ctx.repo_dir, preview=ctx.preview)

View File

@@ -1,19 +0,0 @@
post_install() {
echo ">>> Initializing Nix for this system..."
# Create /nix if missing
if [ ! -d /nix ]; then
sudo mkdir -m 0755 /nix
sudo chown root:root /nix
fi
# Enable Nix daemon
if systemctl list-unit-files | grep -q nix-daemon.service; then
sudo systemctl enable --now nix-daemon.service
fi
# Add user to nix-users
if id -u kevinveenbirkenbach >/dev/null 2>&1; then
sudo usermod -aG nix-users kevinveenbirkenbach
fi
}

View File

@@ -3,14 +3,14 @@ set -euo pipefail
echo ">>> Initializing Nix environment for package-manager..."
# 1. /nix Store
# 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
# 2. Enable nix-daemon if available
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
@@ -18,16 +18,13 @@ else
echo ">>> Warning: nix-daemon.service not found or systemctl not available."
fi
# 3. Gruppe nix-users sicherstellen
# 3. Ensure nix-users group
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
# 4. Add users to nix-users (best-effort)
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
@@ -35,7 +32,6 @@ if command -v loginctl >/dev/null 2>&1; then
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
@@ -44,5 +40,5 @@ elif command -v logname >/dev/null 2>&1; then
fi
fi
echo ">>> Nix initialization for package-manager complete."
echo ">>> Nix initialization complete."
echo ">>> You may need to log out and log back in to activate group membership."

View File

@@ -1,10 +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" -- "$@"
FLAKE_DIR="/usr/lib/package-manager"
exec nix run "${FLAKE_DIR}#pkgmgr" -- "$@"

View File

@@ -1,5 +1,6 @@
import runpy
import sys
import os
import unittest
import subprocess
@@ -34,7 +35,7 @@ def remove_pkgmgr_from_nix_profile() -> None:
prints a descriptive format without an index column inside the container.
Instead, we directly try to remove possible names:
- 'pkgmgr' (the actual name shown in `nix profile list`)
- 'pkgmgr' (the actual name shown in `nix profile list`)
- 'package-manager' (the name mentioned in Nix's own error hints)
"""
for spec in ("pkgmgr", "package-manager"):
@@ -44,6 +45,44 @@ def remove_pkgmgr_from_nix_profile() -> None:
)
def pkgmgr_help_debug() -> None:
"""
Run `pkgmgr --help` after installation *inside an interactive bash shell*,
print its output and return code, but never fail the test.
Reason:
- The installer adds venv/alias setup into shell rc files (~/.bashrc, ~/.zshrc)
- Those changes are only applied in a new interactive shell session.
"""
print("\n--- PKGMGR HELP (after installation, via bash -i) ---")
# Simulate a fresh interactive bash, so ~/.bashrc gets sourced
proc = subprocess.run(
["bash", "-i", "-c", "pkgmgr --help"],
capture_output=True,
text=True,
check=False,
env=os.environ.copy(),
)
stdout = proc.stdout.strip()
stderr = proc.stderr.strip()
if stdout:
print(stdout)
if stderr:
print("stderr:", stderr)
print(f"returncode: {proc.returncode}")
print("--- END ---\n")
# Wichtig: Hier KEIN AssertionError mehr das ist reine Debug-Ausgabe.
# Falls du später hart testen willst, kannst du optional:
# if proc.returncode != 0:
# self.fail("...")
# aber aktuell nur Sichtprüfung.
class TestIntegrationInstalPKGMGRShallow(unittest.TestCase):
def test_install_pkgmgr_self_install(self) -> None:
# Debug before cleanup
@@ -65,7 +104,11 @@ class TestIntegrationInstalPKGMGRShallow(unittest.TestCase):
"shallow",
"--no-verification",
]
# Führt die Installation via main.py aus
runpy.run_module("main", run_name="__main__")
# Nach erfolgreicher Installation: pkgmgr --help anzeigen (Debug)
pkgmgr_help_debug()
finally:
sys.argv = original_argv

View File

@@ -26,39 +26,69 @@ class TestArchPkgbuildInstaller(unittest.TestCase):
)
self.installer = ArchPkgbuildInstaller()
@patch("pkgmgr.installers.os_packages.arch_pkgbuild.os.geteuid", return_value=1000)
@patch("os.path.exists", return_value=True)
@patch("shutil.which", return_value="/usr/bin/pacman")
def test_supports_true_when_pacman_and_pkgbuild_exist(self, mock_which, mock_exists):
@patch("shutil.which")
def test_supports_true_when_tools_and_pkgbuild_exist(
self, mock_which, mock_exists, mock_geteuid
):
def which_side_effect(name):
if name in ("pacman", "makepkg"):
return f"/usr/bin/{name}"
return None
mock_which.side_effect = which_side_effect
self.assertTrue(self.installer.supports(self.ctx))
mock_which.assert_called_with("pacman")
calls = [c.args[0] for c in mock_which.call_args_list]
self.assertIn("pacman", calls)
self.assertIn("makepkg", calls)
mock_exists.assert_called_with(os.path.join(self.ctx.repo_dir, "PKGBUILD"))
@patch("pkgmgr.installers.os_packages.arch_pkgbuild.os.geteuid", return_value=0)
@patch("os.path.exists", return_value=True)
@patch("shutil.which")
def test_supports_false_when_running_as_root(
self, mock_which, mock_exists, mock_geteuid
):
mock_which.return_value = "/usr/bin/pacman"
self.assertFalse(self.installer.supports(self.ctx))
@patch("pkgmgr.installers.os_packages.arch_pkgbuild.os.geteuid", return_value=1000)
@patch("os.path.exists", return_value=False)
@patch("shutil.which", return_value="/usr/bin/pacman")
def test_supports_false_when_pkgbuild_missing(self, mock_which, mock_exists):
@patch("shutil.which")
def test_supports_false_when_pkgbuild_missing(
self, mock_which, mock_exists, mock_geteuid
):
mock_which.return_value = "/usr/bin/pacman"
self.assertFalse(self.installer.supports(self.ctx))
@patch("pkgmgr.installers.os_packages.arch_pkgbuild.run_command")
@patch("subprocess.check_output", return_value="python\ngit\n")
@patch("pkgmgr.installers.os_packages.arch_pkgbuild.os.geteuid", return_value=1000)
@patch("os.path.exists", return_value=True)
@patch("shutil.which", return_value="/usr/bin/pacman")
def test_run_installs_all_packages_and_uses_clean_bash(
self, mock_which, mock_exists, mock_check_output, mock_run_command
@patch("shutil.which")
def test_run_builds_and_installs_with_makepkg(
self, mock_which, mock_exists, mock_geteuid, mock_run_command
):
def which_side_effect(name):
if name in ("pacman", "makepkg"):
return f"/usr/bin/{name}"
return None
mock_which.side_effect = which_side_effect
self.installer.run(self.ctx)
# 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)
# pacman install command
cmd = mock_run_command.call_args[0][0]
self.assertTrue(cmd.startswith("sudo pacman -S --noconfirm "))
self.assertIn("python", cmd)
self.assertIn("git", cmd)
self.assertEqual(
cmd,
"makepkg --syncdeps --cleanbuild --install --noconfirm",
)
self.assertEqual(
mock_run_command.call_args[1].get("cwd"),
self.ctx.repo_dir,
)
if __name__ == "__main__":

View File

@@ -1,7 +1,8 @@
# tests/unit/pkgmgr/installers/os_packages/test_debian_control.py
import os
import unittest
from unittest.mock import patch, mock_open
from unittest.mock import patch
from pkgmgr.context import RepoContext
from pkgmgr.installers.os_packages.debian_control import DebianControlInstaller
@@ -26,40 +27,53 @@ class TestDebianControlInstaller(unittest.TestCase):
self.installer = DebianControlInstaller()
@patch("os.path.exists", return_value=True)
@patch("shutil.which", return_value="/usr/bin/apt-get")
@patch("shutil.which", return_value="/usr/bin/dpkg-buildpackage")
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):
def test_supports_false_without_dpkg_buildpackage(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("glob.glob", return_value=["/tmp/package-manager_0.1.1_all.deb"])
@patch("os.path.exists", return_value=True)
@patch("shutil.which", return_value="/usr/bin/apt-get")
def test_run_installs_parsed_packages(
@patch("shutil.which")
def test_run_builds_and_installs_debs(
self,
mock_which,
mock_exists,
mock_file,
mock_run_command
mock_glob,
mock_run_command,
):
# dpkg-buildpackage + apt-get vorhanden
def which_side_effect(name):
if name == "dpkg-buildpackage":
return "/usr/bin/dpkg-buildpackage"
if name == "apt-get":
return "/usr/bin/apt-get"
return None
mock_which.side_effect = which_side_effect
self.installer.run(self.ctx)
# First call: apt-get update
self.assertIn("apt-get update", mock_run_command.call_args_list[0][0][0])
cmds = [c[0][0] for c in mock_run_command.call_args_list]
# 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)
# 1) apt-get update
self.assertTrue(any("apt-get update" in cmd for cmd in cmds))
# 2) apt-get build-dep ./
self.assertTrue(any("apt-get build-dep -y ./ " in cmd or
"apt-get build-dep -y ./"
in cmd for cmd in cmds))
# 3) dpkg-buildpackage -b -us -uc
self.assertTrue(any("dpkg-buildpackage -b -us -uc" in cmd for cmd in cmds))
# 4) dpkg -i ../*.deb
self.assertTrue(any(cmd.startswith("sudo dpkg -i ") for cmd in cmds))
if __name__ == "__main__":

View File

@@ -1,7 +1,7 @@
# tests/unit/pkgmgr/installers/os_packages/test_rpm_spec.py
import unittest
from unittest.mock import patch, mock_open
from unittest.mock import patch
from pkgmgr.context import RepoContext
from pkgmgr.installers.os_packages.rpm_spec import RpmSpecInstaller
@@ -26,34 +26,67 @@ class TestRpmSpecInstaller(unittest.TestCase):
self.installer = RpmSpecInstaller()
@patch("glob.glob", return_value=["/tmp/repo/test.spec"])
@patch("shutil.which", return_value="/usr/bin/dnf")
@patch("shutil.which")
def test_supports_true(self, mock_which, mock_glob):
def which_side_effect(name):
if name == "rpmbuild":
return "/usr/bin/rpmbuild"
if name == "dnf":
return "/usr/bin/dnf"
return None
mock_which.side_effect = which_side_effect
self.assertTrue(self.installer.supports(self.ctx))
@patch("glob.glob", return_value=[])
@patch("shutil.which", return_value="/usr/bin/dnf")
@patch("shutil.which")
def test_supports_false_missing_spec(self, mock_which, mock_glob):
mock_which.return_value = "/usr/bin/rpmbuild"
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
@patch("glob.glob")
@patch("shutil.which")
def test_run_builds_and_installs_rpms(
self,
mock_which,
mock_glob,
mock_run_command,
):
# glob.glob wird zweimal benutzt: einmal für *.spec, einmal für gebaute RPMs
def glob_side_effect(pattern, recursive=False):
if pattern.endswith("*.spec"):
return ["/tmp/repo/package-manager.spec"]
if "rpmbuild/RPMS" in pattern:
return ["/home/user/rpmbuild/RPMS/x86_64/package-manager-0.1.1.rpm"]
return []
mock_glob.side_effect = glob_side_effect
def which_side_effect(name):
if name == "rpmbuild":
return "/usr/bin/rpmbuild"
if name == "dnf":
return "/usr/bin/dnf"
if name == "rpm":
return "/usr/bin/rpm"
return None
mock_which.side_effect = which_side_effect
self.installer.run(self.ctx)
install_cmd = mock_run_command.call_args_list[0][0][0]
cmds = [c[0][0] for c in mock_run_command.call_args_list]
self.assertIn("dnf install -y", install_cmd)
self.assertIn("python3-devel", install_cmd)
self.assertIn("git", install_cmd)
self.assertIn("curl", install_cmd)
# 1) builddep
self.assertTrue(any("builddep -y" in cmd for cmd in cmds))
# 2) rpmbuild -ba
self.assertTrue(any(cmd.startswith("rpmbuild -ba ") for cmd in cmds))
# 3) rpm -i …
self.assertTrue(any(cmd.startswith("sudo rpm -i ") for cmd in cmds))
if __name__ == "__main__":

View File

@@ -1,87 +0,0 @@
# tests/unit/pkgmgr/installers/test_pkgmgr_manifest.py
import os
import unittest
from unittest.mock import patch, mock_open
from pkgmgr.context import RepoContext
from pkgmgr.installers.pkgmgr_manifest import PkgmgrManifestInstaller
class TestPkgmgrManifestInstaller(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=True,
)
self.installer = PkgmgrManifestInstaller()
@patch("os.path.exists", return_value=True)
def test_supports_true_when_manifest_exists(self, mock_exists):
self.assertTrue(self.installer.supports(self.ctx))
manifest_path = os.path.join(self.ctx.repo_dir, "pkgmgr.yml")
mock_exists.assert_called_with(manifest_path)
@patch("os.path.exists", return_value=False)
def test_supports_false_when_manifest_missing(self, mock_exists):
self.assertFalse(self.installer.supports(self.ctx))
@patch("pkgmgr.installers.pkgmgr_manifest.run_command")
@patch("builtins.open", new_callable=mock_open, read_data="""
version: 1
author: "Kevin"
url: "https://example.com"
description: "Test repo"
dependencies:
- repository: github:user/repo1
version: main
reason: "Core dependency"
- repository: github:user/repo2
""")
@patch("os.path.exists", return_value=True)
def test_run_installs_dependencies_and_pulls_when_update_enabled(
self, mock_exists, mock_file, mock_run_command
):
self.installer.run(self.ctx)
# First call: pkgmgr pull github:user/repo1 github:user/repo2
# Then calls to pkgmgr install ...
cmds = [call_args[0][0] for call_args in mock_run_command.call_args_list]
self.assertIn(
"pkgmgr pull github:user/repo1 github:user/repo2",
cmds,
)
self.assertIn(
"pkgmgr install github:user/repo1 --version main --dependencies --clone-mode ssh",
cmds,
)
# For repo2: no version but dependencies + clone_mode
self.assertIn(
"pkgmgr install github:user/repo2 --dependencies --clone-mode ssh",
cmds,
)
@patch("pkgmgr.installers.pkgmgr_manifest.run_command")
@patch("builtins.open", new_callable=mock_open, read_data="{}")
@patch("os.path.exists", return_value=True)
def test_run_no_dependencies_no_command_called(
self, mock_exists, mock_file, mock_run_command
):
self.ctx.update_dependencies = True
self.installer.run(self.ctx)
mock_run_command.assert_not_called()
if __name__ == "__main__":
unittest.main()

View File

@@ -30,19 +30,12 @@ class TestPythonInstaller(unittest.TestCase):
def test_supports_true_when_pyproject_exists(self, mock_exists):
self.assertTrue(self.installer.supports(self.ctx))
@patch("os.path.exists", side_effect=lambda path: path.endswith("requirements.txt"))
def test_supports_true_when_requirements_exists(self, mock_exists):
self.assertTrue(self.installer.supports(self.ctx))
@patch("os.path.exists", return_value=False)
def test_supports_false_when_no_python_files(self, mock_exists):
def test_supports_false_when_no_pyproject(self, mock_exists):
self.assertFalse(self.installer.supports(self.ctx))
@patch("pkgmgr.installers.python.run_command")
@patch(
"os.path.exists",
side_effect=lambda path: path.endswith("pyproject.toml")
)
@patch("os.path.exists", side_effect=lambda path: path.endswith("pyproject.toml"))
def test_run_installs_project_from_pyproject(self, mock_exists, mock_run_command):
self.installer.run(self.ctx)
cmd = mock_run_command.call_args[0][0]
@@ -52,20 +45,6 @@ class TestPythonInstaller(unittest.TestCase):
self.ctx.repo_dir,
)
@patch("pkgmgr.installers.python.run_command")
@patch(
"os.path.exists",
side_effect=lambda path: path.endswith("requirements.txt")
)
def test_run_installs_dependencies_from_requirements(self, mock_exists, mock_run_command):
self.installer.run(self.ctx)
cmd = mock_run_command.call_args[0][0]
self.assertIn("pip install -r requirements.txt", cmd)
self.assertEqual(
mock_run_command.call_args[1].get("cwd"),
self.ctx.repo_dir,
)
if __name__ == "__main__":
unittest.main()

View File

@@ -0,0 +1,76 @@
# tests/unit/pkgmgr/test_capabilities.py
import os
import unittest
from unittest.mock import patch, mock_open
from pkgmgr.capabilities import (
PythonRuntimeCapability,
MakeInstallCapability,
NixFlakeCapability,
)
class DummyCtx:
"""Minimal RepoContext stub with just repo_dir."""
def __init__(self, repo_dir: str):
self.repo_dir = repo_dir
class TestCapabilities(unittest.TestCase):
def setUp(self):
self.ctx = DummyCtx("/tmp/repo")
@patch("pkgmgr.capabilities.os.path.exists")
def test_python_runtime_python_layer_pyproject(self, mock_exists):
cap = PythonRuntimeCapability()
def exists_side_effect(path):
return path.endswith("pyproject.toml")
mock_exists.side_effect = exists_side_effect
self.assertTrue(cap.applies_to_layer("python"))
self.assertTrue(cap.is_provided(self.ctx, "python"))
@patch("pkgmgr.capabilities._read_text_if_exists")
@patch("pkgmgr.capabilities.os.path.exists")
def test_python_runtime_nix_layer_flake(self, mock_exists, mock_read):
cap = PythonRuntimeCapability()
def exists_side_effect(path):
return path.endswith("flake.nix")
mock_exists.side_effect = exists_side_effect
mock_read.return_value = "buildPythonApplication something"
self.assertTrue(cap.applies_to_layer("nix"))
self.assertTrue(cap.is_provided(self.ctx, "nix"))
@patch("pkgmgr.capabilities.os.path.exists", return_value=True)
@patch(
"builtins.open",
new_callable=mock_open,
read_data="install:\n\t echo 'installing'\n",
)
def test_make_install_makefile_layer(self, mock_file, mock_exists):
cap = MakeInstallCapability()
self.assertTrue(cap.applies_to_layer("makefile"))
self.assertTrue(cap.is_provided(self.ctx, "makefile"))
@patch("pkgmgr.capabilities.os.path.exists")
def test_nix_flake_capability_on_nix_layer(self, mock_exists):
cap = NixFlakeCapability()
def exists_side_effect(path):
return path.endswith("flake.nix")
mock_exists.side_effect = exists_side_effect
self.assertTrue(cap.applies_to_layer("nix"))
self.assertTrue(cap.is_provided(self.ctx, "nix"))
if __name__ == "__main__":
unittest.main()

View File

@@ -1,13 +1,18 @@
from pkgmgr.run_command import run_command
# tests/unit/pkgmgr/test_install_repos.py
import unittest
from unittest.mock import patch, MagicMock
from pkgmgr.context import RepoContext
import pkgmgr.install_repos as install_module
from pkgmgr.installers.base import BaseInstaller
class DummyInstaller:
class DummyInstaller(BaseInstaller):
"""Simple installer for testing orchestration."""
layer = None # keine speziellen Capabilities
def __init__(self):
self.calls = []