diff --git a/src/caelestia/subcommands/install.py b/src/caelestia/subcommands/install.py index 6364b20..a54e12f 100644 --- a/src/caelestia/subcommands/install.py +++ b/src/caelestia/subcommands/install.py @@ -1,9 +1,11 @@ import shutil +import subprocess import textwrap from argparse import Namespace from pathlib import Path 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 @@ -34,14 +36,15 @@ class Command: self.print_greeting() self.create_backup() + legacy_dir = detect_legacy_repo() # Detect legacy repo first cause deploy overwrites legacy syms source, tip, manifest = self.fetch_manifest() deployed = self.deploy_configs(source, manifest) - helper, packages, local_packages = self.install_packages(source, manifest) + installer, packages, local_packages = self.install_packages(source, manifest) run_hooks(manifest, "post_install") DotsState( - aur_helper=helper, + aur_helper=getattr(installer, "helper", DEFAULT_AUR_HELPER), applied_rev=tip, enabled_components=manifest.enabled_components, packages=packages, @@ -49,6 +52,7 @@ class Command: deployed_files=deployed, ).save() + self.migrate_legacy(installer, legacy_dir) self.print_done() def print_greeting(self) -> None: @@ -144,7 +148,9 @@ class Command: return deployer.deployed_files - def install_packages(self, source: DotsSource, manifest: Manifest) -> tuple[str, list[str], dict[str, list[str]]]: + def install_packages( + self, source: DotsSource, manifest: Manifest + ) -> tuple[PackageInstaller, list[str], dict[str, list[str]]]: installer = PackageInstaller.get(self.args.aur_helper, self.args.noconfirm) packages = manifest.enabled_packages() @@ -160,7 +166,32 @@ class Command: log("Building local packages...") local_packages = build_local_packages(installer, source, local_dirs) - return getattr(installer, "helper", DEFAULT_AUR_HELPER), packages, local_packages + return installer, packages, local_packages + + def migrate_legacy(self, installer: PackageInstaller, legacy_dir: Path | None) -> None: + """Clean up a previous install.fish setup (repo, symlinks and metapackage).""" + + to_delete = legacy_to_delete(legacy_dir) + meta_installed = installer.is_installed(LEGACY_META_PKG) + if not to_delete and not meta_installed: + return + + print() + log("Found a legacy Caelestia installation...") + if not confirm("Clear legacy installation?"): + return + + deployer = Deployer() + try: + for path in to_delete: + deployer.remove(path) + info(f"Deleted {path}") + + if meta_installed: + log("Removing legacy meta package...") + installer.remove([LEGACY_META_PKG]) + except (OSError, subprocess.CalledProcessError) as e: + warn(f"could not fully clear the legacy installation: {e}") def print_done(self) -> None: print() diff --git a/src/caelestia/utils/dots/legacy.py b/src/caelestia/utils/dots/legacy.py new file mode 100644 index 0000000..a17fdf7 --- /dev/null +++ b/src/caelestia/utils/dots/legacy.py @@ -0,0 +1,83 @@ +import subprocess +from pathlib import Path + +from caelestia.utils.paths import config_dir, data_dir + +LEGACY_META_PKG = "caelestia-meta" + +_confs = [ + "hypr", + "starship.toml", + "foot", + "fish", + "fastfetch", + "uwsm", + "btop", + "spicetify", + "Code/User/settings.json", + "VSCodium/User/settings.json", + "Code/User/keybindings.json", + "VSCodium/User/keybindings.json", + "code-flags.conf", + "codium-flags.conf", +] + + +def _find_legacy_repo(path: Path) -> Path | None: + try: + remote = subprocess.check_output(["git", "-C", path, "remote", "get-url", "origin"], text=True) + except subprocess.CalledProcessError: + return + + # Check remote + if remote.strip() != "https://github.com/caelestia-dots/caelestia.git": + return + + # Ignore anything outside home + if Path.home() not in path.parents: + return + + # Walk up parents (capped at home) to find the repo root + while path != Path.home() and not (path / ".git").is_dir(): + path = path.parent + + # Only return path if didn't hit home (we really don't want to nuke home) + if path != Path.home(): + return path + + +def detect_legacy_repo() -> Path | None: + for conf in _confs: + path = config_dir / conf + if not path.is_symlink(): + continue + + legacy_dir = _find_legacy_repo(path.resolve()) + if legacy_dir: + return legacy_dir + + return _find_legacy_repo(data_dir / "caelestia") + + +def legacy_to_delete(legacy_dir: Path | None) -> list[Path]: + if not legacy_dir: + return [] + + to_delete = [] + + for conf in _confs: + path = config_dir / conf + if path.is_symlink() and legacy_dir in path.resolve().parents: + to_delete.append(path) + + others = [ + *(Path.home() / ".zen").glob("*/chrome/userChrome.css"), + Path.home() / ".local/lib/caelestia/caelestiafox", + ] + for path in others: + if path.is_symlink() and legacy_dir in path.resolve().parents: + to_delete.append(path) + + to_delete.append(legacy_dir) + + return to_delete diff --git a/src/caelestia/utils/dots/packages.py b/src/caelestia/utils/dots/packages.py index 0b66ec7..990bee1 100644 --- a/src/caelestia/utils/dots/packages.py +++ b/src/caelestia/utils/dots/packages.py @@ -73,6 +73,9 @@ class PackageInstaller(ABC): def build_install(self, directory: Path) -> list[str]: """Build and install the PKGBUILD in `directory`, returning the installed package names.""" + @abstractmethod + def is_installed(self, package: str) -> bool: ... + @abstractmethod def system_update(self) -> None: ... @@ -92,6 +95,9 @@ class NoopInstaller(PackageInstaller): info(f"Skipping local package build (not on Arch): {directory}") return [] + def is_installed(self, package: str) -> bool: + return False + def system_update(self) -> None: info("Skipping system update (not on Arch)") @@ -101,10 +107,18 @@ class ArchInstaller(PackageInstaller): self.helper = helper self.flags = ["--noconfirm"] if noconfirm else [] - def install(self, packages: list[str], extra_flags: list[str] | None = None) -> None: + def install(self, packages: list[str], explicit: bool = True) -> None: if not packages: return - subprocess.run([self.helper, "-S", "--needed", *self.flags, *(extra_flags or []), *packages], check=True) + + 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) + + # Force install reason to explicit install + if explicit: + subprocess.run([self.helper, "-D", "--asexplicit", *self.flags, *packages], check=True) def remove(self, packages: list[str]) -> None: if not packages: @@ -126,7 +140,7 @@ class ArchInstaller(PackageInstaller): elif key == "depends": depends.append(value.strip()) - self.install(depends, extra_flags=["--asdeps"]) + self.install(depends, explicit=False) # Stop makepkg from resetting sudo env = {**os.environ, "PACMAN_AUTH": "sudo"} @@ -135,5 +149,15 @@ class ArchInstaller(PackageInstaller): return names + def is_installed(self, package: str) -> bool: + return ( + subprocess.run( + ["pacman", "-Q", package], + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ).returncode + == 0 + ) + def system_update(self) -> None: subprocess.run([self.helper, "-Syu", *self.flags], check=True)