diff --git a/requirements.txt b/_requirements.txt similarity index 100% rename from requirements.txt rename to _requirements.txt diff --git a/flake.nix b/flake.nix index e332297..dec290b 100644 --- a/flake.nix +++ b/flake.nix @@ -1,7 +1,7 @@ { description = "Nix flake for Kevin's package-manager tool"; - nixConfig = { + nixConfig = { extra-experimental-features = [ "nix-command" "flakes" ]; }; @@ -23,9 +23,16 @@ # Dev shells: nix develop .#default (on both architectures) devShells = forAllSystems (system: let - pkgs = nixpkgs.legacyPackages.${system}; + pkgs = nixpkgs.legacyPackages.${system}; + + # Base Python interpreter python = pkgs.python311; - pypkgs = pkgs.python311Packages; + + # Python env with pip + pyyaml available, so `python -m pip` works + pythonEnv = python.withPackages (ps: with ps; [ + pip + pyyaml + ]); # Be robust: ansible-core if available, otherwise ansible. ansiblePkg = @@ -34,8 +41,7 @@ in { default = pkgs.mkShell { buildInputs = [ - python - pypkgs.pyyaml + pythonEnv pkgs.git ansiblePkg ]; @@ -46,48 +52,59 @@ } ); -packages = forAllSystems (system: - let - pkgs = nixpkgs.legacyPackages.${system}; - python = pkgs.python311; - pypkgs = pkgs.python311Packages; + packages = forAllSystems (system: + let + pkgs = nixpkgs.legacyPackages.${system}; + python = pkgs.python311; - # Optional: ansible mit in den Closure nehmen - ansiblePkg = - if pkgs ? ansible-core then pkgs.ansible-core - else pkgs.ansible; - in - rec { - pkgmgr = pkgs.stdenv.mkDerivation { - pname = "package-manager"; - version = "0.1.1"; + # Runtime Python for pkgmgr (with pip + pyyaml) + pythonEnv = python.withPackages (ps: with ps; [ + pip + pyyaml + ]); - src = ./.; + # Optional: include Ansible in the runtime closure + ansiblePkg = + if pkgs ? ansible-core then pkgs.ansible-core + else pkgs.ansible; + in + rec { + pkgmgr = pkgs.stdenv.mkDerivation { + pname = "package-manager"; + version = "0.1.1"; - # Nix soll *kein* configure / build ausführen (also auch kein make) - dontConfigure = true; - dontBuild = true; + src = ./.; - # Wenn du Python/Ansible im Runtime-Closure haben willst: - buildInputs = [ - python - pypkgs.pyyaml - ansiblePkg - ]; + # Nix should not run configure / build (no make) + dontConfigure = true; + dontBuild = true; - installPhase = '' - mkdir -p "$out/bin" - cp main.py "$out/bin/pkgmgr" - chmod +x "$out/bin/pkgmgr" - ''; - }; + # Runtime deps: Python (with pip) + Ansible + buildInputs = [ + pythonEnv + ansiblePkg + ]; - # default package just points to pkgmgr - default = pkgmgr; - } -); + installPhase = '' + mkdir -p "$out/bin" + # Wrapper that always uses the pythonEnv interpreter, so + # sys.executable -m pip has a working pip. + cat > "$out/bin/pkgmgr" << EOF +#!${pythonEnv}/bin/python3 +import runpy +if __name__ == "__main__": + runpy.run_module("main", run_name="__main__") +EOF + chmod +x "$out/bin/pkgmgr" + ''; + }; + + # default package just points to pkgmgr + default = pkgmgr; + } + ); # Apps: nix run .#pkgmgr / .#default apps = forAllSystems (system: diff --git a/pkgmgr/installers/nix_flake.py b/pkgmgr/installers/nix_flake.py index 2717e21..5ed0453 100644 --- a/pkgmgr/installers/nix_flake.py +++ b/pkgmgr/installers/nix_flake.py @@ -10,17 +10,22 @@ installer will try to install profile outputs from the flake. Behavior: - If flake.nix is present and `nix` exists on PATH: * First remove any existing `package-manager` profile entry (best-effort). - * Then install the flake outputs (pkgmgr, default) via `nix profile install`. - - Any failure in `nix profile install` is treated as fatal (SystemExit). + * Then install the flake outputs (`pkgmgr`, `default`) via `nix profile install`. + - Failure installing `pkgmgr` is treated as fatal. + - Failure installing `default` is logged as an error/warning but does not abort. """ import os import shutil +from typing import TYPE_CHECKING -from pkgmgr.context import RepoContext from pkgmgr.installers.base import BaseInstaller from pkgmgr.run_command import run_command +if TYPE_CHECKING: + from pkgmgr.context import RepoContext + from pkgmgr.install_repos import InstallContext + class NixFlakeInstaller(BaseInstaller): """Install Nix flake profiles for repositories that define flake.nix.""" @@ -28,7 +33,7 @@ class NixFlakeInstaller(BaseInstaller): FLAKE_FILE = "flake.nix" PROFILE_NAME = "package-manager" - def supports(self, ctx: RepoContext) -> bool: + def supports(self, ctx: "RepoContext") -> bool: """ Only support repositories that: - Have a flake.nix @@ -39,7 +44,7 @@ class NixFlakeInstaller(BaseInstaller): flake_path = os.path.join(ctx.repo_dir, self.FLAKE_FILE) return os.path.exists(flake_path) - def _ensure_old_profile_removed(self, ctx: RepoContext) -> None: + def _ensure_old_profile_removed(self, ctx: "RepoContext") -> None: """ Best-effort removal of an existing profile entry. @@ -53,45 +58,46 @@ class NixFlakeInstaller(BaseInstaller): if shutil.which("nix") is None: return - # We do NOT use run_command here, because we explicitly want to ignore - # the failure of `nix profile remove` (e.g. entry not present). - # Using `|| true` makes this idempotent. cmd = f"nix profile remove {self.PROFILE_NAME} || true" - # This will still respect preview mode inside run_command. try: + # NOTE: no allow_failure here → matches the existing unit tests run_command(cmd, cwd=ctx.repo_dir, preview=ctx.preview) except SystemExit: - # Ignore any error here: if the profile entry does not exist, - # that's fine and not a fatal condition. + # Unit tests explicitly assert this is swallowed pass - def run(self, ctx: RepoContext) -> None: + def run(self, ctx: "InstallContext") -> None: """ Install Nix flake profile outputs (pkgmgr, default). - Any failure in `nix profile install` is treated as fatal (SystemExit). - The "already installed / file conflict" situation is avoided by - removing the existing profile entry beforehand. + Any failure installing `pkgmgr` is treated as fatal (SystemExit). + A failure installing `default` is logged but does not abort. """ - flake_path = os.path.join(ctx.repo_dir, self.FLAKE_FILE) - if not os.path.exists(flake_path): - return - - if shutil.which("nix") is None: - print("Warning: flake.nix found but 'nix' command not available. Skipping flake setup.") + # Reuse supports() to keep logic in one place + if not self.supports(ctx): # type: ignore[arg-type] return print("Nix flake detected, attempting to install profile outputs...") # Handle the "already installed" case up-front: - self._ensure_old_profile_removed(ctx) + self._ensure_old_profile_removed(ctx) # type: ignore[arg-type] for output in ("pkgmgr", "default"): cmd = f"nix profile install {ctx.repo_dir}#{output}" + try: - run_command(cmd, cwd=ctx.repo_dir, preview=ctx.preview) + # For 'default' we don't want the process to exit on error + allow_failure = output == "default" + run_command(cmd, cwd=ctx.repo_dir, preview=ctx.preview, allow_failure=allow_failure) print(f"Nix flake output '{output}' successfully installed.") except SystemExit as e: print(f"[Error] Failed to install Nix flake output '{output}': {e}") - # Hard fail: a broken flake is considered a fatal error. - raise + if output == "pkgmgr": + # Broken main CLI install → fatal + raise + # For 'default' we log and continue + print( + "[Warning] Continuing despite failure to install 'default' " + "because 'pkgmgr' is already installed." + ) + break diff --git a/pkgmgr/run_command.py b/pkgmgr/run_command.py index 180fc8e..b108183 100644 --- a/pkgmgr/run_command.py +++ b/pkgmgr/run_command.py @@ -1,18 +1,45 @@ -import sys +# pkgmgr/run_command.py import subprocess -import os +import sys +from typing import List, Optional, Union -def run_command(command, cwd=None, preview=False): - """Run a shell command in a given directory, or print it in preview mode. - - If the command fails, exit the program with the command's exit code. + +CommandType = Union[str, List[str]] + + +def run_command( + cmd: CommandType, + cwd: Optional[str] = None, + preview: bool = False, + allow_failure: bool = False, +) -> subprocess.CompletedProcess: """ - current_dir = cwd or os.getcwd() - if preview: - print(f"[Preview] In '{current_dir}': {command}") + Run a command and optionally exit on error. + + - If `cmd` is a string, it is executed with `shell=True`. + - If `cmd` is a list of strings, it is executed without a shell. + """ + if isinstance(cmd, str): + display = cmd else: - print(f"Running in '{current_dir}': {command}") - result = subprocess.run(command, cwd=cwd, shell=True, check=False) - if result.returncode != 0: - print(f"Command failed with exit code {result.returncode}. Exiting.") - sys.exit(result.returncode) \ No newline at end of file + display = " ".join(cmd) + + where = cwd or "." + + if preview: + print(f"[Preview] In '{where}': {display}") + # Fake a successful result; most callers ignore the return value anyway + return subprocess.CompletedProcess(cmd, 0) # type: ignore[arg-type] + + print(f"Running in '{where}': {display}") + + if isinstance(cmd, str): + result = subprocess.run(cmd, cwd=cwd, shell=True) + else: + result = subprocess.run(cmd, cwd=cwd) + + if result.returncode != 0 and not allow_failure: + print(f"Command failed with exit code {result.returncode}. Exiting.") + sys.exit(result.returncode) + + return result diff --git a/tests/integration/test_integration_install_pkgmgr_shallow.py b/tests/integration/test_integration_install_pkgmgr_shallow.py index 7aa578c..28e0e18 100644 --- a/tests/integration/test_integration_install_pkgmgr_shallow.py +++ b/tests/integration/test_integration_install_pkgmgr_shallow.py @@ -1,43 +1,71 @@ -""" -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 +import subprocess -class TestIntegrationInstallAllShallow(unittest.TestCase): - def test_install_pkgmgr_self_install(self): - """ - Run: pkgmgr install --all --clone-mode shallow --no-verification +def nix_profile_list_debug(label: str) -> None: + """ + Print `nix profile list` for debugging inside the test container. + Never fails the test. + """ + print(f"\n--- NIX PROFILE LIST ({label}) ---") + proc = subprocess.run( + ["nix", "profile", "list"], + capture_output=True, + text=True, + check=False, + ) + stdout = proc.stdout.strip() + stderr = proc.stderr.strip() + + if stdout: + print(stdout) + if stderr: + print("stderr:", stderr) + print("--- END ---\n") + + +def remove_pkgmgr_from_nix_profile() -> None: + """ + Best-effort cleanup before running the integration test. + + We *do not* try to parse profile indices here, because modern `nix profile list` + 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`) + - 'package-manager' (the name mentioned in Nix's own error hints) + """ + for spec in ("pkgmgr", "package-manager"): + subprocess.run( + ["nix", "profile", "remove", spec], + check=False, # never fail on cleanup + ) + + +class TestIntegrationInstalPKGMGRShallow(unittest.TestCase): + def test_install_pkgmgr_self_install(self) -> None: + # 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") - This will perform real installations/clones inside the container. - The test succeeds if no exception is raised. - """ original_argv = sys.argv try: sys.argv = [ - "pkgmgr", + "python", "install", "pkgmgr", "--clone-mode", "shallow", "--no-verification", ] - - # 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__") - finally: sys.argv = original_argv