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()
|
||||
+6
-1
@@ -62,6 +62,7 @@ select = [
|
||||
]
|
||||
ignore = [
|
||||
"A001",
|
||||
"D100",
|
||||
"D101",
|
||||
"D202",
|
||||
"D203",
|
||||
@@ -69,7 +70,11 @@ ignore = [
|
||||
"D301",
|
||||
"D413",
|
||||
"TC006",
|
||||
"COM812"
|
||||
"COM812",
|
||||
"PLR0913",
|
||||
"PLR0917",
|
||||
"PYI011",
|
||||
"UP031"
|
||||
]
|
||||
|
||||
[tool.pyrefly]
|
||||
|
||||
@@ -1,317 +0,0 @@
|
||||
#!/usr/bin/env zsh
|
||||
|
||||
set -e
|
||||
|
||||
DISTRO="$(sed -n 's/^NAME="\([^"]\+\)"/\1/p' /etc/os-release)"
|
||||
|
||||
SCRIPT_FILE="$(readlink -f -- "$0")"
|
||||
SCRIPT_NAME="$(basename -- "$SCRIPT_FILE")"
|
||||
SCRIPT_DIR="$(dirname -- "$SCRIPT_FILE")"
|
||||
|
||||
SOURCE_DIR="$SCRIPT_DIR"
|
||||
DEST_DIR="$HOME"
|
||||
|
||||
PRINT_HELP=false
|
||||
FORCE=false
|
||||
REMOVE_EXISTING=false
|
||||
IGNORE_EXISTING=false
|
||||
ERROR=false
|
||||
PROFILE="hyprland"
|
||||
|
||||
pkg_query_arch() {
|
||||
pacman -Qi "$@"
|
||||
}
|
||||
|
||||
typeset -A PKG_QUERY
|
||||
PKG_QUERY["Arch Linux"]="pkg_query_arch"
|
||||
|
||||
typeset -A PKGS
|
||||
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"
|
||||
|
||||
# Define paths to symlink
|
||||
typeset -a SYMLINKS
|
||||
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"
|
||||
)
|
||||
|
||||
typeset -a COPIES
|
||||
COPIES=(
|
||||
".config/gtk-3.0"
|
||||
".config/gtk-4.0"
|
||||
".gtkrc-2.0"
|
||||
)
|
||||
|
||||
typeset -A SYMLINK_MAP
|
||||
SYMLINK_MAP[zsh/rc]=".zshrc"
|
||||
|
||||
error() {
|
||||
msg="$@"
|
||||
ERROR=true
|
||||
echo "Error: $msg" >&2
|
||||
}
|
||||
|
||||
check_packages_installed() {
|
||||
local -a missing
|
||||
local -a pkgs
|
||||
|
||||
pkgs=(${=PKGS["$DISTRO $PROFILE"]})
|
||||
for pkg in "${pkgs[@]}"; do
|
||||
if ! ${PKG_QUERY["$DISTRO"]} "$pkg" >/dev/null 2>&1; then
|
||||
missing+=($pkg)
|
||||
fi
|
||||
done
|
||||
|
||||
if [ ${#missing[@]} -gt 0 ]; then
|
||||
error "missing ${#missing[@]} packages:" "${missing[@]}"
|
||||
fi
|
||||
}
|
||||
|
||||
remove_symlink() {
|
||||
local link src
|
||||
link="${DEST_DIR}/$1"
|
||||
|
||||
if test -L "$link"; then
|
||||
src="$(readlink -f -- "$link")"
|
||||
echo "Removing symlink: $link -> $src"
|
||||
rm "$link"
|
||||
elif test -e "$link"; then
|
||||
error "object to be removed is not a symlink:"
|
||||
error "${link}: $(stat -c '%F' -- "$link")"
|
||||
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
remove_all_symlinks() {
|
||||
for link in "${SYMLINKS[@]}"; do
|
||||
remove_symlink "$link"
|
||||
done
|
||||
|
||||
for src dst in ${(kv)SYMLINK_MAP}; do
|
||||
remove_symlink "$dst"
|
||||
done
|
||||
}
|
||||
|
||||
create_symlink() {
|
||||
local rel_src rel_dst src dst dst_parent
|
||||
|
||||
rel_src="$1"
|
||||
rel_dst="$2"
|
||||
|
||||
if test -z "$rel_src"; then
|
||||
error "missing src argument:"
|
||||
error "$0 $@"
|
||||
return 1
|
||||
fi
|
||||
|
||||
if test -z "$rel_dst"; then
|
||||
error "missing dst argument:"
|
||||
error "$0 $@"
|
||||
return 1
|
||||
fi
|
||||
|
||||
src="${SCRIPT_DIR}/$rel_src"
|
||||
dst="${DEST_DIR}/$rel_dst"
|
||||
dst_parent="$(dirname -- "$dst")"
|
||||
|
||||
if ! test -e "$src"; then
|
||||
error "the following source path does not exist:"
|
||||
error "$src"
|
||||
return 1
|
||||
fi
|
||||
|
||||
if ! test -d "$dst_parent"; then
|
||||
echo "Creating parent: $dst_parent"
|
||||
mkdir -p "$dst_parent"
|
||||
fi
|
||||
|
||||
if test -L "$dst"; then
|
||||
if $FORCE; then
|
||||
if test "$(readlink -f "$dst")" != "$src"; then
|
||||
remove_symlink "$2"
|
||||
else
|
||||
return 0
|
||||
fi
|
||||
elif $IGNORE_EXISTING; then
|
||||
return 0
|
||||
else
|
||||
error "symbolic link already exists:"
|
||||
error "$dst"
|
||||
return 1
|
||||
fi
|
||||
elif test -e "$dst"; then
|
||||
error "path already exists that is not a symlink:"
|
||||
error "${dst}: $(stat -c '%F' -- "$dst")"
|
||||
return 1
|
||||
fi
|
||||
|
||||
echo "Creating link: $dst -> $rel_src"
|
||||
ln -s "$src" "$dst"
|
||||
}
|
||||
|
||||
remove_path() {
|
||||
local target
|
||||
target="$1"
|
||||
|
||||
echo "Trashing item: $target"
|
||||
gio trash "$target"
|
||||
}
|
||||
|
||||
copy_item() {
|
||||
local rel_src rel_dst src dst dst_parent
|
||||
|
||||
rel_src="$1"
|
||||
rel_dst="$2"
|
||||
|
||||
if test -z "$rel_src"; then
|
||||
error "missing src argument:"
|
||||
error "$0 $@"
|
||||
return 1
|
||||
fi
|
||||
|
||||
if test -z "$rel_dst"; then
|
||||
error "missing dst argument:"
|
||||
error "$0 $@"
|
||||
return 1
|
||||
fi
|
||||
|
||||
src="${SCRIPT_DIR}/$rel_src"
|
||||
dst="${DEST_DIR}/$rel_dst"
|
||||
dst_parent="$(dirname -- "$dst")"
|
||||
|
||||
if ! test -e "$src"; then
|
||||
error "the following source path does not exist:"
|
||||
error "$src"
|
||||
return 1
|
||||
fi
|
||||
|
||||
if ! test -d "$dst_parent"; then
|
||||
echo "Creating parent: $dst_parent"
|
||||
mkdir -p "$dst_parent"
|
||||
fi
|
||||
|
||||
if test -e "$dst"; then
|
||||
if $FORCE; then
|
||||
remove_path "$dst"
|
||||
elif $IGNORE_EXISTING; then
|
||||
return 0
|
||||
else
|
||||
error "path already exists:"
|
||||
error "${dst}"
|
||||
return 1
|
||||
fi
|
||||
fi
|
||||
|
||||
echo "Copying item: from $rel_src to ${dst_parent}/"
|
||||
cp -r "$src" "${dst_parent}/"
|
||||
}
|
||||
|
||||
create_all_symlinks() {
|
||||
for link in "${SYMLINKS[@]}"; do
|
||||
create_symlink "$link" "$link"
|
||||
done
|
||||
|
||||
for src dst in ${(kv)SYMLINK_MAP}; do
|
||||
create_symlink "$src" "$dst"
|
||||
done
|
||||
}
|
||||
|
||||
copy_all_items() {
|
||||
for item in "${COPIES[@]}"; do
|
||||
copy_item "$item" "$item"
|
||||
done
|
||||
}
|
||||
|
||||
check_terminfo() {
|
||||
if ! infocmp tmux-256color > /dev/null; then
|
||||
error "Missing terminfo for tmux-256color. Try installing ncurses-term."
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
|
||||
print_help() {
|
||||
echo "Usage: $SCRIPT_NAME [...]"
|
||||
echo ""
|
||||
echo "Options:"
|
||||
echo " -h, --help Print this help message"
|
||||
echo " -f, --force Overwrite any existing links"
|
||||
echo " -i, --ignore-existing Ignore existing symlinks"
|
||||
echo " -r, --remove-existing Remove any existing symlinks"
|
||||
}
|
||||
|
||||
while [ $# -gt 0 ]; do
|
||||
case $1 in
|
||||
-h|--help)
|
||||
PRINT_HELP=true
|
||||
shift
|
||||
;;
|
||||
-f|--force)
|
||||
FORCE=true
|
||||
shift
|
||||
;;
|
||||
-r|--remove-existing)
|
||||
REMOVE_EXISTING=true
|
||||
shift
|
||||
;;
|
||||
-i|--ignore-existing)
|
||||
IGNORE_EXISTING=true
|
||||
shift
|
||||
;;
|
||||
*)
|
||||
error "unknown option: $1"
|
||||
PRINT_HELP=true
|
||||
shift
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
if $PRINT_HELP; then
|
||||
print_help
|
||||
elif $REMOVE_EXISTING; then
|
||||
remove_all_symlinks
|
||||
else
|
||||
check_terminfo
|
||||
check_packages_installed
|
||||
create_all_symlinks
|
||||
copy_all_items
|
||||
fi
|
||||
|
||||
if $ERROR; then
|
||||
exit 1
|
||||
fi
|
||||
Reference in New Issue
Block a user