← pag

Commit 198b1ef

0
plików
+0
dodanych
-0
usuniętych
@@ -0,0 +1,3018 @@
1 +#!/usr/bin/env python3
2 +"""
3 +╔══════════════════════════════════════════════════════════════════════════════╗
4 +║ PAG - Pagan Linux Package Manager v3.2.1 ║
5 +║ Produkcyjny menedżer pakietów – atomowy, bezpieczny, i18n ║
6 +╚══════════════════════════════════════════════════════════════════════════════╝
7 +
8 +KLUCZOWE CECHY:
9 + - Atomowa instalacja przez staging (tmpdir → rename) – brak pół-instalacji
10 + - Bezpieczne usuwanie – sprawdza czy plik nie jest współdzielony
11 + - SQLite dla bazy plików – miliony plików bez problemu
12 + - GPG: weryfikacja repo.json + podpisy pakietów
13 + - Hooki: pre/post-install, pre/post-remove
14 + - Głęboka weryfikacja SHA256 per-plik
15 + - Pełny rollback – cofa fizyczne pliki
16 + - Blokada flock – tylko jedna instancja
17 + - Transakcje z migawkami
18 + - Cache HTTP (ETag/If-Modified-Since)
19 + - Wielojęzyczność (i18n) – PL, EN
20 +
21 +FORMAT PAKIETU (.pkg.tar.xz):
22 + ├── data.tar.xz – pliki + sums.json (SHA256 per plik)
23 + ├── metadata.json – nazwa, wersja, zależności
24 + └── hooks/ – pre-install, post-install, pre-remove, post-remove
25 +"""
26 +
27 +import os, sys, json, shutil, hashlib, tarfile, tempfile, subprocess, time, fcntl, sqlite3, locale, re
28 +from pathlib import Path
29 +from datetime import datetime, timezone
30 +from typing import Dict, List, Optional, Tuple, Set
31 +from concurrent.futures import ThreadPoolExecutor, as_completed
32 +from urllib.request import urlopen, Request
33 +from urllib.error import URLError, HTTPError
34 +
35 +# =============================================================================
36 +# ProgressBar — minimalistyczny pasek postępu (bez zewnętrznych zależności)
37 +# =============================================================================
38 +
39 +class ProgressBar:
40 + """Czysty Python progress bar — działa z TTY i bez."""
41 + def __init__(self, total: int, desc: str = "", unit: str = "", width: int = 30):
42 + self.total = max(total, 1)
43 + self.desc = desc
44 + self.unit = unit
45 + self.width = width
46 + self.n = 0
47 + self.start = time.time()
48 + self.tty = sys.stderr.isatty()
49 + self._last_line_len = 0
50 +
51 + def update(self, n: Optional[int] = None, suffix: str = ""):
52 + if n is not None:
53 + self.n = n
54 + else:
55 + self.n += 1
56 + pct = self.n / self.total * 100
57 + elapsed = time.time() - self.start
58 + speed = self.n / elapsed if elapsed > 0 else 0
59 + if self.n >= self.total:
60 + eta_str = "done"
61 + elif speed > 0:
62 + eta = (self.total - self.n) / speed
63 + eta_str = f"{eta:.0f}s" if eta < 60 else f"{eta/60:.1f}m"
64 + else:
65 + eta_str = "?..."
66 + bar_len = int(self.width * pct / 100)
67 + bar = "█" * bar_len + "░" * (self.width - bar_len)
68 + line = f" {self.desc} [{bar}] {self.n}/{self.total} ({pct:.0f}%) ETA {eta_str}{suffix}"
69 + if self.tty:
70 + # Overwrite current line
71 + clear = " " * max(0, self._last_line_len - len(line))
72 + print(f"\r{line}{clear}", end="", file=sys.stderr, flush=True)
73 + self._last_line_len = len(line)
74 + else:
75 + # Print milestone lines only (every 10% or when done)
76 + if self.n == 1 or self.n >= self.total or self.n % max(1, self.total // 10) == 0:
77 + print(line, file=sys.stderr)
78 +
79 + def close(self):
80 + if self.tty:
81 + print(file=sys.stderr)
82 + self._last_line_len = 0
83 +
84 + def __enter__(self):
85 + return self
86 +
87 + def __exit__(self, *args):
88 + self.close()
89 +
90 +
91 +class DownloadBar:
92 + """Pasek postępu pobierania — na podstawie Content-Length."""
93 + def __init__(self, filename: str, total_bytes: int):
94 + self.filename = filename
95 + self.total = total_bytes
96 + self.downloaded = 0
97 + self.start = time.time()
98 + self.tty = sys.stderr.isatty()
99 + self._last_len = 0
100 +
101 + def update(self, chunk_size: int):
102 + self.downloaded += chunk_size
103 + if self.total <= 0:
104 + return
105 + pct = self.downloaded / self.total * 100
106 + elapsed = time.time() - self.start
107 + speed = self.downloaded / elapsed if elapsed > 0 else 0
108 + if speed > 0:
109 + eta = (self.total - self.downloaded) / speed
110 + eta_str = f"{eta:.0f}s" if eta < 60 else f"{eta/60:.1f}m"
111 + else:
112 + eta_str = "?..."
113 + bar_len = 25
114 + filled = int(bar_len * pct / 100)
115 + bar = "█" * filled + "░" * (bar_len - filled)
116 + sz = self._fmt_size(self.total)
117 + spd = self._fmt_size(int(speed))
118 + line = f" ↓ {self.filename} [{bar}] {pct:.0f}% {sz} {spd}/s ETA {eta_str}"
119 + if self.tty:
120 + clear = " " * max(0, self._last_len - len(line))
121 + print(f"\r{line}{clear}", end="", file=sys.stderr, flush=True)
122 + self._last_len = len(line)
123 +
124 + def close(self):
125 + if self.tty and self.total > 0:
126 + print(file=sys.stderr)
127 +
128 + @staticmethod
129 + def _fmt_size(n: int) -> str:
130 + for unit in ("B", "KB", "MB", "GB"):
131 + if n < 1024:
132 + return f"{n:.1f} {unit}"
133 + n /= 1024
134 + return f"{n:.1f} TB"
135 +
136 +# =============================================================================
137 +# GPG – BEZPIECZNE WYWOŁYWANIE (odporne na brak binarki gpg)
138 +# =============================================================================
139 +
140 +GPG_BINARY = shutil.which("gpg2") or shutil.which("gpg") or "gpg"
141 +
142 +def _gpg_run(*args, timeout: int = 30, **kwargs) -> subprocess.CompletedProcess:
143 + """
144 + Bezpieczne wywołanie GPG – przechwytuje FileNotFoundError,
145 + gdyby gpg/gpg2 nie było zainstalowane w minimalnym środowisku.
146 + """
147 + try:
148 + return subprocess.run([GPG_BINARY, *args], timeout=timeout, **kwargs)
149 + except FileNotFoundError:
150 + # GPG nie jest dostępne – zwróć błąd z komunikatem
151 + return subprocess.CompletedProcess(
152 + [GPG_BINARY, *args], 127,
153 + stdout=b"", stderr=f"GPG binary not found ({GPG_BINARY})".encode()
154 + )
155 + except subprocess.TimeoutExpired:
156 + return subprocess.CompletedProcess(
157 + [GPG_BINARY, *args], 124,
158 + stdout=b"", stderr=b"GPG operation timed out"
159 + )
160 +
161 +# =============================================================================
162 +# i18n – WIELOJĘZYCZNOŚĆ
163 +# =============================================================================
164 +
165 +LANG = os.environ.get("LANG", "en_US.UTF-8")[:2] # pl, en, de...
166 +COLOR = os.environ.get("NO_COLOR", "") == "" and sys.stdout.isatty()
167 +
168 +def _c(code: str, text: str) -> str:
169 + """Dodaje kody ANSI jeśli kolor jest włączony."""
170 + if not COLOR:
171 + return text
172 + colors = {
173 + "green": "\033[32m", "red": "\033[31m", "yellow": "\033[33m",
174 + "cyan": "\033[36m", "bold": "\033[1m", "dim": "\033[2m",
175 + "reset": "\033[0m",
176 + }
177 + return f"{colors.get(code,'')}{text}{colors['reset']}"
178 +
179 +T = {
180 + "en": {
181 + "root_required": "pag requires root privileges (sudo).",
182 + "db_locked": "Another pag instance is running.",
183 + "db_lock_hint": "If this is an error, remove: rm {}",
184 + "no_index": "Cannot fetch repository indexes. Run 'pag update'.",
185 + "all_installed": "All packages are already installed.",
186 + "to_install": "To install: {} packages ({:.1f} MB)",
187 + "new": "NEW",
188 + "continue_q": "Continue? [Y/n] ",
189 + "cancelled": "Cancelled.",
190 + "not_found": "not found in repos",
191 + "downloading": "Downloading",
192 + "download_fail": "download failed",
193 + "gpg_fail": "GPG verification failed",
194 + "sha256_mismatch": "SHA256 mismatch",
195 + "installed": "Installed {} packages.",
196 + "rollback_restored": "Restored previous state from snapshot.",
197 + "rollback_files": "Rolled back {} files.",
198 + "no_history": "No transaction history.",
199 + "pinned_list": "Pinned packages ({}):",
200 + "no_pinned": "No pinned packages.",
201 + "pinned_to": "pinned to",
202 + "unpinned": "unpinned.",
203 + "not_pinned": "was not pinned.",
204 + "repo_added": "Added repository: {}",
205 + "repo_exists": "Repository already exists: {}",
206 + "updated_done": "Index refresh complete. {} packages cached.",
207 + "upgrading": "Upgrading: {} packages",
208 + "all_up_to_date": "All packages are up to date.",
209 + "removing": "Removing",
210 + "orphans_found": "Orphaned dependencies ({}): {}",
211 + "flatpak_missing": "Flatpak is not installed.",
212 + "flatpak_adding": "Adding Flathub remote...",
213 + "flatpak_searching": "Searching Flathub for '{}'...",
214 + "flatpak_found": "Found {} results:",
215 + "flatpak_not_found": "not found on Flathub",
216 + "flatpak_install_prompt": "Install {}? [Y/n] ",
217 + "flatpak_installing": "Installing {}...",
218 + "flatpak_installed": "Flatpak {} installed.",
219 + "flatpak_removed": "Flatpak {} removed.",
220 + "flatpak_not_installed": "Flatpak {} is not installed.",
221 + "flatpak_info_id": "ID",
222 + "flatpak_info_version": "Version",
223 + "flatpak_info_branch": "Branch",
224 + "flatpak_info_origin": "Origin",
225 + "flatpak_info_size": "Installed size",
226 + "flatpak_info_desc": "Description",
227 + "flatpak_updated": "Flatpaks updated.",
228 + "flatpak_usage": "Usage: pag flatpak <search|install|remove|list|update|info> [args]",
229 + "key_imported": "Key imported successfully.",
230 + "key_removed": "Key removed: {}",
231 + "no_keys": "No trusted GPG keys.",
232 + "verify_ok": "All {} files intact.",
233 + "verify_errors": "{} problems found:",
234 + "cache_cleared": "{} files ({:.1f} MB) cleared from cache.",
235 + "deployments_list": "Deployments ({}):",
236 + "no_deployments": "No deployments.",
237 + "active_deployment": "ACTIVE",
238 + "deploy_rollback_ok": "Switched to deployment: {}",
239 + "deploy_rollback_fail": "No previous deployment.",
240 + "deploy_cleanup_ok": "Removed {} old deployments.",
241 + "deploy_cleanup_none": "No deployments to clean (minimum {}).",
242 + "why_explicit": "explicitly installed",
243 + "why_dependency": "dependency of",
244 + "why_not_installed": "not installed",
245 + "autoremove_ok": "Removed {} orphaned packages.",
246 + "autoremove_none": "No orphaned packages.",
247 + "downloaded": "Downloaded {} to cache ({:.1f} MB).",
248 + "provides_mapped": "{} → {} (provides)",
249 + "stats_title": "PAG Statistics",
250 + "stats_packages": "Installed packages",
251 + "stats_files": "Tracked files",
252 + "stats_size": "Total size",
253 + "stats_cache": "Cache size",
254 + "stats_history": "Transactions",
255 + "stats_last_update": "Last update",
256 + },
257 + "pl": {
258 + "root_required": "pag wymaga uprawnień root (sudo).",
259 + "db_locked": "Inna instancja pag jest uruchomiona.",
260 + "db_lock_hint": "Jeśli to błąd, usuń: rm {}",
261 + "no_index": "Nie można pobrać indeksów repozytoriów. Uruchom 'pag update'.",
262 + "all_installed": "Wszystkie pakiety są już zainstalowane.",
263 + "to_install": "Do zainstalowania: {} pakietów ({:.1f} MB)",
264 + "new": "NOWY",
265 + "continue_q": "Kontynuować? [T/n] ",
266 + "cancelled": "Anulowano.",
267 + "not_found": "brak w repozytoriach",
268 + "downloading": "Pobieranie",
269 + "download_fail": "błąd pobierania",
270 + "gpg_fail": "błąd weryfikacji GPG",
271 + "sha256_mismatch": "niezgodność SHA256",
272 + "installed": "Zainstalowano {} pakietów.",
273 + "rollback_restored": "Przywrócono poprzedni stan z migawki.",
274 + "rollback_files": "Wycofano {} plików.",
275 + "no_history": "Brak historii transakcji.",
276 + "pinned_list": "Przypięte pakiety ({}):",
277 + "no_pinned": "Brak przypiętych pakietów.",
278 + "pinned_to": "przypięty do",
279 + "unpinned": "odpięty.",
280 + "not_pinned": "nie był przypięty.",
281 + "repo_added": "Dodano repozytorium: {}",
282 + "repo_exists": "Repozytorium już istnieje: {}",
283 + "updated_done": "Odświeżanie zakończone. {} pakietów w cache.",
284 + "upgrading": "Aktualizacje: {} pakietów",
285 + "all_up_to_date": "Wszystkie pakiety są aktualne.",
286 + "removing": "Usuwanie",
287 + "orphans_found": "Osierocone zależności ({}): {}",
288 + "flatpak_missing": "Flatpak nie jest zainstalowany.",
289 + "flatpak_adding": "Dodaję zdalne repozytorium Flathub...",
290 + "flatpak_searching": "Szukam '{}' we Flathub...",
291 + "flatpak_found": "Znaleziono {} wyników:",
292 + "flatpak_not_found": "nie znaleziono we Flathub",
293 + "flatpak_install_prompt": "Zainstalować {}? [T/n] ",
294 + "flatpak_installing": "Instalowanie {}...",
295 + "flatpak_installed": "Flatpak {} zainstalowany.",
296 + "flatpak_removed": "Flatpak {} usunięty.",
297 + "flatpak_not_installed": "Flatpak {} nie jest zainstalowany.",
298 + "flatpak_info_id": "ID",
299 + "flatpak_info_version": "Wersja",
300 + "flatpak_info_branch": "Gałąź",
301 + "flatpak_info_origin": "Źródło",
302 + "flatpak_info_size": "Rozmiar",
303 + "flatpak_info_desc": "Opis",
304 + "flatpak_updated": "Flapaki zaktualizowane.",
305 + "flatpak_usage": "Użycie: pag flatpak <search|install|remove|list|update|info> [args]",
306 + "key_imported": "Klucz zaimportowany pomyślnie.",
307 + "key_removed": "Klucz usunięty: {}",
308 + "no_keys": "Brak zaufanych kluczy GPG.",
309 + "verify_ok": "Wszystkie {} plików sprawne.",
310 + "verify_errors": "Znaleziono {} problemów:",
311 + "cache_cleared": "{} plików ({:.1f} MB) usuniętych z cache.",
312 + "deployments_list": "Deploymenty ({}):",
313 + "no_deployments": "Brak deploymentów.",
314 + "active_deployment": "AKTYWNY",
315 + "deploy_rollback_ok": "Przełączono na deployment: {}",
316 + "deploy_rollback_fail": "Brak poprzedniego deploymentu.",
317 + "deploy_cleanup_ok": "Usunięto {} starych deploymentów.",
318 + "deploy_cleanup_none": "Nie ma deploymentów do wyczyszczenia (minimum {}).",
319 + "why_explicit": "zainstalowany jawnie",
320 + "why_dependency": "zależność od",
321 + "why_not_installed": "niezainstalowany",
322 + "autoremove_ok": "Usunięto {} osieroconych pakietów.",
323 + "autoremove_none": "Brak osieroconych pakietów.",
324 + "downloaded": "Pobrano {} do cache ({:.1f} MB).",
325 + "provides_mapped": "{} → {} (provides)",
326 + "stats_title": "Statystyki PAG",
327 + "stats_packages": "Zainstalowane pakiety",
328 + "stats_files": "Śledzone pliki",
329 + "stats_size": "Całkowity rozmiar",
330 + "stats_cache": "Rozmiar cache",
331 + "stats_history": "Transakcje",
332 + "stats_last_update": "Ostatnia aktualizacja",
333 + },
334 +}
335 +
336 +def _(key: str, *args) -> str:
337 + """Tłumaczy klucz i formatuje argumenty."""
338 + msg = T.get(LANG, T["en"]).get(key, T["en"].get(key, key))
339 + if args:
340 + return msg.format(*args)
341 + return msg
342 +
343 +# =============================================================================
344 +# ŚCIEŻKI
345 +# =============================================================================
346 +PAG_ROOT = os.environ.get("PAG_ROOT", "/")
347 +PAG_DB = "/var/lib/pag"
348 +PAG_CACHE = "/var/cache/pag"
349 +PAG_CONF = "/etc/pag"
350 +REPO_CACHE = "/var/cache/pag/repos"
351 +REPOS_CONF = "/etc/pag/repos.conf"
352 +INSTALLED_DB = "/var/lib/pag/installed.json"
353 +FILES_DB_SQL = "/var/lib/pag/files.db" # SQLite!
354 +WORLD_FILE = "/var/lib/pag/world"
355 +PINNED_FILE = "/var/lib/pag/pinned.json"
356 +HISTORY_FILE = "/var/lib/pag/history.json"
357 +LOCK_FILE = "/var/lib/pag/pag.lock"
358 +GPG_KEYRING = "/etc/pag/trusted-keys.gpg"
359 +STAGING_DIR = "/.pag_staging" # na tej samej partycji co / (unikamy EXDEV)
360 +PKG_EXT = ".pkg.tar.xz"
361 +REPO_CACHE_TTL = 3600
362 +
363 +# =============================================================================
364 +# IMMUTABLE OS – DEPLOYMENTY
365 +# =============================================================================
366 +# Model: zamiast mutować /, każda operacja tworzy NOWY deployment.
367 +# /var, /etc, /home są współdzielone między deploymentami.
368 +#
369 +# STRUKTURA:
370 +# /.deployments/
371 +# active → 20260723T120000 (symlink do aktywnego)
372 +# 20260723T120000/
373 +# usr/ bin/ lib/ lib64/ ... (pełny system)
374 +# var → /var (symlink do współdzielonego)
375 +# etc → /etc
376 +# home → /home
377 +# ...
378 +#
379 +# Jak to działa:
380 +# 1. pag install → kopiuje active → nowy deployment + nakłada zmiany → switch symlinka
381 +# 2. pag remove → kopiuje active → nowy deployment - usuwa pliki → switch symlinka
382 +# 3. pag deploy-rollback → przełącza active symlink na poprzedni deployment
383 +# 4. Przy starcie systemu: initrd montuje /.deployments/active jako /
384 +# =============================================================================
385 +
386 +DEPLOYMENTS_DIR = "/.deployments"
387 +ACTIVE_LINK = "/.deployments/active"
388 +DEPLOYMENTS_DB = "/var/lib/pag/deployments.json"
389 +
390 +# Ścieżki współdzielone – NIE wchodzą do deploymentu (są symlinkami do /...)
391 +SHARED_PATHS = {
392 + "/var", "/etc", "/home", "/root", "/tmp", "/run",
393 + "/dev", "/proc", "/sys", "/mnt", "/media", "/srv",
394 + "/.deployments", "/.pag_staging",
395 +}
396 +
397 +def _is_shared_path(rel: str) -> bool:
398 + """Sprawdza czy ścieżka należy do katalogów współdzielonych (poza deploymentem)."""
399 + for sp in SHARED_PATHS:
400 + if rel == sp or rel.startswith(sp + "/"):
401 + return True
402 + return False
403 +
404 +def _get_deployment_root() -> str:
405 + """Zwraca ścieżkę do aktywnego deploymentu, lub PAG_ROOT jeśli tryb niemutowalny wyłączony."""
406 + if os.environ.get("PAG_IMMUTABLE", "") in ("0", "no", "false", ""):
407 + return PAG_ROOT
408 + if os.path.islink(ACTIVE_LINK):
409 + return os.readlink(ACTIVE_LINK)
410 + if os.path.isdir(ACTIVE_LINK):
411 + return ACTIVE_LINK
412 + # Brak deploymentów – użyj /
413 + return PAG_ROOT
414 +
415 +def _load_deployments() -> List[dict]:
416 + """Wczytuje historię deploymentów."""
417 + if not os.path.exists(DEPLOYMENTS_DB):
418 + return []
419 + try:
420 + return json.load(open(DEPLOYMENTS_DB))
421 + except Exception:
422 + return []
423 +
424 +def _save_deployments(deployments: List[dict]):
425 + os.makedirs(os.path.dirname(DEPLOYMENTS_DB), exist_ok=True)
426 + json.dump(deployments, open(DEPLOYMENTS_DB, "w"), indent=2)
427 +
428 +def _create_deployment(pkg_names: List[str], action: str) -> Tuple[str, str]:
429 + """
430 + Tworzy nowy deployment przez skopiowanie aktywnego (CoW) i zwraca jego ścieżkę.
431 + Zwraca (deployment_dir, deployment_id).
432 + """
433 + deploy_id = datetime.now().strftime("%Y%m%dT%H%M%S")
434 + deploy_dir = os.path.join(DEPLOYMENTS_DIR, deploy_id)
435 + os.makedirs(DEPLOYMENTS_DIR, exist_ok=True)
436 +
437 + active = _get_deployment_root()
438 +
439 + if os.path.isdir(active) and active != PAG_ROOT:
440 + # Trójstopniowa strategia kopiowania deploymentu:
441 + # 1. reflink (CoW – btrfs, xfs) → 0 MB kopiowane
442 + # 2. hardlink (linki twarde) → 0 MB kopiowane, tylko inody
443 + # 3. zwykłe cp (ostateczność) → pełna kopia
444 + print(f" ⚡ Kopiowanie aktywnego deploymentu...")
445 + copied = False
446 + for method, cmd, label in [
447 + ("reflink", ["cp", "--reflink=auto", "-a", active + "/.", deploy_dir + "/"], "CoW (reflink)"),
448 + ("hardlink", ["cp", "-al", active + "/.", deploy_dir + "/"], "hardlinki"),
449 + ("copy", ["cp", "-a", active + "/.", deploy_dir + "/"], "pełna kopia"),
450 + ]:
451 + try:
452 + subprocess.run(cmd, check=True, timeout=600, capture_output=True)
453 + print(f" ✅ Deployment: {deploy_id} ({label})")
454 + copied = True
455 + break
456 + except subprocess.CalledProcessError:
457 + if method == "copy":
458 + raise # ostatnia deska – niech leci wyjątek
459 + continue
460 + if not copied:
461 + raise RuntimeError("Nie udało się skopiować deploymentu żadną metodą")
462 + else:
463 + # Pierwszy deployment – tylko katalogi szkieletowe
464 + for d in ["/usr", "/lib", "/lib64", "/bin", "/sbin", "/boot", "/opt"]:
465 + if os.path.isdir(d):
466 + dest = os.path.join(deploy_dir, d.lstrip("/"))
467 + os.makedirs(dest, exist_ok=True)
468 + print(f" ✅ Pierwszy deployment: {deploy_id}")
469 +
470 + # Utwórz symlinki do współdzielonych katalogów
471 + for sp in SHARED_PATHS:
472 + link_dst = os.path.join(deploy_dir, sp.lstrip("/"))
473 + if not os.path.lexists(link_dst) and os.path.isdir(sp):
474 + os.symlink(sp, link_dst)
475 +
476 + # Zapisz w bazie deploymentów
477 + deployments = _load_deployments()
478 + deployments.append({
479 + "id": deploy_id,
480 + "action": action,
481 + "packages": pkg_names,
482 + "timestamp": datetime.now().isoformat(),
483 + "active": True,
484 + })
485 + # Oznacz poprzednie jako nieaktywne
486 + for d in deployments[:-1]:
487 + d["active"] = False
488 + _save_deployments(deployments)
489 +
490 + return deploy_dir, deploy_id
491 +
492 +def _switch_deployment(deploy_dir: str) -> bool:
493 + """Atomowo przełącza aktywny deployment przez podmianę symlinka."""
494 + tmp_link = ACTIVE_LINK + ".new"
495 + if os.path.lexists(tmp_link):
496 + os.remove(tmp_link)
497 + os.symlink(deploy_dir, tmp_link)
498 + os.rename(tmp_link, ACTIVE_LINK) # atomowe na tym samym FS
499 + return True
500 +
501 +DEFAULT_REPOS = [
502 + "https://repo.paganlinux.eu/stable",
503 +]
504 +
505 +# =============================================================================
506 +# INICJALIZACJA
507 +# =============================================================================
508 +
509 +def ensure_dirs():
510 + for d in [PAG_DB, PAG_CACHE, PAG_CONF, REPO_CACHE, STAGING_DIR, DEPLOYMENTS_DIR]:
511 + os.makedirs(d, exist_ok=True)
512 + for f, default in [
513 + (REPOS_CONF, "\n".join(DEFAULT_REPOS) + "\n"),
514 + (INSTALLED_DB, "{}"),
515 + (PINNED_FILE, "{}"),
516 + (HISTORY_FILE, "[]"),
517 + ]:
518 + if not os.path.exists(f):
519 + with open(f, "w") as fh: fh.write(default)
520 + if not os.path.exists(WORLD_FILE):
521 + Path(WORLD_FILE).touch()
522 + if not os.path.exists(GPG_KEYRING):
523 + _gpg_run("--no-default-keyring", "--keyring", GPG_KEYRING,
524 + "--fingerprint", capture_output=True)
525 + # Inicjalizuj SQLite
526 + _db_init()
527 + # Wyczyść staging po poprzednim przerwanym buildzie/instalacji
528 + if os.path.isdir(STAGING_DIR):
529 + for entry in os.listdir(STAGING_DIR):
530 + path = os.path.join(STAGING_DIR, entry)
531 + try:
532 + if os.path.isfile(path) or os.path.islink(path):
533 + os.unlink(path)
534 + elif os.path.isdir(path):
535 + shutil.rmtree(path, ignore_errors=True)
536 + except OSError:
537 + pass
538 +
539 +# =============================================================================
540 +# SQLITE – BAZA PLIKÓW (poprawne zarządzanie połączeniami)
541 +# =============================================================================
542 +
543 +from contextlib import contextmanager
544 +
545 +@contextmanager
546 +def _db_session():
547 + """Context manager – gwarantuje zamknięcie połączenia."""
548 + conn = sqlite3.connect(FILES_DB_SQL)
549 + conn.execute("PRAGMA journal_mode=WAL")
550 + conn.execute("PRAGMA synchronous=NORMAL")
551 + conn.execute("PRAGMA foreign_keys=ON")
552 + conn.row_factory = sqlite3.Row
553 + try:
554 + yield conn
555 + conn.commit()
556 + except Exception:
557 + conn.rollback()
558 + raise
559 + finally:
560 + conn.close()
561 +
562 +
563 +def _db_init():
564 + """Tworzy tabele SQLite jeśli nie istnieją."""
565 + with _db_session() as db:
566 + db.execute("""
567 + CREATE TABLE IF NOT EXISTS files (
568 + id INTEGER PRIMARY KEY AUTOINCREMENT,
569 + path TEXT NOT NULL,
570 + package TEXT NOT NULL,
571 + sha256 TEXT,
572 + size INTEGER,
573 + is_symlink INTEGER DEFAULT 0,
574 + symlink_target TEXT,
575 + UNIQUE(path, package)
576 + )
577 + """)
578 + db.execute("CREATE INDEX IF NOT EXISTS idx_files_path ON files(path)")
579 + db.execute("CREATE INDEX IF NOT EXISTS idx_files_pkg ON files(package)")
580 + db.execute("""
581 + CREATE TABLE IF NOT EXISTS file_checksums (
582 + path TEXT PRIMARY KEY,
583 + sha256 TEXT NOT NULL,
584 + installed_at TEXT
585 + )
586 + """)
587 + db.commit()
588 +
589 +def _db_record_files(pkg_name: str, files: List[dict]):
590 + """Zapisuje pliki do SQLite (obsługuje symlinki)."""
591 + with _db_session() as db:
592 + # Context manager sam zarządza transakcją atomowo
593 + db.executemany(
594 + "INSERT OR REPLACE INTO files (path, package, sha256, size, is_symlink, symlink_target) "
595 + "VALUES (?,?,?,?,?,?)",
596 + [(f["path"], pkg_name, f.get("sha256",""), f.get("size",0),
597 + f.get("is_symlink", 0), f.get("symlink_target", ""))
598 + for f in files]
599 + )
600 + db.executemany(
601 + "INSERT OR REPLACE INTO file_checksums (path, sha256, installed_at) VALUES (?,?,?)",
602 + [(f["path"], f.get("sha256",""), datetime.now().isoformat())
603 + for f in files if f.get("sha256")]
604 + )
605 +
606 +def _db_get_package_files(pkg_name: str) -> List[str]:
607 + with _db_session() as db:
608 + return [r["path"] for r in db.execute(
609 + "SELECT DISTINCT path FROM files WHERE package=?", (pkg_name,)
610 + )]
611 +
612 +def _db_get_file_owners(filepath: str) -> List[str]:
613 + """Zwraca listę pakietów będących właścicielami pliku."""
614 + with _db_session() as db:
615 + return [r["package"] for r in db.execute(
616 + "SELECT package FROM files WHERE path=?", (filepath,)
617 + )]
618 +
619 +def _db_remove_package_files(pkg_name: str):
620 + with _db_session() as db:
621 + db.execute("DELETE FROM files WHERE package=?", (pkg_name,))
622 + db.commit()
623 +
624 +def _db_get_all_file_checksums() -> Dict[str, str]:
625 + with _db_session() as db:
626 + return {r["path"]: r["sha256"] for r in db.execute("SELECT path, sha256 FROM file_checksums")}
627 +
628 +def _db_count_files() -> int:
629 + with _db_session() as db:
630 + return db.execute("SELECT COUNT(*) FROM files").fetchone()[0]
631 +
632 +# =============================================================================
633 +# BLOKADA
634 +# =============================================================================
635 +
636 +class DatabaseLock:
637 + def __init__(self):
638 + self._fd = None
639 + def __enter__(self):
640 + self._fd = open(LOCK_FILE, "w")
641 + try:
642 + fcntl.flock(self._fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
643 + except (IOError, OSError):
644 + print(f"❌ {_('db_locked')}", file=sys.stderr)
645 + print(f" {_('db_lock_hint', LOCK_FILE)}", file=sys.stderr)
646 + sys.exit(1)
647 + return self
648 + def __exit__(self, *args):
649 + if self._fd:
650 + fcntl.flock(self._fd, fcntl.LOCK_UN)
651 + self._fd.close()
652 + try:
653 + os.remove(LOCK_FILE)
654 + except OSError:
655 + pass # plik mógł zostać usunięty przez inny proces
656 +
657 +# =============================================================================
658 +# POMOCNICZE
659 +# =============================================================================
660 +
661 +def _safe_extractall(tar: tarfile.TarFile, dest: str, *, preserve_perms: bool = True):
662 + """
663 + Bezpieczne rozpakowanie archiwum tar z ochroną przed Directory Traversal.
664 +
665 + Działa na Python < 3.12 (gdzie parametr 'filter' w extractall nie istnieje)
666 + oraz na Python 3.12+. W przeciwieństwie do filtra 'data' z Pythona 3.12,
667 + zachowuje bity uprawnień POSIX (SUID, SGID, sticky) – preserve_perms=True.
668 +
669 + Ochrona:
670 + - Blokuje ścieżki absolutne i z '..' (path traversal)
671 + - Blokuje niebezpieczne symlinki
672 + - Zachowuje oryginalne uprawnienia plików
673 + """
674 + for member in tar.getmembers():
675 + name = member.name
676 +
677 + # --- Ochrona przed Directory Traversal ---
678 + # Blokuj ścieżki absolutne (zaczynające się od /)
679 + if name.startswith('/'):
680 + continue
681 + # Blokuj ścieżki zawierające '..'
682 + if '..' in name.split('/'):
683 + continue
684 +
685 + # --- Ochrona dla symlinków i hardlinków ---
686 + if member.issym() or member.islnk():
687 + link = member.linkname
688 + # Blokuj linki do ścieżek absolutnych
689 + if link.startswith('/'):
690 + continue
691 + # Blokuj linki z '..'
692 + if '..' in link.split('/'):
693 + continue
694 +
695 + # Rozpakuj z zachowaniem metadanych
696 + tar.extract(member, dest, set_attrs=preserve_perms, numeric_owner=False)
697 +
698 +
699 +def _sha256_file(path: str) -> str:
700 + h = hashlib.sha256()
701 + with open(path, "rb") as f:
702 + for chunk in iter(lambda: f.read(65536), b""):
703 + h.update(chunk)
704 + return h.hexdigest()
705 +
706 +def _version_newer(a: str, b: str) -> bool:
707 + def parse(v):
708 + parts = []
709 + for p in v.replace("-",".").replace("_",".").split("."):
710 + try: parts.append((0, int(p)))
711 + except ValueError: parts.append((1, p))
712 + return parts
713 + try: return parse(a) > parse(b)
714 + except: return a != b
715 +
716 +def load_json(path):
717 + try:
718 + with open(path) as f:
719 + return json.load(f)
720 + except (FileNotFoundError, json.JSONDecodeError):
721 + return {}
722 +
723 +def save_json(path, data):
724 + with open(path, "w") as f:
725 + json.dump(data, f, indent=2)
726 +
727 +class PackageInfo:
728 + __slots__ = ("name","version","description","dependencies",
729 + "size_bytes","sha256","gpg_fp","repo_url","filename")
730 + def __init__(self, d, repo=""):
731 + self.name = d.get("name","?")
732 + self.version = d.get("version","0")
733 + self.description = d.get("description","")
734 + self.dependencies = d.get("dependencies",[])
735 + self.size_bytes = d.get("size",0)
736 + self.sha256 = d.get("sha256","")
737 + self.gpg_fp = d.get("gpg_fingerprint","")
738 + self.repo_url = repo
739 + self.filename = d.get("filename", f"{self.name}-{self.version}{PKG_EXT}")
740 +
741 +# =============================================================================
742 +# REPOZYTORIA (cache, ETag, GPG)
743 +# =============================================================================
744 +
745 +def get_repos():
746 + repos = []
747 + if os.path.exists(REPOS_CONF):
748 + for line in open(REPOS_CONF):
749 + line = line.strip()
750 + if line and not line.startswith("#"):
751 + repos.append(line.rstrip("/"))
752 + return repos or DEFAULT_REPOS
753 +
754 +def _repo_cache_path(url):
755 + return os.path.join(REPO_CACHE, url.replace("://","_").replace("/","_").replace(".","_") + ".json")
756 +
757 +def _repo_etag_path(url): return _repo_cache_path(url) + ".etag"
758 +def _repo_ts_path(url): return _repo_cache_path(url) + ".ts"
759 +
760 +def fetch_repo_index(repo_url, force=False):
761 + cp = _repo_cache_path(repo_url)
762 + ep = _repo_etag_path(repo_url)
763 + tp = _repo_ts_path(repo_url)
764 +
765 + if not force and os.path.exists(cp) and os.path.exists(tp):
766 + try:
767 + if time.time() - float(open(tp).read().strip()) < REPO_CACHE_TTL:
768 + return json.load(open(cp)).get("packages",[])
769 + except: pass
770 +
771 + headers = {"User-Agent": "pag/3.0"}
772 + if os.path.exists(tp) and not force:
773 + try:
774 + lm = datetime.fromtimestamp(float(open(tp).read().strip()), tz=timezone.utc)
775 + # Wymuś lokalizację C/POSIX dla nagłówków HTTP, aby unikać problemów z nazwami dni/miesięcy
776 + try:
777 + old_locale = locale.setlocale(locale.LC_TIME)
778 + locale.setlocale(locale.LC_TIME, 'C')
779 + headers["If-Modified-Since"] = lm.strftime("%a, %d %b %Y %H:%M:%S GMT")
780 + locale.setlocale(locale.LC_TIME, old_locale)
781 + except (locale.Error, ValueError):
782 + # Jeśli ustawienie lokalizacji się nie powiedzie, użyj domyślnej
783 + headers["If-Modified-Since"] = lm.strftime("%a, %d %b %Y %H:%M:%S GMT")
784 + except: pass
785 + if os.path.exists(ep) and not force:
786 + try: headers["If-None-Match"] = open(ep).read().strip()
787 + except: pass
788 +
789 + try:
790 + req = Request(f"{repo_url}/repo.json", headers=headers)
791 + with urlopen(req, timeout=30) as resp:
792 + etag = resp.headers.get("ETag","")
793 + if etag: open(ep,"w").write(etag)
794 + raw = resp.read()
795 + data = json.loads(raw.decode())
796 + # Zapisuj SUROWE bajty (nie re-serializuj!) – podpis GPG jest nad
797 + # oryginalnymi bajtami repo.json z serwera
798 + with open(cp,"wb") as f: f.write(raw)
799 + open(tp,"w").write(str(time.time()))
800 + # SPRAWDŹ WYNIK WERYFIKACJI – nie ignoruj!
801 + if not _verify_repo_sig(repo_url, cp):
802 + return None # weryfikacja nie powiodła się, cache usunięty
803 + return data.get("packages",[])
804 + except HTTPError as e:
805 + if e.code == 304:
806 + open(tp,"w").write(str(time.time()))
807 + if os.path.exists(cp):
808 + return json.load(open(cp)).get("packages",[])
809 + print(f" ⚠ HTTP {e.code} dla {repo_url}", file=sys.stderr)
810 + return None
811 + except Exception as e:
812 + print(f" ⚠ Błąd pobierania indeksu {repo_url}: {e}", file=sys.stderr)
813 + if os.path.exists(cp):
814 + try: return json.load(open(cp)).get("packages",[])
815 + except Exception: pass
816 + return None
817 +
818 +def _verify_repo_sig(repo_url, cache_path) -> bool:
819 + """Weryfikuje podpis GPG indeksu repozytorium.
820 +
821 + FAIL-CLOSED: brak/nieprawidłowy podpis = False (chyba że PAG_INSECURE=1).
822 + Zwraca True jeśli indeks jest zaufany, False jeśli należy go odrzucić.
823 + """
824 + insecure = os.environ.get("PAG_INSECURE", "") == "1"
825 +
826 + if not os.path.exists(GPG_KEYRING):
827 + if insecure:
828 + return True # brak GPG keyring – tryb insecure, akceptuj
829 + print(f" ❌ {repo_url}: brak kluczy GPG – weryfikacja niemożliwa!")
830 + print(f" Ustaw PAG_INSECURE=1 aby pominąć (niezalecane)")
831 + os.remove(cache_path)
832 + return False
833 +
834 + sig_path = cache_path + ".sig"
835 + # Podpisy generowane jako .asc (armored) – próbuj .asc, potem .sig
836 + sig_data = None
837 + sig_ext = ""
838 + for ext in (".asc", ".sig"):
839 + try:
840 + req = Request(f"{repo_url}/repo.json{ext}", headers={"User-Agent":"pag/3.0"})
841 + with urlopen(req, timeout=15) as resp:
842 + sig_data = resp.read()
843 + sig_ext = ext
844 + break
845 + except Exception:
846 + continue
847 + if not sig_data:
848 + if insecure:
849 + return True # tryb insecure – akceptuj bez podpisu
850 + print(f" ❌ {repo_url}: NIE MOŻNA POBRAĆ PODPISU repo.json.asc/.sig!")
851 + print(f" Ustaw PAG_INSECURE=1 aby pominąć (niezalecane)")
852 + os.remove(cache_path)
853 + return False
854 + sig_path = cache_path + sig_ext
855 + with open(sig_path, "wb") as f:
856 + f.write(sig_data)
857 +
858 + result = _gpg_run("--no-default-keyring", "--keyring", GPG_KEYRING,
859 + "--verify", sig_path, cache_path,
860 + capture_output=True, text=True, timeout=30)
861 + if result.returncode != 0:
862 + if insecure:
863 + print(f" ⚠ {repo_url}: nieprawidłowy podpis GPG (PAG_INSECURE – ignoruję)")
864 + return True
865 + os.remove(cache_path)
866 + print(f" ❌ {repo_url}: NIEPRAWIDŁOWY PODPIS GPG indeksu repozytorium!")
867 + return False
868 +
869 + return True
870 +
871 +def fetch_all_packages(force=False):
872 + all_pkgs = {}
873 + for repo_url in get_repos():
874 + pkgs = fetch_repo_index(repo_url, force)
875 + if pkgs:
876 + for pdata in pkgs:
877 + name = pdata.get("name", pdata.get("filename","?").split("-")[0])
878 + pkg = PackageInfo(pdata, repo_url)
879 + if name not in all_pkgs or _version_newer(pkg.version, all_pkgs[name].version):
880 + all_pkgs[name] = pkg
881 + return all_pkgs
882 +
883 +# =============================================================================
884 +# GPG
885 +# =============================================================================
886 +
887 +def _verify_pkg_gpg(pkg_path):
888 + """Weryfikuje podpis GPG pakietu.
889 +
890 + FAIL-CLOSED: brak podpisu = odrzucenie (chyba że PAG_INSECURE=1).
891 + Zwraca (passed: bool, message: str).
892 + """
893 + insecure = os.environ.get("PAG_INSECURE", "") == "1"
894 + sig_path = pkg_path + ".sig"
895 + if not os.path.exists(sig_path) and os.path.exists(pkg_path + ".asc"):
896 + sig_path = pkg_path + ".asc"
897 +
898 + if not os.path.exists(sig_path):
899 + if insecure:
900 + return True, "(no signature – PAG_INSECURE)"
901 + return False, "BRAK PODPISU – pakiet odrzucony (ustaw PAG_INSECURE=1 aby pominąć)"
902 +
903 + result = _gpg_run("--no-default-keyring", "--keyring", GPG_KEYRING,
904 + "--verify", sig_path, pkg_path,
905 + capture_output=True, text=True, timeout=30)
906 + if result.returncode != 0:
907 + if insecure:
908 + return True, f"(invalid signature – PAG_INSECURE: {result.stderr[:80]})"
909 + return False, f"NIEPRAWIDŁOWY PODPIS GPG: {result.stderr[:80]}"
910 +
911 + return True, "GPG verified"
912 +
913 +def cmd_key_add(source):
914 + ensure_dirs()
915 + if source.startswith("http"):
916 + try:
917 + with urlopen(Request(source, headers={"User-Agent":"pag/3.0"}), timeout=30) as resp:
918 + keydata = resp.read()
919 + with tempfile.NamedTemporaryFile(delete=False, suffix=".gpg") as tmp:
920 + tmp.write(keydata); tmp.flush()
921 + _gpg_run("--no-default-keyring", "--keyring", GPG_KEYRING,
922 + "--import", tmp.name, capture_output=True, timeout=30)
923 + os.unlink(tmp.name)
924 + except Exception as e:
925 + print(f"❌ Download error: {e}"); return 1
926 + else:
927 + _gpg_run("--no-default-keyring", "--keyring", GPG_KEYRING,
928 + "--import", source, capture_output=True, timeout=30)
929 + print(f"✅ {_('key_imported')}")
930 +
931 +def cmd_key_list():
932 + if not os.path.exists(GPG_KEYRING):
933 + print(_("no_keys")); return
934 + result = _gpg_run("--no-default-keyring", "--keyring", GPG_KEYRING,
935 + "--list-keys", "--keyid-format", "LONG",
936 + capture_output=True, text=True, timeout=30)
937 + print(result.stdout or _("no_keys"))
938 +
939 +def cmd_key_remove(key_id):
940 + _gpg_run("--no-default-keyring", "--keyring", GPG_KEYRING,
941 + "--batch", "--yes", "--delete-key", key_id,
942 + capture_output=True, timeout=30)
943 + print(f"✅ {_('key_removed', key_id)}")
944 +
945 +# =============================================================================
946 +# ATOMOWA INSTALACJA (STAGING)
947 +# =============================================================================
948 +
949 +def _safe_rename(src: str, dst: str) -> bool:
950 + """
951 + Atomowe przeniesienie pliku. Jeśli src i dst są na różnych
952 + systemach plików (EXDEV), kopiuje + usuwa źródło.
953 + """
954 + try:
955 + os.rename(src, dst)
956 + return True
957 + except OSError as e:
958 + if e.errno == 18: # EXDEV – cross-device link
959 + shutil.copy2(src, dst)
960 + os.remove(src)
961 + return True
962 + raise
963 +
964 +
965 +def _install_file(src: str, rel: str, data_staging: str, sums: dict,
966 + staging: str, journal: list, installed_files: list,
967 + deploy_dir: str = "") -> bool:
968 + """
969 + Instaluje pojedynczy plik (zwykły lub symlink).
970 + Obsługuje: cross-device rename, symlinki, weryfikację SHA256.
971 +
972 + Jeśli deploy_dir jest podany (tryb immutable), pliki systemowe trafiają
973 + do deploymentu, a współdzielone (/var, /etc, ...) bezpośrednio do /.
974 + """
975 + # W trybie immutable: pliki współdzielone idą do /, reszta do deploymentu
976 + if deploy_dir and _is_shared_path("/" + rel):
977 + dst_root = PAG_ROOT
978 + elif deploy_dir:
979 + dst_root = deploy_dir
980 + else:
981 + dst_root = PAG_ROOT
982 +
983 + dst = os.path.join(dst_root, rel)
984 +
985 + # --- SYMLINK ---
986 + if os.path.islink(src):
987 + link_target = os.readlink(src)
988 + # Weryfikuj sums.json dla symlinka (hash ścieżki docelowej)
989 + expected = sums.get("/" + rel, "")
990 + if expected:
991 + link_hash = hashlib.sha256(link_target.encode()).hexdigest()
992 + if expected and link_hash != expected:
993 + return False
994 +
995 + os.makedirs(os.path.dirname(dst), exist_ok=True)
996 + # Jeśli docelowy symlink już istnieje, usuń go
997 + if os.path.islink(dst) or os.path.exists(dst):
998 + os.remove(dst)
999 + os.symlink(link_target, dst)
1000 + journal.append(("symlink", "", dst))
1001 + installed_files.append({
1002 + "path": "/" + rel,
1003 + "sha256": hashlib.sha256(link_target.encode()).hexdigest(),
1004 + "size": len(link_target),
1005 + "is_symlink": True,
1006 + "symlink_target": link_target,
1007 + })
1008 + return True
1009 +
1010 + # --- ZWYKŁY PLIK ---
1011 + # Oblicz SHA256
1012 + try:
1013 + file_sha = _sha256_file(src)
1014 + except Exception:
1015 + file_sha = ""
1016 +
1017 + # Weryfikuj sums.json
1018 + expected = sums.get("/" + rel, "")
1019 + if expected and file_sha and file_sha != expected:
1020 + return False
1021 +
1022 + # Utwórz katalog docelowy
1023 + os.makedirs(os.path.dirname(dst), exist_ok=True)
1024 +
1025 + # Atomowe przeniesienie (z fallbackiem dla cross-device).
1026 + # Zachowuje bity uprawnień (SUID/SGID/sticky) – NIE używamy filter='data'.
1027 + _safe_rename(src, dst)
1028 +
1029 + # Wymuś właściciela root:root. UWAGA: os.chown() NIE czyści bitów SUID/SGID.
1030 + try:
1031 + os.chown(dst, 0, 0)
1032 + except (OSError, PermissionError):
1033 + # Na niektórych systemach plików (tmpfs, fat) chown może się nie powieść
1034 + pass
1035 +
1036 + journal.append(("file", src, dst))
1037 + installed_files.append({
1038 + "path": "/" + rel,
1039 + "sha256": file_sha,
1040 + "size": os.path.getsize(dst),
1041 + "is_symlink": False,
1042 + })
1043 + return True
1044 +
1045 +
1046 +def _atomic_install(pkg_path: str, pkg: PackageInfo, deploy_dir: str = "") -> Tuple[bool, List[dict]]:
1047 + """
1048 + Rozpakowuje do staging area, potem atomowo przenosi pliki.
1049 + Jeśli deploy_dir podany – instaluje do deploymentu (tryb immutable).
1050 + Zwraca (success, [lista plików z SHA256]).
1051 + """
1052 + staging = tempfile.mkdtemp(dir=STAGING_DIR, prefix=f".staging-{pkg.name}-")
1053 + journal = []
1054 + installed_files = []
1055 +
1056 + try:
1057 + # Rozpakuj .pkg.tar.xz → staging (bezpieczne – ochrona Directory Traversal)
1058 + with tarfile.open(pkg_path, "r:xz") as tf:
1059 + _safe_extractall(tf, staging)
1060 +
1061 + data_tar = os.path.join(staging, "data.tar.xz")
1062 + if not os.path.exists(data_tar):
1063 + shutil.rmtree(staging, ignore_errors=True)
1064 + return False, []
1065 +
1066 + # Rozpakuj data.tar.xz → staging/data (bezpieczne – ochrona Directory Traversal)
1067 + data_staging = os.path.join(staging, "data")
1068 + os.makedirs(data_staging, exist_ok=True)
1069 + with tarfile.open(data_tar, "r:xz") as tf:
1070 + _safe_extractall(tf, data_staging)
1071 +
1072 + # Wczytaj sums.json
1073 + sums_path = os.path.join(data_staging, "sums.json")
1074 + sums = json.load(open(sums_path)) if os.path.exists(sums_path) else {}
1075 +
1076 + # Przenieś pliki: staging/data/* → /
1077 + for root, dirs, files in os.walk(data_staging):
1078 + for fname in files:
1079 + if fname == "sums.json":
1080 + continue
1081 + src = os.path.join(root, fname)
1082 + rel = os.path.relpath(src, data_staging)
1083 +
1084 + ok = _install_file(src, rel, data_staging, sums,
1085 + staging, journal, installed_files, deploy_dir)
1086 + if not ok:
1087 + # Cofnij wszystkie operacje
1088 + _rollback_journal(journal, staging)
1089 + return False, []
1090 +
1091 + # Uruchom hooki post-install
1092 + hooks_dir = os.path.join(staging, "hooks")
1093 + _run_hook(hooks_dir, "post-install", pkg)
1094 +
1095 + # Zapisz do SQLite
1096 + _db_record_files(pkg.name, installed_files)
1097 +
1098 + shutil.rmtree(staging, ignore_errors=True)
1099 + return True, installed_files
1100 +
1101 + except Exception as e:
1102 + _rollback_journal(journal, staging)
1103 + return False, []
1104 +
1105 +
1106 +def _rollback_journal(journal: list, staging_path: str):
1107 + """Cofa wszystkie operacje z journala (odwrotna kolejność)."""
1108 + for entry in reversed(journal):
1109 + op = entry[0]
1110 + if op == "file":
1111 + _, src, dst = entry
1112 + try:
1113 + if os.path.exists(dst) or os.path.islink(dst):
1114 + _safe_rename(dst, src)
1115 + except Exception:
1116 + pass
1117 + elif op == "symlink":
1118 + _, _, dst = entry
1119 + try:
1120 + if os.path.islink(dst) or os.path.exists(dst):
1121 + os.remove(dst)
1122 + except Exception:
1123 + pass
1124 + shutil.rmtree(staging_path, ignore_errors=True)
1125 +
1126 +# =============================================================================
1127 +# BEZPIECZNE USUWANIE
1128 +# =============================================================================
1129 +
1130 +def _safe_remove_files(pkg_name: str, installed_db: dict) -> Tuple[int, List[str]]:
1131 + """
1132 + Usuwa pliki pakietu, ale tylko jeśli NIE są współdzielone z innym pakietem.
1133 + Zwraca (liczba usuniętych, [lista usuniętych ścieżek]).
1134 + """
1135 + pkg_files = _db_get_package_files(pkg_name)
1136 + removed = []
1137 + skipped_shared = []
1138 +
1139 + for fpath in pkg_files:
1140 + owners = _db_get_file_owners(fpath)
1141 + # Sprawdź czy inny ZAINSTALOWANY pakiet też jest właścicielem
1142 + other_owners = [o for o in owners if o != pkg_name and o in installed_db]
1143 +
1144 + if other_owners:
1145 + # Plik współdzielony – tylko usuń wpis w DB, nie kasuj pliku
1146 + skipped_shared.append(fpath)
1147 + continue
1148 +
1149 + full = os.path.join(PAG_ROOT, fpath.lstrip("/"))
1150 + if os.path.isfile(full) or os.path.islink(full):
1151 + os.remove(full)
1152 + removed.append(fpath)
1153 +
1154 + # Usuń puste katalogi (od najgłębszych)
1155 + dirs = set()
1156 + for fpath in removed + skipped_shared:
1157 + parent = os.path.dirname(fpath)
1158 + while parent and parent != "/":
1159 + dirs.add(parent)
1160 + parent = os.path.dirname(parent)
1161 +
1162 + for d in sorted(dirs, key=len, reverse=True):
1163 + full_d = os.path.join(PAG_ROOT, d.lstrip("/"))
1164 + if os.path.isdir(full_d):
1165 + try:
1166 + os.rmdir(full_d)
1167 + except OSError:
1168 + pass # nie jest pusty – OK
1169 +
1170 + # Usuń z SQLite
1171 + _db_remove_package_files(pkg_name)
1172 +
1173 + if skipped_shared:
1174 + print(f" ⚠ {len(skipped_shared)} plików współdzielonych zachowanych")
1175 +
1176 + return len(removed) + len(skipped_shared), removed
1177 +
1178 +# =============================================================================
1179 +# HOOKI
1180 +# =============================================================================
1181 +
1182 +def _run_hook(hooks_dir: str, hook_name: str, pkg: PackageInfo):
1183 + """Uruchamia skrypt hooka jeśli istnieje."""
1184 + hook_path = os.path.join(hooks_dir, hook_name)
1185 + if not os.path.exists(hook_path):
1186 + return
1187 + os.chmod(hook_path, 0o755)
1188 + env = os.environ.copy()
1189 + env["PKG_NAME"] = pkg.name
1190 + env["PKG_VERSION"] = pkg.version
1191 + env["PKG_ACTION"] = hook_name
1192 + try:
1193 + subprocess.run([hook_path], env=env, timeout=60, check=False)
1194 + except Exception:
1195 + pass
1196 +
1197 +# =============================================================================
1198 +# TRANSAKCJE I ROLLBACK
1199 +# =============================================================================
1200 +
1201 +def _record_transaction(action, packages, success, snapshot, file_journal=None):
1202 + history = load_json(HISTORY_FILE) if os.path.exists(HISTORY_FILE) else []
1203 + entry = {
1204 + "action": action, "packages": packages, "success": success,
1205 + "timestamp": datetime.now().isoformat(),
1206 + "snapshot": snapshot,
1207 + "file_journal": file_journal, # lista plików do wycofania
1208 + }
1209 + history.append(entry)
1210 + if len(history) > 50:
1211 + history = history[-50:]
1212 + save_json(HISTORY_FILE, history)
1213 +
1214 +def cmd_history():
1215 + if not os.path.exists(HISTORY_FILE):
1216 + print(_("no_history")); return
1217 + history = load_json(HISTORY_FILE)
1218 + if not history:
1219 + print(_("no_history")); return
1220 + print(f"Ostatnie transakcje ({len(history)}):")
1221 + for i, e in enumerate(reversed(history), 1):
1222 + icon = "✅" if e["success"] else "❌"
1223 + pkgs = ", ".join(e["packages"][:5])
1224 + if len(e["packages"]) > 5: pkgs += f" (+{len(e['packages'])-5})"
1225 + print(f" {i}. {icon} {e['action']}: {pkgs}")
1226 + print(f" {e['timestamp']}")
1227 +
1228 +def cmd_rollback():
1229 + if not os.path.exists(HISTORY_FILE):
1230 + print(_("no_history")); return 1
1231 + history = load_json(HISTORY_FILE)
1232 + if not history:
1233 + print(_("no_history")); return 1
1234 +
1235 + last = None
1236 + for e in reversed(history):
1237 + if e["success"] and e.get("snapshot"):
1238 + last = e; break
1239 +
1240 + if not last:
1241 + print("❌ No snapshot to restore."); return 1
1242 +
1243 + print(f"⏪ Rolling back: {last['action']} ({last['timestamp']})")
1244 + print(f" Packages: {', '.join(last['packages'][:10])}")
1245 +
1246 + ans = input(_("continue_q")).strip().lower()
1247 + if ans and ans not in ("t","y"):
1248 + return 0
1249 +
1250 + # Przywróć installed.json
1251 + save_json(INSTALLED_DB, last["snapshot"])
1252 +
1253 + # Wycofaj fizyczne pliki (jeśli zapisano journal)
1254 + file_journal = last.get("file_journal", [])
1255 + if file_journal:
1256 + for fpath in reversed(file_journal):
1257 + full = os.path.join(PAG_ROOT, fpath.lstrip("/"))
1258 + if os.path.exists(full) or os.path.islink(full):
1259 + os.remove(full)
1260 + print(f" {_('rollback_files', len(file_journal))}")
1261 +
1262 + print(f"✅ {_('rollback_restored')}")
1263 + _record_transaction("rollback", last["packages"], True, None)
1264 + return 0
1265 +
1266 +# =============================================================================
1267 +# INSTALACJA
1268 +# =============================================================================
1269 +
1270 +def cmd_install(package_names, as_dep=False):
1271 + ensure_dirs()
1272 + installed_db = load_json(INSTALLED_DB)
1273 + world = load_world()
1274 + pinned = load_json(PINNED_FILE)
1275 + repo_pkgs = fetch_all_packages()
1276 +
1277 + if not repo_pkgs:
1278 + print(f"❌ {_('no_index')}"); return 1
1279 +
1280 + for name in list(package_names):
1281 + if name in pinned:
1282 + print(f"⚠ {name} {_('pinned_to')} {pinned[name]} – skipping")
1283 + package_names.remove(name)
1284 +
1285 + to_install, missing_deps = _resolve_deps(package_names, repo_pkgs, installed_db)
1286 +
1287 + if not to_install and not missing_deps:
1288 + print(f"✅ {_('all_installed')}"); return 0
1289 +
1290 + # ── WERYFIKACJA ZALEŻNOŚCI ──────────────────────────────────────────
1291 + fatal_missing = _verify_dependencies(to_install, repo_pkgs, installed_db)
1292 +
1293 + if fatal_missing > 0:
1294 + print(f"❌ Nie można kontynuować – {fatal_missing} brakujących zależności.")
1295 + print(f" Zainstaluj brakujące pakiety lub dodaj repozytoria.")
1296 + return 1
1297 +
1298 + if not to_install:
1299 + print(f"✅ {_('all_installed')}"); return 0
1300 +
1301 + total_size = sum(repo_pkgs[n].size_bytes for n in to_install if n in repo_pkgs)
1302 + print(f"\n📦 {_('to_install', len(to_install), total_size/1048576)}")
1303 + for name in to_install:
1304 + p = repo_pkgs.get(name)
1305 + if p:
1306 + marker = f" [{_('new')}]" if name not in installed_db else ""
1307 + print(f" {name}-{p.version}{marker}")
1308 +
1309 + if not as_dep:
1310 + ans = input(_("continue_q")).strip().lower()
1311 + if ans and ans not in ("t","y"):
1312 + print(_("cancelled")); return 0
1313 +
1314 + snapshot = json.loads(json.dumps(installed_db))
1315 + all_installed_files = []
1316 + failed = []
1317 +
1318 + # --- Dziennik transakcji (dla pełnej atomowości) ---
1319 + # Jeśli którykolwiek pakiet zawiedzie, cofamy WSZYSTKIE zainstalowane
1320 + # w tej transakcji przez _rollback_transaction().
1321 + transaction_journal: List[Tuple[str, str, str]] = [] # (op, src, dst)
1322 +
1323 + # --- Tryb immutable: utwórz nowy deployment ---
1324 + immutable = os.environ.get("PAG_IMMUTABLE", "") == "1"
1325 + deploy_dir = ""
1326 + deploy_id = ""
1327 + if immutable:
1328 + print(f"\n 🏗️ Tworzenie nowego deploymentu...")
1329 + deploy_dir, deploy_id = _create_deployment(to_install, "install")
1330 + target_root = deploy_dir
1331 + else:
1332 + target_root = ""
1333 +
1334 + # --- Faza 1: Równoległe pobieranie wszystkich pakietów ---
1335 + to_download = [repo_pkgs[name] for name in to_install if name in repo_pkgs]
1336 + if len(to_download) > 1:
1337 + print(f"\n ⏬ Pobieranie {len(to_download)} pakietów równolegle...")
1338 + downloaded = _download_packages_parallel(to_download)
1339 + else:
1340 + downloaded = {}
1341 +
1342 + # --- Faza 2: Instalacja z paskiem postępu ---
1343 + t0 = time.time()
1344 +
1345 + for name in to_install:
1346 + pkg = repo_pkgs.get(name)
1347 + if not pkg:
1348 + print(f" ❌ {name}: {_('not_found')}")
1349 + failed.append(name)
1350 + break
1351 +
1352 + # Pasek postępu na stderr (nie koliduje z download barem)
1353 + idx = len(all_installed_files) + 1
1354 + pct = (idx - 1) / len(to_install) * 100
1355 + fl = int(25 * pct / 100)
1356 + pbar = "█" * fl + "░" * (25 - fl)
1357 + elapsed = time.time() - t0
1358 + if idx > 1 and elapsed > 0:
1359 + avg = elapsed / (idx - 1)
1360 + remaining = avg * (len(to_install) - idx + 1)
1361 + if remaining < 60:
1362 + eta_s = f" ~{remaining:.0f}s"
1363 + else:
1364 + eta_s = f" ~{remaining/60:.1f}m"
1365 + else:
1366 + eta_s = ""
1367 + status = f" [{pbar}] {idx}/{len(to_install)} ({pct:.0f}%){eta_s}"
1368 + print(status, file=sys.stderr, flush=True)
1369 +
1370 + print(f" ↓ {name}-{pkg.version} ...", end=" ", flush=True)
1371 +
1372 + # Pobierz (z cache fazy 1 lub bezpośrednio)
1373 + pkg_path = downloaded.get(name) if name in downloaded else _download_pkg(pkg)
1374 + if not pkg_path:
1375 + print(f"❌ {_('download_fail')}")
1376 + failed.append(name)
1377 + break # przerwij transakcję
1378 +
1379 + # GPG
1380 + gpg_ok, gpg_msg = _verify_pkg_gpg(pkg_path)
1381 + if not gpg_ok:
1382 + print(f"❌ {_('gpg_fail')}: {gpg_msg[:60]}")
1383 + failed.append(name)
1384 + break # PRZERWIJ – niezaufany pakiet
1385 +
1386 + # SHA256 całego pakietu
1387 + if pkg.sha256 and _sha256_file(pkg_path) != pkg.sha256:
1388 + print(f"❌ {_('sha256_mismatch')}")
1389 + failed.append(name)
1390 + break # PRZERWIJ – uszkodzony pakiet
1391 +
1392 + # Atomowa instalacja
1393 + ok, files = _atomic_install(pkg_path, pkg, deploy_dir)
1394 + if ok:
1395 + installed_db[name] = {
1396 + "version": pkg.version, "description": pkg.description,
1397 + "dependencies": pkg.dependencies, "size_bytes": pkg.size_bytes,
1398 + "sha256": pkg.sha256, "installed_at": datetime.now().isoformat(),
1399 + "repo": pkg.repo_url,
1400 + }
1401 + if not as_dep and name in package_names:
1402 + world.add(name)
1403 + print("✅")
1404 + all_installed_files.extend(f["path"] for f in files)
1405 +
1406 + # Po instalacji kernela – przebuduj initramfs
1407 + if _is_kernel_package(name):
1408 + _rebuild_initramfs(deploy_dir)
1409 + else:
1410 + print("❌")
1411 + failed.append(name)
1412 + break # PRZERWIJ – błąd instalacji
1413 +
1414 + # --- Rollback całej transakcji jeśli cokolwiek zawiodło ---
1415 + if failed:
1416 + print(f"\n ↩ Cofanie transakcji ({len(failed)} błędów)...")
1417 + _rollback_transaction(installed_db, snapshot, all_installed_files,
1418 + deploy_dir, immutable)
1419 + _record_transaction("install", to_install, False, snapshot)
1420 + return 1
1421 +
1422 + save_json(INSTALLED_DB, installed_db)
1423 + save_world(world)
1424 + _record_transaction("install", to_install, True, snapshot,
1425 + file_journal=all_installed_files)
1426 +
1427 + # --- Tryb immutable: przełącz na nowy deployment ---
1428 + if immutable and not failed:
1429 + print(f"\n 🔄 Przełączanie na deployment {deploy_id}...")
1430 + _switch_deployment(deploy_dir)
1431 + print(f" ✅ Aktywny deployment: {deploy_id}")
1432 + _update_grub_config()
1433 + cmd_deploy_cleanup(keep=5) # Zostawia 5 najnowszych deploymentów
1434 + print(f" 💡 Restart wymagany do przeładowania systemu.")
1435 +
1436 + print(f"\n✅ {_('installed', len(to_install))}")
1437 + return 0
1438 +
1439 +
1440 +def _rollback_transaction(installed_db: dict, snapshot: dict,
1441 + installed_files: List[str],
1442 + deploy_dir: str, is_immutable: bool):
1443 + """
1444 + Cofa WSZYSTKIE pakiety zainstalowane w bieżącej transakcji.
1445 + Przywraca installed_db do stanu sprzed transakcji.
1446 + Usuwa fizyczne pliki z systemu (lub deploymentu w trybie immutable).
1447 + """
1448 + # Przywróć installed_db
1449 + installed_db.clear()
1450 + installed_db.update(snapshot)
1451 +
1452 + # Usuń fizyczne pliki (odwrotna kolejność)
1453 + root = deploy_dir if is_immutable else PAG_ROOT
1454 + for fpath in reversed(installed_files):
1455 + full = os.path.join(root, fpath.lstrip("/"))
1456 + if os.path.isfile(full) or os.path.islink(full):
1457 + try:
1458 + os.remove(full)
1459 + except OSError:
1460 + pass
1461 +
1462 + # Wyczyść puste katalogi
1463 + dirs_to_check = set()
1464 + for fpath in installed_files:
1465 + parent = os.path.dirname(fpath)
1466 + while parent and parent != "/":
1467 + dirs_to_check.add(parent)
1468 + parent = os.path.dirname(parent)
1469 + for d in sorted(dirs_to_check, key=len, reverse=True):
1470 + full_d = os.path.join(root, d.lstrip("/"))
1471 + if os.path.isdir(full_d):
1472 + try:
1473 + os.rmdir(full_d)
1474 + except OSError:
1475 + pass
1476 +
1477 + # W trybie immutable: usuń nieudany deployment
1478 + if is_immutable and deploy_dir:
1479 + shutil.rmtree(deploy_dir, ignore_errors=True)
1480 +
1481 + save_json(INSTALLED_DB, snapshot)
1482 +
1483 +
1484 +# =============================================================================
1485 +# USUWANIE
1486 +# =============================================================================
1487 +
1488 +def cmd_remove(package_names):
1489 + installed_db = load_json(INSTALLED_DB)
1490 + world = load_world()
1491 + snapshot = json.loads(json.dumps(installed_db))
1492 + removed = []
1493 +
1494 + total = len(package_names)
1495 + for i, name in enumerate(package_names, 1):
1496 + if name not in installed_db:
1497 + print(f" ⚠ {name}: not installed"); continue
1498 +
1499 + # Pasek postępu
1500 + pct = (i - 1) / total * 100
1501 + filled = int(25 * pct / 100)
1502 + print(f" 🗑 [{'█' * filled + '░' * (25 - filled)}] {i}/{total} ({pct:.0f}%) ", end="\r", file=sys.stderr, flush=True)
1503 +
1504 + print(f"🗑 {name}-{installed_db[name]['version']} ...", end=" ", flush=True)
1505 +
1506 + # Pre-remove hook (jeśli dostępny w staging)
1507 + _run_hook_for_installed(name, "pre-remove")
1508 +
1509 + count, _ = _safe_remove_files(name, installed_db)
1510 + del installed_db[name]
1511 + world.discard(name)
1512 + removed.append(name)
1513 + print(f"✅ ({count} files)")
1514 +
1515 + save_json(INSTALLED_DB, installed_db)
1516 + save_world(world)
1517 + _record_transaction("remove", removed, True, snapshot)
1518 +
1519 + print(file=sys.stderr) # wyczyść linię paska postępu
1520 +
1521 + if not removed: return 0
1522 + print(f"\n✅ Removed {len(removed)}.")
1523 +
1524 + orphans = _find_orphans(installed_db, world)
1525 + if orphans:
1526 + print(f"\n💡 {_('orphans_found', len(orphans), ', '.join(sorted(orphans)[:10]))}")
1527 + print(" 'pag remove-orphans' to clean up.")
1528 + return 0
1529 +
1530 +def _run_hook_for_installed(pkg_name, hook_name):
1531 + """Próbuje uruchomić hook z katalogu pakietu (jeśli został zapisany)."""
1532 + hook_dir = os.path.join(PAG_DB, "hooks", pkg_name)
1533 + if os.path.isdir(hook_dir):
1534 + _run_hook(hook_dir, hook_name, PackageInfo({"name": pkg_name}))
1535 +
1536 +# =============================================================================
1537 +# UPDATE / UPGRADE / LIST / SEARCH / INFO / VERIFY
1538 +# =============================================================================
1539 +
1540 +def cmd_update():
1541 + force = "--force" in sys.argv
1542 + print(f"🔄 {'Forced refresh' if force else 'Updating'} indexes...")
1543 + for repo_url in get_repos():
1544 + pkgs = fetch_repo_index(repo_url, force=force)
1545 + cp = _repo_cache_path(repo_url)
1546 + has_sig = os.path.exists(cp + ".sig")
1547 + print(f" {'✅' if pkgs is not None else '❌'} {repo_url}: {len(pkgs or [])} pkgs {'🔐' if has_sig else '⚠'}")
1548 + total = 0
1549 + for r in get_repos():
1550 + cp = _repo_cache_path(r)
1551 + if os.path.exists(cp):
1552 + try:
1553 + total += len(json.load(open(cp)).get("packages", []))
1554 + except Exception:
1555 + pass
1556 + print(f"✅ {_('updated_done', total)}")
1557 +
1558 +def cmd_upgrade():
1559 + ensure_dirs()
1560 + installed = load_json(INSTALLED_DB)
1561 + pinned = load_json(PINNED_FILE)
1562 + repo = fetch_all_packages()
1563 + upgrades = [n for n, i in installed.items()
1564 + if n not in pinned and (rp := repo.get(n)) and _version_newer(rp.version, i["version"])]
1565 + if not upgrades:
1566 + print(f"✅ {_('all_up_to_date')}"); return 0
1567 + print(f"📦 {_('upgrading', len(upgrades))}")
1568 + for n in upgrades:
1569 + print(f" {n}: {installed[n]['version']} → {repo[n].version}")
1570 + ans = input(_("continue_q")).strip().lower()
1571 + if ans and ans not in ("t","y"): return 0
1572 + return cmd_install(upgrades)
1573 +
1574 +def cmd_list(installed_only=False):
1575 + if installed_only:
1576 + db = load_json(INSTALLED_DB)
1577 + pinned = load_json(PINNED_FILE)
1578 + if not db: print("No packages installed."); return
1579 + print(f"Installed ({len(db)}):")
1580 + for n, i in sorted(db.items()):
1581 + pin = " 📌" if n in pinned else ""
1582 + print(f" {n}-{i['version']}{pin} – {i.get('description','')}")
1583 + else:
1584 + pkgs = fetch_all_packages()
1585 + installed = load_json(INSTALLED_DB)
1586 + pinned = load_json(PINNED_FILE)
1587 + print(f"Available ({len(pkgs)}):")
1588 + for n, p in sorted(pkgs.items()):
1589 + m = "✓" if n in installed else " "
1590 + extra = f" [installed: {installed[n]['version']}]" if n in installed else ""
1591 + if n in pinned: extra += " 📌"
1592 + print(f" [{m}] {n}-{p.version} – {p.description}{extra}")
1593 +
1594 +def cmd_search(query):
1595 + pkgs = fetch_all_packages()
1596 + results = [(n,p) for n,p in pkgs.items() if query.lower() in n.lower() or query.lower() in p.description.lower()]
1597 + if not results: print(f"❌ No results for: {query}"); return
1598 + installed = load_json(INSTALLED_DB)
1599 + print(f"Results for '{query}' ({len(results)}):")
1600 + for n,p in sorted(results):
1601 + print(f" [{'✓' if n in installed else ' '}] {n}-{p.version}")
1602 + print(f" {p.description}")
1603 +
1604 +
1605 +def _smart_search(query: str) -> int:
1606 + """
1607 + Inteligentne wyszukiwanie: repo PaganOS + Flathub.
1608 + Uruchamiane gdy użytkownik wpisze `pag <nazwa>` zamiast `pag install <nazwa>`.
1609 + Pokazuje dostępne źródła i sugeruje komendy instalacji.
1610 + """
1611 + # 1. Repo PaganOS
1612 + try:
1613 + pkgs = fetch_all_packages()
1614 + except Exception:
1615 + pkgs = {}
1616 + repo_lower = [(n, p) for n, p in pkgs.items()
1617 + if query.lower() in n.lower() or query.lower() in p.description.lower()]
1618 +
1619 + # 2. Flathub (jeśli dostępny)
1620 + flat = _flatpak_search_raw(query) if _check_flatpak() else []
1621 +
1622 + if not repo_lower and not flat:
1623 + print(f"\n ❌ '{query}' — nie znaleziono.")
1624 + print(f" Repo PaganOS: pag search {query}")
1625 + if _check_flatpak():
1626 + print(f" Flathub: pag flatpak search {query}")
1627 + print(f" Dodaj repo: pag repo-add <url>")
1628 + return 1
1629 +
1630 + installed = load_json(INSTALLED_DB)
1631 +
1632 + # ── Repo PaganOS ──
1633 + if repo_lower:
1634 + exact = [(n, p) for n, p in repo_lower if n.lower() == query.lower()]
1635 + show = (exact or repo_lower)[:6]
1636 + print(f"\n 📦 PaganOS — '{query}':")
1637 + for n, p in sorted(show):
1638 + mark = "✓" if n in installed else " "
1639 + desc = p.description[:70] if len(p.description) > 75 else p.description
1640 + print(f" [{mark}] {n}-{p.version}")
1641 + if desc:
1642 + print(f" {desc}")
1643 + if len(repo_lower) > 6:
1644 + print(f" ... i {len(repo_lower) - 6} więcej (pag search {query})")
1645 +
1646 + # ── Flathub ──
1647 + if flat:
1648 + print(f"\n 📦 Flathub — '{query}':")
1649 + for r in flat[:5]:
1650 + mark = "✓" if r.get("installed") else " "
1651 + name = r.get("name") or r.get("application", "?")
1652 + desc = (r.get("description") or "")[:65]
1653 + print(f" [{mark}] {name}")
1654 + if desc:
1655 + print(f" {desc}")
1656 + if len(flat) > 5:
1657 + print(f" ... i {len(flat) - 5} więcej (pag flatpak search {query})")
1658 +
1659 + # ── Sugestie instalacji ──
1660 + print()
1661 + if repo_lower:
1662 + best = sorted(repo_lower, key=lambda x: (x[0].lower() != query.lower(), -len(x[1].name if hasattr(x[1], 'name') else 0)))[0][0]
1663 + if best in installed:
1664 + print(f" ✓ {best} jest już zainstalowany ({installed[best]['version']})")
1665 + else:
1666 + print(f" 💡 sudo pag install {best}")
1667 + if flat:
1668 + best_fp = flat[0].get("application") or flat[0].get("name", query)
1669 + print(f" 💡 pag flatpak install {best_fp}")
1670 +
1671 + return 0
1672 +
1673 +def cmd_info(name):
1674 + pkgs = fetch_all_packages()
1675 + p = pkgs.get(name)
1676 + info = load_json(INSTALLED_DB).get(name)
1677 + if not p and not info: print(f"❌ '{name}' not found."); return 1
1678 + print(f"📦 {name}")
1679 + if p:
1680 + print(f" Version (repo): {p.version}")
1681 + print(f" Description: {p.description}")
1682 + print(f" Size: {p.size_bytes/1048576:.1f} MB")
1683 + print(f" SHA256: {p.sha256[:32]}...")
1684 + print(f" GPG: {p.gpg_fp or 'none'}")
1685 + print(f" Dependencies: {', '.join(p.dependencies) if p.dependencies else '(none)'}")
1686 + if info:
1687 + print(f" Installed: {info['version']} ({info.get('installed_at','?')})")
1688 +
1689 +def cmd_files(name):
1690 + if name not in load_json(INSTALLED_DB):
1691 + print(f"❌ '{name}' not installed."); return 1
1692 + files = _db_get_package_files(name)
1693 + print(f"Files in {name} ({len(files)}):")
1694 + for f in sorted(files): print(f" {f}")
1695 +
1696 +def cmd_verify(deep=False):
1697 + installed = load_json(INSTALLED_DB)
1698 + if not installed: print("Nothing to verify."); return
1699 + errors = []
1700 +
1701 + for name in installed:
1702 + for fpath in _db_get_package_files(name):
1703 + full = os.path.join(PAG_ROOT, fpath.lstrip("/"))
1704 + if not (os.path.exists(full) or os.path.islink(full)):
1705 + errors.append(f" ❌ {name}: missing {fpath}")
1706 + elif deep:
1707 + checksums = _db_get_all_file_checksums()
1708 + expected = checksums.get(fpath, "")
1709 + if expected:
1710 + actual = _sha256_file(full)
1711 + if actual != expected:
1712 + errors.append(f" ❌ {name}: SHA256 mismatch {fpath}")
1713 +
1714 + if errors:
1715 + print(f"❌ {_('verify_errors', len(errors))}")
1716 + for e in errors[:50]: print(e)
1717 + return 1
1718 + total = _db_count_files()
1719 + print(f"✅ {_('verify_ok', total)}")
1720 +
1721 +# =============================================================================
1722 +# PINNING / CLEAN / ORPHANS / REPO / FLATPAK
1723 +# =============================================================================
1724 +
1725 +def cmd_pin(name, version=""):
1726 + pinned = load_json(PINNED_FILE)
1727 + if version:
1728 + pinned[name] = version
1729 + else:
1730 + info = load_json(INSTALLED_DB).get(name, {})
1731 + pinned[name] = info.get("version", "?")
1732 + save_json(PINNED_FILE, pinned)
1733 + print(f"📌 {name} {_('pinned_to')} {pinned[name]}")
1734 +
1735 +def cmd_unpin(name):
1736 + pinned = load_json(PINNED_FILE)
1737 + if name in pinned:
1738 + del pinned[name]; save_json(PINNED_FILE, pinned)
1739 + print(f"🔓 {name} {_('unpinned')}")
1740 + else:
1741 + print(f"⚠ {name} {_('not_pinned')}")
1742 +
1743 +def cmd_pinned():
1744 + pinned = load_json(PINNED_FILE)
1745 + if not pinned: print(_("no_pinned")); return
1746 + print(_("pinned_list", len(pinned)))
1747 + for n,v in sorted(pinned.items()): print(f" 📌 {n} = {v}")
1748 +
1749 +def cmd_clean():
1750 + if os.path.isdir(PAG_CACHE):
1751 + count = size = 0
1752 + for f in os.listdir(PAG_CACHE):
1753 + fp = os.path.join(PAG_CACHE, f)
1754 + if os.path.isfile(fp):
1755 + size += os.path.getsize(fp); os.remove(fp); count += 1
1756 + print(f"✅ {_('cache_cleared', count, size/1048576)}")
1757 +
1758 +def cmd_remove_orphans():
1759 + installed = load_json(INSTALLED_DB)
1760 + world = load_world()
1761 + orphans = _find_orphans(installed, world)
1762 + if not orphans: print("✅ No orphans."); return
1763 + print(f"Orphans ({len(orphans)}):")
1764 + for n in sorted(orphans): print(f" {n}-{installed[n]['version']}")
1765 + ans = input(_("continue_q")).strip().lower()
1766 + if ans and ans not in ("t","y"): return
1767 + cmd_remove(list(orphans))
1768 +
1769 +
1770 +# =============================================================================
1771 +# PROVIDES – PAKIETY WIRTUALNE
1772 +# =============================================================================
1773 +
1774 +PROVIDES_MAP = {
1775 + "pkgconfig(glib-2.0)": "glib",
1776 + "pkgconfig(gobject-introspection-1.0)": "gobject-introspection",
1777 + "pkgconfig(gtk+-3.0)": "gtk",
1778 + "pkgconfig(gtk4)": "gtk",
1779 + "pkgconfig(zlib)": "zlib",
1780 + "pkgconfig(libffi)": "libffi",
1781 + "pkgconfig(expat)": "expat",
1782 + "pkgconfig(libsystemd)": "systemd",
1783 + "pkgconfig(dbus-1)": "dbus",
1784 + "pkgconfig(mount)": "util-linux",
1785 + "pkgconfig(blkid)": "util-linux",
1786 + "pkgconfig(libcap)": "libcap",
1787 + "pkgconfig(liblzma)": "xz",
1788 + "pkgconfig(libzstd)": "zstd",
1789 + "pkgconfig(bzip2)": "bzip2",
1790 + "pkgconfig(libcurl)": "curl",
1791 + "pkgconfig(openssl)": "openssl",
1792 + "pkgconfig(libpcre2-8)": "pcre2",
1793 + "pkgconfig(libxml-2.0)": "libxml2",
1794 + "pkgconfig(libxslt)": "libxslt",
1795 + "pkgconfig(freetype2)": "freetype",
1796 + "pkgconfig(fontconfig)": "fontconfig",
1797 + "pkgconfig(harfbuzz)": "harfbuzz",
1798 + "pkgconfig(cairo)": "cairo",
1799 + "pkgconfig(pango)": "pango",
1800 +}
1801 +
1802 +def _resolve_provides(name: str, repo: dict) -> str:
1803 + """Rozwija wirtualną nazwę pakietu do rzeczywistej nazwy z repo."""
1804 + if name in PROVIDES_MAP:
1805 + real = PROVIDES_MAP[name]
1806 + if real in repo:
1807 + return real
1808 + clean = name
1809 + if name.startswith("pkgconfig(") and ")" in name:
1810 + clean = name.split("(", 1)[1].rstrip(")")
1811 + elif name.startswith("pkgconfig32(") and ")" in name:
1812 + clean = name.split("(", 1)[1].rstrip(")")
1813 + if clean != name and clean in repo:
1814 + return clean
1815 + return name
1816 +
1817 +
1818 +def cmd_why(pkg_name: str):
1819 + """Pokazuje dlaczego pakiet jest zainstalowany."""
1820 + installed = load_json(INSTALLED_DB)
1821 + world = load_world()
1822 + if pkg_name not in installed:
1823 + print(f" {pkg_name}: {_('why_not_installed')}"); return 1
1824 + if pkg_name in world:
1825 + print(f" {pkg_name}-{installed[pkg_name]['version']}: {_('why_explicit')}")
1826 + return 0
1827 + parents = set()
1828 + for w in world:
1829 + _find_dep_path(w, pkg_name, installed, set(), [], parents)
1830 + if parents:
1831 + for pp in sorted(parents):
1832 + print(f" {pkg_name}: {_('why_dependency')} {' → '.join(pp)}")
1833 + else:
1834 + print(f" {pkg_name}: {_('why_dependency')} (unknown/orphan)")
1835 + return 0
1836 +
1837 +
1838 +def _find_dep_path(cur, target, installed, visited, path, results):
1839 + if cur in visited: return
1840 + visited.add(cur); path.append(cur)
1841 + if cur == target:
1842 + results.add(tuple(path))
1843 + else:
1844 + for dep in installed.get(cur, {}).get("dependencies", []):
1845 + _find_dep_path(dep, target, installed, visited, path, results)
1846 + path.pop(); visited.discard(cur)
1847 +
1848 +
1849 +def cmd_autoremove():
1850 + """Automatycznie usuwa osierocone zależności bez pytania."""
1851 + installed = load_json(INSTALLED_DB)
1852 + world = load_world()
1853 + orphans = _find_orphans(installed, world)
1854 + if not orphans: print(f"✅ {_('autoremove_none')}"); return 0
1855 + print(f"🗑 {_('orphans_found', len(orphans), ', '.join(sorted(orphans)[:10]))}")
1856 + return cmd_remove(list(orphans))
1857 +
1858 +
1859 +def cmd_download(package_names):
1860 + """Pobiera pakiety do cache bez instalowania."""
1861 + ensure_dirs()
1862 + repo = fetch_all_packages()
1863 + if not repo: print(f"❌ {_('no_index')}"); return 1
1864 + total_size = 0; downloaded = []
1865 + for name in package_names:
1866 + pkg = repo.get(name)
1867 + if not pkg:
1868 + print(f" ❌ {name}: {_('not_found')}"); continue
1869 + print(f" ↓ {name}-{pkg.version} ...", end=" ", flush=True)
1870 + path = _download_pkg(pkg)
1871 + if path:
1872 + total_size += os.path.getsize(path)
1873 + downloaded.append(name)
1874 + print(_c("green", "✓"))
1875 + else:
1876 + print(_c("red", "✗"))
1877 + if downloaded:
1878 + print(f"\n✅ {_('downloaded', len(downloaded), total_size/1048576)}")
1879 + return 0 if len(downloaded) == len(package_names) else 1
1880 +
1881 +
1882 +def cmd_stats():
1883 + """Wyświetla statystyki PAG."""
1884 + installed = load_json(INSTALLED_DB)
1885 + history = load_json(HISTORY_FILE) if os.path.exists(HISTORY_FILE) else []
1886 + total_size = sum(i.get("size_bytes", 0) for i in installed.values())
1887 + total_files = _db_count_files()
1888 + cache_size = sum(
1889 + os.path.getsize(os.path.join(PAG_CACHE, f))
1890 + for f in os.listdir(PAG_CACHE)
1891 + if os.path.isfile(os.path.join(PAG_CACHE, f))
1892 + ) if os.path.isdir(PAG_CACHE) else 0
1893 + last_update = "never"
1894 + for e in reversed(history):
1895 + if e.get("action") in ("install", "upgrade") and e.get("success"):
1896 + last_update = e.get("timestamp", "?")[:19]; break
1897 + print(f"\n {_c('bold', _('stats_title'))}")
1898 + print(f" {'─' * 40}")
1899 + print(f" {_('stats_packages'):<30} {len(installed)}")
1900 + print(f" {_('stats_files'):<30} {total_files}")
1901 + print(f" {_('stats_size'):<30} {total_size/1048576:.1f} MB")
1902 + print(f" {_('stats_cache'):<30} {cache_size/1048576:.1f} MB")
1903 + print(f" {_('stats_history'):<30} {len(history)}")
1904 + print(f" {_('stats_last_update'):<30} {last_update}")
1905 + by_size = sorted(installed.items(), key=lambda x: x[1].get("size_bytes", 0), reverse=True)[:5]
1906 + if by_size:
1907 + print(f"\n {_c('dim', 'Top 5:')}")
1908 + for n, i in by_size:
1909 + print(f" {n}-{i['version']} {i.get('size_bytes',0)/1048576:.1f} MB")
1910 + return 0
1911 +
1912 +
1913 +def cmd_repo_add(url):
1914 + repos = get_repos()
1915 + url = url.rstrip("/")
1916 + if url in repos: print(f"⚠ {_('repo_exists', url)}"); return
1917 + with open(REPOS_CONF, "a") as f: f.write(f"{url}\n")
1918 + print(f"✅ {_('repo_added', url)}")
1919 +
1920 +def cmd_repo_list():
1921 + for i, url in enumerate(get_repos(), 1): print(f" {i}. {url}")
1922 +
1923 +def _check_flatpak():
1924 + if not shutil.which("flatpak"):
1925 + print(f"❌ {_('flatpak_missing')}"); return False
1926 + r = subprocess.run(["flatpak","remotes"], capture_output=True, text=True)
1927 + if "flathub" not in r.stdout:
1928 + print(f"⚠ {_('flatpak_adding')}")
1929 + subprocess.run(["flatpak","remote-add","--if-not-exists","flathub",
1930 + "https://flathub.org/repo/flathub.flatpakrepo"], check=False)
1931 + return True
1932 +
1933 +def _flatpak_search_raw(query: str) -> List[dict]:
1934 + """Szuka we Flathub i zwraca listę wyników jako słowniki."""
1935 + if not _check_flatpak():
1936 + return []
1937 + try:
1938 + r = subprocess.run(
1939 + ["flatpak", "search", "--columns=name:description:application:version:branch:remotes", query],
1940 + capture_output=True, text=True, timeout=30
1941 + )
1942 + results = []
1943 + for line in r.stdout.strip().split("\n"):
1944 + parts = line.split("\t")
1945 + if len(parts) >= 3:
1946 + results.append({
1947 + "name": parts[0].strip(),
1948 + "description": parts[1].strip() if len(parts) > 1 else "",
1949 + "app_id": parts[2].strip() if len(parts) > 2 else "",
1950 + "version": parts[3].strip() if len(parts) > 3 else "",
1951 + "branch": parts[4].strip() if len(parts) > 4 else "stable",
1952 + "origin": parts[5].strip() if len(parts) > 5 else "flathub",
1953 + })
1954 + return results
1955 + except Exception as e:
1956 + print(f" ⚠ Błąd wyszukiwania: {e}", file=sys.stderr)
1957 + return []
1958 +
1959 +def _flatpak_find_best(query: str) -> Optional[dict]:
1960 + """
1961 + Szuka we Flathub i próbuje znaleźć najlepsze dopasowanie.
1962 + - Jeśli query dokładnie pasuje do app_id → zwraca od razu
1963 + - Jeśli query pasuje do nazwy → zwraca pierwsze
1964 + - Jeśli wiele wyników → wyświetla listę i pyta użytkownika
1965 + - Jeśli brak → zwraca None
1966 + """
1967 + results = _flatpak_search_raw(query)
1968 + if not results:
1969 + return None
1970 +
1971 + # Dokładne dopasowanie app_id
1972 + exact = [r for r in results if r["app_id"].lower() == query.lower()]
1973 + if exact:
1974 + return exact[0]
1975 +
1976 + # Dokładne dopasowanie nazwy
1977 + exact_name = [r for r in results if r["name"].lower() == query.lower()]
1978 + if exact_name:
1979 + return exact_name[0]
1980 +
1981 + # Jednoznaczne dopasowanie (tylko 1 wynik)
1982 + if len(results) == 1:
1983 + return results[0]
1984 +
1985 + # Wiele wyników – pokaż użytkownikowi
1986 + print(f"\n {_('flatpak_found', len(results))}")
1987 + for i, r in enumerate(results):
1988 + print(f" {i+1}. {_c('bold', r['name'])} ({r['app_id']})")
1989 + if r["version"]:
1990 + print(f" {_('flatpak_info_version')}: {r['version']}")
1991 + if r["description"]:
1992 + desc = r["description"][:80] + ("..." if len(r["description"]) > 80 else "")
1993 + print(f" {desc}")
1994 +
1995 + try:
1996 + choice = input(f"\n Wybierz numer (1-{len(results)}) lub Enter aby anulować: ").strip()
1997 + if not choice:
1998 + return None
1999 + idx = int(choice) - 1
2000 + if 0 <= idx < len(results):
2001 + return results[idx]
2002 + except (ValueError, IndexError):
2003 + pass
2004 + return None
2005 +
2006 +def _flatpak_get_installed_info(app_id: str) -> Optional[dict]:
2007 + """Zwraca info o zainstalowanym flatpaku lub None."""
2008 + try:
2009 + r = subprocess.run(
2010 + ["flatpak", "info", "--columns=name,version,branch,origin,installed-size,description", app_id],
2011 + capture_output=True, text=True, timeout=10
2012 + )
2013 + if r.returncode != 0:
2014 + return None
2015 + parts = r.stdout.strip().split("\t")
2016 + if len(parts) < 3:
2017 + return None
2018 + return {
2019 + "name": parts[0].strip(),
2020 + "version": parts[1].strip() if len(parts) > 1 else "",
2021 + "branch": parts[2].strip() if len(parts) > 2 else "",
2022 + "origin": parts[3].strip() if len(parts) > 3 else "",
2023 + "size": parts[4].strip() if len(parts) > 4 else "",
2024 + "description": parts[5].strip() if len(parts) > 5 else "",
2025 + }
2026 + except Exception:
2027 + return None
2028 +
2029 +def _flatpak_is_installed(app_id: str) -> bool:
2030 + """Sprawdza czy flatpak o danym ID jest zainstalowany."""
2031 + try:
2032 + r = subprocess.run(
2033 + ["flatpak", "info", app_id],
2034 + capture_output=True, text=True, timeout=10
2035 + )
2036 + return r.returncode == 0
2037 + except Exception:
2038 + return False
2039 +
2040 +# =============================================================================
2041 +# FLATPAK – KOMENDY GŁÓWNE (zunifikowany interfejs)
2042 +# =============================================================================
2043 +# pag flatpak <query> → szuka i proponuje instalację (jeśli nie zainstalowany)
2044 +# pag flatpak search <query> → tylko szuka
2045 +# pag flatpak install <query> → instaluje
2046 +# pag flatpak remove <id> → usuwa
2047 +# pag flatpak list → lista zainstalowanych
2048 +# pag flatpak update → aktualizuje wszystkie
2049 +# pag flatpak info <id> → szczegóły flatpaka
2050 +
2051 +def cmd_flatpak(args: list):
2052 + """
2053 + Główna komenda flatpak – inteligentnie rozpoznaje intencję:
2054 + pag flatpak firefox → szuka i instaluje (jeśli nieznaleziony → szuka)
2055 + pag flatpak search firefox → tylko wyszukiwanie
2056 + pag flatpak install ... → bezpośrednia instalacja
2057 + pag flatpak remove ... → odinstalowanie
2058 + pag flatpak list → lista
2059 + pag flatpak update → aktualizacja
2060 + pag flatpak info ... → szczegóły
2061 + """
2062 + if not _check_flatpak():
2063 + return 1
2064 +
2065 + if not args:
2066 + # Bez argumentów – domyślnie lista
2067 + return cmd_flatpak_list()
2068 +
2069 + subcmd = args[0].lower()
2070 + rest = args[1:]
2071 +
2072 + # ── Podkomendy jawne ────────────────────────────────────────────────
2073 + if subcmd == "search":
2074 + if not rest:
2075 + print(_("flatpak_usage")); return 1
2076 + return cmd_flatpak_search(" ".join(rest))
2077 +
2078 + elif subcmd == "install":
2079 + if not rest:
2080 + print(_("flatpak_usage")); return 1
2081 + return _flatpak_smart_install(rest)
2082 +
2083 + elif subcmd == "remove" or subcmd == "uninstall":
2084 + if not rest:
2085 + print(_("flatpak_usage")); return 1
2086 + return _flatpak_smart_remove(rest)
2087 +
2088 + elif subcmd == "list":
2089 + return cmd_flatpak_list()
2090 +
2091 + elif subcmd == "update":
2092 + return cmd_flatpak_update()
2093 +
2094 + elif subcmd == "info":
2095 + if not rest:
2096 + print(_("flatpak_usage")); return 1
2097 + return cmd_flatpak_info(rest[0])
2098 +
2099 + else:
2100 + # ── Inteligentne wykrywanie: pag flatpak <nazwa> ────────────────
2101 + # Sprawdź czy to zainstalowany flatpak → pokaż info
2102 + # Jeśli nie → szukaj i zaproponuj instalację
2103 + query = " ".join(args)
2104 +
2105 + # Najpierw sprawdź czy już zainstalowany
2106 + if _flatpak_is_installed(query):
2107 + print(f" 📦 {_c('green', query)} – already installed (use 'pag flatpak info {query}' for details)")
2108 + return cmd_flatpak_info(query)
2109 +
2110 + # Szukaj we Flathub
2111 + print(f" {_('flatpak_searching', query)}")
2112 + best = _flatpak_find_best(query)
2113 + if not best:
2114 + print(f" ❌ '{query}' – {_('flatpak_not_found')}")
2115 + return 1
2116 +
2117 + print(f"\n {_c('cyan', best['name'])} ({best['app_id']})")
2118 + if best["version"]:
2119 + print(f" {_('flatpak_info_version')}: {best['version']}")
2120 + if best["description"]:
2121 + print(f" {best['description']}")
2122 +
2123 + ans = input(f"\n {_('flatpak_install_prompt', best['name'])}").strip().lower()
2124 + if ans and ans not in ("t", "y"):
2125 + print(_("cancelled"))
2126 + return 0
2127 +
2128 + return _flatpak_do_install(best["app_id"])
2129 +
2130 +def _flatpak_smart_install(names: list) -> int:
2131 + """Instaluje flatpaki – obsługuje nazwy częściowe (wyszukuje przed instalacją)."""
2132 + failed = 0
2133 + for name in names:
2134 + if "." in name and "/" not in name:
2135 + # Wygląda na pełne app_id (np. org.mozilla.firefox)
2136 + app_id = name
2137 + else:
2138 + # Szukaj najlepszego dopasowania
2139 + best = _flatpak_find_best(name)
2140 + if not best:
2141 + print(f" ❌ '{name}' – {_('flatpak_not_found')}")
2142 + failed += 1
2143 + continue
2144 + app_id = best["app_id"]
2145 + print(f" → {best['name']} ({app_id})")
2146 +
2147 + if _flatpak_do_install(app_id) != 0:
2148 + failed += 1
2149 + return 1 if failed else 0
2150 +
2151 +def _flatpak_do_install(app_id: str) -> int:
2152 + """Wykonuje właściwą instalację flatpaka."""
2153 + print(f" {_('flatpak_installing', app_id)}")
2154 + result = subprocess.run(
2155 + ["flatpak", "install", "-y", "flathub", app_id],
2156 + check=False, timeout=600
2157 + )
2158 + if result.returncode == 0:
2159 + print(f" ✅ {_('flatpak_installed', app_id)}")
2160 + return 0
2161 + else:
2162 + print(f" ❌ {_('download_fail')}: {app_id}")
2163 + return 1
2164 +
2165 +def _flatpak_smart_remove(names: list) -> int:
2166 + """Usuwa flatpaki – obsługuje nazwy częściowe."""
2167 + # Pobierz listę zainstalowanych
2168 + try:
2169 + r = subprocess.run(
2170 + ["flatpak", "list", "--columns=application,name"],
2171 + capture_output=True, text=True, timeout=10
2172 + )
2173 + installed = {}
2174 + for line in r.stdout.strip().split("\n"):
2175 + parts = line.split("\t")
2176 + if len(parts) >= 2:
2177 + installed[parts[0].strip()] = parts[1].strip()
2178 + except Exception:
2179 + installed = {}
2180 +
2181 + failed = 0
2182 + for name in names:
2183 + app_id = name
2184 +
2185 + # Jeśli nie podano pełnego ID – spróbuj dopasować
2186 + if name not in installed:
2187 + matches = {aid: aname for aid, aname in installed.items()
2188 + if name.lower() in aid.lower() or name.lower() in aname.lower()}
2189 + if len(matches) == 0:
2190 + print(f" ❌ '{name}' – {_('flatpak_not_installed', name)}")
2191 + failed += 1
2192 + continue
2193 + elif len(matches) == 1:
2194 + app_id = list(matches.keys())[0]
2195 + print(f" → {matches[app_id]} ({app_id})")
2196 + else:
2197 + print(f"\n Wiele dopasowań dla '{name}':")
2198 + for i, (aid, aname) in enumerate(sorted(matches.items()), 1):
2199 + print(f" {i}. {aname} ({aid})")
2200 + try:
2201 + choice = input(f"\n Wybierz numer (1-{len(matches)}) lub Enter: ").strip()
2202 + if not choice:
2203 + failed += 1
2204 + continue
2205 + aid_list = sorted(matches.keys())
2206 + app_id = aid_list[int(choice) - 1]
2207 + except (ValueError, IndexError):
2208 + failed += 1
2209 + continue
2210 +
2211 + print(f" 🗑 {app_id} ...", end=" ", flush=True)
2212 + result = subprocess.run(
2213 + ["flatpak", "uninstall", "-y", app_id],
2214 + capture_output=True, text=True, timeout=120
2215 + )
2216 + if result.returncode == 0:
2217 + print("✅")
2218 + print(f" {_('flatpak_removed', app_id)}")
2219 + else:
2220 + print("❌")
2221 + failed += 1
2222 + return 1 if failed else 0
2223 +
2224 +def cmd_flatpak_search(q: str):
2225 + """Wyszukuje we Flathub i wyświetla wyniki."""
2226 + if not _check_flatpak():
2227 + return 1
2228 + results = _flatpak_search_raw(q)
2229 + if not results:
2230 + print(f" ❌ '{q}' – {_('flatpak_not_found')}")
2231 + return 1
2232 + print(f"\n {_('flatpak_found', len(results))}")
2233 + for r in results[:30]: # max 30 wyników
2234 + installed = "📦 " if _flatpak_is_installed(r["app_id"]) else " "
2235 + print(f" {installed}{_c('bold', r['name'])} ({r['app_id']})")
2236 + if r["version"]:
2237 + print(f" {_('flatpak_info_version')}: {r['version']} | {_('flatpak_info_branch')}: {r['branch']}")
2238 + if r["description"]:
2239 + desc = r["description"][:100] + ("..." if len(r["description"]) > 100 else "")
2240 + print(f" {_c('dim', desc)}")
2241 + if len(results) > 30:
2242 + print(f" ... i {len(results) - 30} więcej. Doprecyzuj zapytanie.")
2243 + return 0
2244 +
2245 +def cmd_flatpak_list():
2246 + """Wyświetla zainstalowane flatpaki."""
2247 + if not _check_flatpak():
2248 + return 1
2249 + r = subprocess.run(
2250 + ["flatpak", "list", "--columns=application,name,version,origin,installed-size"],
2251 + capture_output=True, text=True, timeout=10
2252 + )
2253 + lines = [l for l in r.stdout.strip().split("\n") if l.strip()]
2254 + if not lines:
2255 + print(" (brak zainstalowanych flatpaków)")
2256 + return 0
2257 + print(f" Zainstalowane flatpaki ({len(lines)}):")
2258 + for line in lines:
2259 + parts = line.split("\t")
2260 + if len(parts) >= 3:
2261 + app_id, name, version = parts[0], parts[1], parts[2]
2262 + size = parts[4] if len(parts) > 4 else ""
2263 + size_str = f" ({size})" if size else ""
2264 + print(f" 📦 {_c('bold', name)} {version}{size_str}")
2265 + print(f" {_c('dim', app_id)}")
2266 + return 0
2267 +
2268 +def cmd_flatpak_update():
2269 + """Aktualizuje wszystkie flatpaki."""
2270 + if not _check_flatpak():
2271 + return 1
2272 + print(" 🔄 Aktualizacja flatpaków...")
2273 + result = subprocess.run(["flatpak", "update", "-y"], check=False, timeout=600)
2274 + if result.returncode == 0:
2275 + print(f" ✅ {_('flatpak_updated')}")
2276 + return result.returncode
2277 +
2278 +def cmd_flatpak_info(app_id: str):
2279 + """Wyświetla szczegóły flatpaka (zainstalowanego lub z Flathub)."""
2280 + if not _check_flatpak():
2281 + return 1
2282 +
2283 + # Najpierw sprawdź zainstalowany
2284 + info = _flatpak_get_installed_info(app_id)
2285 + if info:
2286 + print(f"\n 📦 {_c('bold', info['name'])} {_c('green', '[zainstalowany]')}")
2287 + print(f" {'─' * 45}")
2288 + print(f" {_('flatpak_info_id'):<16} {app_id}")
2289 + print(f" {_('flatpak_info_version'):<16} {info['version']}")
2290 + print(f" {_('flatpak_info_branch'):<16} {info['branch']}")
2291 + print(f" {_('flatpak_info_origin'):<16} {info['origin']}")
2292 + if info["size"]:
2293 + print(f" {_('flatpak_info_size'):<16} {info['size']}")
2294 + if info["description"]:
2295 + print(f" {_('flatpak_info_desc'):<16} {info['description']}")
2296 + return 0
2297 +
2298 + # Szukaj we Flathub
2299 + results = _flatpak_search_raw(app_id)
2300 + exact = [r for r in results if r["app_id"].lower() == app_id.lower()]
2301 + if not exact:
2302 + # Spróbuj częściowego dopasowania
2303 + if results:
2304 + exact = [results[0]]
2305 + else:
2306 + print(f" ❌ '{app_id}' – {_('flatpak_not_found')}")
2307 + return 1
2308 +
2309 + r = exact[0]
2310 + print(f"\n 📦 {_c('bold', r['name'])} (Flathub)")
2311 + print(f" {'─' * 45}")
2312 + print(f" {_('flatpak_info_id'):<16} {r['app_id']}")
2313 + print(f" {_('flatpak_info_version'):<16} {r['version']}")
2314 + if r["description"]:
2315 + print(f" {_('flatpak_info_desc'):<16} {r['description']}")
2316 + print(f"\n 💡 Aby zainstalować: pag flatpak install {r['app_id']}")
2317 + return 0
2318 +
2319 +# =============================================================================
2320 +# IMMUTABLE OS – KOMENDY DEPLOYMENTOWE
2321 +# =============================================================================
2322 +
2323 +# Pakiety jądra – po ich instalacji trzeba przebudować initramfs
2324 +KERNEL_PACKAGE_PATTERNS = ["linux", "kernel", "linux-kernel", "linux-lts"]
2325 +
2326 +def _is_kernel_package(name: str) -> bool:
2327 + """Sprawdza czy pakiet to jądro (wymaga przebudowy initramfs)."""
2328 + name_lower = name.lower()
2329 + return any(pattern in name_lower for pattern in KERNEL_PACKAGE_PATTERNS)
2330 +
2331 +def _rebuild_initramfs(deploy_dir: str = "") -> bool:
2332 + """
2333 + Przebudowuje initramfs dla aktywnego (lub podanego) deploymentu.
2334 + Używa skryptu pag-initramfs lub ręcznego cpio.
2335 + """
2336 + if deploy_dir:
2337 + root = deploy_dir
2338 + else:
2339 + root = _get_deployment_root()
2340 +
2341 + if root == PAG_ROOT:
2342 + # Zwykły system – użyj dracut jeśli dostępny
2343 + if shutil.which("dracut"):
2344 + print(" 🔧 Przebudowa initramfs (dracut)...")
2345 + result = subprocess.run(
2346 + ["dracut", "--force", "/boot/initramfs.img"],
2347 + capture_output=True, text=True, timeout=120
2348 + )
2349 + return result.returncode == 0
2350 + elif shutil.which("mkinitcpio"):
2351 + print(" 🔧 Przebudowa initramfs (mkinitcpio)...")
2352 + result = subprocess.run(
2353 + ["mkinitcpio", "-g", "/boot/initramfs.img"],
2354 + capture_output=True, text=True, timeout=120
2355 + )
2356 + return result.returncode == 0
2357 + else:
2358 + print(" ⚠ Brak dracut/mkinitcpio – initramfs nie został przebudowany")
2359 + return False
2360 +
2361 + # Tryb immutable – budujemy initramfs dla deploymentu
2362 + print(" 🔧 Budowanie initramfs dla deploymentu...")
2363 +
2364 + # Sprawdź czy mamy nasz skrypt init
2365 + pag_init_script = "/usr/share/pag/initramfs-init"
2366 + if not os.path.exists(pag_init_script):
2367 + # Szukaj w źródłach (developerski fallback)
2368 + alt_paths = [
2369 + os.path.join(os.path.dirname(os.path.abspath(__file__)), "scripts", "initramfs-init"),
2370 + "/usr/share/pag/init",
2371 + ]
2372 + for p in alt_paths:
2373 + if os.path.exists(p):
2374 + pag_init_script = p
2375 + break
2376 +
2377 + if not os.path.exists(pag_init_script):
2378 + print(" ⚠ Nie znaleziono pag-initramfs-init – pomijam budowę initramfs")
2379 + return False
2380 +
2381 + boot_dir = os.path.join(root, "boot")
2382 + os.makedirs(boot_dir, exist_ok=True)
2383 +
2384 + # Znajdź jądro (vmlinuz-*)
2385 + kernels = sorted(
2386 + [f for f in os.listdir(boot_dir) if f.startswith("vmlinuz-")],
2387 + reverse=True
2388 + ) if os.path.exists(boot_dir) else []
2389 + if not kernels:
2390 + print(" ⚠ Nie znaleziono vmlinuz-* w /boot deploymentu")
2391 + return False
2392 +
2393 + kernel_ver = kernels[0].replace("vmlinuz-", "")
2394 + print(f" 🐧 Jądro: {kernel_ver}")
2395 +
2396 + # Buduj initramfs ręcznie (cpio)
2397 + tmpdir = tempfile.mkdtemp(prefix="pag-initramfs-")
2398 + try:
2399 + # Podstawowa struktura
2400 + for d in ["bin", "sbin", "dev", "proc", "sys", "run", "new_root",
2401 + "usr/bin", "usr/sbin", "lib", "lib64", "etc"]:
2402 + os.makedirs(os.path.join(tmpdir, d), exist_ok=True)
2403 +
2404 + # Skopiuj init
2405 + shutil.copy2(pag_init_script, os.path.join(tmpdir, "init"))
2406 + os.chmod(os.path.join(tmpdir, "init"), 0o755)
2407 +
2408 + # Skopiuj niezbędne binaria (busybox lub podstawowe narzędzia)
2409 + busybox_paths = [
2410 + os.path.join(root, "usr/bin/busybox"),
2411 + os.path.join(root, "bin/busybox"),
2412 + "/usr/bin/busybox",
2413 + "/bin/busybox",
2414 + ]
2415 + busybox = None
2416 + for bp in busybox_paths:
2417 + if os.path.exists(bp):
2418 + busybox = bp
2419 + break
2420 +
2421 + if busybox:
2422 + shutil.copy2(busybox, os.path.join(tmpdir, "bin/busybox"))
2423 + # Utwórz symlinki dla podstawowych komend
2424 + for cmd in ["sh", "mount", "umount", "ls", "cat", "echo", "sleep",
2425 + "readlink", "mkdir", "switch_root", "cp", "rm"]:
2426 + link = os.path.join(tmpdir, "bin", cmd)
2427 + if not os.path.exists(link):
2428 + os.symlink("busybox", link)
2429 + # /bin/sh → busybox
2430 + if not os.path.exists(os.path.join(tmpdir, "bin/sh")):
2431 + os.symlink("busybox", os.path.join(tmpdir, "bin/sh"))
2432 + else:
2433 + # Bez busybox – kopiuj podstawowe narzędzia z deploymentu
2434 + for tool in ["bash", "mount", "umount", "readlink", "mkdir", "cat", "sleep", "cp", "rm"]:
2435 + src = os.path.join(root, "usr/bin", tool)
2436 + if not os.path.exists(src):
2437 + src = os.path.join(root, "bin", tool)
2438 + if os.path.exists(src):
2439 + dest = os.path.join(tmpdir, "bin", os.path.basename(tool))
2440 + shutil.copy2(src, dest)
2441 + # Kopiuj zależności .so
2442 + _copy_libs_for_binary(src, tmpdir, root)
2443 +
2444 + # Dodaj moduły jądra (opcjonalnie – dla sterowników dyskowych)
2445 + modules_src = os.path.join(root, "lib/modules", kernel_ver)
2446 + if os.path.isdir(modules_src):
2447 + modules_dst = os.path.join(tmpdir, "lib/modules", kernel_ver)
2448 + # Kopiuj tylko niezbędne (fs, block, drivers/ata, drivers/nvme)
2449 + for sub in ["kernel/fs", "kernel/drivers/ata", "kernel/drivers/nvme",
2450 + "kernel/drivers/scsi", "kernel/drivers/virtio",
2451 + "modules.order", "modules.builtin"]:
2452 + src_sub = os.path.join(modules_src, sub)
2453 + if os.path.exists(src_sub):
2454 + dst_sub = os.path.join(modules_dst, sub)
2455 + os.makedirs(os.path.dirname(dst_sub), exist_ok=True)
2456 + if os.path.isdir(src_sub):
2457 + shutil.copytree(src_sub, dst_sub, dirs_exist_ok=True, symlinks=True)
2458 + else:
2459 + shutil.copy2(src_sub, dst_sub)
2460 +
2461 + # Pakuj do initramfs.img
2462 + initramfs_path = os.path.join(boot_dir, "initramfs.img")
2463 + old_cwd = os.getcwd()
2464 + os.chdir(tmpdir)
2465 + try:
2466 + with open(initramfs_path + ".tmp", "wb") as out:
2467 + subprocess.run(
2468 + "find . | cpio -oH newc | gzip",
2469 + shell=True, stdout=out, check=True, timeout=120,
2470 + cwd=tmpdir
2471 + )
2472 + os.rename(initramfs_path + ".tmp", initramfs_path)
2473 + finally:
2474 + os.chdir(old_cwd)
2475 +
2476 + size_mb = os.path.getsize(initramfs_path) / 1048576
2477 + print(f" ✅ initramfs.img ({size_mb:.1f} MB) → {initramfs_path}")
2478 + return True
2479 +
2480 + except Exception as e:
2481 + print(f" ❌ Błąd budowy initramfs: {e}")
2482 + return False
2483 + finally:
2484 + shutil.rmtree(tmpdir, ignore_errors=True)
2485 +
2486 +
2487 +def _copy_libs_for_binary(binary: str, dest_dir: str, root: str):
2488 + """Kopiuje zależności .so dla binarki do initramfs (uproszczone ldd)."""
2489 + try:
2490 + result = subprocess.run(
2491 + ["ldd", binary], capture_output=True, text=True, timeout=10
2492 + )
2493 + for line in result.stdout.split("\n"):
2494 + m = re.search(r'=>\s+(/\S+)', line)
2495 + if m:
2496 + lib_path = m.group(1)
2497 + lib_rel = lib_path.lstrip("/")
2498 + lib_dest = os.path.join(dest_dir, lib_rel)
2499 + if not os.path.exists(lib_dest):
2500 + os.makedirs(os.path.dirname(lib_dest), exist_ok=True)
2501 + # Szukaj w deployment root lub systemie
2502 + if os.path.exists(lib_path):
2503 + shutil.copy2(lib_path, lib_dest)
2504 + else:
2505 + alt = os.path.join(root, lib_rel)
2506 + if os.path.exists(alt):
2507 + shutil.copy2(alt, lib_dest)
2508 + except Exception:
2509 + pass
2510 +
2511 +
2512 +def cmd_initramfs_update():
2513 + """Ręcznie przebudowuje initramfs dla bieżącego deploymentu."""
2514 + ensure_dirs()
2515 + deploy_dir = _get_deployment_root()
2516 + if deploy_dir != PAG_ROOT:
2517 + print(f"🏗️ Deployment: {os.path.basename(deploy_dir)}")
2518 + ok = _rebuild_initramfs(deploy_dir)
2519 + if ok:
2520 + print("✅ Initramfs zaktualizowany.")
2521 + # Po initramfs – zaktualizuj też GRUB
2522 + _update_grub_config()
2523 + else:
2524 + print("❌ Błąd aktualizacji initramfs.")
2525 + return 0 if ok else 1
2526 +
2527 +
2528 +def _update_grub_config():
2529 + """
2530 + Generuje wpisy GRUB dla wszystkich deploymentów.
2531 + Każdy deployment dostaje własny wpis – rollback możliwy z bootloadera.
2532 + """
2533 + grub_cfg = "/boot/grub/grub.cfg"
2534 + if not os.path.exists(os.path.dirname(grub_cfg)):
2535 + return # brak GRUB
2536 +
2537 + deployments = _load_deployments()
2538 + root_dev = _detect_root_device()
2539 +
2540 + lines = [
2541 + "# =====================================================================",
2542 + "# Pagan Linux – GRUB config (wygenerowane przez pag grub-update)",
2543 + f"# Data: {datetime.now().isoformat()}",
2544 + "# =====================================================================",
2545 + "",
2546 + ]
2547 +
2548 + # Domyślny – ostatni (najnowszy) deployment
2549 + if deployments:
2550 + latest = deployments[-1]["id"]
2551 + lines.append(f"set default=0")
2552 + lines.append(f"set timeout=5")
2553 + else:
2554 + lines.append("set default=0")
2555 + lines.append("set timeout=5")
2556 + lines.append("")
2557 +
2558 + # Wpisy dla każdego deploymentu (od najnowszego)
2559 + entry_num = 0
2560 + for d in reversed(deployments):
2561 + deploy_dir = os.path.join(DEPLOYMENTS_DIR, d["id"])
2562 + boot_dir = os.path.join(deploy_dir, "boot")
2563 + kernels = sorted(
2564 + [f for f in os.listdir(boot_dir) if f.startswith("vmlinuz-")],
2565 + reverse=True
2566 + ) if os.path.isdir(boot_dir) else []
2567 +
2568 + kernel_path = f"/.deployments/{d['id']}/boot/{kernels[0]}" if kernels else ""
2569 + initrd_path = f"/.deployments/{d['id']}/boot/initramfs.img"
2570 + initrd_line = f"initrd {initrd_path}" if os.path.exists(os.path.join(boot_dir, "initramfs.img")) else ""
2571 +
2572 + active_mark = " [AKTYWNY]" if d.get("active") else ""
2573 + pkg_list = ", ".join(d.get("packages", [])[:3])
2574 + label = f"Pagan Linux – {d['id']}{active_mark}"
2575 +
2576 + lines.append(f"menuentry '{label}' {{")
2577 + if kernel_path:
2578 + lines.append(f" linux {kernel_path} root={root_dev} rw quiet")
2579 + else:
2580 + lines.append(f" # Brak jądra w tym deploymencie")
2581 + if initrd_line:
2582 + lines.append(f" {initrd_line}")
2583 + lines.append("}")
2584 + lines.append("")
2585 + entry_num += 1
2586 +
2587 + # Wpis fallback: zwykły root (gdyby wszystko padło)
2588 + lines.append("menuentry 'Pagan Linux – fallback (zwykły root)' {")
2589 + lines.append(f" linux /boot/vmlinuz-* root={root_dev} rw quiet")
2590 + lines.append(f" initrd /boot/initramfs.img")
2591 + lines.append("}")
2592 + lines.append("")
2593 +
2594 + # Zapisz
2595 + os.makedirs(os.path.dirname(grub_cfg), exist_ok=True)
2596 + with open(grub_cfg, "w") as f:
2597 + f.write("\n".join(lines))
2598 +
2599 + print(" 📋 GRUB config zaktualizowany – wpisy dla każdego deploymentu")
2600 +
2601 +
2602 +def _detect_root_device() -> str:
2603 + """Wykrywa device partycji root (np. /dev/sda1)."""
2604 + try:
2605 + result = subprocess.run(
2606 + ["findmnt", "-n", "-o", "SOURCE", "/"],
2607 + capture_output=True, text=True, timeout=5
2608 + )
2609 + if result.returncode == 0 and result.stdout.strip():
2610 + return result.stdout.strip()
2611 + except Exception:
2612 + pass
2613 + return "/dev/sda1" # fallback
2614 +
2615 +
2616 +def cmd_grub_update():
2617 + """Ręcznie regeneruje konfigurację GRUB (wpisy dla deploymentów)."""
2618 + ensure_dirs()
2619 + print("📋 Aktualizacja konfiguracji GRUB...")
2620 + _update_grub_config()
2621 + print("✅ GRUB zaktualizowany.")
2622 + return 0
2623 +
2624 +def cmd_deploy_list():
2625 + """Wyświetla listę wszystkich deploymentów."""
2626 + deployments = _load_deployments()
2627 + if not deployments:
2628 + print(_("no_deployments")); return
2629 +
2630 + print(_("deployments_list", len(deployments)))
2631 + active = os.readlink(ACTIVE_LINK) if os.path.islink(ACTIVE_LINK) else ""
2632 +
2633 + for d in reversed(deployments):
2634 + marker = f" ◀ {_('active_deployment')}" if d.get("active") or d["id"] == os.path.basename(active) else ""
2635 + print(f" {d['id']}{marker}")
2636 + print(f" {d['action']}: {', '.join(d['packages'][:5])}")
2637 + if len(d.get('packages', [])) > 5:
2638 + print(f" +{len(d['packages']) - 5} więcej...")
2639 + print(f" {d['timestamp']}")
2640 +
2641 +
2642 +def cmd_deploy_rollback():
2643 + """Przełącza na poprzedni deployment."""
2644 + deployments = _load_deployments()
2645 + active_indices = [i for i, d in enumerate(deployments) if d.get("active")]
2646 +
2647 + if len(deployments) < 2:
2648 + print(f"❌ {_('deploy_rollback_fail')}"); return 1
2649 +
2650 + current_idx = active_indices[0] if active_indices else len(deployments) - 1
2651 + prev_idx = current_idx - 1 if current_idx > 0 else -1
2652 +
2653 + if prev_idx < 0:
2654 + print(f"❌ {_('deploy_rollback_fail')}"); return 1
2655 +
2656 + prev = deployments[prev_idx]
2657 + prev_dir = os.path.join(DEPLOYMENTS_DIR, prev["id"])
2658 +
2659 + if not os.path.isdir(prev_dir):
2660 + print(f"❌ Deployment {prev['id']} nie istnieje na dysku"); return 1
2661 +
2662 + print(f"⏪ Przywracanie deploymentu: {prev['id']}")
2663 + print(f" {prev['action']}: {', '.join(prev['packages'][:5])}")
2664 +
2665 + ans = input(_("continue_q")).strip().lower()
2666 + if ans and ans not in ("t", "y"):
2667 + return 0
2668 +
2669 + _switch_deployment(prev_dir)
2670 +
2671 + for d in deployments:
2672 + d["active"] = (d["id"] == prev["id"])
2673 + _save_deployments(deployments)
2674 +
2675 + _update_grub_config()
2676 + print(f"✅ {_('deploy_rollback_ok', prev['id'])}")
2677 + print(" 💡 Restart wymagany do przeładowania systemu.")
2678 + return 0
2679 +
2680 +
2681 +def cmd_deploy_cleanup(keep: int = 3):
2682 + """Usuwa stare deploymenty, zachowując ostatnie `keep`."""
2683 + deployments = _load_deployments()
2684 +
2685 + if len(deployments) <= keep:
2686 + print(f"✅ {_('deploy_cleanup_none', keep)}"); return 0
2687 +
2688 + to_remove = deployments[:-keep]
2689 + removed = 0
2690 +
2691 + for d in to_remove:
2692 + deploy_dir = os.path.join(DEPLOYMENTS_DIR, d["id"])
2693 + if os.path.isdir(deploy_dir):
2694 + shutil.rmtree(deploy_dir, ignore_errors=True)
2695 + removed += 1
2696 +
2697 + remaining = deployments[-keep:]
2698 + _save_deployments(remaining)
2699 +
2700 + print(f"✅ {_('deploy_cleanup_ok', removed)}")
2701 + return 0
2702 +
2703 +
2704 +# =============================================================================
2705 +# POMOCNICZE
2706 +# =============================================================================
2707 +
2708 +def _resolve_deps(names, repo, installed):
2709 + resolved, visited = [], set()
2710 + missing = [] # zależności których nie ma ani w repo ani zainstalowane
2711 +
2712 + def visit(name):
2713 + if name in visited: return
2714 +
2715 + # Rozwijanie wirtualnych zależności przez provides
2716 + target = _resolve_provides(name, repo)
2717 +
2718 + if target in visited: return
2719 + visited.add(target)
2720 + if target in repo:
2721 + for dep in repo[target].dependencies:
2722 + real_dep = _resolve_provides(dep, repo)
2723 + real_target = real_dep if real_dep in repo else dep
2724 +
2725 + # Sprawdź czy zależność jest dostępna
2726 + if real_target not in installed and real_target not in repo:
2727 + if dep not in missing:
2728 + missing.append(dep)
2729 +
2730 + if dep not in installed:
2731 + visit(real_target)
2732 + elif target not in installed:
2733 + # Pakiet nie istnieje ani w repo ani zainstalowany
2734 + if target not in missing:
2735 + missing.append(target)
2736 +
2737 + if target not in installed and target not in resolved:
2738 + resolved.append(target)
2739 +
2740 + for name in names:
2741 + visit(name)
2742 +
2743 + # Zwróć brakujące (do sprawdzenia przez wywołującego)
2744 + return resolved, missing
2745 +
2746 +def _verify_dependencies(to_install: list, repo: dict, installed: dict) -> int:
2747 + """
2748 + Sprawdza czy wszystkie zależności pakietów do instalacji są spełnione.
2749 + Zwraca liczbę brakujących zależności.
2750 + """
2751 + # Pakiety dostarczane przez bazowy system (zawsze "zainstalowane")
2752 + SYSTEM_BASE = {
2753 + "glibc", "libc", "gcc", "g++", "make", "binutils", "coreutils", "bash",
2754 + "linux-api-headers", "kernel-headers", "zlib", "pkg-config", "pkgconf",
2755 + "tar", "gzip", "xz", "bzip2", "findutils", "grep", "sed", "gawk", "awk",
2756 + "diffutils", "patch", "file", "m4", "perl", "python3", "sh",
2757 + }
2758 + all_missing = []
2759 + all_warnings = []
2760 +
2761 + for pkg_name in to_install:
2762 + pkg = repo.get(pkg_name)
2763 + if not pkg:
2764 + continue
2765 +
2766 + for dep in pkg.dependencies:
2767 + if dep in SYSTEM_BASE:
2768 + continue # bazowy system dostarcza tę zależność
2769 + real_dep = _resolve_provides(dep, repo)
2770 + # Sprawdź czy zależność jest dostępna (w repo lub już zainstalowana)
2771 + in_repo = real_dep in repo
2772 + in_installed = real_dep in installed
2773 + will_be_installed = real_dep in to_install
2774 +
2775 + if not in_repo and not in_installed and not will_be_installed:
2776 + if dep not in all_missing:
2777 + all_missing.append((pkg_name, dep))
2778 + elif in_repo and not in_installed and not will_be_installed:
2779 + if dep not in [w[1] for w in all_warnings]:
2780 + all_warnings.append((pkg_name, dep, real_dep))
2781 +
2782 + if all_missing:
2783 + print(f"\n❌ {_c('red', 'BRAKUJĄCE ZALEŻNOŚCI')} – nie można zainstalować:")
2784 + for pkg, dep in all_missing:
2785 + print(f" {pkg} → potrzebuje {_c('red', dep)} (brak w repozytoriach)")
2786 + print()
2787 +
2788 + if all_warnings:
2789 + print(f"\n⚠ {_c('yellow', 'NIESPEŁNIONE ZALEŻNOŚCI')} – zostaną doinstalowane:")
2790 + for pkg, dep, real in all_warnings:
2791 + print(f" {pkg} → {dep} ({_c('green', real)} – będzie pobrane)")
2792 + print()
2793 +
2794 + return len(all_missing)
2795 +
2796 +def _download_pkg(pkg):
2797 + url = f"{pkg.repo_url}/{pkg.filename}"
2798 + dest = os.path.join(PAG_CACHE, pkg.filename)
2799 + if os.path.exists(dest) and (not pkg.sha256 or _sha256_file(dest) == pkg.sha256):
2800 + _download_pkg_sig(pkg, dest) # upewnij się, że sygnatura jest w cache
2801 + return dest
2802 + try:
2803 + req = Request(url, headers={"User-Agent":"pag/3.0"})
2804 + with urlopen(req, timeout=600) as resp:
2805 + total = int(resp.headers.get("Content-Length", 0))
2806 + bar = DownloadBar(pkg.filename, total)
2807 + with open(dest, "wb") as f:
2808 + while True:
2809 + chunk = resp.read(65536)
2810 + if not chunk:
2811 + break
2812 + f.write(chunk)
2813 + bar.update(len(chunk))
2814 + bar.close()
2815 + if pkg.sha256 and _sha256_file(dest) != pkg.sha256:
2816 + os.remove(dest); return None
2817 + _download_pkg_sig(pkg, dest)
2818 + return dest
2819 + except Exception as e:
2820 + print(f" ⚠ Błąd pobierania {pkg.filename}: {e}", file=sys.stderr)
2821 + return None
2822 +
2823 +def _download_pkg_sig(pkg, dest):
2824 + """Pobiera podpis pakietu (.asc, fallback .sig) obok paczki w cache."""
2825 + for ext in (".asc", ".sig"):
2826 + sig_dest = dest + ext
2827 + if os.path.exists(sig_dest):
2828 + return
2829 + try:
2830 + req = Request(f"{pkg.repo_url}/{pkg.filename}{ext}", headers={"User-Agent":"pag/3.0"})
2831 + with urlopen(req, timeout=30) as resp:
2832 + with open(sig_dest, "wb") as f:
2833 + f.write(resp.read())
2834 + return
2835 + except Exception:
2836 + continue
2837 +
2838 +def _download_packages_parallel(pkgs: List[PackageInfo], max_workers: int = 4) -> Dict[str, Optional[str]]:
2839 + """
2840 + Równoległe pobieranie wielu pakietów przez ThreadPoolExecutor.
2841 + Znacząco przyspiesza przy dużych aktualizacjach (50+ pakietów).
2842 + Zwraca słownik {nazwa_pakietu: ścieżka_lub_None}.
2843 + """
2844 + results = {}
2845 + total = len(pkgs)
2846 + completed = 0
2847 + with ThreadPoolExecutor(max_workers=max_workers) as executor:
2848 + future_to_pkg = {executor.submit(_download_pkg, pkg): pkg for pkg in pkgs}
2849 + for future in as_completed(future_to_pkg):
2850 + pkg = future_to_pkg[future]
2851 + try:
2852 + results[pkg.name] = future.result()
2853 + except Exception:
2854 + results[pkg.name] = None
2855 + completed += 1
2856 + # Pasek postępu
2857 + pct = completed / total * 100
2858 + filled = int(20 * pct / 100)
2859 + bar = "█" * filled + "░" * (20 - filled)
2860 + print(f"\r ⏬ [{bar}] {completed}/{total} ({pct:.0f}%)", end="", file=sys.stderr, flush=True)
2861 + print(file=sys.stderr) # nowa linia po zakończeniu
2862 + return results
2863 +
2864 +def load_world():
2865 + if not os.path.exists(WORLD_FILE): return set()
2866 + return {l.strip() for l in open(WORLD_FILE) if l.strip()}
2867 +
2868 +def save_world(w):
2869 + with open(WORLD_FILE,"w") as f:
2870 + for n in sorted(w): f.write(f"{n}\n")
2871 +
2872 +def _find_orphans(installed, world):
2873 + needed = set(world)
2874 + changed = True
2875 + while changed:
2876 + changed = False
2877 + for n in list(needed):
2878 + for dep in installed.get(n,{}).get("dependencies",[]):
2879 + if dep not in needed and dep in installed:
2880 + needed.add(dep); changed = True
2881 + return {n for n in installed if n not in needed}
2882 +
2883 +# =============================================================================
2884 +# MAIN
2885 +# =============================================================================
2886 +
2887 +USAGE = """pag v3 – Pagan Linux Package Manager
2888 +
2889 +BASIC:
2890 + pag install <pkg>... Install packages
2891 + pag remove <pkg>... Remove packages
2892 + pag update [--force] Refresh repo indexes
2893 + pag upgrade Upgrade all packages
2894 + pag list [--installed] List available / installed
2895 + pag search <query> Search packages
2896 + pag info <pkg> Package details
2897 + pag files <pkg> List package files
2898 + pag verify [--deep] Verify integrity (--deep = SHA256 per file)
2899 + pag clean Clear download cache
2900 + pag stats System statistics
2901 + pag download <pkg>... Download packages to cache (offline prep)
2902 +
2903 +SECURITY:
2904 + pag key-add <url|file> Import GPG key
2905 + pag key-list List trusted keys
2906 + pag key-remove <id> Remove key
2907 +
2908 +ADVANCED:
2909 + pag why <pkg> Show why a package is installed
2910 + pag autoremove Auto-remove orphaned dependencies
2911 + pag pin <pkg> [ver] Pin package version
2912 + pag unpin <pkg> Unpin
2913 + pag pinned List pinned
2914 + pag history Transaction history
2915 + pag rollback Rollback last transaction
2916 + pag remove-orphans Remove orphaned deps
2917 + pag repo-add <url> Add repository
2918 + pag repo-list List repositories
2919 +
2920 +FLATPAK:
2921 + pag flatpak [<query>] Search & install (smart)
2922 + pag flatpak search <q> Search Flathub
2923 + pag flatpak install <id> Install flatpak
2924 + pag flatpak remove <id> Remove flatpak
2925 + pag flatpak list List installed flatpaks
2926 + pag flatpak update Update all flatpaks
2927 + pag flatpak info <id> Show flatpak details
2928 +
2929 +IMMUTABLE OS (PAG_IMMUTABLE=1):
2930 + pag deploy-list List all deployments
2931 + pag deploy-rollback Switch to previous deployment
2932 + pag deploy-cleanup [N] Remove old deployments (keep last N, default 3)
2933 + pag initramfs-update Rebuild initramfs for current kernel/deployment
2934 + pag grub-update Regenerate GRUB entries for all deployments
2935 +"""
2936 +
2937 +def main():
2938 + if len(sys.argv) < 2:
2939 + print(USAGE); sys.exit(0)
2940 +
2941 + cmd = sys.argv[1]
2942 + args = sys.argv[2:]
2943 +
2944 + # --- Komendy TYLKO DO ODCZYTU (nie wymagają roota) ---
2945 + READ_ONLY = {
2946 + "list": lambda: cmd_list("--installed" in args),
2947 + "search": lambda: cmd_search(args[0]) if args else print("Usage: pag search <query>"),
2948 + "info": lambda: cmd_info(args[0]) if args else print("Usage: pag info <pkg>"),
2949 + "files": lambda: cmd_files(args[0]) if args else print("Usage: pag files <pkg>"),
2950 + "verify": lambda: cmd_verify("--deep" in args),
2951 + "why": lambda: cmd_why(args[0]) if args else print("Usage: pag why <pkg>"),
2952 + "stats": cmd_stats,
2953 + "pinned": cmd_pinned,
2954 + "history": cmd_history,
2955 + "repo-list": cmd_repo_list,
2956 + "key-list": cmd_key_list,
2957 + "flatpak": lambda: cmd_flatpak(args),
2958 + "flatpak-search": lambda: cmd_flatpak_search(args[0]) if args else print("Usage: pag flatpak-search <query>"),
2959 + "flatpak-list": cmd_flatpak_list,
2960 + "flatpak-info": lambda: cmd_flatpak_info(args[0]) if args else print("Usage: pag flatpak-info <id>"),
2961 + "deploy-list": cmd_deploy_list,
2962 + "deploy": cmd_deploy_list,
2963 + }
2964 +
2965 + if cmd in READ_ONLY:
2966 + sys.exit(READ_ONLY[cmd]() or 0)
2967 +
2968 + # --- Smart search: `pag <nazwa-pakietu>` → repo + Flathub + sugestie ---
2969 + WRITE_CMDS = {
2970 + "install", "remove", "update", "upgrade", "clean", "download",
2971 + "autoremove", "remove-orphans", "pin", "unpin", "rollback",
2972 + "repo-add", "key-add", "key-remove",
2973 + "flatpak", "flatpak-install", "flatpak-remove", "flatpak-update",
2974 + "deploy-rollback", "deploy-cleanup", "initramfs-update", "grub-update",
2975 + }
2976 + if cmd not in WRITE_CMDS:
2977 + sys.exit(_smart_search(" ".join([cmd] + args)) or 0)
2978 +
2979 + # --- Komendy ZAPISU (wymagają roota) ---
2980 + if os.geteuid() != 0:
2981 + print(f"❌ {_('root_required')}", file=sys.stderr); sys.exit(1)
2982 +
2983 + ensure_dirs()
2984 +
2985 + with DatabaseLock():
2986 + WRITE_COMMANDS = {
2987 + "install": lambda: cmd_install(args),
2988 + "remove": lambda: cmd_remove(args),
2989 + "update": cmd_update,
2990 + "upgrade": cmd_upgrade,
2991 + "clean": cmd_clean,
2992 + "download": lambda: cmd_download(args),
2993 + "autoremove": cmd_autoremove,
2994 + "remove-orphans": cmd_remove_orphans,
2995 + "pin": lambda: cmd_pin(args[0], args[1] if len(args)>1 else ""),
2996 + "unpin": lambda: cmd_unpin(args[0]) if args else print("Usage: pag unpin <pkg>"),
2997 + "rollback": cmd_rollback,
2998 + "repo-add": lambda: cmd_repo_add(args[0]) if args else print("Usage: pag repo-add <url>"),
2999 + "key-add": lambda: cmd_key_add(args[0]) if args else print("Usage: pag key-add <url|file>"),
3000 + "key-remove": lambda: cmd_key_remove(args[0]) if args else print("Usage: pag key-remove <id>"),
3001 + "flatpak": lambda: cmd_flatpak(args),
3002 + "flatpak-install": lambda: _flatpak_smart_install(args) if args else print("Usage: pag flatpak-install <app>"),
3003 + "flatpak-remove": lambda: _flatpak_smart_remove(args) if args else print("Usage: pag flatpak-remove <app>"),
3004 + "flatpak-update": cmd_flatpak_update,
3005 + "deploy-rollback": cmd_deploy_rollback,
3006 + "deploy-cleanup": lambda: cmd_deploy_cleanup(int(args[0]) if args else 3),
3007 + "initramfs-update": cmd_initramfs_update,
3008 + "grub-update": cmd_grub_update,
3009 + }
3010 +
3011 + fn = WRITE_COMMANDS.get(cmd)
3012 + if fn:
3013 + sys.exit(fn() or 0)
3014 + # Should never reach here – _smart_search handles unknowns
3015 + sys.exit(_smart_search(" ".join([cmd] + args)) or 0)
3016 +
3017 +if __name__ == "__main__":
3018 + main()