feat: rewrite setup script in python
This commit is contained in:
Executable
+371
@@ -0,0 +1,371 @@
|
||||
#!/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()
|
||||
Reference in New Issue
Block a user