diff --git a/uni.py b/uni.py new file mode 100644 index 0000000..e3b0754 --- /dev/null +++ b/uni.py @@ -0,0 +1,316 @@ +#!/usr/bin/env python3 + +import sys +import os +import json +import time +import subprocess + +STATE_FILE = os.path.expanduser("~/.uni/state.json") + + +# ------------------------- +# STATE +# ------------------------- +def load_state(): + if not os.path.exists(STATE_FILE): + return {"backends": {}, "packages": {}} + + with open(STATE_FILE, "r") as f: + return json.load(f) + + +def save_state(state): + os.makedirs(os.path.dirname(STATE_FILE), exist_ok=True) + with open(STATE_FILE, "w") as f: + json.dump(state, f, indent=2) + + +# ------------------------- +# NORMALIZATION (FIX) +# ------------------------- +def normalize_state(state): + fixed = {} + + for pkg, entries in state.get("packages", {}).items(): + + # old format: "apt" + if isinstance(entries, str): + fixed[pkg] = [{"backend": entries}] + continue + + # already correct list + if isinstance(entries, list): + clean = [] + for e in entries: + if isinstance(e, dict) and "backend" in e: + clean.append(e) + elif isinstance(e, str): + clean.append({"backend": e}) + fixed[pkg] = clean + continue + + fixed[pkg] = [] + + state["packages"] = fixed + return state + + +# ------------------------- +# BACKEND DETECTION +# ------------------------- +def has(cmd): + return subprocess.call( + ["which", cmd], + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL + ) == 0 + + +def detect_backends(state): + state["backends"] = { + "apt": has("apt"), + "dnf": has("dnf"), + "pacman": has("pacman"), + "flatpak": has("flatpak") + } + return state["backends"] + + +# ------------------------- +# RESOLVER +# ------------------------- +def resolve_backend(pkg, state): + gui_apps = ["firefox", "discord", "spotify", "vscode"] + + if pkg in gui_apps and state["backends"].get("flatpak"): + return "flatpak" + + if state["backends"].get("apt"): + return "apt" + if state["backends"].get("dnf"): + return "dnf" + if state["backends"].get("pacman"): + return "pacman" + + return None + + +# ------------------------- +# CONFLICT CHECK +# ------------------------- +def check_conflict(state, pkg, backend): + if pkg not in state["packages"]: + return False + + for e in state["packages"][pkg]: + if e.get("backend") != backend: + return True + + return False + + +# ------------------------- +# EXEC +# ------------------------- +def run(cmd): + subprocess.run(cmd) + + +def install_pkg(pkg, backend): + if backend == "apt": + run(["sudo", "apt", "install", "-y", pkg]) + elif backend == "dnf": + run(["sudo", "dnf", "install", "-y", pkg]) + elif backend == "pacman": + run(["sudo", "pacman", "-S", "--noconfirm", pkg]) + elif backend == "flatpak": + run(["flatpak", "install", "-y", pkg]) + + +def remove_pkg(pkg, backend): + if backend == "apt": + run(["sudo", "apt", "remove", "-y", pkg]) + elif backend == "dnf": + run(["sudo", "dnf", "remove", "-y", pkg]) + elif backend == "pacman": + run(["sudo", "pacman", "-R", "--noconfirm", pkg]) + elif backend == "flatpak": + run(["flatpak", "uninstall", "-y", pkg]) + + +def update_all(state): + if state["backends"].get("apt"): + run(["sudo", "apt", "update"]) + run(["sudo", "apt", "upgrade", "-y"]) + + if state["backends"].get("dnf"): + run(["sudo", "dnf", "upgrade", "-y"]) + + if state["backends"].get("pacman"): + run(["sudo", "pacman", "-Syu", "--noconfirm"]) + + if state["backends"].get("flatpak"): + run(["flatpak", "update", "-y"]) + + +# ------------------------- +# TRACKING +# ------------------------- +def register_install(state, pkg, backend): + if pkg not in state["packages"]: + state["packages"][pkg] = [] + + state["packages"][pkg].append({ + "backend": backend, + "time": int(time.time()) + }) + + +def register_remove(state, pkg, backend): + if pkg not in state["packages"]: + return + + state["packages"][pkg] = [ + e for e in state["packages"][pkg] + if e.get("backend") != backend + ] + + if not state["packages"][pkg]: + del state["packages"][pkg] + + +# ------------------------- +# LIST +# ------------------------- +def list_installed(): + state = load_state() + state = normalize_state(state) + + for pkg, entries in state["packages"].items(): + backends = ", ".join([e["backend"] for e in entries]) + print(f"{pkg} -> {backends}") + + +# ------------------------- +# DOCTOR +# ------------------------- +def doctor(): + state = load_state() + state = normalize_state(state) + detect_backends(state) + save_state(state) + + print("=== SYSTEM ===") + for k, v in state["backends"].items(): + print(f"{k}: {'yes' if v else 'no'}") + + print("\n=== HEALTH ===") + broken = [] + + for pkg, entries in state["packages"].items(): + if not isinstance(entries, list): + broken.append(pkg) + continue + + for e in entries: + if not isinstance(e, dict) or "backend" not in e: + broken.append(pkg) + + print("OK" if not broken else f"broken: {len(broken)}") + + print("\n=== CONFLICTS ===") + conflicts = {} + + for pkg, entries in state["packages"].items(): + backends = list(set([e["backend"] for e in entries])) + + if len(backends) > 1: + conflicts[pkg] = backends + + if not conflicts: + print("none") + else: + for k, v in conflicts.items(): + print(f"{k}: {', '.join(v)}") + + print("\n=== SUMMARY ===") + print(f"packages: {len(state['packages'])}") + print(f"conflicts: {len(conflicts)}") + print(f"corrupt: {len(broken)}") + + +# ------------------------- +# INSTALL / REMOVE +# ------------------------- +def install(pkg): + state = load_state() + state = normalize_state(state) + detect_backends(state) + + backend = resolve_backend(pkg, state) + + if not backend: + print("no backend") + return + + if check_conflict(state, pkg, backend): + print("WARNING: backend mismatch") + + install_pkg(pkg, backend) + register_install(state, pkg, backend) + save_state(state) + + +def remove(pkg): + state = load_state() + state = normalize_state(state) + + if pkg not in state["packages"]: + print("not tracked") + return + + for e in state["packages"][pkg]: + remove_pkg(pkg, e["backend"]) + + del state["packages"][pkg] + save_state(state) + + +def update(): + state = load_state() + detect_backends(state) + update_all(state) + + +# ------------------------- +# MAIN +# ------------------------- +def main(): + if len(sys.argv) < 2: + print("install|remove|update|list|doctor") + return + + cmd = sys.argv[1] + + if cmd == "install": + install(sys.argv[2]) + + elif cmd == "remove": + remove(sys.argv[2]) + + elif cmd == "update": + update() + + elif cmd == "list": + list_installed() + + elif cmd == "doctor": + doctor() + + else: + print("unknown") + + +if __name__ == "__main__": + main() \ No newline at end of file