372 lines
10 KiB
Python
Executable File
372 lines
10 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
|
|
import argparse
|
|
import platform
|
|
import shutil
|
|
import subprocess
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
|
|
class InstallerError(BaseException): ...
|
|
|
|
|
|
class Installer:
|
|
def __init__(self, profile: str) -> None:
|
|
"""Initialize a new installer."""
|
|
self.script_file = Path(__file__).resolve()
|
|
self.script_name = self.script_file.name
|
|
self.script_dir = self.script_file.parent
|
|
self.source_dir = self.script_dir
|
|
self.dest_dir = Path.home()
|
|
|
|
self.distro = platform.freedesktop_os_release().get("NAME", "Unknown")
|
|
self.profile = profile
|
|
self.has_error = False
|
|
|
|
# Package query functions by distro
|
|
self.pkg_query = {"Arch Linux": self._pkg_query_arch}
|
|
|
|
# Packages by distro and profile
|
|
self.pkgs = {
|
|
"Arch Linux hyprland": [
|
|
"alacritty",
|
|
"tmux",
|
|
"zsh",
|
|
"hyprland",
|
|
"uwsm",
|
|
"fnott",
|
|
"pipewire",
|
|
"wireplumber",
|
|
"hyprpolkitagent",
|
|
"qt5-wayland",
|
|
"qt6-wayland",
|
|
"hyprlock",
|
|
"hypridle",
|
|
"xdg-desktop-portal-hyprland",
|
|
"xdg-desktop-portal-gtk",
|
|
"hyprland-qt-support",
|
|
"waybar",
|
|
"rofi",
|
|
"wl-clipboard",
|
|
"dolphin",
|
|
"pasystray",
|
|
"playerctl",
|
|
"brightnessctl",
|
|
"breeze",
|
|
"pavucontrol",
|
|
"otf-font-awesome",
|
|
]
|
|
}
|
|
|
|
self.symlinks = [
|
|
".config/alacritty",
|
|
".config/dolphinrc",
|
|
".config/dunst",
|
|
".config/fish",
|
|
".config/fnott",
|
|
".config/foot",
|
|
".config/frogminer",
|
|
".config/ghostty",
|
|
".config/hypr",
|
|
".config/i3",
|
|
".config/i3status",
|
|
".config/lf",
|
|
".config/kde-mimeapps.list",
|
|
".config/kdeglobals",
|
|
".config/kglobalshortcutsrc",
|
|
".config/klipperrc",
|
|
".config/kwinrc",
|
|
".config/picom",
|
|
".config/rofi",
|
|
".config/strawberry",
|
|
".config/tmux",
|
|
".config/uwsm",
|
|
".config/waybar",
|
|
".config/wezterm",
|
|
".config/xdg-desktop-portal",
|
|
".config/yay",
|
|
".config/zed",
|
|
".local/bin",
|
|
".local/share/fonts",
|
|
".local/share/konsole",
|
|
".local/share/kxmlgui5/dolphin/dolphinui.rc",
|
|
".vimrc",
|
|
".xinit-scripts",
|
|
".xinitrc",
|
|
".Xresources",
|
|
".zprofile",
|
|
]
|
|
|
|
self.copies = [
|
|
".config/gtk-3.0",
|
|
".config/gtk-4.0",
|
|
".gtkrc-2.0",
|
|
]
|
|
|
|
self.symlink_map = {"zsh/rc": ".zshrc"}
|
|
|
|
@staticmethod
|
|
def _pkg_query_arch(package: str) -> bool:
|
|
"""
|
|
Check if package is installed on Arch Linux using pacman.
|
|
|
|
Returns:
|
|
True if package is found, otherwise False.
|
|
"""
|
|
result = subprocess.run(
|
|
["pacman", "-Qi", package],
|
|
capture_output=True,
|
|
text=True,
|
|
check=False,
|
|
)
|
|
return result.returncode == 0
|
|
|
|
def error(self, msg: str) -> None:
|
|
"""Print error message and set error flag."""
|
|
self.has_error = True
|
|
print(f"Error: {msg}", file=sys.stderr)
|
|
|
|
def check_packages_installed(self) -> None:
|
|
"""Check if required packages are installed."""
|
|
key = f"{self.distro} {self.profile}"
|
|
if key not in self.pkgs:
|
|
self.error(f"No package list defined for {key}")
|
|
return
|
|
|
|
if self.distro not in self.pkg_query:
|
|
self.error(f"No package query function for {self.distro}")
|
|
return
|
|
|
|
missing: list[str] = []
|
|
pkg_list = self.pkgs[key]
|
|
query_func = self.pkg_query[self.distro]
|
|
|
|
for pkg in pkg_list:
|
|
if not query_func(pkg):
|
|
missing.append(pkg)
|
|
|
|
if missing:
|
|
self.error(f"missing {len(missing)} packages: {' '.join(missing)}")
|
|
|
|
def remove_symlink(self, rel_path: str) -> None:
|
|
"""
|
|
Remove a symlink.
|
|
|
|
Raises:
|
|
InstallerError: on error
|
|
"""
|
|
link_path = self.dest_dir / rel_path
|
|
if link_path.is_symlink():
|
|
src = link_path.resolve()
|
|
print(f"Removing symlink: {link_path} -> {src}")
|
|
link_path.unlink()
|
|
elif link_path.exists():
|
|
raise InstallerError(
|
|
f"object to be removed is not a symlink: {link_path}"
|
|
)
|
|
|
|
def remove_all_symlinks(self) -> None:
|
|
"""Remove all symlinks."""
|
|
for link in self.symlinks:
|
|
self.remove_symlink(link)
|
|
|
|
for dst in self.symlink_map.values():
|
|
self.remove_symlink(dst)
|
|
|
|
def create_symlink(
|
|
self,
|
|
rel_src: str,
|
|
rel_dst: str,
|
|
force: bool = False,
|
|
ignore: bool = False,
|
|
) -> None:
|
|
"""
|
|
Create a symlink.
|
|
|
|
Raises:
|
|
InstallerError: on error
|
|
"""
|
|
if not rel_src or not rel_dst:
|
|
raise InstallerError(
|
|
f"missing src or dst argument: src='{rel_src}', dst='{rel_dst}'"
|
|
)
|
|
|
|
src = self.script_dir / rel_src
|
|
dst = self.dest_dir / rel_dst
|
|
dst_parent = dst.parent
|
|
|
|
if not src.exists():
|
|
raise InstallerError(f"source path does not exist: {src}")
|
|
|
|
# Create parent directory if needed
|
|
if not dst_parent.exists():
|
|
print(f"Creating parent: {dst_parent}")
|
|
dst_parent.mkdir(parents=True, exist_ok=True)
|
|
|
|
if dst.is_symlink():
|
|
if ignore or dst.resolve() == src:
|
|
return
|
|
|
|
if force:
|
|
self.remove_symlink(rel_dst)
|
|
else:
|
|
raise InstallerError(f"symbolic link already exists: {dst}")
|
|
elif dst.exists():
|
|
raise InstallerError(
|
|
f"path already exists and is not a symlink: {dst}"
|
|
)
|
|
|
|
print(f"Creating link: {dst} -> {rel_src}")
|
|
dst.symlink_to(src)
|
|
|
|
@staticmethod
|
|
def remove_path(target: Path) -> None:
|
|
"""Remove path using gio trash if available, otherwise rm."""
|
|
print(f"Trashing item: {target}")
|
|
subprocess.run(["gio", "trash", str(target)], check=True)
|
|
|
|
def copy_item(
|
|
self,
|
|
rel_src: str,
|
|
rel_dst: str,
|
|
force: bool = False,
|
|
ignore_existing: bool = False,
|
|
) -> None:
|
|
"""
|
|
Copy an item.
|
|
|
|
Raises:
|
|
InstallerError: on error
|
|
"""
|
|
if not rel_src or not rel_dst:
|
|
raise InstallerError(
|
|
f"missing src or dst argument: src='{rel_src}', dst='{rel_dst}'"
|
|
)
|
|
|
|
src = self.script_dir / rel_src
|
|
dst = self.dest_dir / rel_dst
|
|
dst_parent = dst.parent
|
|
|
|
if not src.exists():
|
|
raise InstallerError(f"source path does not exist: {src}")
|
|
|
|
# Create parent directory if needed
|
|
if not dst_parent.exists():
|
|
print(f"Creating parent: {dst_parent}")
|
|
dst_parent.mkdir(parents=True, exist_ok=True)
|
|
|
|
if dst.exists():
|
|
if force:
|
|
self.remove_path(dst)
|
|
elif ignore_existing:
|
|
return
|
|
else:
|
|
raise InstallerError(f"path already exists: {dst}")
|
|
|
|
print(f"Copying item: from {rel_src} to {dst_parent}/")
|
|
if src.is_dir():
|
|
shutil.copytree(src, dst)
|
|
else:
|
|
shutil.copy2(src, dst)
|
|
|
|
def create_all_symlinks(
|
|
self, force: bool = False, ignore_existing: bool = False
|
|
) -> None:
|
|
"""Create all symlinks."""
|
|
for link in self.symlinks:
|
|
self.create_symlink(link, link, force, ignore_existing)
|
|
|
|
for src, dst in self.symlink_map.items():
|
|
self.create_symlink(src, dst, force, ignore_existing)
|
|
|
|
def copy_all_items(
|
|
self, force: bool = False, ignore_existing: bool = False
|
|
) -> None:
|
|
"""Copy all items."""
|
|
for item in self.copies:
|
|
self.copy_item(item, item, force, ignore_existing)
|
|
|
|
def check_terminfo(self) -> None:
|
|
"""Check if tmux-256color terminfo is available."""
|
|
try:
|
|
result = subprocess.run(
|
|
["infocmp", "tmux-256color"],
|
|
capture_output=True,
|
|
text=True,
|
|
check=False,
|
|
)
|
|
if result.returncode != 0:
|
|
self.error(
|
|
"Missing terminfo for tmux-256color. Try installing ncurses-term."
|
|
)
|
|
except FileNotFoundError:
|
|
self.error("infocmp command not found")
|
|
|
|
def run(
|
|
self,
|
|
force: bool = False,
|
|
ignore: bool = False,
|
|
remove: bool = False,
|
|
) -> None:
|
|
"""Run main run function."""
|
|
if remove:
|
|
self.remove_all_symlinks()
|
|
return
|
|
|
|
self.check_packages_installed()
|
|
self.check_terminfo()
|
|
self.create_all_symlinks(force, ignore)
|
|
self.copy_all_items(force, ignore)
|
|
|
|
|
|
def main() -> None:
|
|
"""Run installer."""
|
|
parser = argparse.ArgumentParser(
|
|
description="Linux dotfiles setup script",
|
|
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
)
|
|
parser.add_argument(
|
|
"profile",
|
|
nargs="?",
|
|
default="hyprland",
|
|
help="Profile to install (default: hyprland)",
|
|
)
|
|
parser.add_argument(
|
|
"-f",
|
|
"--force",
|
|
action="store_true",
|
|
help="Overwrite any existing links",
|
|
)
|
|
parser.add_argument(
|
|
"-i",
|
|
"--ignore",
|
|
action="store_true",
|
|
help="Ignore existing symlinks",
|
|
)
|
|
parser.add_argument(
|
|
"-r",
|
|
"--remove",
|
|
action="store_true",
|
|
help="Remove any existing symlinks",
|
|
)
|
|
|
|
args = parser.parse_args()
|
|
|
|
installer = Installer(args.profile)
|
|
try:
|
|
installer.run(
|
|
force=args.force,
|
|
ignore=args.ignore,
|
|
remove=args.remove,
|
|
)
|
|
except InstallerError as e:
|
|
installer.error(str(e))
|
|
|
|
if installer.has_error:
|
|
sys.exit(1)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|