#!/usr/bin/env python3 # -*- coding: utf-8 -*- """ pagan-gui-build.py – GUI do lokalnych buildów PaganOS + push na VPS. (architektura Wayland-friendly: GUI jako użytkownik, root tylko dla pagsync) Listuje pakiety z receptur (git.paganlinux.eu/recipes.git), pozwala wybrać kolejkę, buduje SEKWENCYJNIE na PC (pagsync z VPS, rootfs lokalny – hasło sudo pytane w okienku, bez terminala) i wysyła .pag na VPS (rsync -> podpis GPG na serwerze -> pagsync --gen-repo). Uruchomienie: python3 pagan-gui-build.py Konfiguracja (env): PAGAN_VPS_HOST, PAGAN_SSH_KEY, PAGAN_GPG_KEY, PAGAN_ROOTFS, PAGAN_RECIPES_URL, PAGAN_JOBS, PAGAN_GUI_WORK """ import os import queue import re import shutil import subprocess import sys import threading import tkinter as tk from tkinter import simpledialog, ttk # ── Konfiguracja ────────────────────────────────────────────────────────── HOME = os.path.expanduser("~") WORK = os.environ.get("PAGAN_GUI_WORK", os.path.join(HOME, ".pagan-gui")) RECIPES_URL = os.environ.get("PAGAN_RECIPES_URL", "https://git.paganlinux.eu/recipes.git") VPS_HOST = os.environ.get("PAGAN_VPS_HOST", "root@paganlinux.eu") SSH_KEY = os.environ.get("PAGAN_SSH_KEY", os.path.join(HOME, ".ssh", "id_ed25519_pagan")) GPG_KEY = os.environ.get("PAGAN_GPG_KEY", "A14CBE52BE0D63F5") REPO_STABLE_VPS = "/var/www/repo.paganlinux.eu/stable" JOBS = os.environ.get("PAGAN_JOBS", str(os.cpu_count() or 4)) ROOTFS = os.environ.get("PAGAN_ROOTFS") or ( "/mnt/pagan" if os.path.isdir("/mnt/pagan/usr/bin") else "/mnt/pagan-native-rootfs" ) RECIPES = os.path.join(WORK, "recipes") TOOLS = os.path.join(WORK, "tools") OUTPUT = os.path.join(WORK, "output") REPO = os.path.join(WORK, "repo") STATE = os.path.join(WORK, "state.json") DIAG = os.path.join(WORK, "diagnostics") LOCK = os.path.join(WORK, "pagsync.lock") GUILOG = os.path.join(WORK, "gui.log") PAGBUILD = os.path.join(TOOLS, "pagbuild") PAGSYNC = os.path.join(TOOLS, "pagsync") SSH_OPTS = ["-i", SSH_KEY, "-o", "BatchMode=yes", "-o", "ConnectTimeout=15", "-o", "StrictHostKeyChecking=accept-new", "-o", "IdentitiesOnly=yes", "-o", "UserKnownHostsFile=" + os.path.join(HOME, ".ssh", "known_hosts")] # ── Sudo przez okienko (bez terminala, Wayland-safe) ───────────────────── _pw = None _pw_evt = threading.Event() _pw_cb = None # ustawiane przez GUI: pokaż okienko na głównym wątku def _ask_password_ui(): """Wywoływane na głównym wątku Tk – okienko z hasłem sudo.""" global _pw d = simpledialog.askstring("Hasło sudo", "pagbuild/pagsync wymaga root (chroot).\n" "Podaj hasło sudo:", show="*") _pw = d _pw_evt.set() def _sudo_authed() -> bool: if subprocess.run(["sudo", "-n", "true"], capture_output=True).returncode == 0: return True global _pw _pw = None _pw_evt.clear() if _pw_cb: _pw_cb() return _pw_evt.wait(timeout=180) and bool(_pw) def run_root(cmd, env=None, cwd=None): """Polecenie jako root przez `sudo -S` (hasło z okienka). Loguje output.""" if not _sudo_authed(): GUI.log("❌ Brak autoryzacji sudo – przerwano.", "err") return 1 envpairs = [] if env: envpairs = [f"{k}={v}" for k, v in env.items()] full = ["sudo", "-S", "-E", "/usr/bin/env", *envpairs, *cmd] GUI.log("$ (sudo) " + " ".join(cmd), "cmd") try: p = subprocess.Popen(full, cwd=cwd, stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, errors="replace") p.stdin.write((_pw or "") + "\n") p.stdin.flush() p.stdin.close() for line in p.stdout: line = line.rstrip() if not line: continue tag = "" if any(s in line for s in ("✅", "Sukces", "OK")): tag = "ok" elif any(s in line for s in ("❌", "FAIL", "Błąd", "error", "denied", "permitted", "wrong password")): tag = "err" elif any(s in line for s in ("⚠", "WARN")): tag = "warn" GUI.log(line, tag) p.wait() return p.returncode except Exception as e: GUI.log(f"❌ wyjątek: {e}", "err") return 1 def run(cmd, cwd=None, env=None): """Polecenie jako bieżący użytkownik. Loguje output.""" GUI.log("$ " + " ".join(cmd), "cmd") try: p = subprocess.Popen(cmd, cwd=cwd, env=env, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, errors="replace") for line in p.stdout: line = line.rstrip() if not line: continue tag = "" if any(s in line for s in ("✅", "Sukces", "OK")): tag = "ok" elif any(s in line for s in ("❌", "FAIL", "Błąd", "error")): tag = "err" elif any(s in line for s in ("⚠", "WARN")): tag = "warn" GUI.log(line, tag) p.wait() return p.returncode except Exception as e: GUI.log(f"❌ wyjątek: {e}", "err") return 1 def ssh(cmd): return run(["ssh", *SSH_OPTS, VPS_HOST] + cmd) def _writable(p): """Czy katalog istnieje i da się w nim pisać (probe file).""" if not os.path.isdir(p): return True try: t = os.path.join(p, ".wtest") open(t, "w").close() os.unlink(t) return True except OSError: return False def ensure_dirs(): """Tworzy katalogi robocze i (raz) przejmuje WORK z roota na użytkownika. Zwraca True gdy wszystko jest zapisywalne.""" for d in (WORK, TOOLS, OUTPUT, DIAG, os.path.join(REPO, "stable")): try: os.makedirs(d, exist_ok=True) except OSError: pass dirty = [d for d in (WORK, RECIPES, TOOLS, OUTPUT, DIAG, os.path.join(REPO, "stable")) if os.path.isdir(d) and not _writable(d)] if not dirty: return True GUI.log("⚠ katalogi robocze należą do roota – przejmuję na użytkownika (sudo)", "warn") rc = run_root(["chown", "-R", f"{os.getuid()}:{os.getgid()}", WORK]) if rc != 0: GUI.log("❌ nie udało się przejąć ~/.pagan-gui – sprawdź hasło sudo", "err") return False return all(_writable(d) for d in dirty) def _flog(msg: str): try: with open(GUILOG, "a", encoding="utf-8") as fh: fh.write(msg + "\n") except OSError: pass # ── Akcje ───────────────────────────────────────────────────────────────── def sync_recipes(): if not ensure_dirs(): return GUI.log("── Sync receptur ──", "hdr") if os.path.isdir(os.path.join(RECIPES, ".git")): run(["git", "-C", RECIPES, "pull", "--ff-only", "origin", "main"]) else: run(["git", "clone", RECIPES_URL, RECIPES]) if not os.path.exists(PAGBUILD) or not os.path.exists(PAGSYNC): GUI.log("── Pobieram pagbuild/pagsync z VPS ──", "hdr") for dst, src in ((PAGBUILD, "/opt/pagan-web-v2/pagbuild"), (PAGSYNC, "/usr/bin/pagsync")): run(["scp", *SSH_OPTS, f"{VPS_HOST}:{src}", dst]) os.chmod(dst, 0o755) GUI.log("✅ Sync gotowy", "ok") def recipe_list(): out = [] if not os.path.isdir(RECIPES): return out for root, _dirs, files in os.walk(RECIPES): for fn in ("PAGBUILD.yaml", "package.yml", "recipe.yaml"): if fn in files: fp = os.path.join(root, fn) try: txt = open(fp, encoding="utf-8", errors="replace").read() except OSError: continue m = re.search(r"(?m)^pkgname:\s*['\"]?([\w+.-]+)", txt) v = re.search(r"(?m)^pkgver:\s*['\"]?([\w.+~-]+)", txt) d = re.search(r"(?m)^pkgdesc:\s*['\"]?([^\n'\"]+)", txt) name = m.group(1) if m else os.path.basename(root) cat = os.path.relpath(root, RECIPES).split(os.sep)[0] out.append((name, v.group(1) if v else "?", cat, (d.group(1).strip() if d else ""), fp)) break out.sort(key=lambda x: x[0].lower()) return out def _run_capture(prefix, cmd, env=None, cwd=None): """Wykonuje polecenie, loguje linie i zwraca (rc, lista_linii). prefix: lista (np. ['sudo','-S','-E','/usr/bin/env', ...] albo []).""" full = prefix + cmd GUI.log("$ " + " ".join(full), "cmd") lines = [] try: p = subprocess.Popen(full, cwd=cwd, env=env, stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, errors="replace") if prefix and prefix[0] == "sudo": p.stdin.write((_pw or "") + "\n") p.stdin.flush() p.stdin.close() for ln in p.stdout: ln = ln.rstrip() lines.append(ln) if not ln: continue tag = "" if any(s in ln for s in ("✅", "Sukces", "OK")): tag = "ok" elif any(s in ln for s in ("❌", "FAIL", "Błąd", "error", "denied", "permitted")): tag = "err" elif any(s in ln for s in ("⚠", "WARN")): tag = "warn" GUI.log(ln, tag) p.wait() return p.returncode, lines except Exception as e: GUI.log(f"❌ wyjątek: {e}", "err") return 1, lines def _missing_names_from_log(lines): """Wyciąga nazwy brakujących pakietów z logu pagsync (auto-deps).""" out = [] for ln in lines: m = re.search(r"brak receptury i brak w repo: (.+)$", ln) if not m: m = re.search(r"brak receptury dostarczającej: (.+)$", ln) if m: for x in re.split(r"[,\s]+", m.group(1).strip()): if x and x not in out: out.append(x) return out[:6] def _fill_missing_from_repo(names): """Dla brakujących pakietów: pag search -> pobierz .pag z repo VPS -> pagsync --install (do rootfs buildera). Zwraca listę doinstalowanych.""" filled = [] for name in names: GUI.log(f"── Brak {name}: pag search … ──", "hdr") rc, out = _run_capture([], ["pag", "search", name]) found = rc == 0 and any(re.match(rf"^\s*{re.escape(name)}\b", ln) for ln in out) if not found: GUI.log(f"⤼ {name}: brak też w repo (pag search) – pomijam", "warn") continue # pobierz nazwę pliku z repo.json (VPS) try: import json as _json import urllib.request as _ur req = _ur.Request("https://repo.paganlinux.eu/stable/repo.json", headers={"User-Agent": "pag-gui"}) data = _json.load(_ur.urlopen(req, timeout=30)) except Exception as e: GUI.log(f"⤼ {name}: nie mogę pobrać repo.json: {e}", "warn") continue entry = next((p for p in data.get("packages", []) if p.get("name") == name), None) if not entry or not entry.get("filename"): GUI.log(f"⤼ {name}: brak wpisu w repo.json", "warn") continue fname = entry["filename"] stable = os.path.join(REPO, "stable") os.makedirs(stable, exist_ok=True) dst = os.path.join(stable, fname) GUI.log(f"↓ pobieram {fname}") try: import urllib.request as _ur _ur.urlretrieve(f"https://repo.paganlinux.eu/stable/{fname}", dst) except Exception as e: GUI.log(f"⤼ {name}: pobieranie nieudane: {e}", "warn") continue GUI.log(f"📦 {name}: instaluję do rootfs (pagsync --install)") env = {"PAGAN_RECIPES": RECIPES, "PAGAN_REPO": REPO, "PAGAN_PAGBUILD": PAGBUILD, "PAGAN_ROOTFS": ROOTFS, "PAGAN_STATE": STATE, "PAGAN_BUILD_OUT": OUTPUT, "PAGAN_DIAG": DIAG, "PAGAN_LOCK": LOCK, "PAGAN_DO_SIGN": ""} if run_root([PAGSYNC, "--once", "--install", name, "--rootfs", ROOTFS], env=env) == 0: filled.append(name) return filled def _sudo_prefix(): return ["sudo", "-S", "-E"] if os.geteuid() != 0 else [] def _run_build(pkg, env): """pagsync --build jako root; zwraca (rc, linie_logu).""" if not _sudo_authed(): return 1, [] return _run_capture(_sudo_prefix(), [PAGSYNC, "--once", "--build", pkg, "--rootfs", ROOTFS], env=env) def check_updates(pkg=None, apply=False): """Heurystyka aktualizacji receptur (pagsync --check-updates). pkg=None -> skan wszystkich; pkg=nazwa -> tylko ten pakiet. apply=True -> podbija pkgver/pkgrel (zmiany w recipes + commit).""" if not ensure_dirs(): return GUI.log("── Sync receptur (git pull) ──", "hdr") if os.path.isdir(os.path.join(RECIPES, ".git")): run(["git", "-C", RECIPES, "pull", "--ff-only", "origin", "main"]) GUI.log("── " + ("Podbijam (--apply)" if apply else "Sprawdzam nowsze wersje") + " ──", "hdr") env = {"PAGAN_RECIPES": RECIPES, "PAGAN_STATE": STATE, "PAGAN_LOCK": LOCK, "PATH": os.environ.get("PATH", "")} cmd = [PAGSYNC, "--once", "--check-updates"] if pkg: cmd += ["--update-pkg", pkg] if apply: cmd += ["--apply"] rc = run(cmd, env=env) if rc != 0: GUI.log("⚠ check-updates zakończone z błędem", "warn") else: GUI.log("✅ Sprawdzone" + (" – pkgver podbite (commit lokalny)" if apply else ""), "ok") if apply: GUI.log("ℹ Odśwież listę pakietów (zmieniły się wersje)", "warn") GUI.instance.pkgs = recipe_list() GUI.instance.root.after(0, GUI.instance.render_pkgs) def build_queue(names, auto_push): if not ensure_dirs(): return # git safe.directory dla roota (pagsync robi pull w recipes jako root) safe_env = {"GIT_CONFIG_COUNT": "1", "GIT_CONFIG_KEY_0": "safe.directory", "GIT_CONFIG_VALUE_0": RECIPES} for pkg in names: GUI.log(f"── [{pkg}] build ──", "hdr") env = dict(safe_env) env.update({ "PAGAN_RECIPES": RECIPES, "PAGAN_REPO": REPO, "PAGAN_PAGBUILD": PAGBUILD, "PAGAN_ROOTFS": ROOTFS, "PAGAN_STATE": STATE, "PAGAN_BUILD_OUT": OUTPUT, "PAGAN_DIAG": DIAG, "PAGAN_LOCK": LOCK, "PAGAN_DO_SIGN": "", "MAKEFLAGS": f"-j{JOBS}", }) rc, loglines = _run_build(pkg, env) if rc != 0: missing = _missing_names_from_log(loglines) if missing: GUI.log(f"ℹ {pkg}: brakujące do doinstalowania: {', '.join(missing)}", "warn") filled = _fill_missing_from_repo(missing) if filled: GUI.log(f"🔁 {pkg}: ponawiam build po doinstalowaniu: {', '.join(filled)}") rc, _log2 = _run_build(pkg, env) if rc != 0: GUI.log(f"⚠ {pkg}: build FAIL (rc={rc})", "warn") GUI.log("🏁 Kolejka buildów zakończona", "hdr") if auto_push: push_to_vps() def push_to_vps(): if not ensure_dirs(): return stable = os.path.join(REPO, "stable") GUI.log("── rsync .pag na VPS ──", "hdr") ssh_cmd = " ".join(["ssh"] + SSH_OPTS) if run(["rsync", "-avz", "--update", "--mkpath", "-e", ssh_cmd, stable + "/", f"{VPS_HOST}:{REPO_STABLE_VPS}/"]): GUI.log("❌ rsync nieudany", "err") return GUI.log("── Podpis .pag na VPS (klucz zostaje na serwerze) ──", "hdr") ssh(["bash", "-c", f"cd {REPO_STABLE_VPS} && for f in *.pag; do [ -f \"$f.asc\" ] || " f"gpg --detach-sign --armor --batch --no-tty --local-user {GPG_KEY} \"$f\" " f"2>/dev/null && echo ' 🔏 $f.asc'; done; true"]) GUI.log("── repo.json (VPS) ──", "hdr") ssh(["bash", "-c", f"PAGAN_DO_SIGN=1 PAGAN_GPG_KEY={GPG_KEY} pagsync --gen-repo 2>&1 | tail -3"]) GUI.log("✅ Push zakończony", "ok") # ── GUI ─────────────────────────────────────────────────────────────────── class GUI: log_queue = queue.Queue() instance = None @staticmethod def log(msg, tag=""): GUI.log_queue.put((msg, tag)) _flog(msg) def __init__(self, root): GUI.instance = self self.root = root self.pkgs = [] self.busy = False root.title("PaganOS – lokalny build + push na VPS") root.geometry("1120x720") top = ttk.Frame(root, padding=6) top.pack(fill="x") for text, fn in ( ("🔄 Pobierz receptury", self.cmd_sync), ("➕ Dodaj do kolejki", self.cmd_add), ("🗑 Usuń z kolejki", self.cmd_remove), ("🔨 Zbuduj kolejkę", lambda: self.cmd_build(False)), ("🔨+📤 Zbuduj i wyślij", lambda: self.cmd_build(True)), ("📤 Wyślij na VPS", self.cmd_push), ("🧹 Wyczyść kolejki", self.cmd_clear), ): ttk.Button(top, text=text, command=fn).pack(side="left", padx=2) top2 = ttk.Frame(root, padding=(6, 0, 6, 4)) top2.pack(fill="x") for text, fn in ( ("📈 Sprawdź nowsze wersje (heurystyka)", lambda: self._submit(check_updates, None, False)), ("⬆ Podbij zaznaczony pakiet (--apply)", self.cmd_bump), ): ttk.Button(top2, text=text, command=fn).pack(side="left", padx=2) ttk.Label(top2, text="zaznacz 1 pakiet na liście → podbija pkgver + commit", foreground="#666").pack(side="left", padx=6) self.status = tk.Label(root, text="init…", anchor="w", bg="#111", fg="#8cf", padx=8) self.status.pack(fill="x") mid = ttk.Frame(root, padding=6) mid.pack(fill="both", expand=True) left = ttk.LabelFrame(mid, text="Pakiety (recipes.git) – Ctrl/Shift: wiele") left.pack(side="left", fill="both", expand=True, padx=(0, 4)) self.filter_var = tk.StringVar() ttk.Entry(left, textvariable=self.filter_var).pack(fill="x", padx=4, pady=(4, 2)) self.filter_var.trace_add("write", lambda *a: self.render_pkgs()) self.listbox = tk.Listbox(left, selectmode="extended", exportselection=False, font=("monospace", 9)) self.listbox.pack(fill="both", expand=True, padx=4, pady=4) self.count_lbl = ttk.Label(left, text="") self.count_lbl.pack(anchor="w", padx=4) qf = ttk.LabelFrame(mid, text="Kolejka (kernel najpierw!)") qf.pack(side="left", fill="y", padx=4) self.queue_lb = tk.Listbox(qf, selectmode="extended", width=34, font=("monospace", 9)) self.queue_lb.pack(fill="both", expand=True, padx=4, pady=4) cons = ttk.LabelFrame(root, text="Konsola") cons.pack(fill="both", expand=True, padx=6, pady=(0, 6)) self.txt = tk.Text(cons, height=14, state="disabled", bg="#0d1117", fg="#ddd", font=("monospace", 9), wrap="none") self.txt.pack(fill="both", expand=True, padx=4, pady=4) self.txt.tag_configure("ok", foreground="#3fb950") self.txt.tag_configure("err", foreground="#f85149") self.txt.tag_configure("warn", foreground="#d29922") self.txt.tag_configure("hdr", foreground="#58a6ff") self.txt.tag_configure("cmd", foreground="#888") self._taskq = queue.Queue() self.worker = threading.Thread(target=self._worker, daemon=True) self.worker.start() self.root.after(80, self._drain_log) self.cmd_sync() # --- wątek roboczy / log --- def _worker(self): while True: task, args = self._taskq.get() self.busy = True self.root.after(0, lambda: self.status.config(text="⏳ pracuję…")) try: task(*args) except Exception as e: GUI.log(f"❌ {e}", "err") finally: self.busy = False self._taskq.task_done() self.root.after(0, lambda: self.status.config(text="gotowy")) def _submit(self, fn, *args): if self.busy: GUI.log("⏳ Poczekaj – trwa inna operacja", "warn") return self._taskq.put((fn, args)) def _drain_log(self): try: while True: msg, tag = GUI.log_queue.get_nowait() self.txt.config(state="normal") self.txt.insert("end", msg + "\n", tag) self.txt.see("end") self.txt.config(state="disabled") except queue.Empty: pass self.root.after(80, self._drain_log) # --- akcje --- def cmd_sync(self): self._submit(self._do_sync) def _do_sync(self): sync_recipes() self.pkgs = recipe_list() self.root.after(0, self.render_pkgs) def cmd_clear(self): self.queue_lb.delete(0, "end") def cmd_add(self): existing = set(self.queue_lb.get(0, "end")) for i in self.listbox.curselection(): name = self.pkgs[i][0] if name not in existing: self.queue_lb.insert("end", name) def cmd_remove(self): for i in reversed(self.queue_lb.curselection()): self.queue_lb.delete(i) def cmd_build(self, auto_push): names = list(self.queue_lb.get(0, "end")) if not names: GUI.log("Brak pakietów w kolejce", "warn") return self._submit(build_queue, names, auto_push) def cmd_push(self): self._submit(push_to_vps) def cmd_bump(self): sel = self.listbox.curselection() if len(sel) != 1: GUI.log("Zaznacz DOKŁADNIE jeden pakiet na liście, żeby podbić wersję", "warn") return name = self.pkgs[sel[0]][0] GUI.log(f"⬆ Podbijam {name} (pkgver + pkgrel=1, sha=SKIP)", "hdr") self._submit(check_updates, name, True) def render_pkgs(self): f = self.filter_var.get().lower() self.listbox.delete(0, "end") shown = 0 for p in self.pkgs: name, ver, cat = p[0], p[1], p[2] if f and f not in name.lower() and f not in cat.lower(): continue self.listbox.insert("end", f"{name:<32} {ver:<12} {cat}") shown += 1 self.count_lbl.config(text=f"pokazano {shown} z {len(self.pkgs)}") def main(): global _pw_cb _flog("\n===== start pagan-gui-build " + __import__("datetime").datetime.now().isoformat() + " =====") def _hook(et, ev, tb): import traceback as _tb msg = "".join(_tb.format_exception(et, ev, tb)) _flog("❌ NIEZŁAPANY WYJĄTEK:\n" + msg) print(msg, file=sys.stderr) sys.excepthook = _hook if not os.path.isdir(ROOTFS): print(f"⚠ Uwaga: rootfs nie istnieje: {ROOTFS}\n" f" Ustaw PAGAN_ROOTFS=/ścieżka/do/rootfs") root = tk.Tk() _pw_cb = lambda: root.after(0, _ask_password_ui) gui = GUI(root) root.mainloop() if __name__ == "__main__": main()