Compare commits

..

14 Commits

Author SHA1 Message Date
Kevin Veen-Birkenbach
3b2c657bfa Release version 0.7.12 2025-12-09 23:36:38 +01:00
Kevin Veen-Birkenbach
e335ab05a1 fix(core/ink): prevent self-referential symlinks + add unit tests
This commit adds a safety guard to create_ink() to prevent creation of
self-referential symlinks when the resolved command already lives at the
intended link target (e.g. ~/.local/bin/package-manager). Such a situation
previously resulted in broken shells with the error:

    "zsh: too many levels of symbolic links"

Key changes:
  - create_ink():
      • Introduce early-abort guard when command == link_path
      • Improve function signature and formatting
      • Enhance alias creation messaging

  - Added comprehensive unit tests under:
        tests/unit/pkgmgr/core/command/test_ink.py
    Tests cover:
      • Self-referential command path → skip symlink creation
      • Standard symlink + alias creation behaviour

This prevents pkgmgr from overwriting user-managed binaries inside ~/.local/bin
and ensures predictable, safe behaviour across all installer layers.

https://chatgpt.com/share/6938a43b-0eb8-800f-9545-6cb555ab406d
2025-12-09 23:35:29 +01:00
Kevin Veen-Birkenbach
75f963d6e2 Removed tests/e2e/test_install_all_shallow.py 2025-12-09 23:18:49 +01:00
Kevin Veen-Birkenbach
94b998741f Release version 0.7.11 2025-12-09 23:16:48 +01:00
Kevin Veen-Birkenbach
172c734866 test: fix installer unit tests for OS packages and Nix dev shell
Update Debian, RPM, Nix flake, and Python installer unit tests to match the current
installer behavior and to run correctly inside the Nix development shell.

- DebianControlInstaller:
  - Add clearer docstrings for supports() behavior.
  - Relax final install assertion to accept dpkg -i, sudo dpkg -i, or
    sudo apt-get install -y.
  - Keep checks for apt-get update, apt-get build-dep, and dpkg-buildpackage.

- RpmSpecInstaller:
  - Add docstrings for supports() conditions.
  - Mock _prepare_source_tarball() to avoid touching the filesystem.
  - Assert builddep, rpmbuild -ba, and sudo dnf install -y commands.

- NixFlakeInstaller:
  - Ensure supports() and run() tests simulate a non-Nix-shell environment
    via IN_NIX_SHELL and PKGMGR_DISABLE_NIX_FLAKE_INSTALLER.
  - Verify that the old profile entry is removed and both pkgmgr and default
    flake outputs are installed.
  - Confirm _ensure_old_profile_removed() swallows SystemExit.

- PythonInstaller:
  - Make supports() and run() tests ignore the real IN_NIX_SHELL environment.
  - Assert that pip install . is invoked with cwd set to the repository
    directory.

These changes make the unit tests stable in the Nix dev shell and align them
with the current installer implementations.
2025-12-09 23:15:56 +01:00
Kevin Veen-Birkenbach
1b483e178d Release version 0.7.10 2025-12-09 22:57:11 +01:00
Kevin Veen-Birkenbach
78693225f1 test: share persistent Nix store across all test containers
This commit adds the `pkgmgr_nix_store` volume mount (`/nix`) to all test
runners (unit, integration, container sanity checks, and E2E tests).

Previously only the Arch-based E2E container mounted a persistent `/nix`
store, causing all other distros (Debian, Ubuntu, Fedora, CentOS, etc.)
to download the entire Nix closure repeatedly during test runs.

Changes:
- Add `-v pkgmgr_nix_store:/nix` to:
  - scripts/test/test-container.sh
  - scripts/test/test-e2e.sh (remove Arch-only condition)
  - scripts/test/test-unit.sh
  - scripts/test/test-integration.sh
- Ensures all test containers reuse the same Nix store.

Benefits:
- Significantly faster test execution after the first run.
- Prevents redundant downloads from cache.nixos.org.
- Ensures consistent Nix environments across all test distros.

No functional changes to pkgmgr itself; only test infrastructure improved.

https://chatgpt.com/share/693890f5-2f54-800f-b47e-1925da85b434
2025-12-09 22:13:01 +01:00
Kevin Veen-Birkenbach
ca08c84789 Merge branch 'fix/branch-master' 2025-12-09 21:19:53 +01:00
Kevin Veen-Birkenbach
e930b422e5 Release version 0.7.9 2025-12-09 21:19:13 +01:00
Kevin Veen-Birkenbach
0833d04376 Improve branch helpers with main/master base resolution
- Update pkgmgr.actions.branch.open_branch() to resolve the base branch
  via _resolve_base_branch(), preferring 'main' and falling back to
  'master' when the preferred branch does not exist.
- Adjust the open_branch logic to:
  - fetch from origin
  - checkout the resolved base branch
  - pull the resolved base branch
  - create the feature branch
  - push the new branch with upstream tracking
- Add and refine unit tests in tests/unit/pkgmgr/actions/test_branch.py
  to cover:
  - normal branch creation with explicit name and default base
  - interactive name prompting when no name is provided
  - error handling when fetch fails after successful base resolution
  - fallback to 'master' when 'main' is missing.
- Clean up and clarify docstrings and comments for open_branch(),
  close_branch(), and _resolve_base_branch(), and fix the module header
  comment to match the new package path.

This fixes branch opening in repositories that still use 'master' as
their primary branch while keeping the default behavior for 'main'.

https://chatgpt.com/share/6938838f-7aac-800f-b130-924e07ef48b9
2025-12-09 21:16:10 +01:00
Kevin Veen-Birkenbach
55f36d76ec Merge branch 'fix/file-error' 2025-12-09 21:09:48 +01:00
Kevin Veen-Birkenbach
6a838ee84f Release version 0.7.8 2025-12-09 21:03:24 +01:00
Kevin Veen-Birkenbach
4285bf4a54 Fix: release now skips missing pyproject.toml without failing
- Updated update_pyproject_version() to gracefully skip missing or unreadable pyproject.toml
- Added corresponding unit test ensuring missing file triggers no exception and no file creation
- Updated test wording for spec changelog section
- Ref: adjustments discussed in ChatGPT conversation (2025-12-09) - https://chatgpt.com/share/69388024-93e4-800f-a09f-bf78a6b9a53f
2025-12-09 21:02:01 +01:00
Kevin Veen-Birkenbach
640b1042c2 git commit -m "Harden installers for Nix, OS packages and Docker CA handling
- NixFlakeInstaller:
  - Skip when running inside a Nix dev shell (IN_NIX_SHELL).
  - Add PKGMGR_DISABLE_NIX_FLAKE_INSTALLER kill-switch for CI/debugging.
  - Ensure run() respects supports() and handles preview/allow_failure cleanly.

- DebianControlInstaller:
  - Introduce _privileged_prefix() to handle sudo vs. root vs. no elevation.
  - Avoid hard-coded sudo usage and degrade gracefully when neither sudo nor
    root is available.
  - Improve messaging around build-dep and .deb installation.

- RpmSpecInstaller:
  - Prepare rpmbuild tree and source tarball in ~/rpmbuild/SOURCES based on
    Name/Version from the spec file.
  - Reuse a helper to resolve the rpmbuild topdir.
  - Install built RPMs via dnf/yum when available, falling back to rpm -Uvh
    to avoid file conflicts during upgrades.

- PythonInstaller:
  - Skip pip-based installation inside Nix dev shells (IN_NIX_SHELL).
  - Add PKGMGR_DISABLE_PYTHON_INSTALLER kill-switch.
  - Make pip command resolution explicit and overridable via PKGMGR_PIP.
  - Type-hint supports() and run() with RepoContext/InstallContext.

- Docker entrypoint:
  - Add robust CA bundle detection for Nix, Git, Python requests and curl.
  - Export NIX_SSL_CERT_FILE, SSL_CERT_FILE, REQUESTS_CA_BUNDLE and
    GIT_SSL_CAINFO from a single detected CA path.
  - Improve logging and section comments in the entrypoint script."

https://chatgpt.com/share/69387df8-bda0-800f-a053-aa9e2999dc84
2025-12-09 20:52:07 +01:00
29 changed files with 946 additions and 292 deletions

View File

@@ -1,3 +1,28 @@
## [0.7.12] - 2025-12-09
* Fixed self refering alias during setup
## [0.7.11] - 2025-12-09
* test: fix installer unit tests for OS packages and Nix dev shell
## [0.7.10] - 2025-12-09
* Fixed test_install_pkgmgr_shallow.py
## [0.7.9] - 2025-12-09
* 'main' and 'master' are now both accepted as branches for branch close merge
## [0.7.8] - 2025-12-09
* Missing pyproject.toml doesn't lead to an error during release
## [0.7.7] - 2025-12-09 ## [0.7.7] - 2025-12-09
* Added TEST_PATTERN parameter to execute dedicated tests * Added TEST_PATTERN parameter to execute dedicated tests

View File

@@ -1,7 +1,7 @@
# Maintainer: Kevin Veen-Birkenbach <info@veen.world> # Maintainer: Kevin Veen-Birkenbach <info@veen.world>
pkgname=package-manager pkgname=package-manager
pkgver=0.7.7 pkgver=0.7.12
pkgrel=1 pkgrel=1
pkgdesc="Local-flake wrapper for Kevin's package-manager (Nix-based)." pkgdesc="Local-flake wrapper for Kevin's package-manager (Nix-based)."
arch=('any') arch=('any')

30
debian/changelog vendored
View File

@@ -1,3 +1,33 @@
package-manager (0.7.12-1) unstable; urgency=medium
* Fixed self refering alias during setup
-- Kevin Veen-Birkenbach <kevin@veen.world> Tue, 09 Dec 2025 23:36:35 +0100
package-manager (0.7.11-1) unstable; urgency=medium
* test: fix installer unit tests for OS packages and Nix dev shell
-- Kevin Veen-Birkenbach <kevin@veen.world> Tue, 09 Dec 2025 23:16:46 +0100
package-manager (0.7.10-1) unstable; urgency=medium
* Fixed test_install_pkgmgr_shallow.py
-- Kevin Veen-Birkenbach <kevin@veen.world> Tue, 09 Dec 2025 22:57:08 +0100
package-manager (0.7.9-1) unstable; urgency=medium
* 'main' and 'master' are now both accepted as branches for branch close merge
-- Kevin Veen-Birkenbach <kevin@veen.world> Tue, 09 Dec 2025 21:19:13 +0100
package-manager (0.7.8-1) unstable; urgency=medium
* Missing pyproject.toml doesn't lead to an error during release
-- Kevin Veen-Birkenbach <kevin@veen.world> Tue, 09 Dec 2025 21:03:24 +0100
package-manager (0.7.7-1) unstable; urgency=medium package-manager (0.7.7-1) unstable; urgency=medium
* Added TEST_PATTERN parameter to execute dedicated tests * Added TEST_PATTERN parameter to execute dedicated tests

View File

@@ -31,7 +31,7 @@
rec { rec {
pkgmgr = pyPkgs.buildPythonApplication { pkgmgr = pyPkgs.buildPythonApplication {
pname = "package-manager"; pname = "package-manager";
version = "0.7.7"; version = "0.7.12";
# Use the git repo as source # Use the git repo as source
src = ./.; src = ./.;

View File

@@ -1,5 +1,5 @@
Name: package-manager Name: package-manager
Version: 0.7.7 Version: 0.7.12
Release: 1%{?dist} Release: 1%{?dist}
Summary: Wrapper that runs Kevin's package-manager via Nix flake Summary: Wrapper that runs Kevin's package-manager via Nix flake
@@ -77,6 +77,21 @@ echo ">>> package-manager removed. Nix itself was not removed."
/usr/lib/package-manager/ /usr/lib/package-manager/
%changelog %changelog
* Tue Dec 09 2025 Kevin Veen-Birkenbach <kevin@veen.world> - 0.7.12-1
- Fixed self refering alias during setup
* Tue Dec 09 2025 Kevin Veen-Birkenbach <kevin@veen.world> - 0.7.11-1
- test: fix installer unit tests for OS packages and Nix dev shell
* Tue Dec 09 2025 Kevin Veen-Birkenbach <kevin@veen.world> - 0.7.10-1
- Fixed test_install_pkgmgr_shallow.py
* Tue Dec 09 2025 Kevin Veen-Birkenbach <kevin@veen.world> - 0.7.9-1
- 'main' and 'master' are now both accepted as branches for branch close merge
* Tue Dec 09 2025 Kevin Veen-Birkenbach <kevin@veen.world> - 0.7.8-1
- Missing pyproject.toml doesn't lead to an error during release
* Tue Dec 09 2025 Kevin Veen-Birkenbach <kevin@veen.world> - 0.7.7-1 * Tue Dec 09 2025 Kevin Veen-Birkenbach <kevin@veen.world> - 0.7.7-1
- Added TEST_PATTERN parameter to execute dedicated tests - Added TEST_PATTERN parameter to execute dedicated tests

View File

@@ -1,4 +1,4 @@
# pkgmgr/branch_commands.py # pkgmgr/actions/branch/__init__.py
#!/usr/bin/env python3 #!/usr/bin/env python3
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
@@ -16,30 +16,43 @@ from typing import Optional
from pkgmgr.core.git import run_git, GitError, get_current_branch from pkgmgr.core.git import run_git, GitError, get_current_branch
# ---------------------------------------------------------------------------
# Branch creation (open)
# ---------------------------------------------------------------------------
def open_branch( def open_branch(
name: Optional[str], name: Optional[str],
base_branch: str = "main", base_branch: str = "main",
fallback_base: str = "master",
cwd: str = ".", cwd: str = ".",
) -> None: ) -> None:
""" """
Create and push a new feature branch on top of `base_branch`. Create and push a new feature branch on top of a base branch.
The base branch is resolved by:
1. Trying 'base_branch' (default: 'main')
2. Falling back to 'fallback_base' (default: 'master')
Steps: Steps:
1) git fetch origin 1) git fetch origin
2) git checkout <base_branch> 2) git checkout <resolved_base>
3) git pull origin <base_branch> 3) git pull origin <resolved_base>
4) git checkout -b <name> 4) git checkout -b <name>
5) git push -u origin <name> 5) git push -u origin <name>
If `name` is None or empty, the user is prompted on stdin. If `name` is None or empty, the user is prompted to enter one.
""" """
# Request name interactively if not provided
if not name: if not name:
name = input("Enter new branch name: ").strip() name = input("Enter new branch name: ").strip()
if not name: if not name:
raise RuntimeError("Branch name must not be empty.") raise RuntimeError("Branch name must not be empty.")
# Resolve which base branch to use (main or master)
resolved_base = _resolve_base_branch(base_branch, fallback_base, cwd=cwd)
# 1) Fetch from origin # 1) Fetch from origin
try: try:
run_git(["fetch", "origin"], cwd=cwd) run_git(["fetch", "origin"], cwd=cwd)
@@ -50,18 +63,18 @@ def open_branch(
# 2) Checkout base branch # 2) Checkout base branch
try: try:
run_git(["checkout", base_branch], cwd=cwd) run_git(["checkout", resolved_base], cwd=cwd)
except GitError as exc: except GitError as exc:
raise RuntimeError( raise RuntimeError(
f"Failed to checkout base branch {base_branch!r}: {exc}" f"Failed to checkout base branch {resolved_base!r}: {exc}"
) from exc ) from exc
# 3) Pull latest changes on base # 3) Pull latest changes for base branch
try: try:
run_git(["pull", "origin", base_branch], cwd=cwd) run_git(["pull", "origin", resolved_base], cwd=cwd)
except GitError as exc: except GitError as exc:
raise RuntimeError( raise RuntimeError(
f"Failed to pull latest changes for base branch {base_branch!r}: {exc}" f"Failed to pull latest changes for base branch {resolved_base!r}: {exc}"
) from exc ) from exc
# 4) Create new branch # 4) Create new branch
@@ -69,10 +82,10 @@ def open_branch(
run_git(["checkout", "-b", name], cwd=cwd) run_git(["checkout", "-b", name], cwd=cwd)
except GitError as exc: except GitError as exc:
raise RuntimeError( raise RuntimeError(
f"Failed to create new branch {name!r} from base {base_branch!r}: {exc}" f"Failed to create new branch {name!r} from base {resolved_base!r}: {exc}"
) from exc ) from exc
# 5) Push and set upstream # 5) Push new branch to origin
try: try:
run_git(["push", "-u", "origin", name], cwd=cwd) run_git(["push", "-u", "origin", name], cwd=cwd)
except GitError as exc: except GitError as exc:
@@ -81,15 +94,21 @@ def open_branch(
) from exc ) from exc
# ---------------------------------------------------------------------------
# Base branch resolver (shared by open/close)
# ---------------------------------------------------------------------------
def _resolve_base_branch( def _resolve_base_branch(
preferred: str, preferred: str,
fallback: str, fallback: str,
cwd: str, cwd: str,
) -> str: ) -> str:
""" """
Resolve the base branch to use for merging. Resolve the base branch to use.
Try `preferred` first (default: main),
fall back to `fallback` (default: master).
Try `preferred` (default: main) first, then `fallback` (default: master).
Raise RuntimeError if neither exists. Raise RuntimeError if neither exists.
""" """
for candidate in (preferred, fallback): for candidate in (preferred, fallback):
@@ -104,6 +123,10 @@ def _resolve_base_branch(
) )
# ---------------------------------------------------------------------------
# Branch closing (merge + deletion)
# ---------------------------------------------------------------------------
def close_branch( def close_branch(
name: Optional[str], name: Optional[str],
base_branch: str = "main", base_branch: str = "main",
@@ -111,23 +134,22 @@ def close_branch(
cwd: str = ".", cwd: str = ".",
) -> None: ) -> None:
""" """
Merge a feature branch into the main/master branch and optionally delete it. Merge a feature branch into the base branch and delete it afterwards.
Steps: Steps:
1) Determine branch name (argument or current branch) 1) Determine the branch name (argument or current branch)
2) Resolve base branch (prefers `base_branch`, falls back to `fallback_base`) 2) Resolve base branch (main/master)
3) Ask for confirmation (y/N) 3) Ask for confirmation
4) git fetch origin 4) git fetch origin
5) git checkout <base> 5) git checkout <base>
6) git pull origin <base> 6) git pull origin <base>
7) git merge --no-ff <name> 7) git merge --no-ff <name>
8) git push origin <base> 8) git push origin <base>
9) Delete branch locally and on origin 9) Delete branch locally
10) Delete branch on origin (best effort)
If the user does not confirm with 'y', the operation is aborted.
""" """
# 1) Determine which branch to close # 1) Determine which branch should be closed
if not name: if not name:
try: try:
name = get_current_branch(cwd=cwd) name = get_current_branch(cwd=cwd)
@@ -137,7 +159,7 @@ def close_branch(
if not name: if not name:
raise RuntimeError("Branch name must not be empty.") raise RuntimeError("Branch name must not be empty.")
# 2) Resolve base branch (main/master) # 2) Resolve base branch
target_base = _resolve_base_branch(base_branch, fallback_base, cwd=cwd) target_base = _resolve_base_branch(base_branch, fallback_base, cwd=cwd)
if name == target_base: if name == target_base:
@@ -146,7 +168,7 @@ def close_branch(
"Please specify a feature branch." "Please specify a feature branch."
) )
# 3) Confirmation prompt # 3) Ask user for confirmation
prompt = ( prompt = (
f"Merge branch '{name}' into '{target_base}' and delete it afterwards? " f"Merge branch '{name}' into '{target_base}' and delete it afterwards? "
"(y/N): " "(y/N): "
@@ -164,7 +186,7 @@ def close_branch(
f"Failed to fetch from origin before closing branch {name!r}: {exc}" f"Failed to fetch from origin before closing branch {name!r}: {exc}"
) from exc ) from exc
# 5) Checkout base branch # 5) Checkout base
try: try:
run_git(["checkout", target_base], cwd=cwd) run_git(["checkout", target_base], cwd=cwd)
except GitError as exc: except GitError as exc:
@@ -172,7 +194,7 @@ def close_branch(
f"Failed to checkout base branch {target_base!r}: {exc}" f"Failed to checkout base branch {target_base!r}: {exc}"
) from exc ) from exc
# 6) Pull latest base # 6) Pull latest base state
try: try:
run_git(["pull", "origin", target_base], cwd=cwd) run_git(["pull", "origin", target_base], cwd=cwd)
except GitError as exc: except GitError as exc:
@@ -180,7 +202,7 @@ def close_branch(
f"Failed to pull latest changes for base branch {target_base!r}: {exc}" f"Failed to pull latest changes for base branch {target_base!r}: {exc}"
) from exc ) from exc
# 7) Merge feature branch into base # 7) Merge the feature branch
try: try:
run_git(["merge", "--no-ff", name], cwd=cwd) run_git(["merge", "--no-ff", name], cwd=cwd)
except GitError as exc: except GitError as exc:
@@ -193,22 +215,21 @@ def close_branch(
run_git(["push", "origin", target_base], cwd=cwd) run_git(["push", "origin", target_base], cwd=cwd)
except GitError as exc: except GitError as exc:
raise RuntimeError( raise RuntimeError(
f"Failed to push base branch {target_base!r} to origin after merge: {exc}" f"Failed to push base branch {target_base!r} after merge: {exc}"
) from exc ) from exc
# 9) Delete feature branch locally # 9) Delete branch locally
try: try:
run_git(["branch", "-d", name], cwd=cwd) run_git(["branch", "-d", name], cwd=cwd)
except GitError as exc: except GitError as exc:
raise RuntimeError( raise RuntimeError(
f"Failed to delete local branch {name!r} after merge: {exc}" f"Failed to delete local branch {name!r}: {exc}"
) from exc ) from exc
# 10) Delete feature branch on origin (best effort) # 10) Delete branch on origin (best effort)
try: try:
run_git(["push", "origin", "--delete", name], cwd=cwd) run_git(["push", "origin", "--delete", name], cwd=cwd)
except GitError as exc: except GitError as exc:
# Remote delete is nice-to-have; surface as RuntimeError for clarity.
raise RuntimeError( raise RuntimeError(
f"Branch {name!r} was deleted locally, but remote deletion failed: {exc}" f"Branch {name!r} was deleted locally, but remote deletion failed: {exc}"
) from exc ) from exc

View File

@@ -85,7 +85,6 @@ def _open_editor_for_changelog(initial_message: Optional[str] = None) -> str:
# File update helpers (pyproject + extra packaging + changelog) # File update helpers (pyproject + extra packaging + changelog)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
def update_pyproject_version( def update_pyproject_version(
pyproject_path: str, pyproject_path: str,
new_version: str, new_version: str,
@@ -99,13 +98,25 @@ def update_pyproject_version(
version = "X.Y.Z" version = "X.Y.Z"
and replaces the version part with the given new_version string. and replaces the version part with the given new_version string.
If the file does not exist, it is skipped without failing the release.
""" """
if not os.path.exists(pyproject_path):
print(
f"[INFO] pyproject.toml not found at: {pyproject_path}, "
"skipping version update."
)
return
try: try:
with open(pyproject_path, "r", encoding="utf-8") as f: with open(pyproject_path, "r", encoding="utf-8") as f:
content = f.read() content = f.read()
except FileNotFoundError: except OSError as exc:
print(f"[ERROR] pyproject.toml not found at: {pyproject_path}") print(
sys.exit(1) f"[WARN] Could not read pyproject.toml at {pyproject_path}: {exc}. "
"Skipping version update."
)
return
pattern = r'^(version\s*=\s*")([^"]+)(")' pattern = r'^(version\s*=\s*")([^"]+)(")'
new_content, count = re.subn( new_content, count = re.subn(

View File

@@ -13,6 +13,13 @@ Behavior:
* Then install the flake outputs (`pkgmgr`, `default`) via `nix profile install`. * Then install the flake outputs (`pkgmgr`, `default`) via `nix profile install`.
- Failure installing `pkgmgr` is treated as fatal. - Failure installing `pkgmgr` is treated as fatal.
- Failure installing `default` is logged as an error/warning but does not abort. - Failure installing `default` is logged as an error/warning but does not abort.
Special handling for dev shells / CI:
- If IN_NIX_SHELL is set (e.g. inside `nix develop`), the installer is
disabled. In that environment the flake outputs are already provided
by the dev shell and we must not touch the user profile.
- If PKGMGR_DISABLE_NIX_FLAKE_INSTALLER=1 is set, the installer is
globally disabled (useful for CI or debugging).
""" """
import os import os
@@ -36,14 +43,45 @@ class NixFlakeInstaller(BaseInstaller):
FLAKE_FILE = "flake.nix" FLAKE_FILE = "flake.nix"
PROFILE_NAME = "package-manager" PROFILE_NAME = "package-manager"
def _in_nix_shell(self) -> bool:
"""
Return True if we appear to be running inside a Nix dev shell.
Nix sets IN_NIX_SHELL in `nix develop` environments. In that case
the flake outputs are already available, and touching the user
profile (nix profile install/remove) is undesirable.
"""
return bool(os.environ.get("IN_NIX_SHELL"))
def supports(self, ctx: "RepoContext") -> bool: def supports(self, ctx: "RepoContext") -> bool:
""" """
Only support repositories that: Only support repositories that:
- Have a flake.nix - Are NOT inside a Nix dev shell (IN_NIX_SHELL unset),
- Are NOT explicitly disabled via PKGMGR_DISABLE_NIX_FLAKE_INSTALLER=1,
- Have a flake.nix,
- And have the `nix` command available. - And have the `nix` command available.
""" """
# 1) Skip when running inside a dev shell flake is already active.
if self._in_nix_shell():
print(
"[INFO] IN_NIX_SHELL detected; skipping NixFlakeInstaller. "
"Flake outputs are provided by the development shell."
)
return False
# 2) Optional global kill-switch for CI or debugging.
if os.environ.get("PKGMGR_DISABLE_NIX_FLAKE_INSTALLER") == "1":
print(
"[INFO] PKGMGR_DISABLE_NIX_FLAKE_INSTALLER=1 "
"NixFlakeInstaller is disabled."
)
return False
# 3) Nix must be available.
if shutil.which("nix") is None: if shutil.which("nix") is None:
return False return False
# 4) flake.nix must exist in the repository.
flake_path = os.path.join(ctx.repo_dir, self.FLAKE_FILE) flake_path = os.path.join(ctx.repo_dir, self.FLAKE_FILE)
return os.path.exists(flake_path) return os.path.exists(flake_path)
@@ -76,6 +114,14 @@ class NixFlakeInstaller(BaseInstaller):
Any failure installing `pkgmgr` is treated as fatal (SystemExit). Any failure installing `pkgmgr` is treated as fatal (SystemExit).
A failure installing `default` is logged but does not abort. A failure installing `default` is logged but does not abort.
""" """
# Extra guard in case run() is called directly without supports().
if self._in_nix_shell():
print(
"[INFO] IN_NIX_SHELL detected in run(); "
"skipping Nix flake profile installation."
)
return
# Reuse supports() to keep logic in one place # Reuse supports() to keep logic in one place
if not self.supports(ctx): # type: ignore[arg-type] if not self.supports(ctx): # type: ignore[arg-type]
return return
@@ -91,7 +137,12 @@ class NixFlakeInstaller(BaseInstaller):
try: try:
# For 'default' we don't want the process to exit on error # For 'default' we don't want the process to exit on error
allow_failure = output == "default" allow_failure = output == "default"
run_command(cmd, cwd=ctx.repo_dir, preview=ctx.preview, allow_failure=allow_failure) run_command(
cmd,
cwd=ctx.repo_dir,
preview=ctx.preview,
allow_failure=allow_failure,
)
print(f"Nix flake output '{output}' successfully installed.") print(f"Nix flake output '{output}' successfully installed.")
except SystemExit as e: except SystemExit as e:
print(f"[Error] Failed to install Nix flake output '{output}': {e}") print(f"[Error] Failed to install Nix flake output '{output}': {e}")

View File

@@ -17,7 +17,6 @@ apt/dpkg tooling are available.
import glob import glob
import os import os
import shutil import shutil
from typing import List from typing import List
from pkgmgr.actions.repository.install.context import RepoContext from pkgmgr.actions.repository.install.context import RepoContext
@@ -68,6 +67,32 @@ class DebianControlInstaller(BaseInstaller):
pattern = os.path.join(parent, "*.deb") pattern = os.path.join(parent, "*.deb")
return sorted(glob.glob(pattern)) return sorted(glob.glob(pattern))
def _privileged_prefix(self) -> str | None:
"""
Determine how to run privileged commands:
- If 'sudo' is available, return 'sudo '.
- If we are running as root (e.g. inside CI/container), return ''.
- Otherwise, return None, meaning we cannot safely elevate.
Callers are responsible for handling the None case (usually by
warning and skipping automatic installation).
"""
sudo_path = shutil.which("sudo")
is_root = False
try:
is_root = os.geteuid() == 0
except AttributeError: # pragma: no cover - non-POSIX platforms
# On non-POSIX systems, fall back to assuming "not root".
is_root = False
if sudo_path is not None:
return "sudo "
if is_root:
return ""
return None
def _install_build_dependencies(self, ctx: RepoContext) -> None: def _install_build_dependencies(self, ctx: RepoContext) -> None:
""" """
Install build dependencies using `apt-get build-dep ./`. Install build dependencies using `apt-get build-dep ./`.
@@ -86,12 +111,25 @@ class DebianControlInstaller(BaseInstaller):
) )
return return
prefix = self._privileged_prefix()
if prefix is None:
print(
"[Warning] Neither 'sudo' is available nor running as root. "
"Skipping automatic build-dep installation for Debian. "
"Please install build dependencies from debian/control manually."
)
return
# Update package lists first for reliable build-dep resolution. # Update package lists first for reliable build-dep resolution.
run_command("sudo apt-get update", cwd=ctx.repo_dir, preview=ctx.preview) run_command(
f"{prefix}apt-get update",
cwd=ctx.repo_dir,
preview=ctx.preview,
)
# Install build dependencies based on debian/control in the current tree. # Install build dependencies based on debian/control in the current tree.
# `apt-get build-dep ./` uses the source in the current directory. # `apt-get build-dep ./` uses the source in the current directory.
builddep_cmd = "sudo apt-get build-dep -y ./" builddep_cmd = f"{prefix}apt-get build-dep -y ./"
run_command(builddep_cmd, cwd=ctx.repo_dir, preview=ctx.preview) run_command(builddep_cmd, cwd=ctx.repo_dir, preview=ctx.preview)
def run(self, ctx: RepoContext) -> None: def run(self, ctx: RepoContext) -> None:
@@ -101,7 +139,7 @@ class DebianControlInstaller(BaseInstaller):
Steps: Steps:
1. apt-get build-dep ./ (automatic build dependency installation) 1. apt-get build-dep ./ (automatic build dependency installation)
2. dpkg-buildpackage -b -us -uc 2. dpkg-buildpackage -b -us -uc
3. sudo dpkg -i ../*.deb 3. sudo dpkg -i ../*.deb (or plain dpkg -i when running as root)
""" """
control_path = self._control_path(ctx) control_path = self._control_path(ctx)
if not os.path.exists(control_path): if not os.path.exists(control_path):
@@ -123,7 +161,17 @@ class DebianControlInstaller(BaseInstaller):
) )
return return
prefix = self._privileged_prefix()
if prefix is None:
print(
"[Warning] Neither 'sudo' is available nor running as root. "
"Skipping automatic .deb installation. "
"You can manually install the following files with dpkg -i:\n "
+ "\n ".join(debs)
)
return
# 4) Install .deb files # 4) Install .deb files
install_cmd = "sudo dpkg -i " + " ".join(os.path.basename(d) for d in debs) install_cmd = prefix + "dpkg -i " + " ".join(os.path.basename(d) for d in debs)
parent = os.path.dirname(ctx.repo_dir) parent = os.path.dirname(ctx.repo_dir)
run_command(install_cmd, cwd=parent, preview=ctx.preview) run_command(install_cmd, cwd=parent, preview=ctx.preview)

View File

@@ -7,8 +7,10 @@ Installer for RPM-based packages defined in *.spec files.
This installer: This installer:
1. Installs build dependencies via dnf/yum builddep (where available) 1. Installs build dependencies via dnf/yum builddep (where available)
2. Uses rpmbuild to build RPMs from the provided .spec file 2. Prepares a source tarball in ~/rpmbuild/SOURCES based on the .spec
3. Installs the resulting RPMs via `rpm -i` 3. Uses rpmbuild to build RPMs from the provided .spec file
4. Installs the resulting RPMs via the system package manager (dnf/yum)
or rpm as a fallback.
It targets RPM-based systems (Fedora / RHEL / CentOS / Rocky / Alma, etc.). It targets RPM-based systems (Fedora / RHEL / CentOS / Rocky / Alma, etc.).
""" """
@@ -16,8 +18,8 @@ It targets RPM-based systems (Fedora / RHEL / CentOS / Rocky / Alma, etc.).
import glob import glob
import os import os
import shutil import shutil
import tarfile
from typing import List, Optional from typing import List, Optional, Tuple
from pkgmgr.actions.repository.install.context import RepoContext from pkgmgr.actions.repository.install.context import RepoContext
from pkgmgr.actions.repository.install.installers.base import BaseInstaller from pkgmgr.actions.repository.install.installers.base import BaseInstaller
@@ -59,6 +61,117 @@ class RpmSpecInstaller(BaseInstaller):
return None return None
return matches[0] return matches[0]
# ------------------------------------------------------------------
# Helpers for preparing rpmbuild topdir and source tarball
# ------------------------------------------------------------------
def _rpmbuild_topdir(self) -> str:
"""
Return the rpmbuild topdir that rpmbuild will use by default.
By default this is: ~/rpmbuild
In the self-install tests, $HOME is set to /tmp/pkgmgr-self-install,
so this becomes /tmp/pkgmgr-self-install/rpmbuild which matches the
paths in the RPM build logs.
"""
home = os.path.expanduser("~")
return os.path.join(home, "rpmbuild")
def _ensure_rpmbuild_tree(self, topdir: str) -> None:
"""
Ensure the standard rpmbuild directory tree exists:
<topdir>/
BUILD/
BUILDROOT/
RPMS/
SOURCES/
SPECS/
SRPMS/
"""
for sub in ("BUILD", "BUILDROOT", "RPMS", "SOURCES", "SPECS", "SRPMS"):
os.makedirs(os.path.join(topdir, sub), exist_ok=True)
def _parse_name_version(self, spec_path: str) -> Optional[Tuple[str, str]]:
"""
Parse Name and Version from the given .spec file.
Returns (name, version) or None if either cannot be determined.
"""
name = None
version = None
with open(spec_path, "r", encoding="utf-8") as f:
for raw_line in f:
line = raw_line.strip()
# Ignore comments
if not line or line.startswith("#"):
continue
lower = line.lower()
if lower.startswith("name:"):
# e.g. "Name: package-manager"
parts = line.split(":", 1)
if len(parts) == 2:
name = parts[1].strip()
elif lower.startswith("version:"):
# e.g. "Version: 0.7.7"
parts = line.split(":", 1)
if len(parts) == 2:
version = parts[1].strip()
if name and version:
break
if not name or not version:
print(
"[Warning] Could not determine Name/Version from spec file "
f"'{spec_path}'. Skipping RPM source tarball preparation."
)
return None
return name, version
def _prepare_source_tarball(self, ctx: RepoContext, spec_path: str) -> None:
"""
Prepare a source tarball in <HOME>/rpmbuild/SOURCES that matches
the Name/Version in the .spec file.
"""
parsed = self._parse_name_version(spec_path)
if parsed is None:
return
name, version = parsed
topdir = self._rpmbuild_topdir()
self._ensure_rpmbuild_tree(topdir)
build_dir = os.path.join(topdir, "BUILD")
sources_dir = os.path.join(topdir, "SOURCES")
source_root = os.path.join(build_dir, f"{name}-{version}")
tarball_path = os.path.join(sources_dir, f"{name}-{version}.tar.gz")
# Clean any previous build directory for this name/version.
if os.path.exists(source_root):
shutil.rmtree(source_root)
# Copy the repository tree into BUILD/<name>-<version>.
shutil.copytree(ctx.repo_dir, source_root)
# Create the tarball with the top-level directory <name>-<version>.
if os.path.exists(tarball_path):
os.remove(tarball_path)
with tarfile.open(tarball_path, "w:gz") as tar:
tar.add(source_root, arcname=f"{name}-{version}")
print(
f"[INFO] Prepared RPM source tarball at '{tarball_path}' "
f"from '{ctx.repo_dir}'."
)
# ------------------------------------------------------------------
def supports(self, ctx: RepoContext) -> bool: def supports(self, ctx: RepoContext) -> bool:
""" """
This installer is supported if: This installer is supported if:
@@ -77,26 +190,13 @@ class RpmSpecInstaller(BaseInstaller):
By default, rpmbuild outputs RPMs into: By default, rpmbuild outputs RPMs into:
~/rpmbuild/RPMS/*/*.rpm ~/rpmbuild/RPMS/*/*.rpm
""" """
home = os.path.expanduser("~") topdir = self._rpmbuild_topdir()
pattern = os.path.join(home, "rpmbuild", "RPMS", "**", "*.rpm") pattern = os.path.join(topdir, "RPMS", "**", "*.rpm")
return sorted(glob.glob(pattern, recursive=True)) return sorted(glob.glob(pattern, recursive=True))
def _install_build_dependencies(self, ctx: RepoContext, spec_path: str) -> None: def _install_build_dependencies(self, ctx: RepoContext, spec_path: str) -> None:
""" """
Install build dependencies for the given .spec file. Install build dependencies for the given .spec file.
Strategy (best-effort):
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.
Any failure in builddep installation is treated as fatal (SystemExit),
consistent with other installer steps.
""" """
spec_basename = os.path.basename(spec_path) spec_basename = os.path.basename(spec_path)
@@ -105,7 +205,6 @@ class RpmSpecInstaller(BaseInstaller):
elif shutil.which("yum-builddep") is not None: elif shutil.which("yum-builddep") is not None:
cmd = f"sudo yum-builddep -y {spec_basename}" cmd = f"sudo yum-builddep -y {spec_basename}"
elif shutil.which("yum") is not None: elif shutil.which("yum") is not None:
# Some distributions ship yum-builddep as a plugin.
cmd = f"sudo yum-builddep -y {spec_basename}" cmd = f"sudo yum-builddep -y {spec_basename}"
else: else:
print( print(
@@ -114,33 +213,17 @@ class RpmSpecInstaller(BaseInstaller):
) )
return return
# Run builddep in the repository directory so relative spec paths work.
run_command(cmd, cwd=ctx.repo_dir, preview=ctx.preview) run_command(cmd, cwd=ctx.repo_dir, preview=ctx.preview)
def run(self, ctx: RepoContext) -> None: def _install_built_rpms(self, ctx: RepoContext, rpms: List[str]) -> None:
""" """
Build and install RPM-based packages. Install or upgrade the built RPMs.
Steps: Strategy:
1. dnf/yum builddep <spec> (automatic build dependency installation) - Prefer dnf install -y <rpms> (handles upgrades cleanly)
2. rpmbuild -ba path/to/spec - Else yum install -y <rpms>
3. sudo rpm -i ~/rpmbuild/RPMS/*/*.rpm - Else fallback to rpm -Uvh <rpms> (upgrade/replace existing)
""" """
spec_path = self._spec_path(ctx)
if not spec_path:
return
# 1) Install build dependencies
self._install_build_dependencies(ctx, spec_path)
# 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: if not rpms:
print( print(
"[Warning] No RPM files found after rpmbuild. " "[Warning] No RPM files found after rpmbuild. "
@@ -148,13 +231,52 @@ class RpmSpecInstaller(BaseInstaller):
) )
return return
# 4) Install RPMs dnf = shutil.which("dnf")
if shutil.which("rpm") is None: yum = shutil.which("yum")
rpm = shutil.which("rpm")
if dnf is not None:
install_cmd = "sudo dnf install -y " + " ".join(rpms)
elif yum is not None:
install_cmd = "sudo yum install -y " + " ".join(rpms)
elif rpm is not None:
# Fallback: use rpm in upgrade mode so an existing older
# version is replaced instead of causing file conflicts.
install_cmd = "sudo rpm -Uvh " + " ".join(rpms)
else:
print( print(
"[Warning] rpm binary not found on PATH. " "[Warning] No suitable RPM installer (dnf/yum/rpm) found. "
"Cannot install built RPMs." "Cannot install built RPMs."
) )
return return
install_cmd = "sudo rpm -i " + " ".join(rpms)
run_command(install_cmd, cwd=ctx.repo_dir, preview=ctx.preview) run_command(install_cmd, cwd=ctx.repo_dir, preview=ctx.preview)
def run(self, ctx: RepoContext) -> None:
"""
Build and install RPM-based packages.
Steps:
1. Prepare source tarball in ~/rpmbuild/SOURCES matching Name/Version
2. dnf/yum builddep <spec> (automatic build dependency installation)
3. rpmbuild -ba path/to/spec
4. Install built RPMs via dnf/yum (or rpm as fallback)
"""
spec_path = self._spec_path(ctx)
if not spec_path:
return
# 1) Prepare source tarball so rpmbuild finds Source0 in SOURCES.
self._prepare_source_tarball(ctx, spec_path)
# 2) Install build dependencies
self._install_build_dependencies(ctx, spec_path)
# 3) Build RPMs
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)
# 4) Find and install built RPMs
rpms = self._find_built_rpms()
self._install_built_rpms(ctx, rpms)

View File

@@ -11,15 +11,28 @@ Strategy:
3. "pip" from PATH as last resort 3. "pip" from PATH as last resort
- If pyproject.toml exists: pip install . - If pyproject.toml exists: pip install .
All installation failures are treated as fatal errors (SystemExit). All installation failures are treated as fatal errors (SystemExit),
except when we explicitly skip the installer:
- If IN_NIX_SHELL is set, we assume Python is managed by Nix and
skip this installer entirely.
- If PKGMGR_DISABLE_PYTHON_INSTALLER=1 is set, the installer is
globally disabled (useful for CI or debugging).
""" """
from __future__ import annotations
import os import os
import sys import sys
from typing import TYPE_CHECKING
from pkgmgr.actions.repository.install.installers.base import BaseInstaller from pkgmgr.actions.repository.install.installers.base import BaseInstaller
from pkgmgr.core.command.run import run_command from pkgmgr.core.command.run import run_command
if TYPE_CHECKING:
from pkgmgr.actions.repository.install.context import RepoContext
from pkgmgr.actions.repository.install import InstallContext
class PythonInstaller(BaseInstaller): class PythonInstaller(BaseInstaller):
"""Install Python projects and dependencies via pip.""" """Install Python projects and dependencies via pip."""
@@ -27,19 +40,54 @@ class PythonInstaller(BaseInstaller):
# Logical layer name, used by capability matchers. # Logical layer name, used by capability matchers.
layer = "python" layer = "python"
def supports(self, ctx) -> bool: def _in_nix_shell(self) -> bool:
"""
Return True if we appear to be running inside a Nix dev shell.
Nix sets IN_NIX_SHELL in `nix develop` environments. In that case
the Python environment is already provided by Nix, so we must not
attempt an additional pip-based installation.
"""
return bool(os.environ.get("IN_NIX_SHELL"))
def supports(self, ctx: "RepoContext") -> bool:
""" """
Return True if this installer should handle the given repository. Return True if this installer should handle the given repository.
Only pyproject.toml is supported as the single source of truth Only pyproject.toml is supported as the single source of truth
for Python dependencies and packaging metadata. for Python dependencies and packaging metadata.
The installer is *disabled* when:
- IN_NIX_SHELL is set (Python managed by Nix dev shell), or
- PKGMGR_DISABLE_PYTHON_INSTALLER=1 is set.
""" """
# 1) Skip in Nix dev shells Python is managed by the flake/devShell.
if self._in_nix_shell():
print(
"[INFO] IN_NIX_SHELL detected; skipping PythonInstaller. "
"Python runtime is provided by the Nix dev shell."
)
return False
# 2) Optional global kill-switch.
if os.environ.get("PKGMGR_DISABLE_PYTHON_INSTALLER") == "1":
print(
"[INFO] PKGMGR_DISABLE_PYTHON_INSTALLER=1 "
"PythonInstaller is disabled."
)
return False
repo_dir = ctx.repo_dir repo_dir = ctx.repo_dir
return os.path.exists(os.path.join(repo_dir, "pyproject.toml")) return os.path.exists(os.path.join(repo_dir, "pyproject.toml"))
def _pip_cmd(self) -> str: def _pip_cmd(self) -> str:
""" """
Resolve the pip command to use. Resolve the pip command to use.
Order:
1) PKGMGR_PIP (explicit override)
2) sys.executable -m pip
3) plain "pip"
""" """
explicit = os.environ.get("PKGMGR_PIP", "").strip() explicit = os.environ.get("PKGMGR_PIP", "").strip()
if explicit: if explicit:
@@ -50,12 +98,23 @@ class PythonInstaller(BaseInstaller):
return "pip" return "pip"
def run(self, ctx) -> None: def run(self, ctx: "InstallContext") -> None:
""" """
Install Python project defined via pyproject.toml. Install Python project defined via pyproject.toml.
Any pip failure is propagated as SystemExit. Any pip failure is propagated as SystemExit.
""" """
# Extra guard in case run() is called directly without supports().
if self._in_nix_shell():
print(
"[INFO] IN_NIX_SHELL detected in PythonInstaller.run(); "
"skipping pip-based installation."
)
return
if not self.supports(ctx): # type: ignore[arg-type]
return
pip_cmd = self._pip_cmd() pip_cmd = self._pip_cmd()
pyproject = os.path.join(ctx.repo_dir, "pyproject.toml") pyproject = os.path.join(ctx.repo_dir, "pyproject.toml")

View File

@@ -6,8 +6,14 @@ from pkgmgr.core.repository.identifier import get_repo_identifier
from pkgmgr.core.repository.dir import get_repo_dir from pkgmgr.core.repository.dir import get_repo_dir
def create_ink(repo, repositories_base_dir, bin_dir, all_repos, def create_ink(
quiet=False, preview=False): repo,
repositories_base_dir,
bin_dir,
all_repos,
quiet: bool = False,
preview: bool = False,
) -> None:
""" """
Create a symlink for the repository's command. Create a symlink for the repository's command.
@@ -18,6 +24,11 @@ def create_ink(repo, repositories_base_dir, bin_dir, all_repos,
Behavior: Behavior:
- If repo["command"] is defined → create a symlink to it. - If repo["command"] is defined → create a symlink to it.
- If repo["command"] is missing or None → do NOT create a link. - If repo["command"] is missing or None → do NOT create a link.
Safety:
- If the resolved command path is identical to the final link target,
we skip symlink creation to avoid self-referential symlinks that
would break shell resolution ("too many levels of symbolic links").
""" """
repo_identifier = get_repo_identifier(repo, all_repos) repo_identifier = get_repo_identifier(repo, all_repos)
@@ -31,6 +42,27 @@ def create_ink(repo, repositories_base_dir, bin_dir, all_repos,
link_path = os.path.join(bin_dir, repo_identifier) link_path = os.path.join(bin_dir, repo_identifier)
# ------------------------------------------------------------------
# Safety guard: avoid self-referential symlinks
#
# Example of a broken situation we must avoid:
# - command = ~/.local/bin/package-manager
# - link_path = ~/.local/bin/package-manager
# - create_ink() removes the real binary and creates a symlink
# pointing to itself → zsh: too many levels of symbolic links
#
# If the resolved command already lives exactly at the target path,
# we treat it as "already installed" and skip any modification.
# ------------------------------------------------------------------
if os.path.abspath(command) == os.path.abspath(link_path):
if not quiet:
print(
f"[pkgmgr] Command for '{repo_identifier}' already lives at "
f"'{link_path}'. Skipping symlink creation to avoid a "
"self-referential link."
)
return
if preview: if preview:
print(f"[Preview] Would link {link_path}{command}") print(f"[Preview] Would link {link_path}{command}")
return return
@@ -65,7 +97,10 @@ def create_ink(repo, repositories_base_dir, bin_dir, all_repos,
if alias_name == repo_identifier: if alias_name == repo_identifier:
if not quiet: if not quiet:
print(f"Alias '{alias_name}' equals identifier. Skipping alias creation.") print(
f"Alias '{alias_name}' equals identifier. "
"Skipping alias creation."
)
return return
try: try:

View File

@@ -7,7 +7,7 @@ build-backend = "setuptools.build_meta"
[project] [project]
name = "package-manager" name = "package-manager"
version = "0.7.7" version = "0.7.12"
description = "Kevin's package-manager tool (pkgmgr)" description = "Kevin's package-manager tool (pkgmgr)"
readme = "README.md" readme = "README.md"
requires-python = ">=3.11" requires-python = ">=3.11"

View File

@@ -2,28 +2,59 @@
set -euo pipefail set -euo pipefail
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Ensure Nix has access to a valid CA bundle (TLS trust store) # Detect and export a valid CA bundle so Nix, Git, curl and Python tooling
# can successfully perform HTTPS requests on all distros (Debian, Ubuntu,
# Fedora, RHEL, CentOS, etc.)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
if [[ -z "${NIX_SSL_CERT_FILE:-}" ]]; then detect_ca_bundle() {
if [[ -f /etc/ssl/certs/ca-certificates.crt ]]; then # Common CA bundle locations across major Linux distributions
# Debian/Ubuntu-style path local candidates=(
export NIX_SSL_CERT_FILE=/etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ca-certificates.crt # Debian/Ubuntu
echo "[docker] Using CA bundle: ${NIX_SSL_CERT_FILE}" /etc/ssl/cert.pem # Some distros
elif [[ -f /etc/pki/tls/certs/ca-bundle.crt ]]; then /etc/pki/tls/certs/ca-bundle.crt # Fedora/RHEL/CentOS
# Fedora/RHEL/CentOS-style path /etc/pki/ca-trust/extracted/pem/tls-ca-bundle.pem # CentOS/RHEL extracted bundle
export NIX_SSL_CERT_FILE=/etc/pki/tls/certs/ca-bundle.crt /etc/ssl/ca-bundle.pem # Generic fallback
echo "[docker] Using CA bundle: ${NIX_SSL_CERT_FILE}" )
else
echo "[docker] WARNING: No CA bundle found for Nix (NIX_SSL_CERT_FILE not set)." for path in "${candidates[@]}"; do
echo "[docker] HTTPS access for Nix flakes may fail." if [[ -f "$path" ]]; then
echo "$path"
return 0
fi fi
done
return 1
}
# Use existing NIX_SSL_CERT_FILE if provided, otherwise auto-detect
CA_BUNDLE="${NIX_SSL_CERT_FILE:-}"
if [[ -z "${CA_BUNDLE}" ]]; then
CA_BUNDLE="$(detect_ca_bundle || true)"
fi
if [[ -n "${CA_BUNDLE}" ]]; then
# Export for Nix (critical)
export NIX_SSL_CERT_FILE="${CA_BUNDLE}"
# Export for Git, Python requests, curl, etc.
export SSL_CERT_FILE="${CA_BUNDLE}"
export REQUESTS_CA_BUNDLE="${CA_BUNDLE}"
export GIT_SSL_CAINFO="${CA_BUNDLE}"
echo "[docker] Using CA bundle: ${CA_BUNDLE}"
else
echo "[docker] WARNING: No CA certificate bundle found."
echo "[docker] HTTPS access for Nix flakes and other tools may fail."
fi fi
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
echo "[docker] Starting package-manager container" echo "[docker] Starting package-manager container"
# Distro info for logging # ---------------------------------------------------------------------------
# Log distribution info
# ---------------------------------------------------------------------------
if [[ -f /etc/os-release ]]; then if [[ -f /etc/os-release ]]; then
# shellcheck disable=SC1091 # shellcheck disable=SC1091
. /etc/os-release . /etc/os-release
@@ -34,9 +65,9 @@ fi
echo "[docker] Using /src as working directory" echo "[docker] Using /src as working directory"
cd /src cd /src
# ------------------------------------------------------------ # ---------------------------------------------------------------------------
# DEV mode: build/install package-manager from current /src # DEV mode: rebuild package-manager from the mounted /src tree
# ------------------------------------------------------------ # ---------------------------------------------------------------------------
if [[ "${PKGMGR_DEV:-0}" == "1" ]]; then if [[ "${PKGMGR_DEV:-0}" == "1" ]]; then
echo "[docker] DEV mode enabled (PKGMGR_DEV=1)" echo "[docker] DEV mode enabled (PKGMGR_DEV=1)"
echo "[docker] Rebuilding package-manager from /src via scripts/installation/run-package.sh..." echo "[docker] Rebuilding package-manager from /src via scripts/installation/run-package.sh..."
@@ -49,9 +80,9 @@ if [[ "${PKGMGR_DEV:-0}" == "1" ]]; then
fi fi
fi fi
# ------------------------------------------------------------ # ---------------------------------------------------------------------------
# Hand-off to pkgmgr / arbitrary command # Hand off to pkgmgr or arbitrary command
# ------------------------------------------------------------ # ---------------------------------------------------------------------------
if [[ $# -eq 0 ]]; then if [[ $# -eq 0 ]]; then
echo "[docker] No arguments provided. Showing pkgmgr help..." echo "[docker] No arguments provided. Showing pkgmgr help..."
exec pkgmgr --help exec pkgmgr --help

View File

@@ -13,6 +13,7 @@ dnf -y install \
bash \ bash \
curl-minimal \ curl-minimal \
ca-certificates \ ca-certificates \
sudo \
xz xz
dnf clean all dnf clean all

View File

@@ -19,6 +19,7 @@ for distro in $DISTROS; do
# Run the command and capture the output # Run the command and capture the output
if OUTPUT=$(docker run --rm \ if OUTPUT=$(docker run --rm \
-e PKGMGR_DEV=1 \ -e PKGMGR_DEV=1 \
-v pkgmgr_nix_store:/nix \
-v "$(pwd):/src" \ -v "$(pwd):/src" \
-v "pkgmgr_nix_cache:/root/.cache/nix" \ -v "pkgmgr_nix_cache:/root/.cache/nix" \
"$IMAGE" 2>&1); then "$IMAGE" 2>&1); then

View File

@@ -8,14 +8,9 @@ for distro in $DISTROS; do
echo ">>> Running E2E tests: $distro" echo ">>> Running E2E tests: $distro"
echo "============================================================" echo "============================================================"
MOUNT_NIX=""
if [[ "$distro" == "arch" ]]; then
MOUNT_NIX="-v pkgmgr_nix_store:/nix"
fi
docker run --rm \ docker run --rm \
-v "$(pwd):/src" \ -v "$(pwd):/src" \
$MOUNT_NIX \ -v pkgmgr_nix_store:/nix \
-v "pkgmgr_nix_cache:/root/.cache/nix" \ -v "pkgmgr_nix_cache:/root/.cache/nix" \
-e PKGMGR_DEV=1 \ -e PKGMGR_DEV=1 \
-e TEST_PATTERN="${TEST_PATTERN}" \ -e TEST_PATTERN="${TEST_PATTERN}" \

View File

@@ -7,6 +7,7 @@ echo "============================================================"
docker run --rm \ docker run --rm \
-v "$(pwd):/src" \ -v "$(pwd):/src" \
-v pkgmgr_nix_store:/nix \
-v "pkgmgr_nix_cache:/root/.cache/nix" \ -v "pkgmgr_nix_cache:/root/.cache/nix" \
--workdir /src \ --workdir /src \
-e PKGMGR_DEV=1 \ -e PKGMGR_DEV=1 \

View File

@@ -8,6 +8,7 @@ echo "============================================================"
docker run --rm \ docker run --rm \
-v "$(pwd):/src" \ -v "$(pwd):/src" \
-v "pkgmgr_nix_cache:/root/.cache/nix" \ -v "pkgmgr_nix_cache:/root/.cache/nix" \
-v pkgmgr_nix_store:/nix \
--workdir /src \ --workdir /src \
-e PKGMGR_DEV=1 \ -e PKGMGR_DEV=1 \
-e TEST_PATTERN="${TEST_PATTERN}" \ -e TEST_PATTERN="${TEST_PATTERN}" \

View File

@@ -1,91 +0,0 @@
"""
Integration test: install all configured repositories using
--clone-mode shallow (HTTPS shallow clone) and --no-verification.
This test is intended to be run inside the Docker container where:
- network access is available,
- the config/config.yaml is present,
- and it is safe to perform real git operations.
It passes if the command completes without raising an exception.
"""
import runpy
import sys
import unittest
from test_install_pkgmgr_shallow import (
nix_profile_list_debug,
remove_pkgmgr_from_nix_profile,
pkgmgr_help_debug,
)
class TestIntegrationInstallAllShallow(unittest.TestCase):
def _run_pkgmgr_install_all(self) -> None:
"""
Helper that runs the CLI command via main.py and provides
extra diagnostics if the command exits with a non-zero code.
"""
cmd_repr = "pkgmgr install --all --clone-mode shallow --no-verification"
original_argv = sys.argv
try:
sys.argv = [
"pkgmgr",
"install",
"--all",
"--clone-mode",
"shallow",
"--no-verification",
]
try:
# Execute main.py as if it was called from CLI.
# This will run the full install pipeline inside the container.
runpy.run_module("main", run_name="__main__")
except SystemExit as exc:
# Convert SystemExit into a more helpful assertion with debug output.
exit_code = exc.code if isinstance(exc.code, int) else str(exc.code)
print("\n[TEST] pkgmgr install --all failed with SystemExit")
print(f"[TEST] Command : {cmd_repr}")
print(f"[TEST] Exit code: {exit_code}")
# Additional Nix profile debug on failure
nix_profile_list_debug("ON FAILURE (AFTER SystemExit)")
raise AssertionError(
f"{cmd_repr!r} failed with exit code {exit_code}. "
"Scroll up to see the full pkgmgr/make output inside the container."
) from exc
finally:
sys.argv = original_argv
def test_install_all_repositories_shallow(self) -> None:
"""
Run: pkgmgr install --all --clone-mode shallow --no-verification
This will perform real installations/clones inside the container.
The test succeeds if no exception is raised and `pkgmgr --help`
works in a fresh interactive bash session afterwards.
"""
# Debug before cleanup
nix_profile_list_debug("BEFORE CLEANUP")
# Cleanup: aggressively try to drop any pkgmgr/profile entries
remove_pkgmgr_from_nix_profile()
# Debug after cleanup
nix_profile_list_debug("AFTER CLEANUP")
# Run the actual install with extended diagnostics
self._run_pkgmgr_install_all()
# After successful installation: show `pkgmgr --help`
# via interactive bash (same as the pkgmgr-only test).
pkgmgr_help_debug()
if __name__ == "__main__":
unittest.main()

View File

@@ -35,8 +35,8 @@ def remove_pkgmgr_from_nix_profile() -> None:
prints a descriptive format without an index column inside the container. prints a descriptive format without an index column inside the container.
Instead, we directly try to remove possible names: Instead, we directly try to remove possible names:
- 'pkgmgr' (the actual name shown in `nix profile list`) - 'pkgmgr'
- 'package-manager' (the name mentioned in Nix's own error hints) - 'package-manager'
""" """
for spec in ("pkgmgr", "package-manager"): for spec in ("pkgmgr", "package-manager"):
subprocess.run( subprocess.run(
@@ -45,18 +45,34 @@ def remove_pkgmgr_from_nix_profile() -> None:
) )
def configure_git_safe_directory() -> None:
"""
Configure Git to treat /src as a safe directory.
Needed because /src is a bind-mounted repository in CI, often owned by a
different UID. Modern Git aborts with:
'fatal: detected dubious ownership in repository at /src/.git'
This fix applies ONLY inside this test container.
"""
try:
subprocess.run(
["git", "config", "--global", "--add", "safe.directory", "/src"],
check=False,
)
except FileNotFoundError:
print("[WARN] git not found skipping safe.directory configuration")
def pkgmgr_help_debug() -> None: def pkgmgr_help_debug() -> None:
""" """
Run `pkgmgr --help` after installation *inside an interactive bash shell*, Run `pkgmgr --help` after installation *inside an interactive bash shell*,
print its output and return code, but never fail the test. print its output and return code, but never fail the test.
Reason: This ensures the installers shell RC changes are actually loaded.
- 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) ---") print("\n--- PKGMGR HELP (after installation, via bash -i) ---")
# Simulate a fresh interactive bash, so ~/.bashrc gets sourced
proc = subprocess.run( proc = subprocess.run(
["bash", "-i", "-c", "pkgmgr --help"], ["bash", "-i", "-c", "pkgmgr --help"],
capture_output=True, capture_output=True,
@@ -76,10 +92,6 @@ def pkgmgr_help_debug() -> None:
print(f"returncode: {proc.returncode}") print(f"returncode: {proc.returncode}")
print("--- END ---\n") print("--- END ---\n")
# Important: this is **debug-only**. Do NOT fail the test here.
# If you ever want to hard-assert on this, you can add an explicit
# assertion in the test method instead of here.
class TestIntegrationInstalPKGMGRShallow(unittest.TestCase): class TestIntegrationInstalPKGMGRShallow(unittest.TestCase):
def test_install_pkgmgr_self_install(self) -> None: def test_install_pkgmgr_self_install(self) -> None:
@@ -87,12 +99,8 @@ class TestIntegrationInstalPKGMGRShallow(unittest.TestCase):
End-to-end test that runs "python main.py install pkgmgr ..." inside End-to-end test that runs "python main.py install pkgmgr ..." inside
the test container. the test container.
We isolate HOME into /tmp/pkgmgr-self-install so that: HOME is isolated to avoid permission problems with Nix & repositories.
- ~/.config/pkgmgr points to an isolated test config area
- ~/Repositories is owned by the current user inside the container
(avoiding Nix's 'repository path is not owned by current user' error)
""" """
# Use a dedicated HOME for this test to avoid permission/ownership issues
temp_home = "/tmp/pkgmgr-self-install" temp_home = "/tmp/pkgmgr-self-install"
os.makedirs(temp_home, exist_ok=True) os.makedirs(temp_home, exist_ok=True)
@@ -103,20 +111,24 @@ class TestIntegrationInstalPKGMGRShallow(unittest.TestCase):
# Isolate HOME so that ~ expands to /tmp/pkgmgr-self-install # Isolate HOME so that ~ expands to /tmp/pkgmgr-self-install
os.environ["HOME"] = temp_home os.environ["HOME"] = temp_home
# Optional: ensure XDG_* also use the temp HOME for extra isolation # Optional XDG override for a fully isolated environment
os.environ.setdefault("XDG_CONFIG_HOME", os.path.join(temp_home, ".config")) os.environ.setdefault("XDG_CONFIG_HOME", os.path.join(temp_home, ".config"))
os.environ.setdefault("XDG_CACHE_HOME", os.path.join(temp_home, ".cache")) os.environ.setdefault("XDG_CACHE_HOME", os.path.join(temp_home, ".cache"))
os.environ.setdefault("XDG_DATA_HOME", os.path.join(temp_home, ".local", "share")) os.environ.setdefault("XDG_DATA_HOME", os.path.join(temp_home, ".local", "share"))
# 🔧 IMPORTANT FIX: allow Git to access /src safely
configure_git_safe_directory()
# Debug before cleanup # Debug before cleanup
nix_profile_list_debug("BEFORE CLEANUP") nix_profile_list_debug("BEFORE CLEANUP")
# Cleanup: aggressively try to drop any pkgmgr/profile entries # Cleanup: drop any pkgmgr entries from nix profile
remove_pkgmgr_from_nix_profile() remove_pkgmgr_from_nix_profile()
# Debug after cleanup # Debug after cleanup
nix_profile_list_debug("AFTER CLEANUP") nix_profile_list_debug("AFTER CLEANUP")
# Prepare argv for module execution
sys.argv = [ sys.argv = [
"python", "python",
"install", "install",
@@ -126,15 +138,15 @@ class TestIntegrationInstalPKGMGRShallow(unittest.TestCase):
"--no-verification", "--no-verification",
] ]
# Run installation via main.py # Execute installation via main.py
runpy.run_module("main", run_name="__main__") runpy.run_module("main", run_name="__main__")
# After successful installation: run `pkgmgr --help` for debug # Debug: interactive shell test
pkgmgr_help_debug() pkgmgr_help_debug()
finally: finally:
# Restore system state
sys.argv = original_argv sys.argv = original_argv
# Restore full environment
os.environ.clear() os.environ.clear()
os.environ.update(original_environ) os.environ.update(original_environ)

View File

@@ -1,11 +1,10 @@
# tests/unit/pkgmgr/installers/os_packages/test_debian_control.py
import os
import unittest import unittest
from unittest.mock import patch from unittest.mock import patch
from pkgmgr.actions.repository.install.context import RepoContext from pkgmgr.actions.repository.install.context import RepoContext
from pkgmgr.actions.repository.install.installers.os_packages.debian_control import DebianControlInstaller from pkgmgr.actions.repository.install.installers.os_packages.debian_control import (
DebianControlInstaller,
)
class TestDebianControlInstaller(unittest.TestCase): class TestDebianControlInstaller(unittest.TestCase):
@@ -29,14 +28,24 @@ class TestDebianControlInstaller(unittest.TestCase):
@patch("os.path.exists", return_value=True) @patch("os.path.exists", return_value=True)
@patch("shutil.which", return_value="/usr/bin/dpkg-buildpackage") @patch("shutil.which", return_value="/usr/bin/dpkg-buildpackage")
def test_supports_true(self, mock_which, mock_exists): def test_supports_true(self, mock_which, mock_exists):
"""
supports() should return True when dpkg-buildpackage is available
and a debian/control file exists in the repository.
"""
self.assertTrue(self.installer.supports(self.ctx)) self.assertTrue(self.installer.supports(self.ctx))
@patch("os.path.exists", return_value=True) @patch("os.path.exists", return_value=True)
@patch("shutil.which", return_value=None) @patch("shutil.which", return_value=None)
def test_supports_false_without_dpkg_buildpackage(self, mock_which, mock_exists): def test_supports_false_without_dpkg_buildpackage(self, mock_which, mock_exists):
"""
supports() should return False when dpkg-buildpackage is not available,
even if a debian/control file exists.
"""
self.assertFalse(self.installer.supports(self.ctx)) self.assertFalse(self.installer.supports(self.ctx))
@patch("pkgmgr.actions.repository.install.installers.os_packages.debian_control.run_command") @patch(
"pkgmgr.actions.repository.install.installers.os_packages.debian_control.run_command"
)
@patch("glob.glob", return_value=["/tmp/package-manager_0.1.1_all.deb"]) @patch("glob.glob", return_value=["/tmp/package-manager_0.1.1_all.deb"])
@patch("os.path.exists", return_value=True) @patch("os.path.exists", return_value=True)
@patch("shutil.which") @patch("shutil.which")
@@ -47,7 +56,19 @@ class TestDebianControlInstaller(unittest.TestCase):
mock_glob, mock_glob,
mock_run_command, mock_run_command,
): ):
# dpkg-buildpackage + apt-get vorhanden """
run() should:
1. Install build dependencies (apt-get build-dep).
2. Build the package using dpkg-buildpackage -b -us -uc.
3. Discover built .deb files via glob.
4. Install the resulting .deb packages using a suitable tool:
- dpkg -i
- sudo dpkg -i
- or sudo apt-get install -y
"""
# Simulate dpkg-buildpackage and apt-get being available.
def which_side_effect(name): def which_side_effect(name):
if name == "dpkg-buildpackage": if name == "dpkg-buildpackage":
return "/usr/bin/dpkg-buildpackage" return "/usr/bin/dpkg-buildpackage"
@@ -64,16 +85,35 @@ class TestDebianControlInstaller(unittest.TestCase):
# 1) apt-get update # 1) apt-get update
self.assertTrue(any("apt-get update" in cmd for cmd in cmds)) self.assertTrue(any("apt-get update" in cmd for cmd in cmds))
# 2) apt-get build-dep ./ # 2) apt-get build-dep -y ./ (with or without trailing space)
self.assertTrue(any("apt-get build-dep -y ./ " in cmd or self.assertTrue(
"apt-get build-dep -y ./" any(
in cmd for cmd in cmds)) "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 # 3) dpkg-buildpackage -b -us -uc
self.assertTrue(any("dpkg-buildpackage -b -us -uc" in cmd for cmd in cmds)) self.assertTrue(any("dpkg-buildpackage -b -us -uc" in cmd for cmd in cmds))
# 4) dpkg -i ../*.deb # 4) final installation of .deb packages:
self.assertTrue(any(cmd.startswith("sudo dpkg -i ") for cmd in cmds)) # accept dpkg -i, sudo dpkg -i, or sudo apt-get install -y
has_plain_dpkg_install = any(cmd.startswith("dpkg -i ") for cmd in cmds)
has_sudo_dpkg_install = any(cmd.startswith("sudo dpkg -i ") for cmd in cmds)
has_apt_install = any(
cmd.startswith("sudo apt-get install -y ") for cmd in cmds
)
self.assertTrue(
has_plain_dpkg_install or has_sudo_dpkg_install or has_apt_install,
msg=(
"Expected one of 'dpkg -i', 'sudo dpkg -i' or "
"'sudo apt-get install -y', but got commands: "
f"{cmds}"
),
)
if __name__ == "__main__": if __name__ == "__main__":

View File

@@ -1,10 +1,10 @@
# tests/unit/pkgmgr/installers/os_packages/test_rpm_spec.py
import unittest import unittest
from unittest.mock import patch from unittest.mock import patch
from pkgmgr.actions.repository.install.context import RepoContext from pkgmgr.actions.repository.install.context import RepoContext
from pkgmgr.actions.repository.install.installers.os_packages.rpm_spec import RpmSpecInstaller from pkgmgr.actions.repository.install.installers.os_packages.rpm_spec import (
RpmSpecInstaller,
)
class TestRpmSpecInstaller(unittest.TestCase): class TestRpmSpecInstaller(unittest.TestCase):
@@ -28,6 +28,13 @@ class TestRpmSpecInstaller(unittest.TestCase):
@patch("glob.glob", return_value=["/tmp/repo/test.spec"]) @patch("glob.glob", return_value=["/tmp/repo/test.spec"])
@patch("shutil.which") @patch("shutil.which")
def test_supports_true(self, mock_which, mock_glob): def test_supports_true(self, mock_which, mock_glob):
"""
supports() should return True when:
- rpmbuild is available, and
- at least one of dnf/yum/yum-builddep is available, and
- a *.spec file is present in the repo.
"""
def which_side_effect(name): def which_side_effect(name):
if name == "rpmbuild": if name == "rpmbuild":
return "/usr/bin/rpmbuild" return "/usr/bin/rpmbuild"
@@ -42,9 +49,14 @@ class TestRpmSpecInstaller(unittest.TestCase):
@patch("glob.glob", return_value=[]) @patch("glob.glob", return_value=[])
@patch("shutil.which") @patch("shutil.which")
def test_supports_false_missing_spec(self, mock_which, mock_glob): def test_supports_false_missing_spec(self, mock_which, mock_glob):
"""
supports() should return False if no *.spec file is found,
even if rpmbuild is present.
"""
mock_which.return_value = "/usr/bin/rpmbuild" mock_which.return_value = "/usr/bin/rpmbuild"
self.assertFalse(self.installer.supports(self.ctx)) self.assertFalse(self.installer.supports(self.ctx))
@patch.object(RpmSpecInstaller, "_prepare_source_tarball")
@patch("pkgmgr.actions.repository.install.installers.os_packages.rpm_spec.run_command") @patch("pkgmgr.actions.repository.install.installers.os_packages.rpm_spec.run_command")
@patch("glob.glob") @patch("glob.glob")
@patch("shutil.which") @patch("shutil.which")
@@ -53,8 +65,20 @@ class TestRpmSpecInstaller(unittest.TestCase):
mock_which, mock_which,
mock_glob, mock_glob,
mock_run_command, mock_run_command,
mock_prepare_source_tarball,
): ):
# glob.glob wird zweimal benutzt: einmal für *.spec, einmal für gebaute RPMs """
run() should:
1. Determine the .spec file in the repo.
2. Call _prepare_source_tarball() once with ctx and spec path.
3. Install build dependencies via dnf/yum-builddep/yum.
4. Call rpmbuild -ba <spec>.
5. Find built RPMs via glob.
6. Install built RPMs via dnf/yum/rpm (here: dnf).
"""
# glob.glob is used twice: once for *.spec, once for built RPMs.
def glob_side_effect(pattern, recursive=False): def glob_side_effect(pattern, recursive=False):
if pattern.endswith("*.spec"): if pattern.endswith("*.spec"):
return ["/tmp/repo/package-manager.spec"] return ["/tmp/repo/package-manager.spec"]
@@ -77,16 +101,23 @@ class TestRpmSpecInstaller(unittest.TestCase):
self.installer.run(self.ctx) self.installer.run(self.ctx)
# _prepare_source_tarball must have been called with the resolved spec path.
mock_prepare_source_tarball.assert_called_once_with(
self.ctx,
"/tmp/repo/package-manager.spec",
)
# Collect all command strings passed to run_command.
cmds = [c[0][0] for c in mock_run_command.call_args_list] cmds = [c[0][0] for c in mock_run_command.call_args_list]
# 1) builddep # 1) build dependencies (dnf builddep)
self.assertTrue(any("builddep -y" in cmd for cmd in cmds)) self.assertTrue(any("builddep -y" in cmd for cmd in cmds))
# 2) rpmbuild -ba # 2) rpmbuild -ba <spec>
self.assertTrue(any(cmd.startswith("rpmbuild -ba ") for cmd in cmds)) self.assertTrue(any(cmd.startswith("rpmbuild -ba ") for cmd in cmds))
# 3) rpm -i … # 3) installation via dnf: "sudo dnf install -y <rpms>"
self.assertTrue(any(cmd.startswith("sudo rpm -i ") for cmd in cmds)) self.assertTrue(any(cmd.startswith("sudo dnf install -y ") for cmd in cmds))
if __name__ == "__main__": if __name__ == "__main__":

View File

@@ -28,13 +28,26 @@ class TestNixFlakeInstaller(unittest.TestCase):
@patch("shutil.which", return_value="/usr/bin/nix") @patch("shutil.which", return_value="/usr/bin/nix")
@patch("os.path.exists", return_value=True) @patch("os.path.exists", return_value=True)
def test_supports_true_when_nix_and_flake_exist(self, mock_exists, mock_which): def test_supports_true_when_nix_and_flake_exist(self, mock_exists, mock_which):
"""
supports() should return True when:
- nix is available,
- flake.nix exists in the repo,
- and we are not inside a Nix dev shell.
"""
with patch.dict(os.environ, {"IN_NIX_SHELL": ""}, clear=False):
self.assertTrue(self.installer.supports(self.ctx)) self.assertTrue(self.installer.supports(self.ctx))
mock_which.assert_called_with("nix") mock_which.assert_called_with("nix")
mock_exists.assert_called_with(os.path.join(self.ctx.repo_dir, "flake.nix")) mock_exists.assert_called_with(os.path.join(self.ctx.repo_dir, "flake.nix"))
@patch("shutil.which", return_value=None) @patch("shutil.which", return_value=None)
@patch("os.path.exists", return_value=True) @patch("os.path.exists", return_value=True)
def test_supports_false_when_nix_missing(self, mock_exists, mock_which): def test_supports_false_when_nix_missing(self, mock_exists, mock_which):
"""
supports() should return False if nix is not available,
even if a flake.nix file exists.
"""
with patch.dict(os.environ, {"IN_NIX_SHELL": ""}, clear=False):
self.assertFalse(self.installer.supports(self.ctx)) self.assertFalse(self.installer.supports(self.ctx))
@patch("os.path.exists", return_value=True) @patch("os.path.exists", return_value=True)
@@ -47,10 +60,12 @@ class TestNixFlakeInstaller(unittest.TestCase):
mock_exists, mock_exists,
): ):
""" """
Ensure that run(): run() should:
- first tries to remove the old 'package-manager' profile entry
- then installs both 'pkgmgr' and 'default' outputs. 1. attempt to remove the old 'package-manager' profile entry, and
2. install both 'pkgmgr' and 'default' flake outputs.
""" """
cmds = [] cmds = []
def side_effect(cmd, cwd=None, preview=False, *args, **kwargs): def side_effect(cmd, cwd=None, preview=False, *args, **kwargs):
@@ -59,18 +74,24 @@ class TestNixFlakeInstaller(unittest.TestCase):
mock_run_command.side_effect = side_effect mock_run_command.side_effect = side_effect
# Simulate a normal environment (not inside nix develop, installer enabled).
with patch.dict(
os.environ,
{"IN_NIX_SHELL": "", "PKGMGR_DISABLE_NIX_FLAKE_INSTALLER": ""},
clear=False,
):
self.installer.run(self.ctx) self.installer.run(self.ctx)
remove_cmd = f"nix profile remove {self.installer.PROFILE_NAME} || true" remove_cmd = f"nix profile remove {self.installer.PROFILE_NAME} || true"
install_pkgmgr_cmd = f"nix profile install {self.ctx.repo_dir}#pkgmgr" install_pkgmgr_cmd = f"nix profile install {self.ctx.repo_dir}#pkgmgr"
install_default_cmd = f"nix profile install {self.ctx.repo_dir}#default" install_default_cmd = f"nix profile install {self.ctx.repo_dir}#default"
# Mindestens diese drei Kommandos müssen aufgerufen worden sein # At least these three commands must have been issued.
self.assertIn(remove_cmd, cmds) self.assertIn(remove_cmd, cmds)
self.assertIn(install_pkgmgr_cmd, cmds) self.assertIn(install_pkgmgr_cmd, cmds)
self.assertIn(install_default_cmd, cmds) self.assertIn(install_default_cmd, cmds)
# Optional: sicherstellen, dass der remove-Aufruf zuerst kam # Optional: ensure the remove call came first.
self.assertEqual(cmds[0], remove_cmd) self.assertEqual(cmds[0], remove_cmd)
@patch("shutil.which", return_value="/usr/bin/nix") @patch("shutil.which", return_value="/usr/bin/nix")
@@ -90,6 +111,11 @@ class TestNixFlakeInstaller(unittest.TestCase):
mock_run_command.side_effect = side_effect mock_run_command.side_effect = side_effect
with patch.dict(
os.environ,
{"IN_NIX_SHELL": "", "PKGMGR_DISABLE_NIX_FLAKE_INSTALLER": ""},
clear=False,
):
# Should not raise, SystemExit is swallowed internally. # Should not raise, SystemExit is swallowed internally.
self.installer._ensure_old_profile_removed(self.ctx) self.installer._ensure_old_profile_removed(self.ctx)

View File

@@ -1,5 +1,3 @@
# tests/unit/pkgmgr/installers/test_python_installer.py
import os import os
import unittest import unittest
from unittest.mock import patch from unittest.mock import patch
@@ -28,18 +26,40 @@ class TestPythonInstaller(unittest.TestCase):
@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_supports_true_when_pyproject_exists(self, mock_exists): def test_supports_true_when_pyproject_exists(self, mock_exists):
"""
supports() should return True when a pyproject.toml exists in the repo
and we are not inside a Nix dev shell.
"""
with patch.dict(os.environ, {"IN_NIX_SHELL": ""}, clear=False):
self.assertTrue(self.installer.supports(self.ctx)) self.assertTrue(self.installer.supports(self.ctx))
@patch("os.path.exists", return_value=False) @patch("os.path.exists", return_value=False)
def test_supports_false_when_no_pyproject(self, mock_exists): def test_supports_false_when_no_pyproject(self, mock_exists):
"""
supports() should return False when no pyproject.toml exists.
"""
with patch.dict(os.environ, {"IN_NIX_SHELL": ""}, clear=False):
self.assertFalse(self.installer.supports(self.ctx)) self.assertFalse(self.installer.supports(self.ctx))
@patch("pkgmgr.actions.repository.install.installers.python.run_command") @patch("pkgmgr.actions.repository.install.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): def test_run_installs_project_from_pyproject(self, mock_exists, mock_run_command):
"""
run() should invoke pip to install the project from pyproject.toml
when we are not inside a Nix dev shell.
"""
# Simulate a normal environment (not inside nix develop).
with patch.dict(os.environ, {"IN_NIX_SHELL": ""}, clear=False):
self.installer.run(self.ctx) self.installer.run(self.ctx)
# Ensure run_command was actually called.
mock_run_command.assert_called()
# Extract the command string.
cmd = mock_run_command.call_args[0][0] cmd = mock_run_command.call_args[0][0]
self.assertIn("pip install .", cmd) self.assertIn("pip install .", cmd)
# Ensure the working directory is the repo dir.
self.assertEqual( self.assertEqual(
mock_run_command.call_args[1].get("cwd"), mock_run_command.call_args[1].get("cwd"),
self.ctx.repo_dir, self.ctx.repo_dir,

View File

@@ -80,6 +80,21 @@ class TestUpdatePyprojectVersion(unittest.TestCase):
self.assertNotEqual(cm.exception.code, 0) self.assertNotEqual(cm.exception.code, 0)
def test_update_pyproject_version_missing_file_is_skipped(self) -> None:
"""
If pyproject.toml does not exist, the function must not raise
and must not create the file. It should simply be skipped.
"""
with tempfile.TemporaryDirectory() as tmpdir:
path = os.path.join(tmpdir, "pyproject.toml")
self.assertFalse(os.path.exists(path))
# Must not raise an exception
update_pyproject_version(path, "1.2.3", preview=False)
# Still no file created
self.assertFalse(os.path.exists(path))
class TestUpdateFlakeVersion(unittest.TestCase): class TestUpdateFlakeVersion(unittest.TestCase):
def test_update_flake_version_normal(self) -> None: def test_update_flake_version_normal(self) -> None:
@@ -352,11 +367,11 @@ class TestUpdateSpecChangelog(unittest.TestCase):
with open(path, "r", encoding="utf-8") as f: with open(path, "r", encoding="utf-8") as f:
content = f.read() content = f.read()
# Neue Stanza muss nach %changelog stehen # New stanza must appear after the %changelog marker
self.assertIn("%changelog", content) self.assertIn("%changelog", content)
self.assertIn("Fedora changelog entry", content) self.assertIn("Fedora changelog entry", content)
self.assertIn("Test Maintainer <test@example.com>", content) self.assertIn("Test Maintainer <test@example.com>", content)
# Alte Einträge müssen erhalten bleiben # Old entries must still be present
self.assertIn("Old Maintainer <old@example.com>", content) self.assertIn("Old Maintainer <old@example.com>", content)
def test_update_spec_changelog_preview_does_not_write(self) -> None: def test_update_spec_changelog_preview_does_not_write(self) -> None:
@@ -396,7 +411,7 @@ class TestUpdateSpecChangelog(unittest.TestCase):
with open(path, "r", encoding="utf-8") as f: with open(path, "r", encoding="utf-8") as f:
content = f.read() content = f.read()
# Im Preview-Modus darf nichts verändert werden # In preview mode, the spec file must not change
self.assertEqual(content, original) self.assertEqual(content, original)

View File

@@ -1,7 +1,6 @@
from __future__ import annotations from __future__ import annotations
import unittest import unittest
from types import SimpleNamespace
from unittest.mock import patch from unittest.mock import patch
from pkgmgr.actions.branch import open_branch from pkgmgr.actions.branch import open_branch
@@ -13,9 +12,10 @@ class TestOpenBranch(unittest.TestCase):
def test_open_branch_with_explicit_name_and_default_base(self, mock_run_git) -> None: def test_open_branch_with_explicit_name_and_default_base(self, mock_run_git) -> None:
""" """
open_branch(name, base='main') should: open_branch(name, base='main') should:
- resolve base branch (prefers 'main', falls back to 'master')
- fetch origin - fetch origin
- checkout base - checkout resolved base
- pull base - pull resolved base
- create new branch - create new branch
- push with upstream - push with upstream
""" """
@@ -25,6 +25,7 @@ class TestOpenBranch(unittest.TestCase):
# We expect a specific sequence of Git calls. # We expect a specific sequence of Git calls.
expected_calls = [ expected_calls = [
(["rev-parse", "--verify", "main"], "/repo"),
(["fetch", "origin"], "/repo"), (["fetch", "origin"], "/repo"),
(["checkout", "main"], "/repo"), (["checkout", "main"], "/repo"),
(["pull", "origin", "main"], "/repo"), (["pull", "origin", "main"], "/repo"),
@@ -50,7 +51,7 @@ class TestOpenBranch(unittest.TestCase):
) -> None: ) -> None:
""" """
If name is None/empty, open_branch should prompt via input() If name is None/empty, open_branch should prompt via input()
and still perform the full Git sequence. and still perform the full Git sequence on the resolved base.
""" """
mock_run_git.return_value = "" mock_run_git.return_value = ""
@@ -60,6 +61,7 @@ class TestOpenBranch(unittest.TestCase):
mock_input.assert_called_once() mock_input.assert_called_once()
expected_calls = [ expected_calls = [
(["rev-parse", "--verify", "develop"], "/repo"),
(["fetch", "origin"], "/repo"), (["fetch", "origin"], "/repo"),
(["checkout", "develop"], "/repo"), (["checkout", "develop"], "/repo"),
(["pull", "origin", "develop"], "/repo"), (["pull", "origin", "develop"], "/repo"),
@@ -76,15 +78,20 @@ class TestOpenBranch(unittest.TestCase):
self.assertEqual(kwargs.get("cwd"), cwd_expected) self.assertEqual(kwargs.get("cwd"), cwd_expected)
@patch("pkgmgr.actions.branch.run_git") @patch("pkgmgr.actions.branch.run_git")
def test_open_branch_raises_runtimeerror_on_git_failure(self, mock_run_git) -> None: def test_open_branch_raises_runtimeerror_on_fetch_failure(self, mock_run_git) -> None:
""" """
If a GitError occurs (e.g. fetch fails), open_branch should If a GitError occurs on fetch, open_branch should raise a RuntimeError
raise a RuntimeError with a helpful message. with a helpful message.
""" """
def side_effect(args, cwd="."): def side_effect(args, cwd="."):
# Simulate a failure on the first call (fetch) # First call: base resolution (rev-parse) should succeed
if args == ["rev-parse", "--verify", "main"]:
return ""
# Second call: fetch should fail
if args == ["fetch", "origin"]:
raise GitError("simulated fetch failure") raise GitError("simulated fetch failure")
return ""
mock_run_git.side_effect = side_effect mock_run_git.side_effect = side_effect
@@ -95,6 +102,45 @@ class TestOpenBranch(unittest.TestCase):
self.assertIn("Failed to fetch from origin", msg) self.assertIn("Failed to fetch from origin", msg)
self.assertIn("simulated fetch failure", msg) self.assertIn("simulated fetch failure", msg)
@patch("pkgmgr.actions.branch.run_git")
def test_open_branch_uses_fallback_master_if_main_missing(self, mock_run_git) -> None:
"""
If the preferred base (e.g. 'main') does not exist, open_branch should
fall back to the fallback base (default: 'master').
"""
def side_effect(args, cwd="."):
# First: rev-parse main -> fails
if args == ["rev-parse", "--verify", "main"]:
raise GitError("main does not exist")
# Second: rev-parse master -> succeeds
if args == ["rev-parse", "--verify", "master"]:
return ""
# Then normal flow on master
return ""
mock_run_git.side_effect = side_effect
open_branch(name="feature/fallback", base_branch="main", cwd="/repo")
expected_calls = [
(["rev-parse", "--verify", "main"], "/repo"),
(["rev-parse", "--verify", "master"], "/repo"),
(["fetch", "origin"], "/repo"),
(["checkout", "master"], "/repo"),
(["pull", "origin", "master"], "/repo"),
(["checkout", "-b", "feature/fallback"], "/repo"),
(["push", "-u", "origin", "feature/fallback"], "/repo"),
]
self.assertEqual(mock_run_git.call_count, len(expected_calls))
for call, (args_expected, cwd_expected) in zip(
mock_run_git.call_args_list, expected_calls
):
args, kwargs = call
self.assertEqual(args[0], args_expected)
self.assertEqual(kwargs.get("cwd"), cwd_expected)
if __name__ == "__main__": if __name__ == "__main__":
unittest.main() unittest.main()

View File

@@ -0,0 +1,108 @@
import os
import tempfile
import unittest
from unittest.mock import patch
from pkgmgr.core.command.ink import create_ink
class TestCreateInk(unittest.TestCase):
@patch("pkgmgr.core.command.ink.get_repo_dir")
@patch("pkgmgr.core.command.ink.get_repo_identifier")
def test_self_referential_command_skips_symlink(
self,
mock_get_repo_identifier,
mock_get_repo_dir,
):
"""
If the resolved command path is identical to the final link target,
create_ink() must NOT replace it with a self-referential symlink.
This simulates the situation where the command already lives at
~/.local/bin/<identifier> and we would otherwise create a symlink
pointing to itself.
"""
mock_get_repo_identifier.return_value = "package-manager"
mock_get_repo_dir.return_value = "/fake/repo"
with tempfile.TemporaryDirectory() as bin_dir:
# Simulate an existing real binary at the final link location.
command_path = os.path.join(bin_dir, "package-manager")
with open(command_path, "w", encoding="utf-8") as f:
f.write("#!/bin/sh\necho package-manager\n")
# Sanity check: not a symlink yet.
self.assertTrue(os.path.exists(command_path))
self.assertFalse(os.path.islink(command_path))
repo = {"command": command_path}
# This must NOT turn the file into a self-referential symlink.
create_ink(
repo=repo,
repositories_base_dir="/fake/base",
bin_dir=bin_dir,
all_repos=[],
quiet=True,
preview=False,
)
# After create_ink(), the file must still exist and must not be a symlink.
self.assertTrue(os.path.exists(command_path))
self.assertFalse(
os.path.islink(command_path),
"create_ink() must not create a self-referential symlink "
"when command == link_path",
)
@patch("pkgmgr.core.command.ink.get_repo_dir")
@patch("pkgmgr.core.command.ink.get_repo_identifier")
def test_create_symlink_for_normal_command(
self,
mock_get_repo_identifier,
mock_get_repo_dir,
):
"""
In the normal case (command path != link target), create_ink()
must create a symlink in bin_dir pointing to the given command,
and optionally an alias symlink when repo['alias'] is set.
"""
mock_get_repo_identifier.return_value = "mytool"
with tempfile.TemporaryDirectory() as repo_dir, tempfile.TemporaryDirectory() as bin_dir:
mock_get_repo_dir.return_value = repo_dir
# Create a fake executable inside the repository.
command_path = os.path.join(repo_dir, "main.sh")
with open(command_path, "w", encoding="utf-8") as f:
f.write("#!/bin/sh\necho mytool\n")
os.chmod(command_path, 0o755)
repo = {
"command": command_path,
"alias": "mt",
}
create_ink(
repo=repo,
repositories_base_dir="/fake/base",
bin_dir=bin_dir,
all_repos=[],
quiet=True,
preview=False,
)
link_path = os.path.join(bin_dir, "mytool")
alias_path = os.path.join(bin_dir, "mt")
# Main link must exist and point to the command.
self.assertTrue(os.path.islink(link_path))
self.assertEqual(os.readlink(link_path), command_path)
# Alias must exist and point to the main link.
self.assertTrue(os.path.islink(alias_path))
self.assertEqual(os.readlink(alias_path), link_path)
if __name__ == "__main__":
unittest.main()