feat(setup): port setup.sh to Python
This commit is contained in:
@@ -0,0 +1,337 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Install dotfiles and system configuration from this repo."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import filecmp
|
||||
import platform
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
SCRIPT_DIR = Path(__file__).resolve().parent
|
||||
HOME = Path.home()
|
||||
|
||||
# Populated now so per-distro package tracking can key off it.
|
||||
try:
|
||||
DISTRO: str = platform.freedesktop_os_release().get("NAME", "")
|
||||
except OSError:
|
||||
DISTRO = ""
|
||||
|
||||
|
||||
PKGS: list[str] = [
|
||||
"alacritty",
|
||||
"i3",
|
||||
"i3lock",
|
||||
"i3status",
|
||||
"jq",
|
||||
"lf",
|
||||
"rofi",
|
||||
"startx",
|
||||
"tmux",
|
||||
"zsh",
|
||||
]
|
||||
|
||||
|
||||
SYMLINKS: list[str] = [
|
||||
".config/alacritty",
|
||||
".config/dolphinrc",
|
||||
".config/dunst",
|
||||
".config/fish",
|
||||
".config/foot",
|
||||
".config/fontconfig",
|
||||
".config/frogminer",
|
||||
".config/ghostty",
|
||||
".config/i3",
|
||||
".config/i3status",
|
||||
".config/kde-mimeapps.list",
|
||||
".config/kglobalshortcutsrc",
|
||||
".config/klipperrc",
|
||||
".config/kwinrc",
|
||||
".config/lf",
|
||||
".config/mimeapps.list",
|
||||
".config/opencode",
|
||||
".config/picom",
|
||||
".config/pipewire",
|
||||
".config/rofi",
|
||||
".config/strawberry",
|
||||
".config/tmux",
|
||||
".config/vlc",
|
||||
".config/wezterm",
|
||||
".config/wireplumber",
|
||||
".config/yay",
|
||||
".config/zed",
|
||||
".local/bin",
|
||||
".local/share/dbus-1",
|
||||
".local/share/easyeffects",
|
||||
".local/share/fonts",
|
||||
".local/share/konsole",
|
||||
".gdbinit",
|
||||
".vimrc",
|
||||
".xinit-scripts",
|
||||
".xinitrc",
|
||||
".Xresources",
|
||||
]
|
||||
|
||||
|
||||
COPIES: list[str] = [
|
||||
".claude/settings.json",
|
||||
".codex/config.toml",
|
||||
".config/gtk-3.0/bookmarks",
|
||||
".config/gtk-3.0/settings.ini",
|
||||
".config/gtk-4.0/settings.ini",
|
||||
".gtkrc-2.0",
|
||||
]
|
||||
|
||||
|
||||
SYSTEM_INSTALLS: list[str] = [
|
||||
"sysctl.d/99-gaming-perf.conf",
|
||||
"sysctl.d/99-network.conf",
|
||||
"tmpfiles.d/99-gaming-perf.conf",
|
||||
"udev/rules.d/99-perf.rules",
|
||||
"ssh/sshd_config.d/sshd_harden.conf",
|
||||
]
|
||||
|
||||
|
||||
# repo-relative path -> $HOME-relative path
|
||||
SYMLINK_MAP: dict[str, str] = {
|
||||
"zsh/rc": ".zshrc",
|
||||
}
|
||||
|
||||
|
||||
class Context:
|
||||
def __init__(self, args: argparse.Namespace) -> None:
|
||||
self.force: bool = args.force
|
||||
self.ignore_existing: bool = args.ignore_existing
|
||||
self.diff: bool = args.diff
|
||||
self.errors: list[str] = []
|
||||
|
||||
def error(self, *parts: str) -> None:
|
||||
msg = " ".join(parts)
|
||||
self.errors.append(msg)
|
||||
print(f"Error: {msg}", file=sys.stderr)
|
||||
|
||||
|
||||
def check_packages_installed(ctx: Context) -> None:
|
||||
for pkg in PKGS:
|
||||
if shutil.which(pkg) is None:
|
||||
ctx.error(f"missing {pkg}")
|
||||
|
||||
|
||||
def check_terminfo(ctx: Context) -> None:
|
||||
result = subprocess.run(
|
||||
["infocmp", "tmux-256color"],
|
||||
stdout=subprocess.DEVNULL,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
ctx.error("Missing terminfo for tmux-256color. Try installing ncurses-term.")
|
||||
|
||||
|
||||
def _describe_kind(p: Path) -> str:
|
||||
return "directory" if p.is_dir() else "regular file"
|
||||
|
||||
|
||||
def remove_symlink(ctx: Context, rel_dst: str) -> None:
|
||||
link = HOME / rel_dst
|
||||
if link.is_symlink():
|
||||
target = link.resolve()
|
||||
print(f"Removing symlink: {link} -> {target}")
|
||||
link.unlink()
|
||||
elif link.exists():
|
||||
ctx.error(
|
||||
"object to be removed is not a symlink:",
|
||||
f"{link}: {_describe_kind(link)}",
|
||||
)
|
||||
|
||||
|
||||
def remove_all_symlinks(ctx: Context) -> None:
|
||||
for rel in SYMLINKS:
|
||||
remove_symlink(ctx, rel)
|
||||
for rel_dst in SYMLINK_MAP.values():
|
||||
remove_symlink(ctx, rel_dst)
|
||||
|
||||
|
||||
def create_symlink(ctx: Context, rel_src: str, rel_dst: str) -> None:
|
||||
src = SCRIPT_DIR / rel_src
|
||||
dst = HOME / rel_dst
|
||||
|
||||
if not src.exists():
|
||||
ctx.error(f"the following source path does not exist: {src}")
|
||||
return
|
||||
|
||||
if not dst.parent.is_dir():
|
||||
print(f"Creating parent: {dst.parent}")
|
||||
dst.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
if dst.is_symlink():
|
||||
dst_target = dst.resolve()
|
||||
if dst_target == src.resolve():
|
||||
return
|
||||
|
||||
if ctx.force:
|
||||
remove_symlink(ctx, rel_dst)
|
||||
elif ctx.ignore_existing:
|
||||
return
|
||||
else:
|
||||
ctx.error(
|
||||
"symlink exists but points elsewhere:",
|
||||
f"{dst} -> {dst_target}",
|
||||
)
|
||||
return
|
||||
elif dst.exists():
|
||||
ctx.error(
|
||||
"path already exists but is not a symlink:",
|
||||
f"{dst}: {_describe_kind(dst)}",
|
||||
)
|
||||
return
|
||||
|
||||
print(f"Creating link: {dst} -> {src}")
|
||||
dst.symlink_to(src)
|
||||
|
||||
|
||||
def remove_path(path: Path) -> None:
|
||||
print(f"Trashing item: {path}")
|
||||
subprocess.run(["gio", "trash", str(path)], check=True)
|
||||
|
||||
|
||||
def copy_item(ctx: Context, rel_src: str, rel_dst: str) -> None:
|
||||
src = SCRIPT_DIR / rel_src
|
||||
dst = HOME / rel_dst
|
||||
|
||||
if not src.is_file():
|
||||
ctx.error(f"source is not a regular file: {src}")
|
||||
return
|
||||
|
||||
if not dst.parent.is_dir():
|
||||
print(f"Creating parent: {dst.parent}")
|
||||
dst.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
if dst.exists():
|
||||
if filecmp.cmp(src, dst, shallow=False):
|
||||
return
|
||||
|
||||
if ctx.diff:
|
||||
subprocess.run(["diff", "--color=always", "-u", str(dst), str(src)])
|
||||
|
||||
if ctx.force:
|
||||
remove_path(dst)
|
||||
elif ctx.ignore_existing:
|
||||
return
|
||||
else:
|
||||
ctx.error(f"file already exists and differs: {dst}")
|
||||
return
|
||||
|
||||
print(f"Copying item: from {rel_src} to {dst.parent}/")
|
||||
shutil.copy2(src, dst)
|
||||
|
||||
|
||||
def _sudo_test(*args: str) -> bool:
|
||||
return subprocess.run(["sudo", "test", *args]).returncode == 0
|
||||
|
||||
|
||||
def _sudo_cmp(a: Path, b: Path) -> bool:
|
||||
return subprocess.run(["sudo", "cmp", "-s", str(a), str(b)]).returncode == 0
|
||||
|
||||
|
||||
def install_system_file(ctx: Context, rel: str) -> None:
|
||||
src = SCRIPT_DIR / "etc" / rel
|
||||
dst = Path("/etc") / rel
|
||||
|
||||
if not src.exists():
|
||||
ctx.error(f"the following source path does not exist: {src}")
|
||||
return
|
||||
|
||||
if not _sudo_test("-d", str(dst.parent)):
|
||||
print(f"Creating parent: {dst.parent}")
|
||||
subprocess.run(["sudo", "mkdir", "-p", str(dst.parent)], check=True)
|
||||
|
||||
if _sudo_test("-e", str(dst)):
|
||||
if _sudo_cmp(src, dst):
|
||||
return
|
||||
|
||||
if ctx.diff:
|
||||
subprocess.run(["sudo", "diff", "--color=always", "-u", str(dst), str(src)])
|
||||
|
||||
if ctx.force:
|
||||
print(f"Overwriting: {dst}")
|
||||
elif ctx.ignore_existing:
|
||||
return
|
||||
else:
|
||||
ctx.error(f"system file already exists and differs: {dst}")
|
||||
return
|
||||
|
||||
print(f"Installing: etc/{rel} -> {dst}")
|
||||
subprocess.run(["sudo", "install", "-m", "644", str(src), str(dst)], check=True)
|
||||
|
||||
|
||||
def create_all_symlinks(ctx: Context) -> None:
|
||||
for rel in SYMLINKS:
|
||||
create_symlink(ctx, rel, rel)
|
||||
for src, dst in SYMLINK_MAP.items():
|
||||
create_symlink(ctx, src, dst)
|
||||
|
||||
|
||||
def copy_all_items(ctx: Context) -> None:
|
||||
for rel in COPIES:
|
||||
copy_item(ctx, rel, rel)
|
||||
|
||||
|
||||
def install_all_system_files(ctx: Context) -> None:
|
||||
if not SYSTEM_INSTALLS:
|
||||
return
|
||||
subprocess.run(["sudo", "-v"], check=True)
|
||||
for rel in SYSTEM_INSTALLS:
|
||||
install_system_file(ctx, rel)
|
||||
|
||||
|
||||
def parse_args() -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Install dotfiles and system configuration.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"-f",
|
||||
"--force",
|
||||
action="store_true",
|
||||
help="Overwrite any existing links or files",
|
||||
)
|
||||
parser.add_argument(
|
||||
"-i",
|
||||
"--ignore-existing",
|
||||
action="store_true",
|
||||
help="Ignore existing links or files",
|
||||
)
|
||||
parser.add_argument(
|
||||
"-r",
|
||||
"--remove-existing",
|
||||
action="store_true",
|
||||
help="Remove any existing symlinks",
|
||||
)
|
||||
parser.add_argument(
|
||||
"-d",
|
||||
"--diff",
|
||||
action="store_true",
|
||||
help="Show unified diff when a managed file differs",
|
||||
)
|
||||
return parser.parse_args()
|
||||
|
||||
|
||||
def main() -> int:
|
||||
args = parse_args()
|
||||
ctx = Context(args)
|
||||
|
||||
if args.remove_existing:
|
||||
remove_all_symlinks(ctx)
|
||||
else:
|
||||
check_terminfo(ctx)
|
||||
check_packages_installed(ctx)
|
||||
create_all_symlinks(ctx)
|
||||
copy_all_items(ctx)
|
||||
install_all_system_files(ctx)
|
||||
|
||||
return 1 if ctx.errors else 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
Reference in New Issue
Block a user