diff --git a/src/caelestia/subcommands/install.py b/src/caelestia/subcommands/install.py index a54e12f..4ce064d 100644 --- a/src/caelestia/subcommands/install.py +++ b/src/caelestia/subcommands/install.py @@ -1,5 +1,4 @@ import shutil -import subprocess import textwrap from argparse import Namespace from pathlib import Path @@ -8,7 +7,7 @@ from caelestia.utils.dots.deployer import Deployer from caelestia.utils.dots.legacy import LEGACY_META_PKG, detect_legacy_repo, legacy_to_delete from caelestia.utils.dots.manifest import ComponentError, Manifest, ManifestError from caelestia.utils.dots.misc import build_local_packages, run_hooks -from caelestia.utils.dots.packages import DEFAULT_AUR_HELPER, PackageInstaller +from caelestia.utils.dots.packages import DEFAULT_AUR_HELPER, PackageError, PackageInstaller from caelestia.utils.dots.source import DotsSource, SourceError from caelestia.utils.dots.state import DotsState from caelestia.utils.io import confirm, disable_input, fatal, info, log, pause, prompt_selection, warn @@ -40,7 +39,10 @@ class Command: source, tip, manifest = self.fetch_manifest() deployed = self.deploy_configs(source, manifest) - installer, packages, local_packages = self.install_packages(source, manifest) + try: + installer, packages, local_packages = self.install_packages(source, manifest) + except PackageError as e: + fatal(e) run_hooks(manifest, "post_install") DotsState( @@ -190,7 +192,7 @@ class Command: if meta_installed: log("Removing legacy meta package...") installer.remove([LEGACY_META_PKG]) - except (OSError, subprocess.CalledProcessError) as e: + except (OSError, PackageError) as e: warn(f"could not fully clear the legacy installation: {e}") def print_done(self) -> None: diff --git a/src/caelestia/subcommands/update.py b/src/caelestia/subcommands/update.py index 294e823..cadd432 100644 --- a/src/caelestia/subcommands/update.py +++ b/src/caelestia/subcommands/update.py @@ -6,7 +6,7 @@ from caelestia.utils.dots.deployer import Deployer from caelestia.utils.dots.diff import Changeset from caelestia.utils.dots.manifest import ComponentError, Manifest, ManifestError from caelestia.utils.dots.misc import build_local_packages, run_hooks -from caelestia.utils.dots.packages import PackageInstaller +from caelestia.utils.dots.packages import PackageError, PackageInstaller from caelestia.utils.dots.source import DotsSource, SourceError from caelestia.utils.dots.state import DotsState from caelestia.utils.io import disable_input, fatal, info, log, prompt_selection, warn @@ -27,8 +27,11 @@ class Command: fatal("dots not installed yet. Run `caelestia install` first.") # Run system update - installer = PackageInstaller.get(self.args.aur_helper or state.aur_helper, self.args.noconfirm) - installer.system_update() + try: + installer = PackageInstaller.get(self.args.aur_helper or state.aur_helper, self.args.noconfirm) + installer.system_update() + except PackageError as e: + fatal(e) # Get manifest or exit if up to date source, tip, manifest = self.fetch_manifest(state, state.applied_rev) @@ -54,13 +57,14 @@ class Command: # Install new/remove old packages desired = manifest.enabled_packages() - state.packages = self.sync_packages(installer, state.packages, desired) - state.save() - - # Install new/remove old local PKGBUILD packages desired_local = manifest.enabled_local_packages() - state.local_packages = self.sync_local_packages(installer, source, state.local_packages, desired_local) - state.save() + try: + state.packages = self.sync_packages(installer, state.packages, desired) + state.save() + state.local_packages = self.sync_local_packages(installer, source, state.local_packages, desired_local) + state.save() + except PackageError as e: + fatal(e) # Run hooks run_hooks(manifest, "post_update") diff --git a/src/caelestia/utils/dots/packages.py b/src/caelestia/utils/dots/packages.py index 990bee1..45fef6c 100644 --- a/src/caelestia/utils/dots/packages.py +++ b/src/caelestia/utils/dots/packages.py @@ -5,32 +5,48 @@ import tempfile from abc import ABC, abstractmethod from pathlib import Path -from caelestia.utils.io import fatal, info +from caelestia.utils.io import fatal, info, warn DEFAULT_AUR_HELPER = "paru" AUR_HELPERS = DEFAULT_AUR_HELPER, "yay" +class PackageError(Exception): + """Raised when a package operation (install/remove/build/update) fails.""" + + +def _try_run(cmd: list[str], error_msg: str, **kwargs) -> None: + """Run a subprocess, raising `PackageError` if it fails.""" + + try: + subprocess.run(cmd, check=True, **kwargs) + except (subprocess.CalledProcessError, FileNotFoundError) as e: + raise PackageError(error_msg) from e + + def _install_aur_helper(helper: str, noconfirm: bool = False) -> None: pacman_cmd = ["sudo", "pacman", "-S", "--needed", "git", "base-devel"] if noconfirm: pacman_cmd.append("--noconfirm") - subprocess.run(pacman_cmd, check=True) + _try_run(pacman_cmd, "failed to install AUR helper build dependencies") repo_url = f"https://aur.archlinux.org/{helper}.git" with tempfile.TemporaryDirectory() as repo_dir: - subprocess.run(["git", "clone", repo_url, repo_dir], check=True) + _try_run(["git", "clone", repo_url, repo_dir], f"failed to clone {helper} from the AUR") makepkg_cmd = ["makepkg", "-si"] if noconfirm: makepkg_cmd.append("--noconfirm") - subprocess.run(makepkg_cmd, cwd=repo_dir, check=True) + _try_run(makepkg_cmd, f"failed to build and install {helper}", cwd=repo_dir) - if helper == "yay": - subprocess.run(["yay", "-Y", "--gendb"], check=True) - subprocess.run(["yay", "-Y", "--devel", "--save"], check=True) - elif helper == "paru": - subprocess.run(["paru", "--gendb"], check=True) + try: + if helper == "yay": + subprocess.run(["yay", "-Y", "--gendb"], check=True) + subprocess.run(["yay", "-Y", "--devel", "--save"], check=True) + elif helper == "paru": + subprocess.run(["paru", "--gendb"], check=True) + except (subprocess.CalledProcessError, FileNotFoundError) as e: + warn(f"failed to run AUR helper post install actions: {e}") class PackageInstaller(ABC): @@ -114,19 +130,26 @@ class ArchInstaller(PackageInstaller): cmd = [self.helper, "-S", "--needed", *self.flags] if not explicit: cmd.append("--asdeps") # Set install reason to dep (does not affect already installed packages) - subprocess.run(cmd + packages, check=True) + _try_run(cmd + packages, f"failed to install packages: {', '.join(packages)}") # Force install reason to explicit install if explicit: - subprocess.run([self.helper, "-D", "--asexplicit", *self.flags, *packages], check=True) + try: + subprocess.run([self.helper, "-D", "--asexplicit", *self.flags, *packages], check=True) + except (subprocess.CalledProcessError, FileNotFoundError): + warn(f"failed to mark packages as explicitly installed: {', '.join(packages)}") def remove(self, packages: list[str]) -> None: if not packages: return - subprocess.run([self.helper, "-Rns", *self.flags, *packages], check=True) + _try_run([self.helper, "-Rns", *self.flags, *packages], f"failed to remove packages: {', '.join(packages)}") def build_install(self, directory: Path) -> list[str]: - srcinfo = subprocess.check_output(["makepkg", "--printsrcinfo"], cwd=directory, text=True) + try: + srcinfo = subprocess.check_output(["makepkg", "--printsrcinfo"], cwd=directory, text=True) + except (subprocess.CalledProcessError, FileNotFoundError) as e: + raise PackageError(f"failed to read package metadata in {directory}") from e + names = [] depends = [] for line in srcinfo.splitlines(): @@ -145,7 +168,9 @@ class ArchInstaller(PackageInstaller): # Stop makepkg from resetting sudo env = {**os.environ, "PACMAN_AUTH": "sudo"} # -f = force, -s = sync deps, -i = install - subprocess.run(["makepkg", "-fsi", *self.flags], cwd=directory, env=env, check=True) + _try_run( + ["makepkg", "-fsi", *self.flags], f"failed to build local package in {directory}", cwd=directory, env=env + ) return names @@ -160,4 +185,4 @@ class ArchInstaller(PackageInstaller): ) def system_update(self) -> None: - subprocess.run([self.helper, "-Syu", *self.flags], check=True) + _try_run([self.helper, "-Syu", *self.flags], "failed to perform system update")