431 lines
11 KiB
Python
Executable File
431 lines
11 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
# SPDX-FileCopyrightText: 2026 Oscar Wallberg
|
|
# SPDX-License-Identifier: Apache-2.0
|
|
"""Install dotfiles and system configuration from this repo."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import filecmp
|
|
import platform
|
|
import shlex
|
|
import shutil
|
|
import subprocess
|
|
import sys
|
|
from abc import ABC, abstractmethod
|
|
from dataclasses import dataclass
|
|
from pathlib import Path
|
|
|
|
SCRIPT_DIR = Path(__file__).resolve().parent
|
|
HOME = Path.home()
|
|
DISTRO: str = platform.freedesktop_os_release().get("ID", "")
|
|
|
|
|
|
class PackageManager(ABC):
|
|
@property
|
|
@abstractmethod
|
|
def check_cmd(self) -> list[str]: ...
|
|
|
|
@property
|
|
@abstractmethod
|
|
def install_cmd(self) -> list[str]: ...
|
|
|
|
def is_installed(self, name: str) -> bool:
|
|
return (
|
|
subprocess.run(
|
|
[*self.check_cmd, name],
|
|
stdout=subprocess.DEVNULL,
|
|
stderr=subprocess.DEVNULL,
|
|
).returncode
|
|
== 0
|
|
)
|
|
|
|
def install(self, names: list[str]) -> None:
|
|
subprocess.run([*self.install_cmd, *names], check=True)
|
|
|
|
|
|
class Pacman(PackageManager):
|
|
@property
|
|
def check_cmd(self) -> list[str]:
|
|
return ["pacman", "-Q"]
|
|
|
|
@property
|
|
def install_cmd(self) -> list[str]:
|
|
return ["sudo", "pacman", "-S", "--needed"]
|
|
|
|
|
|
class Apt(PackageManager):
|
|
@property
|
|
def check_cmd(self) -> list[str]:
|
|
return ["dpkg-query", "-W", "-f=${Status}"]
|
|
|
|
@property
|
|
def install_cmd(self) -> list[str]:
|
|
return ["sudo", "apt", "install"]
|
|
|
|
def is_installed(self, name: str) -> bool:
|
|
r = subprocess.run(
|
|
[*self.check_cmd, name], capture_output=True, text=True
|
|
)
|
|
return r.returncode == 0 and r.stdout.strip() == "install ok installed"
|
|
|
|
|
|
_apt = Apt()
|
|
PACKAGE_MANAGERS: dict[str, PackageManager] = {
|
|
"arch": Pacman(),
|
|
"ubuntu": _apt,
|
|
"debian": _apt,
|
|
}
|
|
|
|
PM: PackageManager | None = PACKAGE_MANAGERS.get(DISTRO)
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class Pkg:
|
|
default_name: str
|
|
distro_names: dict[str, str] | None = None
|
|
|
|
def name(self, distro: str) -> str:
|
|
return (self.distro_names or {}).get(distro, self.default_name)
|
|
|
|
|
|
PKGS: list[Pkg] = [
|
|
Pkg("foot"),
|
|
Pkg("jq"),
|
|
Pkg("lf"),
|
|
Pkg("tmux"),
|
|
Pkg("vim"),
|
|
Pkg("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/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 prompt_yes_no(question: str, default: bool = False) -> bool:
|
|
if not sys.stdin.isatty():
|
|
return default
|
|
suffix = " [Y/n] " if default else " [y/N] "
|
|
try:
|
|
answer = input(question + suffix).strip().lower()
|
|
except EOFError:
|
|
return default
|
|
if answer in ("y", "yes"):
|
|
return True
|
|
if answer in ("n", "no"):
|
|
return False
|
|
return default
|
|
|
|
|
|
def check_packages_installed(ctx: Context) -> None:
|
|
if PM is None:
|
|
ctx.error(f"don't know how to manage packages on distro {DISTRO!r}")
|
|
return
|
|
|
|
missing = [name for pkg in PKGS if not PM.is_installed(name := pkg.name(DISTRO))]
|
|
if not missing:
|
|
return
|
|
|
|
print(f"Missing packages: {', '.join(missing)}")
|
|
|
|
if not prompt_yes_no(f"Install with `{shlex.join([*PM.install_cmd, *missing])}`?"):
|
|
ctx.error(f"packages not installed: {', '.join(missing)}")
|
|
return
|
|
|
|
try:
|
|
PM.install(missing)
|
|
except subprocess.CalledProcessError as e:
|
|
ctx.error(f"package install failed (exit {e.returncode})")
|
|
|
|
|
|
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())
|