← pag

Commit b959bcc

0
plików
+0
dodanych
-0
usuniętych
@@ -1,4878 +1,5027 @@
1 -#!/usr/bin/env python3
2 -"""
3 -╔══════════════════════════════════════════════════════════════════════════════╗
4 -║ PAG - Pagan Linux Package Manager v3.3.17 ║
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 + pinning fingerprintu
13 - - Hooki: pre/post-install, pre/post-remove (piaskownica env, timeout, audit)
14 - - Głęboka weryfikacja SHA256 per-plik
15 - - Pełny rollback – cofa fizyczne pliki
16 - - Blokada flock – tylko jedna instancja
17 - - Transakcje z migawkami + rejestr wykonanych hooków
18 - - Cache HTTP (ETag/If-Modified-Since)
19 - - Wielojęzyczność (i18n) – PL, EN
20 -
21 -FORMAT PAKIETU (.pag):
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 -MODEL ZAUFANIA / BEZPIECZEŃSTWO:
27 - - Repozytorium MUSI być zaufane: podpisy GPG zweryfikowane; fingerprint
28 - klucza przypiętego do repo (TOFU przy pierwszym użyciu, potem pinning).
29 - - Hooki uruchamiają dowolny plik z pakietu jako ROOT (jak apt/pacman).
30 - Ograniczamy je (czyste env, timeout, PAG_NO_HOOKS=1, log do
31 - /var/log/pag/audit.log) i rejestrujemy w transakcji, ale ostatecznie
32 - instalujesz kod, któremu ufasz.
33 - - self-update: weryfikacja podpisu + SHA256 + składnia, atomowa podmiana.
34 -"""
35 -
36 -import os, sys, json, shutil, hashlib, tarfile, tempfile, subprocess, time, fcntl, sqlite3, locale, re, difflib
37 -
38 -# Fix TLS trust inside the Pagan chroot: point Python at the CA bundle that
39 -# pag ships, otherwise urlopen() fails with "unable to get local issuer
40 -# certificate" (no default capath/cafile is resolved in the chroot).
41 -for _cafile in (
42 - "/etc/ssl/certs/ca-certificates.crt",
43 - "/etc/ssl/cert.pem",
44 -):
45 - if os.path.isfile(_cafile):
46 - os.environ["SSL_CERT_FILE"] = _cafile
47 - break
48 -
49 -from pathlib import Path
50 -from datetime import datetime, timezone
51 -from typing import Dict, List, Optional, Tuple, Set
52 -from concurrent.futures import ThreadPoolExecutor, as_completed
53 -from urllib.request import urlopen, Request
54 -import threading, itertools
55 -import uuid # serialNumber SBOM (CycloneDX)
56 -
57 -# Wersja klienta – do porównania z repo.json["pag_version"] (self-update)
58 -PAG_VERSION = "3.3.17"
59 -from urllib.error import URLError, HTTPError
60 -
61 -# =============================================================================
62 -# ProgressBar — minimalistyczny pasek postępu (bez zewnętrznych zależności)
63 -# =============================================================================
64 -
65 -class ProgressBar:
66 - """Czysty Python progress bar — działa z TTY i bez."""
67 - def __init__(self, total: int, desc: str = "", unit: str = "", width: int = 30):
68 - self.total = max(total, 1)
69 - self.desc = desc
70 - self.unit = unit
71 - self.width = width
72 - self.n = 0
73 - self.start = time.time()
74 - self.tty = sys.stderr.isatty()
75 - self._last_line_len = 0
76 -
77 - def update(self, n: Optional[int] = None, suffix: str = ""):
78 - if n is not None:
79 - self.n = n
80 - else:
81 - self.n += 1
82 - pct = self.n / self.total * 100
83 - elapsed = time.time() - self.start
84 - speed = self.n / elapsed if elapsed > 0 else 0
85 - if self.n >= self.total:
86 - eta_str = "done"
87 - elif speed > 0:
88 - eta = (self.total - self.n) / speed
89 - eta_str = f"{eta:.0f}s" if eta < 60 else f"{eta/60:.1f}m"
90 - else:
91 - eta_str = "?..."
92 - bar_len = int(self.width * pct / 100)
93 - bar = "█" * bar_len + "░" * (self.width - bar_len)
94 - line = f" {self.desc} [{bar}] {self.n}/{self.total} ({pct:.0f}%) ETA {eta_str}{suffix}"
95 - if self.tty:
96 - # Overwrite current line
97 - clear = " " * max(0, self._last_line_len - len(line))
98 - print(f"\r{line}{clear}", end="", file=sys.stderr, flush=True)
99 - self._last_line_len = len(line)
100 - else:
101 - # Print milestone lines only (every 10% or when done)
102 - if self.n == 1 or self.n >= self.total or self.n % max(1, self.total // 10) == 0:
103 - print(line, file=sys.stderr)
104 -
105 - def close(self):
106 - if self.tty:
107 - print(file=sys.stderr)
108 - self._last_line_len = 0
109 -
110 - def __enter__(self):
111 - return self
112 -
113 - def __exit__(self, *args):
114 - self.close()
115 -
116 -
117 -class DownloadBar:
118 - """Pasek postępu pobierania — na podstawie Content-Length."""
119 - def __init__(self, filename: str, total_bytes: int):
120 - self.filename = filename
121 - self.total = total_bytes
122 - self.downloaded = 0
123 - self.start = time.time()
124 - self.tty = sys.stderr.isatty()
125 - self._last_len = 0
126 -
127 - def update(self, chunk_size: int):
128 - self.downloaded += chunk_size
129 - if self.total <= 0:
130 - return
131 - pct = self.downloaded / self.total * 100
132 - elapsed = time.time() - self.start
133 - speed = self.downloaded / elapsed if elapsed > 0 else 0
134 - if speed > 0:
135 - eta = (self.total - self.downloaded) / speed
136 - eta_str = f"{eta:.0f}s" if eta < 60 else f"{eta/60:.1f}m"
137 - else:
138 - eta_str = "?..."
139 - bar_len = 25
140 - filled = int(bar_len * pct / 100)
141 - bar = "█" * filled + "░" * (bar_len - filled)
142 - sz = self._fmt_size(self.total)
143 - spd = self._fmt_size(int(speed))
144 - line = f" ↓ {self.filename} [{bar}] {pct:.0f}% {sz} {spd}/s ETA {eta_str}"
145 - if self.tty:
146 - clear = " " * max(0, self._last_len - len(line))
147 - print(f"\r{line}{clear}", end="", file=sys.stderr, flush=True)
148 - self._last_len = len(line)
149 -
150 - def close(self):
151 - if self.tty and self.total > 0:
152 - print(file=sys.stderr)
153 -
154 - @staticmethod
155 - def _fmt_size(n: int) -> str:
156 - for unit in ("B", "KB", "MB", "GB"):
157 - if n < 1024:
158 - return f"{n:.1f} {unit}"
159 - n /= 1024
160 - return f"{n:.1f} TB"
161 -
162 -# =============================================================================
163 -# GPG – BEZPIECZNE WYWOŁYWANIE (odporne na brak binarki gpg)
164 -# =============================================================================
165 -
166 -GPG_BINARY = shutil.which("gpg2") or shutil.which("gpg") or "gpg"
167 -GPG_HOME = "/etc/pag/gpg" # izolowany keyring (działa z keyboxd GPG 2.4+)
168 -
169 -def _gpg_run(*args, timeout: int = 30, **kwargs) -> subprocess.CompletedProcess:
170 - """
171 - Bezpieczne wywołanie GPG – przechwytuje FileNotFoundError,
172 - gdyby gpg/gpg2 nie było zainstalowane w minimalnym środowisku.
173 - Wymusza LC_ALL=C aby komunikaty GPG były zawsze po angielsku
174 - (niezależnie od locale systemu) – kluczowe dla parsowania stderr.
175 - """
176 - env = kwargs.pop("env", None) or os.environ.copy()
177 - env["LC_ALL"] = "C"
178 - env["GNUPGHOME"] = GPG_HOME
179 - try:
180 - return subprocess.run([GPG_BINARY, *args], timeout=timeout, env=env, **kwargs)
181 - except FileNotFoundError:
182 - # GPG nie jest dostępne – zwróć błąd z komunikatem
183 - # (szanuj text=True – inaczej caller dostaje bytes i może wybuchnąć TypeError)
184 - _text = bool(kwargs.get("text") or kwargs.get("universal_newlines"))
185 - _msg = f"GPG binary not found ({GPG_BINARY})"
186 - return subprocess.CompletedProcess(
187 - [GPG_BINARY, *args], 127,
188 - stdout=("" if _text else b""),
189 - stderr=(_msg if _text else _msg.encode()),
190 - )
191 - except subprocess.TimeoutExpired:
192 - return subprocess.CompletedProcess(
193 - [GPG_BINARY, *args], 124,
194 - stdout=b"", stderr=b"GPG operation timed out"
195 - )
196 -
197 -def _load_trust_db() -> dict:
198 - """Mapa repo_url → fingerprint klucza podpisującego (baza zaufania)."""
199 - try:
200 - with open(TRUST_DB) as f:
201 - return json.load(f)
202 - except (FileNotFoundError, json.JSONDecodeError):
203 - return {}
204 -
205 -
206 -def _save_trust_db(db: dict):
207 - os.makedirs(os.path.dirname(TRUST_DB), exist_ok=True)
208 - with open(TRUST_DB, "w") as f:
209 - json.dump(db, f, indent=2)
210 -
211 -
212 -def _gpg_verify_fp(sig_path: str, data_path: str, timeout: int = 30):
213 - """Weryfikuje podpis i odczytuje fingerprint podpisującego.
214 -
215 - Używa --status-fd=1 i linii VALIDSIG <fingerprint>. Zwraca (ok, fingerprint).
216 - """
217 - env = os.environ.copy()
218 - res = _gpg_run("--verify", "--status-fd", "1", sig_path, data_path,
219 - capture_output=True, text=True, timeout=timeout, env=env)
220 - if res.returncode != 0:
221 - return False, None
222 - m = re.search(r"\[GNUPG:\]\s+VALIDSIG\s+([0-9A-Fa-f]+)", res.stdout or "")
223 - if not m:
224 - m = re.search(r"VALIDSIG\s+([0-9A-Fa-f]{16,})", res.stdout or "")
225 - return True, (m.group(1).upper() if m else None)
226 -
227 -
228 -# =============================================================================
229 -# i18n – WIELOJĘZYCZNOŚĆ
230 -# =============================================================================
231 -
232 -LANG = os.environ.get("LANG", "en_US.UTF-8")[:2] # pl, en, de...
233 -COLOR = os.environ.get("NO_COLOR", "") == "" and sys.stdout.isatty()
234 -
235 -def _c(code: str, text: str) -> str:
236 - """Dodaje kody ANSI jeśli kolor jest włączony."""
237 - if not COLOR:
238 - return text
239 - colors = {
240 - "green": "\033[32m", "red": "\033[31m", "yellow": "\033[33m",
241 - "cyan": "\033[36m", "bold": "\033[1m", "dim": "\033[2m",
242 - "reset": "\033[0m",
243 - }
244 - return f"{colors.get(code,'')}{text}{colors['reset']}"
245 -
246 -T = {
247 - "en": {
248 - "root_required": "pag requires root privileges (sudo).",
249 - "db_locked": "Another pag instance is running.",
250 - "db_lock_hint": "If no other pag process is running, wait a moment and retry.",
251 - "no_index": "Cannot fetch repository indexes. Run 'pag update'.",
252 - "cache_ro": "Repo cache is read-only ({cache}) – using local index (may be outdated).\n Refresh as root: sudo pag sync",
253 - "all_installed": "All packages are already installed.",
254 - "to_install": "To install: {} packages ({:.2f} MB)",
255 - "new": "NEW",
256 - "continue_q": "Continue? [Y/n] ",
257 - "no_tty": "No TTY / stdin closed (EOF) – cancelling.",
258 - "cancelled": "Cancelled.",
259 - "not_found": "not found in repos",
260 - "pkg_not_found": "Package not found: {} (not in any repo)",
261 - "not_found_hint": "Check the spelling or run 'pag search <query>'.",
262 - "downloading": "Downloading",
263 - "download_fail": "download failed",
264 - "gpg_fail": "GPG verification failed",
265 - "sha256_mismatch": "SHA256 mismatch",
266 - "installed": "Installed {} packages.",
267 - "rollback_restored": "Restored previous state from snapshot.",
268 - "rollback_files": "Rolled back {} files.",
269 - "no_history": "No transaction history.",
270 - "pinned_list": "Pinned packages ({}):",
271 - "no_pinned": "No pinned packages.",
272 - "pinned_to": "pinned to",
273 - "unpinned": "unpinned.",
274 - "not_pinned": "was not pinned.",
275 - "repo_added": "Added repository: {}",
276 - "repo_exists": "Repository already exists: {}",
277 - "updated_done": "Index refresh complete. {} packages cached.",
278 - "indexes_refreshed": "Indexes refreshed.",
279 - "updates_available": "⚠ {} packages have updates – run: pag update",
280 - "upgrading": "Upgrading: {} packages",
281 - "all_up_to_date": "All packages are up to date.",
282 - "removing": "Removing",
283 - "orphans_found": "Orphaned dependencies ({}): {}",
284 - "flatpak_missing": "Flatpak is not installed.",
285 - "flatpak_adding": "Adding Flathub remote...",
286 - "flatpak_searching": "Searching Flathub for '{}'...",
287 - "flatpak_found": "Found {} results:",
288 - "flatpak_not_found": "not found on Flathub",
289 - "flatpak_install_prompt": "Install {}? [Y/n] ",
290 - "flatpak_installing": "Installing {}...",
291 - "flatpak_installed": "Flatpak {} installed.",
292 - "flatpak_removed": "Flatpak {} removed.",
293 - "flatpak_not_installed": "Flatpak {} is not installed.",
294 - "flatpak_info_id": "ID",
295 - "flatpak_info_version": "Version",
296 - "flatpak_info_branch": "Branch",
297 - "flatpak_info_origin": "Origin",
298 - "flatpak_info_size": "Installed size",
299 - "flatpak_info_desc": "Description",
300 - "flatpak_updated": "Flatpaks updated.",
301 - "flatpak_usage": "Usage: pag flatpak <search|install|remove|list|update|info> [args]",
302 - "key_imported": "Key imported successfully.",
303 - "key_removed": "Key removed: {}",
304 - "no_keys": "No trusted GPG keys.",
305 - "verify_ok": "All {} files intact.",
306 - "verify_errors": "{} problems found:",
307 - "cache_cleared": "{} files ({:.2f} MB) cleared from cache.",
308 - "deployments_list": "Deployments ({}):",
309 - "no_deployments": "No deployments.",
310 - "active_deployment": "ACTIVE",
311 - "deploy_rollback_ok": "Switched to deployment: {}",
312 - "deploy_rollback_fail": "No previous deployment.",
313 - "deploy_cleanup_ok": "Removed {} old deployments.",
314 - "deploy_cleanup_none": "No deployments to clean (minimum {}).",
315 - "why_explicit": "explicitly installed",
316 - "why_dependency": "dependency of",
317 - "why_not_installed": "not installed",
318 - "autoremove_ok": "Removed {} orphaned packages.",
319 - "autoremove_none": "No orphaned packages.",
320 - "downloaded": "Downloaded {} to cache ({:.2f} MB).",
321 - "provides_mapped": "{} → {} (provides)",
322 - "stats_title": "PAG Statistics",
323 - "stats_packages": "Installed packages",
324 - "stats_files": "Tracked files",
325 - "stats_size": "Total size",
326 - "stats_cache": "Cache size",
327 - "stats_history": "Transactions",
328 - "stats_last_update": "Last update",
329 - },
330 - "pl": {
331 - "root_required": "pag wymaga uprawnień root (sudo).",
332 - "db_locked": "Inna instancja pag jest uruchomiona.",
333 - "db_lock_hint": "Jeśli żaden inny proces pag nie działa, poczekaj chwilę i spróbuj ponownie.",
334 - "no_index": "Nie można pobrać indeksów repozytoriów. Uruchom 'pag update'.",
335 - "cache_ro": "Cache repozytoriów jest tylko-do-odczytu ({cache}) – używam lokalnego indeksu (może być nieaktualny).\n Odśwież jako root: sudo pag sync",
336 - "all_installed": "Wszystkie pakiety są już zainstalowane.",
337 - "to_install": "Do zainstalowania: {} pakietów ({:.2f} MB)",
338 - "new": "NOWY",
339 - "continue_q": "Kontynuować? [T/n] ",
340 - "no_tty": "Brak terminala (EOF) – anuluję.",
341 - "cancelled": "Anulowano.",
342 - "not_found": "brak w repozytoriach",
343 - "pkg_not_found": "Nie znaleziono pakietu: {} (brak w repozytoriach)",
344 - "not_found_hint": "Sprawdź pisownię lub uruchom 'pag search <fraza>'.",
345 - "downloading": "Pobieranie",
346 - "download_fail": "błąd pobierania",
347 - "gpg_fail": "błąd weryfikacji GPG",
348 - "sha256_mismatch": "niezgodność SHA256",
349 - "installed": "Zainstalowano {} pakietów.",
350 - "rollback_restored": "Przywrócono poprzedni stan z migawki.",
351 - "rollback_files": "Wycofano {} plików.",
352 - "no_history": "Brak historii transakcji.",
353 - "pinned_list": "Przypięte pakiety ({}):",
354 - "no_pinned": "Brak przypiętych pakietów.",
355 - "pinned_to": "przypięty do",
356 - "unpinned": "odpięty.",
357 - "not_pinned": "nie był przypięty.",
358 - "repo_added": "Dodano repozytorium: {}",
359 - "repo_exists": "Repozytorium już istnieje: {}",
360 - "updated_done": "Odświeżanie zakończone. {} pakietów w cache.",
361 - "indexes_refreshed": "Indeksy odświeżone.",
362 - "updates_available": "⚠ jest {} pakietów do zaktualizowania – wpisz: pag update",
363 - "upgrading": "Aktualizacje: {} pakietów",
364 - "all_up_to_date": "Wszystkie pakiety są aktualne.",
365 - "removing": "Usuwanie",
366 - "orphans_found": "Osierocone zależności ({}): {}",
367 - "flatpak_missing": "Flatpak nie jest zainstalowany.",
368 - "flatpak_adding": "Dodaję zdalne repozytorium Flathub...",
369 - "flatpak_searching": "Szukam '{}' we Flathub...",
370 - "flatpak_found": "Znaleziono {} wyników:",
371 - "flatpak_not_found": "nie znaleziono we Flathub",
372 - "flatpak_install_prompt": "Zainstalować {}? [T/n] ",
373 - "flatpak_installing": "Instalowanie {}...",
374 - "flatpak_installed": "Flatpak {} zainstalowany.",
375 - "flatpak_removed": "Flatpak {} usunięty.",
376 - "flatpak_not_installed": "Flatpak {} nie jest zainstalowany.",
377 - "flatpak_info_id": "ID",
378 - "flatpak_info_version": "Wersja",
379 - "flatpak_info_branch": "Gałąź",
380 - "flatpak_info_origin": "Źródło",
381 - "flatpak_info_size": "Rozmiar",
382 - "flatpak_info_desc": "Opis",
383 - "flatpak_updated": "Flapaki zaktualizowane.",
384 - "flatpak_usage": "Użycie: pag flatpak <search|install|remove|list|update|info> [args]",
385 - "key_imported": "Klucz zaimportowany pomyślnie.",
386 - "key_removed": "Klucz usunięty: {}",
387 - "no_keys": "Brak zaufanych kluczy GPG.",
388 - "verify_ok": "Wszystkie {} plików sprawne.",
389 - "verify_errors": "Znaleziono {} problemów:",
390 - "cache_cleared": "{} plików ({:.2f} MB) usuniętych z cache.",
391 - "deployments_list": "Deploymenty ({}):",
392 - "no_deployments": "Brak deploymentów.",
393 - "active_deployment": "AKTYWNY",
394 - "deploy_rollback_ok": "Przełączono na deployment: {}",
395 - "deploy_rollback_fail": "Brak poprzedniego deploymentu.",
396 - "deploy_cleanup_ok": "Usunięto {} starych deploymentów.",
397 - "deploy_cleanup_none": "Nie ma deploymentów do wyczyszczenia (minimum {}).",
398 - "why_explicit": "zainstalowany jawnie",
399 - "why_dependency": "zależność od",
400 - "why_not_installed": "niezainstalowany",
401 - "autoremove_ok": "Usunięto {} osieroconych pakietów.",
402 - "autoremove_none": "Brak osieroconych pakietów.",
403 - "downloaded": "Pobrano {} do cache ({:.2f} MB).",
404 - "sec_downgrade": "Downgrade blocked: {pkg} {new} < {old}",
405 - "sec_suid": "SUID stripped from {path}",
406 - "sec_https": "HTTPS required for repos",
407 - "sec_badname": "Invalid package name: {name}",
408 - "sec_toobig": "Package too large: {size_mb}MB > {max_mb}MB",
409 - "sec_conflict": "File conflict: {path} owned by {owner}",
410 - "sec_audit": "{pkg} installed by {user}",
411 - "sec_locked": "Another pag process is running",
412 - "sec_downgrade_pl": "Blokada downgrade: {pkg} {new} < {old}",
413 - "sec_suid_pl": "SUID usuniety z {path}",
414 - "sec_https_pl": "Repozytorium wymaga HTTPS",
415 - "sec_badname_pl": "Nieprawidlowa nazwa pakietu: {name}",
416 - "sec_toobig_pl": "Paczka za duza: {size_mb}MB > {max_mb}MB",
417 - "sec_conflict_pl": "Konflikt plikow: {path} nalezy do {owner}",
418 - "sec_audit_pl": "{pkg} zainstalowany przez {user}",
419 - "sec_locked_pl": "Inny proces pag juz dziala",
420 -
421 - "provides_mapped": "{} → {} (provides)",
422 - "stats_title": "Statystyki PAG",
423 - "stats_packages": "Zainstalowane pakiety",
424 - "stats_files": "Śledzone pliki",
425 - "stats_size": "Całkowity rozmiar",
426 - "stats_cache": "Rozmiar cache",
427 - "stats_history": "Transakcje",
428 - "stats_last_update": "Ostatnia aktualizacja",
429 - },
430 -}
431 -
432 -def _(key: str, *args, **kwargs) -> str:
433 - """Tłumaczy klucz i formatuje argumenty."""
434 - msg = T.get(LANG, T["en"]).get(key, T["en"].get(key, key))
435 - if args or kwargs:
436 - return msg.format(*args, **kwargs)
437 - return msg
438 -
439 -
440 -def _ask_confirm() -> bool:
441 - """Pytanie potwierdzające (T/n). PAG_YES=1 → zawsze tak.
442 -
443 - EOF/brak terminala (stdin zamknięty, np. ssh bez TTY, cron, subprocess
444 - panelu webowego) → NIE – anuluj, nie wykonuj operacji bez potwierdzenia
445 - (inaczej input() rzuca EOFError i pag pada tracebackiem).
446 - Enter → tak (domyślne Y/n).
447 - """
448 - if os.environ.get("PAG_YES", "") == "1":
449 - print(_("continue_q") + " t (--yes)")
450 - return True
451 - try:
452 - ans = input(_("continue_q")).strip().lower()
453 - except (EOFError, KeyboardInterrupt):
454 - print(f"\n ⚠ {_('no_tty')}")
455 - return False
456 - return not ans or ans in ("t", "y")
457 -
458 -
459 -# =============================================================================
460 -# ŚCIEŻKI
461 -# =============================================================================
462 -PAG_ROOT = os.environ.get("PAG_ROOT", "/")
463 -PAG_DB = "/var/lib/pag"
464 -PAG_CACHE = "/var/cache/pag"
465 -PAG_CONF = "/etc/pag"
466 -REPO_CACHE = "/var/cache/pag/repos"
467 -REPOS_CONF = "/etc/pag/repos.conf"
468 -REPOS_DIR = PAG_CONF + "/repos" # drop-in: /etc/pag/repos/<nazwa>.conf
469 -INSTALLED_DB = "/var/lib/pag/installed.json"
470 -FILES_DB_SQL = "/var/lib/pag/files.db" # SQLite!
471 -WORLD_FILE = "/var/lib/pag/world"
472 -PINNED_FILE = "/var/lib/pag/pinned.json"
473 -HISTORY_FILE = "/var/lib/pag/history.json"
474 -LOCK_FILE = "/var/lib/pag/pag.lock"
475 -STAGING_DIR = "/.pag_staging" # na tej samej partycji co / (unikamy EXDEV)
476 -PKG_EXT = ".pag"
477 -REPO_CACHE_TTL = 3600
478 -MAX_PKG_SIZE = 2 * 1024 * 1024 * 1024 # 2 GB – maksymalny rozmiar paczki
479 -ALLOWED_PKG_RE = re.compile(r'^[a-zA-Z0-9][a-zA-Z0-9._+@-]*$')
480 -
481 -# Bezpieczeństwo / audyt
482 -AUDIT_LOG = "/var/log/pag/audit.log" # dziennik operacji krytycznych (hooki, self-update)
483 -TRUST_DB = "/etc/pag/trusted.json" # mapa repo_url → fingerprint klucza podpisującego
484 -HOOK_API_VERSION = "1" # wersjonowane API hooków (env PKG_HOOK_API)
485 -
486 -# =============================================================================
487 -# IMMUTABLE OS – DEPLOYMENTY
488 -# =============================================================================
489 -# Model: zamiast mutować /, każda operacja tworzy NOWY deployment.
490 -# /var, /etc, /home są współdzielone między deploymentami.
491 -#
492 -# STRUKTURA:
493 -# /.deployments/
494 -# active → 20260723T120000 (symlink do aktywnego)
495 -# 20260723T120000/
496 -# usr/ bin/ lib/ lib64/ ... (pełny system)
497 -# var → /var (symlink do współdzielonego)
498 -# etc → /etc
499 -# home → /home
500 -# ...
501 -#
502 -# Jak to działa:
503 -# 1. pag install → kopiuje active → nowy deployment + nakłada zmiany → switch symlinka
504 -# 2. pag remove → kopiuje active → nowy deployment - usuwa pliki → switch symlinka
505 -# 3. pag deploy-rollback → przełącza active symlink na poprzedni deployment
506 -# 4. Przy starcie systemu: initrd montuje /.deployments/active jako /
507 -# =============================================================================
508 -
509 -DEPLOYMENTS_DIR = "/.deployments"
510 -ACTIVE_LINK = "/.deployments/active"
511 -DEPLOYMENTS_DB = "/var/lib/pag/deployments.json"
512 -
513 -# Ścieżki współdzielone – NIE wchodzą do deploymentu (są symlinkami do /...)
514 -SHARED_PATHS = {
515 - "/var", "/etc", "/home", "/root", "/tmp", "/run",
516 - "/dev", "/proc", "/sys", "/mnt", "/media", "/srv",
517 - "/.deployments", "/.pag_staging",
518 -}
519 -
520 -def _is_shared_path(rel: str) -> bool:
521 - """Sprawdza czy ścieżka należy do katalogów współdzielonych (poza deploymentem)."""
522 - for sp in SHARED_PATHS:
523 - if rel == sp or rel.startswith(sp + "/"):
524 - return True
525 - return False
526 -
527 -def _get_deployment_root() -> str:
528 - """Zwraca ścieżkę do aktywnego deploymentu, lub PAG_ROOT jeśli tryb niemutowalny wyłączony."""
529 - if os.environ.get("PAG_IMMUTABLE", "") in ("0", "no", "false", ""):
530 - return PAG_ROOT
531 - if os.path.islink(ACTIVE_LINK):
532 - return os.readlink(ACTIVE_LINK)
533 - if os.path.isdir(ACTIVE_LINK):
534 - return ACTIVE_LINK
535 - # Brak deploymentów – użyj /
536 - return PAG_ROOT
537 -
538 -def _load_deployments() -> List[dict]:
539 - """Wczytuje historię deploymentów."""
540 - if not os.path.exists(DEPLOYMENTS_DB):
541 - return []
542 - try:
543 - return json.load(open(DEPLOYMENTS_DB))
544 - except Exception:
545 - return []
546 -
547 -def _save_deployments(deployments: List[dict]):
548 - os.makedirs(os.path.dirname(DEPLOYMENTS_DB), exist_ok=True)
549 - json.dump(deployments, open(DEPLOYMENTS_DB, "w"), indent=2)
550 -
551 -def _create_deployment(pkg_names: List[str], action: str) -> Tuple[str, str]:
552 - """
553 - Tworzy nowy deployment przez skopiowanie aktywnego (CoW) i zwraca jego ścieżkę.
554 - Zwraca (deployment_dir, deployment_id).
555 - """
556 - deploy_id = datetime.now().strftime("%Y%m%dT%H%M%S")
557 - deploy_dir = os.path.join(DEPLOYMENTS_DIR, deploy_id)
558 - os.makedirs(DEPLOYMENTS_DIR, exist_ok=True)
559 -
560 - active = _get_deployment_root()
561 -
562 - if os.path.isdir(active) and active != PAG_ROOT:
563 - # Trójstopniowa strategia kopiowania deploymentu:
564 - # 1. reflink (CoW – btrfs, xfs) → 0 MB kopiowane
565 - # 2. hardlink (linki twarde) → 0 MB kopiowane, tylko inody
566 - # 3. zwykłe cp (ostateczność) → pełna kopia
567 - print(f" ⚡ Kopiowanie aktywnego deploymentu...")
568 - copied = False
569 - for method, cmd, label in [
570 - ("reflink", ["cp", "--reflink=auto", "-a", active + "/.", deploy_dir + "/"], "CoW (reflink)"),
571 - ("hardlink", ["cp", "-al", active + "/.", deploy_dir + "/"], "hardlinki"),
572 - ("copy", ["cp", "-a", active + "/.", deploy_dir + "/"], "pełna kopia"),
573 - ]:
574 - try:
575 - subprocess.run(cmd, check=True, timeout=600, capture_output=True)
576 - print(f" ✅ Deployment: {deploy_id} ({label})")
577 - copied = True
578 - break
579 - except subprocess.CalledProcessError:
580 - if method == "copy":
581 - raise # ostatnia deska – niech leci wyjątek
582 - continue
583 - if not copied:
584 - raise RuntimeError("Nie udało się skopiować deploymentu żadną metodą")
585 - else:
586 - # Pierwszy deployment – tylko katalogi szkieletowe
587 - for d in ["/usr", "/lib", "/lib64", "/bin", "/sbin", "/boot", "/opt"]:
588 - if os.path.isdir(d):
589 - dest = os.path.join(deploy_dir, d.lstrip("/"))
590 - os.makedirs(dest, exist_ok=True)
591 - print(f" ✅ Pierwszy deployment: {deploy_id}")
592 -
593 - # Utwórz symlinki do współdzielonych katalogów
594 - for sp in SHARED_PATHS:
595 - link_dst = os.path.join(deploy_dir, sp.lstrip("/"))
596 - if not os.path.lexists(link_dst) and os.path.isdir(sp):
597 - os.symlink(sp, link_dst)
598 -
599 - # Zapisz w bazie deploymentów
600 - deployments = _load_deployments()
601 - deployments.append({
602 - "id": deploy_id,
603 - "action": action,
604 - "packages": pkg_names,
605 - "timestamp": datetime.now().isoformat(),
606 - "active": True,
607 - })
608 - # Oznacz poprzednie jako nieaktywne
609 - for d in deployments[:-1]:
610 - d["active"] = False
611 - _save_deployments(deployments)
612 -
613 - return deploy_dir, deploy_id
614 -
615 -def _switch_deployment(deploy_dir: str) -> bool:
616 - """Atomowo przełącza aktywny deployment przez podmianę symlinka."""
617 - tmp_link = ACTIVE_LINK + ".new"
618 - if os.path.lexists(tmp_link):
619 - os.remove(tmp_link)
620 - os.symlink(deploy_dir, tmp_link)
621 - os.rename(tmp_link, ACTIVE_LINK) # atomowe na tym samym FS
622 - return True
623 -
624 -DEFAULT_REPOS = [
625 - "https://repo.paganlinux.eu/stable/",
626 -]
627 -
628 -# =============================================================================
629 -# INICJALIZACJA
630 -# =============================================================================
631 -
632 -def ensure_dirs():
633 - for d in [PAG_DB, PAG_CACHE, PAG_CONF, REPO_CACHE, REPOS_DIR, STAGING_DIR, DEPLOYMENTS_DIR]:
634 - os.makedirs(d, exist_ok=True)
635 - for f, default in [
636 - (REPOS_CONF, "\n".join(DEFAULT_REPOS) + "\n"),
637 - (INSTALLED_DB, "{}"),
638 - (PINNED_FILE, "{}"),
639 - (HISTORY_FILE, "[]"),
640 - ]:
641 - if not os.path.exists(f):
642 - with open(f, "w") as fh: fh.write(default)
643 - if not os.path.exists(WORLD_FILE):
644 - Path(WORLD_FILE).touch()
645 - if not os.path.exists(GPG_HOME):
646 - os.makedirs(GPG_HOME, exist_ok=True)
647 - os.chmod(GPG_HOME, 0o700)
648 - _gpg_run("--list-keys", capture_output=True)
649 - # Inicjalizuj SQLite
650 - _db_init()
651 - # Wyczyść staging po poprzednim przerwanym buildzie/instalacji
652 - if os.path.isdir(STAGING_DIR):
653 - for entry in os.listdir(STAGING_DIR):
654 - if entry == "backups":
655 - continue # backupy starych wersji – potrzebne do `pag rollback`
656 - path = os.path.join(STAGING_DIR, entry)
657 - try:
658 - if os.path.isfile(path) or os.path.islink(path):
659 - os.unlink(path)
660 - elif os.path.isdir(path):
661 - shutil.rmtree(path, ignore_errors=True)
662 - except OSError:
663 - pass
664 -
665 -# =============================================================================
666 -# SQLITE – BAZA PLIKÓW (poprawne zarządzanie połączeniami)
667 -# =============================================================================
668 -
669 -from contextlib import contextmanager
670 -
671 -@contextmanager
672 -def _db_session():
673 - """Context manager – gwarantuje zamknięcie połączenia."""
674 - conn = sqlite3.connect(FILES_DB_SQL, timeout=15)
675 - conn.execute("PRAGMA journal_mode=WAL")
676 - conn.execute("PRAGMA synchronous=NORMAL")
677 - conn.execute("PRAGMA foreign_keys=ON")
678 - conn.execute("PRAGMA busy_timeout=15000")
679 - conn.row_factory = sqlite3.Row
680 - try:
681 - yield conn
682 - conn.commit()
683 - except Exception:
684 - conn.rollback()
685 - raise
686 - finally:
687 - conn.close()
688 -
689 -
690 -def _db_init():
691 - """Tworzy tabele SQLite jeśli nie istnieją."""
692 - with _db_session() as db:
693 - db.execute("""
694 - CREATE TABLE IF NOT EXISTS files (
695 - id INTEGER PRIMARY KEY AUTOINCREMENT,
696 - path TEXT NOT NULL,
697 - package TEXT NOT NULL,
698 - sha256 TEXT,
699 - size INTEGER,
700 - is_symlink INTEGER DEFAULT 0,
701 - symlink_target TEXT,
702 - UNIQUE(path, package)
703 - )
704 - """)
705 - db.execute("CREATE INDEX IF NOT EXISTS idx_files_path ON files(path)")
706 - db.execute("CREATE INDEX IF NOT EXISTS idx_files_pkg ON files(package)")
707 - db.execute("""
708 - CREATE TABLE IF NOT EXISTS file_checksums (
709 - path TEXT PRIMARY KEY,
710 - sha256 TEXT NOT NULL,
711 - installed_at TEXT
712 - )
713 - """)
714 - db.commit()
715 -
716 -def _db_record_files(pkg_name: str, files: List[dict]):
717 - """Zapisuje pliki do SQLite (obsługuje symlinki)."""
718 - with _db_session() as db:
719 - # Jawna transakcja – atomowość obu zapisów i szybsze wykrycie blokady
720 - try:
721 - db.execute("BEGIN IMMEDIATE")
722 - except sqlite3.OperationalError:
723 - pass # transakcja już otwarta (implicit)
724 - db.executemany(
725 - "INSERT OR REPLACE INTO files (path, package, sha256, size, is_symlink, symlink_target) "
726 - "VALUES (?,?,?,?,?,?)",
727 - [(f["path"], pkg_name, f.get("sha256",""), f.get("size",0),
728 - f.get("is_symlink", 0), f.get("symlink_target", ""))
729 - for f in files]
730 - )
731 - db.executemany(
732 - "INSERT OR REPLACE INTO file_checksums (path, sha256, installed_at) VALUES (?,?,?)",
733 - [(f["path"], f.get("sha256",""), datetime.now().isoformat())
734 - for f in files if f.get("sha256")]
735 - )
736 -
737 -def _db_get_package_files(pkg_name: str) -> List[str]:
738 - with _db_session() as db:
739 - return [r["path"] for r in db.execute(
740 - "SELECT DISTINCT path FROM files WHERE package=?", (pkg_name,)
741 - )]
742 -
743 -def _db_get_file_owners(filepath: str) -> List[str]:
744 - """Zwraca listę pakietów będących właścicielami pliku."""
745 - with _db_session() as db:
746 - return [r["package"] for r in db.execute(
747 - "SELECT package FROM files WHERE path=?", (filepath,)
748 - )]
749 -
750 -def _db_remove_package_files(pkg_name: str):
751 - with _db_session() as db:
752 - db.execute("DELETE FROM files WHERE package=?", (pkg_name,))
753 - db.commit()
754 -
755 -def _db_get_all_file_checksums() -> Dict[str, str]:
756 - with _db_session() as db:
757 - return {r["path"]: r["sha256"] for r in db.execute("SELECT path, sha256 FROM file_checksums")}
758 -
759 -def _db_count_files() -> int:
760 - with _db_session() as db:
761 - return db.execute("SELECT COUNT(*) FROM files").fetchone()[0]
762 -
763 -# =============================================================================
764 -# BLOKADA
765 -# =============================================================================
766 -
767 -class DatabaseLock:
768 - """Blokada plikowa (flock) – jądro zwalnia ją AUTOMATYCZNIE, gdy proces
769 - ginie (kill -9, twardy reset). Stary PID-file miał race condition: po
770 - śmierci pag PID mógł zostać przydzielony obcemu procesowi (PID reuse)
771 - i pag odmawiał działania na zawsze („baza zablokowana”).
772 - """
773 - def __init__(self):
774 - self._f = None
775 - def __enter__(self):
776 - os.makedirs(os.path.dirname(LOCK_FILE), exist_ok=True)
777 - self._f = open(LOCK_FILE, "w")
778 - try:
779 - # LOCK_NB: rzuca wyjątek zamiast czekać w nieskończoność
780 - fcntl.flock(self._f, fcntl.LOCK_EX | fcntl.LOCK_NB)
781 - except BlockingIOError:
782 - print(f"❌ {_('db_locked')}", file=sys.stderr)
783 - print(f" {_('db_lock_hint', LOCK_FILE)}", file=sys.stderr)
784 - sys.exit(1)
785 - self._f.write(str(os.getpid()))
786 - self._f.flush()
787 - return self
788 - def __exit__(self, *args):
789 - if self._f:
790 - try:
791 - fcntl.flock(self._f, fcntl.LOCK_UN)
792 - except OSError:
793 - pass
794 - self._f.close()
795 - self._f = None
796 - # Uwaga: NIE usuwamy pliku blokady. Stały plik + flock na inode to jedyny
797 - # bezpieczny wzorzec – os.remove(), gdy inny proces trzyma blokadę na starym
798 - # inode, otwiera wyścig (nowy proces blokowałby nowo utworzony inode).
799 -
800 -# =============================================================================
801 -# POMOCNICZE
802 -# =============================================================================
803 -
804 -
805 -_ALLOWED_PREFIXES = ("/usr/", "/etc/", "/var/", "/opt/",
806 - "/boot/", "/lib/", # kernel: vmlinuz/System.map + moduły (usrmerge: lib→usr/lib)
807 - # Pliki wewnętrzne paczki .pkg.tar.xz
808 - "metadata.json", "data.tar.xz", "hooks/",
809 - "sums.json")
810 -
811 -def _check_path_safety(name: str) -> bool:
812 - # Normalizuj – usuń leading ./
813 - if name.startswith("./"):
814 - name = name[2:]
815 - if name in (".", ""):
816 - return True
817 - # Porównuj z prefiksami BEZ wiodącego '/', by zarówno "/usr/bin/ls", jak i
818 - # wewnętrzne pliki pakietu ("hooks/pre-install", "data.tar.xz") przechodziły.
819 - norm = name.lstrip("/")
820 - for prefix in _ALLOWED_PREFIXES:
821 - p = prefix.lstrip("/").rstrip("/")
822 - if norm == p or norm.startswith(p + "/"):
823 - return True
824 - return False
825 -
826 -
827 -def _validate_pkg_name(name):
828 - return bool(ALLOWED_PKG_RE.match(name))
829 -
830 -
831 -
832 -def _audit(msg):
833 - from datetime import datetime, timezone
834 - os.makedirs(os.path.dirname(AUDIT_LOG), exist_ok=True)
835 - with open(AUDIT_LOG, "a") as f:
836 - f.write(datetime.now(timezone.utc).isoformat() + " " + msg + "\n")
837 -
838 -def _strip_suid(path):
839 - try:
840 - st = os.stat(path)
841 - if st.st_mode & 0o4000:
842 - os.chmod(path, st.st_mode & ~0o4000)
843 - print(f" {_("sec_suid", path=path)}")
844 - except OSError:
845 - pass
846 -
847 -def _check_downgrade(pkg_name, new_ver, installed_db):
848 - if pkg_name in installed_db:
849 - old = installed_db[pkg_name].get("version", "0")
850 - if new_ver < old:
851 - print(f" {_("sec_downgrade", pkg=pkg_name, new=new_ver, old=old)}")
852 - return False
853 - return True
854 -
855 -def _safe_extractall(tar: tarfile.TarFile, dest: str, *, preserve_perms: bool = True):
856 - """
857 - Bezpieczne rozpakowanie archiwum tar z ochroną przed Directory Traversal.
858 -
859 - Działa na Python < 3.12 (gdzie parametr 'filter' w extractall nie istnieje)
860 - oraz na Python 3.12+. W przeciwieństwie do filtra 'data' z Pythona 3.12,
861 - zachowuje bity uprawnień POSIX (SUID, SGID, sticky) – preserve_perms=True.
862 -
863 - Ochrona oparta jest na FINALNEJ ścieżce (os.path.realpath), nie tylko na
864 - prostym sprawdzaniu stringa:
865 - - Blokuje ścieżki absolutne i z '..' (path traversal)
866 - - Blokuje symlinki/hardlinki, których cel wychodzi poza dest
867 - - Blokuje zapis "przez" złośliwy symlink, który został wcześniej
868 - rozpakowany (np. katalog → /etc, potem zapis katalog/plik)
869 - - Zachowuje oryginalne uprawnienia plików
870 - """
871 - dest_real = os.path.realpath(dest)
872 - os.makedirs(dest_real, exist_ok=True)
873 -
874 - def _target_within(path: str) -> bool:
875 - try:
876 - return os.path.commonpath([dest_real, os.path.realpath(path)]) == dest_real
877 - except ValueError:
878 - # różne napędy / ścieżki nie da się wspólnie porównać → odrzuć
879 - return False
880 -
881 - for member in tar.getmembers():
882 - name = member.name
883 -
884 - # --- Ochrona przed Directory Traversal (szybkie string-checki) ---
885 - if name.startswith('/'):
886 - continue
887 - if '..' in name.split('/'):
888 - continue
889 - # Zablokuj bajt NUL i backslash (bugi/obejścia tarfile na niektórych platformach)
890 - if '\x00' in name or '\\' in name:
891 - continue
892 - if not _check_path_safety(name):
893 - print(f" BLOCKED: {name}")
894 - continue
895 -
896 - target = os.path.join(dest, name)
897 -
898 - # --- Ochrona na podstawie finalnej ścieżki ---
899 - # Jeśli którykolwiek komponent nadrzędny jest (złośliwym) symlinkiem
900 - # wskazującym poza dest, realpath to wykryje – zablokuj zapis.
901 - if not _target_within(target):
902 - print(f" BLOCKED (escape): {name}")
903 - continue
904 -
905 - # --- Ochrona dla symlinków i hardlinków ---
906 - if member.issym() or member.islnk():
907 - link = member.linkname
908 - # Szybkie odrzucenie linków absolutnych / z '..'
909 - if link.startswith('/') or '..' in link.split('/'):
910 - continue
911 - # Sprawdź, gdzie realnie prowadzi cel linku (względem katalogu linku)
912 - link_target = os.path.join(os.path.dirname(target), link)
913 - if not _target_within(link_target):
914 - print(f" BLOCKED (link escape): {name} -> {link}")
915 - continue
916 -
917 - # Rozpakuj z zachowaniem metadanych. Python 3.12+ wymaga jawnego
918 - # `filter=` (inaczej DeprecationWarning, w 3.14+ błąd) – nasza ręczna
919 - # walidacja powyżej już zabezpiecza ścieżki, więc 'fully_trusted'
920 - # (pomija filtr Pythona i zachowuje SUID/SGID/sticky z preserve_perms).
921 - try:
922 - if hasattr(tarfile, 'data_filter'):
923 - # Python 3.12+
924 - tar.extract(member, dest, set_attrs=preserve_perms, numeric_owner=False,
925 - filter='fully_trusted')
926 - else:
927 - tar.extract(member, dest, set_attrs=preserve_perms, numeric_owner=False)
928 - except Exception as e:
929 - print(f" ⚠ Nie rozpakowano {name}: {e}")
930 - continue
931 - _strip_suid(target)
932 -
933 -
934 -def _sha256_file(path: str) -> str:
935 - h = hashlib.sha256()
936 - with open(path, "rb") as f:
937 - for chunk in iter(lambda: f.read(65536), b""):
938 - h.update(chunk)
939 - return h.hexdigest()
940 -
941 -def _split_version(v: str):
942 - """Rozdziela wersję na (release_parts, prerelease_parts).
943 -
944 - Przykład: '1.2.0-rc1' → ([1,2,0], ['rc','1']).
945 - """
946 - v = v.strip().lower().lstrip("v")
947 - # build metadata po '+' jest ignorowane przy porównywaniu (semver)
948 - v = v.split("+", 1)[0]
949 - # prerelease po '-' lub '_' (np. 1.2.0-rc1, 1.2.0_rc1)
950 - if "-" in v:
951 - rel, pre = v.split("-", 1)
952 - elif "_" in v:
953 - rel, pre = v.split("_", 1)
954 - else:
955 - rel, pre = v, ""
956 - nums = []
957 - for part in rel.split("."):
958 - m = re.match(r"(\d+)", part)
959 - nums.append(int(m.group(1)) if m else 0)
960 - pre_parts = [p for p in pre.split(".") if p]
961 - return nums, pre_parts
962 -
963 -
964 -def _cmp_pre(a, b):
965 - """Porównuje ciągi identyfikatorów prerelease (reguły semver)."""
966 - for i in range(max(len(a), len(b))):
967 - if i >= len(a):
968 - return -1 # krótszy prerelease jest niższy
969 - if i >= len(b):
970 - return 1
971 - ia, ib = a[i], b[i]
972 - if ia == ib:
973 - continue
974 - na, nb = ia.isdigit(), ib.isdigit()
975 - if na and nb:
976 - return 1 if int(ia) > int(ib) else -1
977 - if na != nb:
978 - return -1 if na else 1 # identyfikator liczbowy < alfanumeryczny
979 - return 1 if ia > ib else -1
980 - return 0
981 -
982 -
983 -def _cmp_version(a: str, b: str) -> int:
984 - """Porównuje dwie wersje; zwraca -1/0/1. Obsługuje prerelease (rc1, beta...)."""
985 - a_rel, a_pre = _split_version(a)
986 - b_rel, b_pre = _split_version(b)
987 - # Porównaj część release (brakujące komponenty traktuj jako 0)
988 - for i in range(max(len(a_rel), len(b_rel))):
989 - xa = a_rel[i] if i < len(a_rel) else 0
990 - xb = b_rel[i] if i < len(b_rel) else 0
991 - if xa != xb:
992 - return 1 if xa > xb else -1
993 - # Część release równa → decyduje prerelease.
994 - # Wersja finalna (bez prerelease) jest ZAWSZE nowsza od prerelease.
995 - if not a_pre and not b_pre:
996 - return 0
997 - if not a_pre:
998 - return 1
999 - if not b_pre:
1000 - return -1
1001 - return _cmp_pre(a_pre, b_pre)
1002 -
1003 -
1004 -def _version_newer(a: str, b: str) -> bool:
1005 - """True gdy wersja a jest nowsza od b (z poprawną obsługą prerelease)."""
1006 - try:
1007 - return _cmp_version(a, b) > 0
1008 - except Exception:
1009 - return a != b
1010 -
1011 -def load_json(path):
1012 - try:
1013 - with open(path) as f:
1014 - return json.load(f)
1015 - except (FileNotFoundError, json.JSONDecodeError):
1016 - return {}
1017 -
1018 -def save_json(path, data):
1019 - with open(path, "w") as f:
1020 - json.dump(data, f, indent=2)
1021 -
1022 -class PackageInfo:
1023 - __slots__ = ("name","version","release","description","dependencies",
1024 - "size_bytes","sha256","gpg_fp","repo_url","filename","provides","license",
1025 - "provides_so","requires_so")
1026 - def __init__(self, d, repo=""):
1027 - self.name = d.get("name","?")
1028 - self.version = d.get("version","0")
1029 - self.release = d.get("release", 1)
1030 - self.description = d.get("description","")
1031 - self.dependencies = d.get("dependencies", d.get("depends", []))
1032 - self.size_bytes = d.get("size",0)
1033 - self.sha256 = d.get("sha256","")
1034 - self.gpg_fp = d.get("gpg_fingerprint","")
1035 - self.repo_url = repo
1036 - self.filename = d.get("filename", f"{self.name}-{self.version}{PKG_EXT}")
1037 - self.provides = d.get("provides", []) or []
1038 - self.license = d.get("license", []) or []
1039 - self.provides_so = d.get("provides_so", []) or []
1040 - self.requires_so = d.get("requires_so", []) or []
1041 -
1042 -# =============================================================================
1043 -# REPOZYTORIA (cache, ETag, GPG)
1044 -# =============================================================================
1045 -
1046 -def _parse_repos_config():
1047 - """Parsuje repozytoria z /etc/pag/repos.conf oraz /etc/pag/repos/*.conf.
1048 -
1049 - Format linii: <url> [fingerprint]
1050 - Opcjonalny `fingerprint` (40 znaków hex) pozwala przypiąć klucz
1051 - podpisujący repo do konkretnego adresu – wtedy TOFU (auto-zaufanie przy
1052 - pierwszym użyciu) nie jest potrzebne, a zmiana klucza = błąd bezpieczeństwa.
1053 -
1054 - Drop-iny (np. stable.conf) są czytane alfabetycznie – pozwalają na
1055 - wygodne dodawanie repo bez dotykania głównego repos.conf
1056 - (np. `echo 'https://repo.paganlinux.eu/stable' > /etc/pag/repos/stable.conf`).
1057 - """
1058 - entries = []
1059 -
1060 - def _read_lines(path):
1061 - if not os.path.exists(path):
1062 - return
1063 - for line in open(path):
1064 - line = line.strip()
1065 - if not line or line.startswith("#"):
1066 - continue
1067 - parts = line.split()
1068 - url = parts[0].rstrip("/")
1069 - fp = parts[1].lower() if len(parts) > 1 else ""
1070 - entries.append({"url": url, "fingerprint": fp or None})
1071 -
1072 - # 1) Legacy: pojedynczy plik /etc/pag/repos.conf
1073 - _read_lines(REPOS_CONF)
1074 - # 2) Drop-in: /etc/pag/repos/<nazwa>.conf (sortowane, stabilna kolejność)
1075 - if os.path.isdir(REPOS_DIR):
1076 - for drop in sorted(os.listdir(REPOS_DIR)):
1077 - if drop.endswith(".conf"):
1078 - _read_lines(os.path.join(REPOS_DIR, drop))
1079 -
1080 - # Dedupe po URL (zachowaj pierwszy wpis – może mieć fingerprint)
1081 - seen, unique = set(), []
1082 - for e in entries:
1083 - if e["url"] not in seen:
1084 - seen.add(e["url"])
1085 - unique.append(e)
1086 -
1087 - if not unique:
1088 - for url in DEFAULT_REPOS:
1089 - unique.append({"url": url, "fingerprint": None})
1090 - return unique
1091 -
1092 -
1093 -def get_repos():
1094 - return [e["url"] for e in _parse_repos_config()]
1095 -
1096 -
1097 -def _repo_pinned_fp(repo_url):
1098 - """Zwraca przypięty fingerprint klucza dla repo (z konfiguracji lub trust DB)."""
1099 - by_url = {e["url"]: e["fingerprint"] for e in _parse_repos_config()}
1100 - if by_url.get(repo_url):
1101 - return by_url[repo_url]
1102 - db = _load_trust_db()
1103 - fp = db.get(repo_url)
1104 - return fp.lower() if fp else None
1105 -
1106 -def _repo_cache_path(url):
1107 - return os.path.join(REPO_CACHE, url.replace("://","_").replace("/","_").replace(".","_") + ".json")
1108 -
1109 -def _repo_etag_path(url): return _repo_cache_path(url) + ".etag"
1110 -def _repo_ts_path(url): return _repo_cache_path(url) + ".ts"
1111 -
1112 -# Informacja (raz na uruchomienie), gdy cache repozytoriów jest tylko-do-odczytu –
1113 -# np. komendy read-only (`pag info`, `pag search`…) jako zwykły user: nie ma sensu
1114 -# ani prawa odświeżać /var/cache/pag/repos, więc używamy lokalnej kopii indeksu.
1115 -_cache_ro_notice_done = False
1116 -
1117 -def _cache_ro_notice():
1118 - global _cache_ro_notice_done
1119 - if _cache_ro_notice_done:
1120 - return
1121 - _cache_ro_notice_done = True
1122 - print(f" ⚠ {_('cache_ro', cache=REPO_CACHE)}", file=sys.stderr)
1123 -
1124 -def fetch_repo_index(repo_url, force=False):
1125 - cp = _repo_cache_path(repo_url)
1126 - ep = _repo_etag_path(repo_url)
1127 - tp = _repo_ts_path(repo_url)
1128 -
1129 - if not force and os.path.exists(cp) and os.path.exists(tp):
1130 - try:
1131 - if time.time() - float(open(tp).read().strip()) < REPO_CACHE_TTL:
1132 - return json.load(open(cp)).get("packages",[])
1133 - except: pass
1134 -
1135 - # --- Cache tylko-do-odczytu (np. `pag info` jako zwykły user) ---
1136 - # /var/cache/pag/repos należy do roota. Nie próbuj odświeżać ani pisać –
1137 - # zwykły user i tak nie zapisze indeksu; użyj lokalnej kopii (może być
1138 - # nieaktualna). Pełne odświeżenie indeksu: sudo pag sync
1139 - if not (os.path.isdir(REPO_CACHE) and os.access(REPO_CACHE, os.W_OK)):
1140 - if force:
1141 - print(f" ❌ {repo_url}: nie można odświeżyć indeksu – {REPO_CACHE} jest tylko-do-odczytu",
1142 - file=sys.stderr)
1143 - return None
1144 - _cache_ro_notice()
1145 - if os.path.exists(cp):
1146 - try:
1147 - return json.load(open(cp)).get("packages",[])
1148 - except Exception:
1149 - pass
1150 - return None
1151 -
1152 - headers = {"User-Agent": "pag/3.0"}
1153 - if os.path.exists(tp) and not force:
1154 - try:
1155 - lm = datetime.fromtimestamp(float(open(tp).read().strip()), tz=timezone.utc)
1156 - # Wymuś lokalizację C/POSIX dla nagłówków HTTP, aby unikać problemów z nazwami dni/miesięcy
1157 - try:
1158 - old_locale = locale.setlocale(locale.LC_TIME)
1159 - locale.setlocale(locale.LC_TIME, 'C')
1160 - headers["If-Modified-Since"] = lm.strftime("%a, %d %b %Y %H:%M:%S GMT")
1161 - locale.setlocale(locale.LC_TIME, old_locale)
1162 - except (locale.Error, ValueError):
1163 - # Jeśli ustawienie lokalizacji się nie powiedzie, użyj domyślnej
1164 - headers["If-Modified-Since"] = lm.strftime("%a, %d %b %Y %H:%M:%S GMT")
1165 - except: pass
1166 - if os.path.exists(ep) and not force:
1167 - try: headers["If-None-Match"] = open(ep).read().strip()
1168 - except: pass
1169 -
1170 - # --- Pobranie indeksu (błędy SIECI nie są błędami zapisu cache) ---
1171 - try:
1172 - req = Request(f"{repo_url}/repo.json", headers=headers)
1173 - with urlopen(req, timeout=30) as resp:
1174 - etag = resp.headers.get("ETag","")
1175 - raw = resp.read()
1176 - data = json.loads(raw.decode())
1177 - except HTTPError as e:
1178 - if e.code == 304:
1179 - # Serwer: indeks bez zmian – odśwież tylko znacznik czasu (best-effort)
1180 - try:
1181 - open(tp,"w").write(str(time.time()))
1182 - except OSError:
1183 - pass
1184 - if os.path.exists(cp):
1185 - try:
1186 - return json.load(open(cp)).get("packages",[])
1187 - except Exception:
1188 - pass # uszkodzona kopia – potraktuj jak brak (ostrzeżenie niżej)
1189 - print(f" ⚠ HTTP {e.code} dla {repo_url}", file=sys.stderr)
1190 - return None
1191 - except Exception as e:
1192 - print(f" ⚠ Błąd pobierania indeksu {repo_url}: {e}", file=sys.stderr)
1193 - if os.path.exists(cp):
1194 - try:
1195 - return json.load(open(cp)).get("packages",[])
1196 - except Exception:
1197 - pass
1198 - return None
1199 -
1200 - # Indeks pobrany – zapisz SUROWE bajty (nie re-serializuj! podpis GPG jest
1201 - # nad oryginalnymi bajtami repo.json z serwera) i zweryfikuj podpis.
1202 - # Najpierw zapis tymczasowy + weryfikacja GPG, dopiero potem podmiana cp:
1203 - # błąd zapisu (np. pełny dysk) nie niszczy starej, zweryfikowanej kopii
1204 - # i NIGDY nie zwracamy danych, które nie przeszły weryfikacji.
1205 - tmp_path = cp + ".tmp"
1206 - try:
1207 - with open(tmp_path, "wb") as f:
1208 - f.write(raw)
1209 - if not _verify_repo_sig(repo_url, tmp_path):
1210 - return None # weryfikacja nie powiodła się – stary cache zostaje
1211 - os.replace(tmp_path, cp)
1212 - # przenieś podpis obok docelowego pliku (marker „repo ma podpis")
1213 - for _ext in (".asc", ".sig"):
1214 - if os.path.exists(tmp_path + _ext):
1215 - try:
1216 - os.replace(tmp_path + _ext, cp + _ext)
1217 - except OSError:
1218 - pass
1219 - break
1220 - if etag:
1221 - try:
1222 - open(ep,"w").write(etag)
1223 - except OSError:
1224 - pass
1225 - try:
1226 - open(tp,"w").write(str(time.time()))
1227 - except OSError:
1228 - pass
1229 - return data.get("packages",[])
1230 - except OSError as e:
1231 - print(f" ⚠ Indeks pobrany, ale nie udało się zapisać cache ({REPO_CACHE}): {e}",
1232 - file=sys.stderr)
1233 - # cp nie został podmieniony (podmiana jest po weryfikacji) – lokalna kopia
1234 - # to wciąż stare, zweryfikowane dane
1235 - if os.path.exists(cp):
1236 - try:
1237 - return json.load(open(cp)).get("packages",[])
1238 - except Exception:
1239 - pass
1240 - return None
1241 - finally:
1242 - for _p in (tmp_path, tmp_path + ".asc", tmp_path + ".sig"):
1243 - try:
1244 - os.unlink(_p)
1245 - except OSError:
1246 - pass
1247 -
1248 -def _verify_repo_sig(repo_url, cache_path) -> bool:
1249 - """Weryfikuje podpis GPG indeksu repozytorium i przypina fingerprint.
1250 -
1251 - FAIL-CLOSED: brak/nieprawidłowy podpis = False (chyba że PAG_INSECURE=1).
1252 - Zwraca True jeśli indeks jest zaufany, False jeśli należy go odrzucić.
1253 -
1254 - Model zaufania (TOFU + pinning):
1255 - - Pierwszy raz (brak przypiętego fingerprintu) → klucz jest importowany,
1256 - a fingerprint zapisywany w /etc/pag/trusted.json z JAWNYM ostrzeżeniem.
1257 - To świadomy kompromis wygody i bezpieczeństwa.
1258 - - Kolejne uruchomienia: fingerprint jest porównywany z przypiętym.
1259 - Zmiana klucza = ❌ SECURITY ERROR (fail-closed), wymagane ręczne:
1260 - pag key-trust <repo_url> (po weryfikacji nowego klucza)
1261 - """
1262 - insecure = os.environ.get("PAG_INSECURE", "") == "1"
1263 -
1264 - if not os.path.exists(GPG_HOME):
1265 - if insecure:
1266 - return True # brak GPG home – tryb insecure, akceptuj
1267 - print(f" ❌ {repo_url}: brak kluczy GPG – weryfikacja niemożliwa!")
1268 - print(f" Ustaw PAG_INSECURE=1 aby pominąć (niezalecane)")
1269 - os.remove(cache_path)
1270 - return False
1271 -
1272 - sig_path = cache_path + ".sig"
1273 - # Podpisy generowane jako .asc (armored) – próbuj .asc, potem .sig
1274 - sig_data = None
1275 - sig_ext = ""
1276 - for ext in (".asc", ".sig"):
1277 - try:
1278 - req = Request(f"{repo_url}/repo.json{ext}", headers={"User-Agent":"pag/3.0"})
1279 - with urlopen(req, timeout=15) as resp:
1280 - sig_data = resp.read()
1281 - sig_ext = ext
1282 - break
1283 - except Exception:
1284 - continue
1285 - if not sig_data:
1286 - if insecure:
1287 - return True # tryb insecure – akceptuj bez podpisu
1288 - print(f" ❌ {repo_url}: NIE MOŻNA POBRAĆ PODPISU repo.json.asc/.sig!")
1289 - print(f" Ustaw PAG_INSECURE=1 aby pominąć (niezalecane)")
1290 - os.remove(cache_path)
1291 - return False
1292 - sig_path = cache_path + sig_ext
1293 - with open(sig_path, "wb") as f:
1294 - f.write(sig_data)
1295 -
1296 - ok, fingerprint = _gpg_verify_fp(sig_path, cache_path)
1297 - if not ok:
1298 - # Automatyczny import klucza repo przy pierwszym uruchomieniu (TOFU,
1299 - # jak apt) – gdy w keyringu brakuje klucza (No public key).
1300 - res = _gpg_run("--verify", sig_path, cache_path,
1301 - capture_output=True, text=True, timeout=30)
1302 - _stderr = res.stderr.decode(errors="replace") if isinstance(res.stderr, bytes) else (res.stderr or "")
1303 - if "public key" in _stderr.lower() and "no public key" in _stderr.lower():
1304 - try:
1305 - with urlopen(Request(f"{repo_url}/paganos.asc", headers={"User-Agent": "pag/3.0"}), timeout=20) as r:
1306 - keydata = r.read()
1307 - with tempfile.NamedTemporaryFile(delete=False, suffix=".asc") as tmp:
1308 - tmp.write(keydata)
1309 - tmp.flush()
1310 - _gpg_run("--import", tmp.name, capture_output=True, timeout=30)
1311 - os.unlink(tmp.name)
1312 - print(f" 🔑 Importowano klucz repo z {repo_url}/paganos.asc")
1313 - ok, fingerprint = _gpg_verify_fp(sig_path, cache_path)
1314 - except Exception:
1315 - pass
1316 - if not ok:
1317 - if insecure:
1318 - print(f" ⚠ {repo_url}: nieprawidłowy podpis GPG (PAG_INSECURE – ignoruję)")
1319 - return True
1320 - os.remove(cache_path)
1321 - if not shutil.which(GPG_BINARY):
1322 - print(f" ❌ {repo_url}: GPG nie jest zainstalowane – nie można zweryfikować podpisu!")
1323 - print(f" Zainstaluj gnupg lub ustaw PAG_INSECURE=1 (niezalecane)")
1324 - else:
1325 - print(f" ❌ {repo_url}: NIEPRAWIDŁOWY PODPIS GPG indeksu repozytorium!")
1326 - return False
1327 -
1328 - # --- Wymuś przypięty fingerprint (TOFU + pinning) ---
1329 - pinned = _repo_pinned_fp(repo_url)
1330 - if pinned:
1331 - if not fingerprint:
1332 - if insecure:
1333 - print(f" ⚠ {repo_url}: nie można odczytać fingerprintu (PAG_INSECURE – ignoruję)")
1334 - return True
1335 - os.remove(cache_path)
1336 - print(f" ❌ [SECURITY ERROR] {repo_url}: nie można odczytać fingerprintu podpisu!")
1337 - print(f" Przypięty klucz: {pinned} – odrzucam indeks.")
1338 - return False
1339 - if fingerprint != pinned.upper():
1340 - if insecure:
1341 - print(f" ⚠ {repo_url}: ZMIENIONY KLUCZ PODPISU (PAG_INSECURE – ignoruję)")
1342 - return True
1343 - os.remove(cache_path)
1344 - print(f" ❌ [SECURITY ERROR] {repo_url}: Klucz podpisujący repo uległ zmianie!")
1345 - print(f" Oczekiwany: {pinned}")
1346 - print(f" Otrzymany: {fingerprint}")
1347 - print(f" Jeśli to celowa rotacja klucza: pag key-trust {repo_url}")
1348 - return False
1349 - return True
1350 -
1351 - if fingerprint:
1352 - # Brak przypiętego fingerprintu → TOFU: zapisz go w bazie zaufania.
1353 - db = _load_trust_db()
1354 - if db.get(repo_url) != fingerprint:
1355 - _save_trust_db({**db, repo_url: fingerprint})
1356 - print(f" 🔐 Przypięto fingerprint repo {repo_url}: {fingerprint}")
1357 - print(f" (TOFU – pierwsze zaufanie. Gdy klucz się zmieni, pag odmówi aktualizacji.)")
1358 - print(f" Aby uniknąć TOFU, dopisz fingerprint w /etc/pag/repos.conf.")
1359 - return True
1360 -
1361 -def fetch_all_packages(force=False):
1362 - all_pkgs = {}
1363 - for repo_url in get_repos():
1364 - pkgs = fetch_repo_index(repo_url, force)
1365 - if pkgs:
1366 - for pdata in pkgs:
1367 - name = pdata.get("name", pdata.get("filename","?").split("-")[0])
1368 - pkg = PackageInfo(pdata, repo_url)
1369 - if name not in all_pkgs or _version_newer(pkg.version, all_pkgs[name].version):
1370 - all_pkgs[name] = pkg
1371 - return all_pkgs
1372 -
1373 -# =============================================================================
1374 -# GPG
1375 -# =============================================================================
1376 -
1377 -def _verify_pkg_gpg(pkg_path, repo_url=None):
1378 - """Weryfikuje podpis GPG pakietu i (jeśli znamy repo) przypięty fingerprint.
1379 -
1380 - FAIL-CLOSED: brak podpisu = odrzucenie (chyba że PAG_INSECURE=1).
1381 - Zwraca (passed: bool, message: str).
1382 - """
1383 - insecure = os.environ.get("PAG_INSECURE", "") == "1"
1384 - sig_path = pkg_path + ".sig"
1385 - if not os.path.exists(sig_path) and os.path.exists(pkg_path + ".asc"):
1386 - sig_path = pkg_path + ".asc"
1387 -
1388 - if not os.path.exists(sig_path):
1389 - if insecure:
1390 - return True, "(no signature – PAG_INSECURE)"
1391 - return False, "BRAK PODPISU – pakiet odrzucony (ustaw PAG_INSECURE=1 aby pominąć)"
1392 -
1393 - ok, fp = _gpg_verify_fp(sig_path, pkg_path)
1394 - if not ok:
1395 - if insecure:
1396 - return True, "(invalid signature – PAG_INSECURE)"
1397 - return False, "NIEPRAWIDŁOWY PODPIS GPG"
1398 -
1399 - # Opcjonalnie: sprawdź, czy podpis pochodzi od klucza przypiętego dla repo.
1400 - if repo_url:
1401 - pinned = _repo_pinned_fp(repo_url)
1402 - if pinned and fp and fp != pinned.upper():
1403 - if insecure:
1404 - return True, "(pkg signer mismatch – PAG_INSECURE)"
1405 - return False, f"PAKIET PODPISANY INNYM KLUCZEM niż repo (oczekiwano {pinned})"
1406 -
1407 - return True, "GPG verified"
1408 -
1409 -def cmd_key_add(source):
1410 - ensure_dirs()
1411 - if source.startswith("http"):
1412 - try:
1413 - with urlopen(Request(source, headers={"User-Agent":"pag/3.0"}), timeout=30) as resp:
1414 - keydata = resp.read()
1415 - with tempfile.NamedTemporaryFile(delete=False, suffix=".gpg") as tmp:
1416 - tmp.write(keydata); tmp.flush()
1417 - _gpg_run("--import", tmp.name, capture_output=True, timeout=30)
1418 - os.unlink(tmp.name)
1419 - except Exception as e:
1420 - print(f"❌ Download error: {e}"); return 1
1421 - else:
1422 - _gpg_run("--import", source, capture_output=True, timeout=30)
1423 - print(f"✅ {_('key_imported')}")
1424 -
1425 -def cmd_key_list():
1426 - if not os.path.exists(GPG_HOME):
1427 - print(_("no_keys")); return
1428 - result = _gpg_run("--list-keys", "--keyid-format", "LONG",
1429 - capture_output=True, text=True, timeout=30)
1430 - print(result.stdout or _("no_keys"))
1431 -
1432 -def cmd_key_remove(key_id):
1433 - _gpg_run("--batch", "--yes", "--delete-key", key_id,
1434 - capture_output=True, timeout=30)
1435 - print(f"✅ {_('key_removed', key_id)}")
1436 -
1437 -def _repo_signer_fp(repo_url):
1438 - """Pobiera repo.json + podpis i zwraca fingerprint podpisującego (bez pinningu)."""
1439 - repo_url = repo_url.rstrip("/")
1440 - try:
1441 - with urlopen(Request(f"{repo_url}/repo.json", headers={"User-Agent":"pag/3.0"}), timeout=30) as r:
1442 - data = r.read()
1443 - except Exception:
1444 - return None
1445 - sig = None
1446 - sig_ext = ".asc"
1447 - for ext in (".asc", ".sig"):
1448 - try:
1449 - with urlopen(Request(f"{repo_url}/repo.json{ext}", headers={"User-Agent":"pag/3.0"}), timeout=20) as r:
1450 - sig = r.read()
1451 - sig_ext = ext
1452 - break
1453 - except Exception:
1454 - continue
1455 - if not sig:
1456 - return None
1457 - with tempfile.NamedTemporaryFile(delete=False, suffix=".json") as tf:
1458 - tf.write(data); tf.flush()
1459 - data_path = tf.name
1460 - sig_path = data_path + sig_ext
1461 - try:
1462 - with open(sig_path, "wb") as f:
1463 - f.write(sig)
1464 - ok, fp = _gpg_verify_fp(sig_path, data_path)
1465 - finally:
1466 - for p in (data_path, sig_path):
1467 - try: os.unlink(p)
1468 - except OSError: pass
1469 - return fp if ok else None
1470 -
1471 -
1472 -def cmd_key_trust(repo_url):
1473 - """Przypina fingerprint klucza podpisującego repo (koniec z TOFU dla tego repo)."""
1474 - repo_url = repo_url.rstrip("/")
1475 - print(f"🔐 Przypinam klucz repo {repo_url}...")
1476 - fp = _repo_signer_fp(repo_url)
1477 - if not fp:
1478 - print(" ❌ Nie można odczytać fingerprintu podpisu (brak/nieudany).")
1479 - print(" Upewnij się, że klucz repo jest w keyringu (pag key-add <url|file>).")
1480 - return 1
1481 - db = _load_trust_db()
1482 - _save_trust_db({**db, repo_url: fp})
1483 - print(f" ✅ Przypięto {fp} dla {repo_url}")
1484 - print(" Od teraz zmiana klucza zostanie zgłoszona jako SECURITY ERROR.")
1485 - return 0
1486 -
1487 -
1488 -def cmd_key_untrust(repo_url):
1489 - """Usuwa przypięcie fingerprintu dla repo (wraca do TOFU)."""
1490 - repo_url = repo_url.rstrip("/")
1491 - db = _load_trust_db()
1492 - if repo_url not in db:
1493 - print(f" ℹ {repo_url} nie ma przypiętego fingerprintu.")
1494 - return 0
1495 - del db[repo_url]
1496 - _save_trust_db(db)
1497 - print(f" ✅ Usunięto przypięcie dla {repo_url}.")
1498 - return 0
1499 -
1500 -
1501 -def cmd_key_trusted():
1502 - """Listuje przypięte fingerprinty repozytoriów."""
1503 - db = _load_trust_db()
1504 - if not db:
1505 - print(_("no_keys"))
1506 - return
1507 - for url, fp in sorted(db.items()):
1508 - print(f" {url}\n {fp}")
1509 -
1510 -# =============================================================================
1511 -# ATOMOWA INSTALACJA (STAGING)
1512 -# =============================================================================
1513 -
1514 -def _safe_rename(src: str, dst: str) -> bool:
1515 - """
1516 - Atomowe przeniesienie pliku. Jeśli src i dst są na różnych
1517 - systemach plików (EXDEV), kopiuje + usuwa źródło.
1518 - """
1519 - try:
1520 - os.rename(src, dst)
1521 - return True
1522 - except OSError as e:
1523 - if e.errno == 18: # EXDEV – cross-device link
1524 - shutil.copy2(src, dst)
1525 - os.remove(src)
1526 - return True
1527 - raise
1528 -
1529 -
1530 -def _install_file(src: str, rel: str, data_staging: str, sums: dict,
1531 - staging: str, journal: list, installed_files: list,
1532 - deploy_dir: str = "", backup_dir: str = "",
1533 - backup_journal: Optional[list] = None) -> bool:
1534 - """
1535 - Instaluje pojedynczy plik (zwykły lub symlink).
1536 - Obsługuje: cross-device rename, symlinki, weryfikację SHA256.
1537 -
1538 - Jeśli deploy_dir jest podany (tryb immutable), pliki systemowe trafiają
1539 - do deploymentu, a współdzielone (/var, /etc, ...) bezpośrednio do /.
1540 -
1541 - Jeśli backup_dir jest podany, a pod dst istnieje już plik (upgrade/reinstall),
1542 - stara wersja jest przenoszona do backup_dir, by rollback mógł ją przywrócić.
1543 - """
1544 - # W trybie immutable: pliki współdzielone idą do /, reszta do deploymentu
1545 - if deploy_dir and _is_shared_path("/" + rel):
1546 - dst_root = PAG_ROOT
1547 - elif deploy_dir:
1548 - dst_root = deploy_dir
1549 - else:
1550 - dst_root = PAG_ROOT
1551 -
1552 - dst = os.path.join(dst_root, rel)
1553 -
1554 - # --- SYMLINK ---
1555 - if os.path.islink(src):
1556 - link_target = os.readlink(src)
1557 - # Weryfikuj sums.json dla symlinka (hash ścieżki docelowej)
1558 - expected = sums.get("/" + rel, "")
1559 - if expected:
1560 - link_hash = hashlib.sha256(link_target.encode()).hexdigest()
1561 - if expected and link_hash != expected:
1562 - return False
1563 -
1564 - os.makedirs(os.path.dirname(dst), exist_ok=True)
1565 - # Backup istniejącego symlinka (upgrade) – dla poprawnego rollbacku
1566 - if backup_dir and backup_journal is not None and os.path.lexists(dst):
1567 - try:
1568 - backup_path = os.path.join(backup_dir, rel)
1569 - os.makedirs(os.path.dirname(backup_path), exist_ok=True)
1570 - os.replace(dst, backup_path)
1571 - backup_journal.append((backup_path, "/" + rel))
1572 - journal.append(("backup", backup_path, dst))
1573 - except OSError:
1574 - pass
1575 - # Jeśli docelowy symlink już istnieje, usuń go
1576 - if os.path.islink(dst) or os.path.exists(dst):
1577 - os.remove(dst)
1578 - os.symlink(link_target, dst)
1579 - journal.append(("symlink", "", dst))
1580 - installed_files.append({
1581 - "path": "/" + rel,
1582 - "sha256": hashlib.sha256(link_target.encode()).hexdigest(),
1583 - "size": len(link_target),
1584 - "is_symlink": True,
1585 - "symlink_target": link_target,
1586 - })
1587 - return True
1588 -
1589 - # --- ZWYKŁY PLIK ---
1590 - # Oblicz SHA256
1591 - try:
1592 - file_sha = _sha256_file(src)
1593 - except Exception:
1594 - file_sha = ""
1595 -
1596 - # Weryfikuj sums.json
1597 - expected = sums.get("/" + rel, "")
1598 - if expected and file_sha and file_sha != expected:
1599 - return False
1600 -
1601 - # Utwórz katalog docelowy
1602 - os.makedirs(os.path.dirname(dst), exist_ok=True)
1603 -
1604 - # Backup istniejącego pliku (upgrade) – dla poprawnego rollbacku
1605 - if backup_dir and backup_journal is not None and os.path.lexists(dst):
1606 - try:
1607 - backup_path = os.path.join(backup_dir, rel)
1608 - os.makedirs(os.path.dirname(backup_path), exist_ok=True)
1609 - os.replace(dst, backup_path)
1610 - backup_journal.append((backup_path, "/" + rel))
1611 - journal.append(("backup", backup_path, dst))
1612 - except OSError:
1613 - pass
1614 -
1615 - # Atomowe przeniesienie (z fallbackiem dla cross-device).
1616 - # Zachowuje bity uprawnień (SUID/SGID/sticky) – NIE używamy filter='data'.
1617 - _safe_rename(src, dst)
1618 -
1619 - # Wymuś właściciela root:root. UWAGA: os.chown() NIE czyści bitów SUID/SGID.
1620 - try:
1621 - os.chown(dst, 0, 0)
1622 - except (OSError, PermissionError):
1623 - # Na niektórych systemach plików (tmpfs, fat) chown może się nie powieść
1624 - pass
1625 -
1626 - journal.append(("file", src, dst))
1627 - installed_files.append({
1628 - "path": "/" + rel,
1629 - "sha256": file_sha,
1630 - "size": os.path.getsize(dst),
1631 - "is_symlink": False,
1632 - })
1633 - return True
1634 -
1635 -
1636 -def _atomic_install(pkg_path: str, pkg: PackageInfo, deploy_dir: str = "",
1637 - backup_dir: str = "") -> Tuple[bool, List[dict], List[Tuple[str, str]]]:
1638 - """
1639 - Rozpakowuje do staging area, potem atomowo przenosi pliki.
1640 - Jeśli deploy_dir podany – instaluje do deploymentu (tryb immutable).
1641 - Zwraca (success, [lista plików z SHA256], [(backup_path, dst), ...]).
1642 - """
1643 - staging = tempfile.mkdtemp(dir=STAGING_DIR, prefix=f".staging-{pkg.name}-")
1644 - journal = []
1645 - installed_files = []
1646 - backup_journal: List[Tuple[str, str]] = []
1647 -
1648 - try:
1649 - # Rozpakuj .pkg.tar.xz → staging (bezpieczne – ochrona Directory Traversal)
1650 - with tarfile.open(pkg_path, "r:xz") as tf:
1651 - _safe_extractall(tf, staging)
1652 -
1653 - data_tar = os.path.join(staging, "data.tar.xz")
1654 - if not os.path.exists(data_tar):
1655 - shutil.rmtree(staging, ignore_errors=True)
1656 - return False, [], backup_journal
1657 -
1658 - # Rozpakuj data.tar.xz → staging/data (bezpieczne – ochrona Directory Traversal)
1659 - data_staging = os.path.join(staging, "data")
1660 - os.makedirs(data_staging, exist_ok=True)
1661 - with tarfile.open(data_tar, "r:xz") as tf:
1662 - _safe_extractall(tf, data_staging)
1663 -
1664 - # Wczytaj sums.json
1665 - sums_path = os.path.join(data_staging, "sums.json")
1666 - sums = json.load(open(sums_path)) if os.path.exists(sums_path) else {}
1667 -
1668 - # Hook pre-install (przed przeniesieniem plików do systemu)
1669 - _run_hook(os.path.join(staging, "hooks"), "pre-install", pkg)
1670 -
1671 - # Przenieś pliki: staging/data/* → /
1672 - for root, dirs, files in os.walk(data_staging):
1673 - # Odtwórz katalogi z pakietu – w tym PUSTE (np. /etc/pulse/default.pa.d).
1674 - # Pętla plików tworzy tylko rodziców instalowanych plików, przez co
1675 - # puste katalogi z data.tar.xz ginęły przy instalacji.
1676 - for d in dirs:
1677 - src_dir = os.path.join(root, d)
1678 - rel_dir = os.path.relpath(src_dir, data_staging)
1679 - if deploy_dir and _is_shared_path("/" + rel_dir):
1680 - dst_root = PAG_ROOT
1681 - elif deploy_dir:
1682 - dst_root = deploy_dir
1683 - else:
1684 - dst_root = PAG_ROOT
1685 - dst_dir = os.path.join(dst_root, rel_dir)
1686 - if not os.path.isdir(dst_dir):
1687 - try:
1688 - os.makedirs(dst_dir, exist_ok=True)
1689 - except OSError:
1690 - pass
1691 - for fname in files:
1692 - if fname == "sums.json":
1693 - continue
1694 - src = os.path.join(root, fname)
1695 - rel = os.path.relpath(src, data_staging)
1696 -
1697 - ok = _install_file(src, rel, data_staging, sums,
1698 - staging, journal, installed_files, deploy_dir,
1699 - backup_dir, backup_journal)
1700 - if not ok:
1701 - # Cofnij wszystkie operacje
1702 - _rollback_journal(journal, staging)
1703 - return False, [], backup_journal
1704 -
1705 - # Odbuduj cache ikon GTK dla motywów dotkniętych instalacją.
1706 - # Bez icon-theme.cache aplikacje GTK nie widzą ikon mimo obecności
1707 - # motywu (np. /usr/share/icons/Papirus). Pomijamy, gdy narzędzie
1708 - # nie jest zainstalowane.
1709 - _icon_dirs = set()
1710 - for f in installed_files:
1711 - fp = f.get("path", "") or ""
1712 - if fp.startswith("/usr/share/icons/"):
1713 - _rest = fp[len("/usr/share/icons/"):]
1714 - _theme = _rest.split("/", 1)[0]
1715 - if _theme:
1716 - _icon_dirs.add(os.path.join(PAG_ROOT, "usr/share/icons", _theme))
1717 - if _icon_dirs:
1718 - try:
1719 - subprocess.run(["gtk-update-icon-cache", "--version"],
1720 - capture_output=True, timeout=10)
1721 - for _d in sorted(_icon_dirs):
1722 - if os.path.isdir(_d):
1723 - subprocess.run(["gtk-update-icon-cache", "-f", "-q", _d],
1724 - capture_output=True, timeout=300)
1725 - except Exception:
1726 - pass
1727 -
1728 - # Uruchom hooki post-install
1729 - hooks_dir = os.path.join(staging, "hooks")
1730 - _run_hook(hooks_dir, "post-install", pkg)
1731 -
1732 - # Zachowaj hooki na wypadek usunięcia pakietu (pre/post-remove)
1733 - try:
1734 - if os.path.isdir(hooks_dir):
1735 - persisted = os.path.join(PAG_DB, "hooks", pkg.name)
1736 - shutil.rmtree(persisted, ignore_errors=True)
1737 - shutil.copytree(hooks_dir, persisted)
1738 - except Exception:
1739 - pass
1740 -
1741 - # Zapisz do SQLite
1742 - _db_record_files(pkg.name, installed_files)
1743 -
1744 - shutil.rmtree(staging, ignore_errors=True)
1745 - return True, installed_files, backup_journal
1746 -
1747 - except Exception as e:
1748 - _rollback_journal(journal, staging)
1749 - return False, [], backup_journal
1750 -
1751 -
1752 -def _refresh_dynamic_linker_cache(deploy_dir: str = "") -> bool:
1753 - """Odświeża cache ld.so po udanej instalacji pakietów."""
1754 - ldconfig = shutil.which("ldconfig")
1755 - if not ldconfig:
1756 - print(" ⚠ Nie znaleziono ldconfig — cache linkera nie został odświeżony.",
1757 - file=sys.stderr)
1758 - return False
1759 -
1760 - target_root = deploy_dir or PAG_ROOT
1761 - command = [ldconfig]
1762 - if target_root != "/":
1763 - command.extend(["-r", target_root])
1764 -
1765 - try:
1766 - subprocess.run(command, check=True, timeout=60,
1767 - stdout=subprocess.DEVNULL, stderr=subprocess.PIPE,
1768 - text=True)
1769 - return True
1770 - except (OSError, subprocess.CalledProcessError, subprocess.TimeoutExpired) as exc:
1771 - detail = getattr(exc, "stderr", None) or str(exc)
1772 - print(f" ⚠ Nie udało się odświeżyć cache'a ld.so: {detail.strip()}",
1773 - file=sys.stderr)
1774 - return False
1775 -
1776 -
1777 -def _rollback_journal(journal: list, staging_path: str):
1778 - """Cofa wszystkie operacje z journala (odwrotna kolejność)."""
1779 - for entry in reversed(journal):
1780 - op = entry[0]
1781 - if op == "file":
1782 - _, src, dst = entry
1783 - try:
1784 - if os.path.exists(dst) or os.path.islink(dst):
1785 - _safe_rename(dst, src)
1786 - except Exception:
1787 - pass
1788 - elif op == "symlink":
1789 - _, _, dst = entry
1790 - try:
1791 - if os.path.islink(dst) or os.path.exists(dst):
1792 - os.remove(dst)
1793 - except Exception:
1794 - pass
1795 - elif op == "backup":
1796 - # Przywróć starą wersję pliku z backupu (upgrade)
1797 - _, bpath, dst = entry
1798 - try:
1799 - if os.path.lexists(bpath):
1800 - os.replace(bpath, dst)
1801 - except Exception:
1802 - pass
1803 - shutil.rmtree(staging_path, ignore_errors=True)
1804 -
1805 -# =============================================================================
1806 -# BEZPIECZNE USUWANIE
1807 -# =============================================================================
1808 -
1809 -def _safe_remove_files(pkg_name: str, installed_db: dict) -> Tuple[int, List[str]]:
1810 - """
1811 - Usuwa pliki pakietu, ale tylko jeśli NIE są współdzielone z innym pakietem.
1812 - Zwraca (liczba usuniętych, [lista usuniętych ścieżek]).
1813 - """
1814 - pkg_files = _db_get_package_files(pkg_name)
1815 - removed = []
1816 - skipped_shared = []
1817 -
1818 - for fpath in pkg_files:
1819 - owners = _db_get_file_owners(fpath)
1820 - # Sprawdź czy inny ZAINSTALOWANY pakiet też jest właścicielem
1821 - other_owners = [o for o in owners if o != pkg_name and o in installed_db]
1822 -
1823 - if other_owners:
1824 - # Plik współdzielony – tylko usuń wpis w DB, nie kasuj pliku
1825 - skipped_shared.append(fpath)
1826 - continue
1827 -
1828 - full = os.path.join(PAG_ROOT, fpath.lstrip("/"))
1829 - if os.path.isfile(full) or os.path.islink(full):
1830 - os.remove(full)
1831 - removed.append(fpath)
1832 -
1833 - # Usuń puste katalogi (od najgłębszych)
1834 - dirs = set()
1835 - for fpath in removed + skipped_shared:
1836 - parent = os.path.dirname(fpath)
1837 - while parent and parent != "/":
1838 - dirs.add(parent)
1839 - parent = os.path.dirname(parent)
1840 -
1841 - for d in sorted(dirs, key=len, reverse=True):
1842 - full_d = os.path.join(PAG_ROOT, d.lstrip("/"))
1843 - if os.path.isdir(full_d):
1844 - try:
1845 - os.rmdir(full_d)
1846 - except OSError:
1847 - pass # nie jest pusty – OK
1848 -
1849 - # Usuń z SQLite
1850 - _db_remove_package_files(pkg_name)
1851 -
1852 - if skipped_shared:
1853 - print(f" ⚠ {len(skipped_shared)} plików współdzielonych zachowanych")
1854 -
1855 - return len(removed) + len(skipped_shared), removed
1856 -
1857 -
1858 -def _remove_stale_files(pkg_name: str, old_files: List[str], new_paths: List[str],
1859 - installed_db: dict, deploy_dir: str = "",
1860 - backup_dir: str = "", backup_journal: Optional[list] = None) -> Tuple[int, List[str]]:
1861 - """
1862 - Po upgrade usuwa pliki starej wersji, których nie ma w nowej.
1863 -
1864 - - Pliki współdzielone z innym zainstalowanym pakietem są ZACHOWYWANE
1865 - (usuwany jest tylko wpis z bazy `files` dla tego pakietu).
1866 - - Sprząta puste katalogi i wpisy SQLite starej wersji.
1867 - Zwraca (liczba usuniętych, [usunięte ścieżki]).
1868 - """
1869 - new_set = set(new_paths)
1870 - stale = [f for f in old_files if f not in new_set]
1871 - if not stale:
1872 - return 0, []
1873 -
1874 - root = deploy_dir or PAG_ROOT
1875 - removed = []
1876 - skipped = 0
1877 - for fpath in stale:
1878 - owners = _db_get_file_owners(fpath)
1879 - other_owners = [o for o in owners if o != pkg_name and o in installed_db]
1880 - if other_owners:
1881 - # Współdzielony z innym pakietem – tylko usuń wpis z DB dla tego pakietu
1882 - skipped += 1
1883 - else:
1884 - full = os.path.join(root, fpath.lstrip("/"))
1885 - if os.path.isfile(full) or os.path.islink(full):
1886 - try:
1887 - if backup_dir and backup_journal is not None:
1888 - backup_path = os.path.join(backup_dir, fpath.lstrip("/"))
1889 - os.makedirs(os.path.dirname(backup_path), exist_ok=True)
1890 - os.replace(full, backup_path) # przenieś do backupu (rollback)
1891 - backup_journal.append((backup_path, fpath))
1892 - else:
1893 - os.remove(full)
1894 - removed.append(fpath)
1895 - except OSError:
1896 - pass
1897 - # Usuń wpis `files` dla tego pakietu (stara wersja już go nie zawiera)
1898 - with _db_session() as db:
1899 - db.execute("DELETE FROM files WHERE package=? AND path=?", (pkg_name, fpath))
1900 -
1901 - # Usuń puste katalogi (od najgłębszych)
1902 - dirs = set()
1903 - for fpath in removed:
1904 - parent = os.path.dirname(fpath)
1905 - while parent and parent != "/":
1906 - dirs.add(parent)
1907 - parent = os.path.dirname(parent)
1908 - for d in sorted(dirs, key=len, reverse=True):
1909 - full_d = os.path.join(root, d.lstrip("/"))
1910 - if os.path.isdir(full_d):
1911 - try:
1912 - os.rmdir(full_d)
1913 - except OSError:
1914 - pass # nie jest pusty – OK
1915 -
1916 - if removed:
1917 - print(f" 🧹 Usunięto {len(removed)} nieaktualnych plików ({pkg_name})")
1918 - if skipped:
1919 - print(f" ⚠ {skipped} plików współdzielonych zachowanych")
1920 -
1921 - return len(removed), removed
1922 -
1923 -
1924 -def _new_upgrade_backup_root() -> str:
1925 - """Tworzy katalog na backupy starych wersji dla bieżącej transakcji upgrade."""
1926 - txn = datetime.now().strftime("%Y%m%dT%H%M%S") + "-" + str(os.getpid())
1927 - root = os.path.join(STAGING_DIR, "backups", txn)
1928 - os.makedirs(root, exist_ok=True)
1929 - return root
1930 -
1931 -
1932 -def _purge_old_backups(keep_root: str = ""):
1933 - """Usuwa backupy starszych transakcji (zostawia bieżący – dla `pag rollback`)."""
1934 - base = os.path.join(STAGING_DIR, "backups")
1935 - if not os.path.isdir(base):
1936 - return
1937 - for entry in os.listdir(base):
1938 - p = os.path.join(base, entry)
1939 - if p != keep_root and os.path.isdir(p):
1940 - shutil.rmtree(p, ignore_errors=True)
1941 -
1942 -# =============================================================================
1943 -# HOOKI
1944 -# =============================================================================
1945 -# Hooki uruchamiają dowolny plik z pakietu jako root — to naturalna cecha
1946 -# menedżera pakietów (apt/pacman też tak mają), dlatego MUSISZ ufać repozytorium.
1947 -# Aby ograniczyć ryzyko:
1948 -# - hook dostaje minimalne, "czyste" środowisko (bez LD_PRELOAD, BASH_ENV itp.)
1949 -# - hooki można wyłączyć (PAG_NO_HOOKS=1) i ustawić timeout (PAG_HOOK_TIMEOUT)
1950 -# - każde uruchomienie jest logowane do /var/log/pag/audit.log
1951 -# - hook ma wersjonowane API (PKG_HOOK_API)
1952 -# =============================================================================
1953 -
1954 -# Lista wykonanych hooków — trafia do wpisu transakcji (informacja w rejestrze).
1955 -_HOOKS_RUN: List[str] = []
1956 -
1957 -
1958 -def _hook_env(pkg: PackageInfo, hook_name: str) -> dict:
1959 - """Buduje minimalne środowisko dla hooka (bez niebezpiecznych zmiennych)."""
1960 - return {
1961 - "PATH": "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin",
1962 - "HOME": "/root",
1963 - "LANG": "C.UTF-8",
1964 - "LC_ALL": "C.UTF-8",
1965 - "PKG_NAME": pkg.name,
1966 - "PKG_VERSION": pkg.version,
1967 - "PKG_ACTION": hook_name,
1968 - "PKG_HOOK_API": HOOK_API_VERSION,
1969 - }
1970 -
1971 -
1972 -def _hook_timeout() -> int:
1973 - try:
1974 - return max(1, int(os.environ.get("PAG_HOOK_TIMEOUT", "60")))
1975 - except Exception:
1976 - return 60
1977 -
1978 -
1979 -def _run_hook(hooks_dir: str, hook_name: str, pkg: PackageInfo) -> bool:
1980 - """Uruchamia skrypt hooka jeśli istnieje.
1981 -
1982 - Zwraca True jeśli hook został WYKONANY (istniał i uruchomiono go), False w
1983 - pozostałych przypadkach (brak pliku, wyłączone hooki, błąd). Obsługuje
1984 - ograniczone środowisko, timeout, logowanie do audytu i rejestr w transakcji.
1985 - """
1986 - hook_path = os.path.join(hooks_dir, hook_name)
1987 - if not os.path.exists(hook_path):
1988 - return False
1989 -
1990 - if os.environ.get("PAG_NO_HOOKS", "") == "1":
1991 - print(f" ⚠ Hook pominięty (PAG_NO_HOOKS=1): {hook_name} dla {pkg.name}")
1992 - _audit(f"hook SKIP {hook_name} {pkg.name}-{pkg.version} (PAG_NO_HOOKS=1)")
1993 - return False
1994 -
1995 - os.chmod(hook_path, 0o755)
1996 - env = _hook_env(pkg, hook_name)
1997 - tag = f"{hook_name} {pkg.name}-{pkg.version}"
1998 - try:
1999 - result = subprocess.run([hook_path], env=env, timeout=_hook_timeout(),
2000 - check=False, capture_output=True, text=True,
2001 - cwd="/")
2002 - _HOOKS_RUN.append(tag)
2003 - if result.returncode != 0:
2004 - print(f" ⚠ Hook {hook_name} dla {pkg.name} zakończony z kodem {result.returncode}")
2005 - if result.stderr:
2006 - print(f" {result.stderr.strip()[-200:]}")
2007 - _audit(f"hook FAIL {tag} rc={result.returncode}")
2008 - else:
2009 - _audit(f"hook OK {tag}")
2010 - return True
2011 - except subprocess.TimeoutExpired:
2012 - print(f" ⚠ Hook {hook_name} dla {pkg.name} przekroczył timeout ({_hook_timeout()}s)")
2013 - _audit(f"hook TIMEOUT {tag}")
2014 - return False
2015 - except Exception as e:
2016 - print(f" ⚠ Hook {hook_name} dla {pkg.name}: {e}")
2017 - _audit(f"hook ERROR {tag}: {e}")
2018 - return False
2019 -
2020 -# =============================================================================
2021 -# TRANSAKCJE I ROLLBACK
2022 -# =============================================================================
2023 -
2024 -def _record_transaction(action, packages, success, snapshot, file_journal=None, hooks=None,
2025 - upgrade_backups=None, upgrade_backup_root=""):
2026 - history = load_json(HISTORY_FILE) if os.path.exists(HISTORY_FILE) else []
2027 - # Rejestr wykonanych hooków – informacja o tym, że uruchomiono kod pakietu
2028 - # jako root. Trafia do historii, by dało się później sprawdzić, co się działo.
2029 - executed_hooks = list(_HOOKS_RUN) if hooks is None else hooks
2030 - _HOOKS_RUN.clear()
2031 - entry = {
2032 - "action": action, "packages": packages, "success": success,
2033 - "timestamp": datetime.now().isoformat(),
2034 - "snapshot": snapshot,
2035 - "file_journal": file_journal, # lista plików do wycofania
2036 - "hooks": executed_hooks, # wykonane hooki (pre/post-install/remove)
2037 - }
2038 - if upgrade_backups:
2039 - entry["upgrade_backups"] = upgrade_backups # {dst: backup_path}
2040 - entry["upgrade_backup_root"] = upgrade_backup_root
2041 - history.append(entry)
2042 - if len(history) > 50:
2043 - history = history[-50:]
2044 - save_json(HISTORY_FILE, history)
2045 -
2046 -def cmd_history():
2047 - if not os.path.exists(HISTORY_FILE):
2048 - print(_("no_history")); return
2049 - history = load_json(HISTORY_FILE)
2050 - if not history:
2051 - print(_("no_history")); return
2052 - print(f"Ostatnie transakcje ({len(history)}):")
2053 - for i, e in enumerate(reversed(history), 1):
2054 - icon = "✅" if e["success"] else "❌"
2055 - pkgs = ", ".join(e["packages"][:5])
2056 - if len(e["packages"]) > 5: pkgs += f" (+{len(e['packages'])-5})"
2057 - print(f" {i}. {icon} {e['action']}: {pkgs}")
2058 - print(f" {e['timestamp']}")
2059 -
2060 -def cmd_rollback():
2061 - if not os.path.exists(HISTORY_FILE):
2062 - print(_("no_history")); return 1
2063 - history = load_json(HISTORY_FILE)
2064 - if not history:
2065 - print(_("no_history")); return 1
2066 -
2067 - last = None
2068 - for e in reversed(history):
2069 - if e["success"] and e.get("snapshot"):
2070 - last = e; break
2071 -
2072 - if not last:
2073 - print("❌ No snapshot to restore."); return 1
2074 -
2075 - print(f"⏪ Rolling back: {last['action']} ({last['timestamp']})")
2076 - print(f" Packages: {', '.join(last['packages'][:10])}")
2077 -
2078 - if not _ask_confirm():
2079 - return 0
2080 -
2081 - # Przywróć installed.json
2082 - save_json(INSTALLED_DB, last["snapshot"])
2083 -
2084 - # Wycofaj fizyczne pliki (jeśli zapisano journal)
2085 - file_journal = last.get("file_journal", [])
2086 - upgrade_backups = last.get("upgrade_backups", {}) or {}
2087 - backup_root = last.get("upgrade_backup_root", "")
2088 -
2089 - # Przywróć stare wersje z backupów (upgrade) – nadpisane i usunięte stale pliki
2090 - for dst, bpath in upgrade_backups.items():
2091 - full = os.path.join(PAG_ROOT, dst.lstrip("/"))
2092 - if bpath and os.path.lexists(bpath):
2093 - try:
2094 - os.makedirs(os.path.dirname(full), exist_ok=True)
2095 - os.replace(bpath, full)
2096 - except OSError:
2097 - pass
2098 -
2099 - # Usuń nowe pliki (które nie miały poprzedniej wersji)
2100 - backed = set(upgrade_backups)
2101 - if file_journal:
2102 - for fpath in reversed(file_journal):
2103 - if fpath in backed:
2104 - continue
2105 - full = os.path.join(PAG_ROOT, fpath.lstrip("/"))
2106 - if os.path.exists(full) or os.path.islink(full):
2107 - os.remove(full)
2108 - print(f" {_('rollback_files', len(file_journal))}")
2109 -
2110 - # Sprzątanie pustych katalogów + katalogu backupów
2111 - dirs = set()
2112 - for fpath in file_journal:
2113 - parent = os.path.dirname(fpath)
2114 - while parent and parent != "/":
2115 - dirs.add(parent)
2116 - parent = os.path.dirname(parent)
2117 - for d in sorted(dirs, key=len, reverse=True):
2118 - full_d = os.path.join(PAG_ROOT, d.lstrip("/"))
2119 - if os.path.isdir(full_d):
2120 - try:
2121 - os.rmdir(full_d)
2122 - except OSError:
2123 - pass
2124 - if backup_root:
2125 - shutil.rmtree(backup_root, ignore_errors=True)
2126 -
2127 - print(f"✅ {_('rollback_restored')}")
2128 - _record_transaction("rollback", last["packages"], True, None)
2129 - return 0
2130 -
2131 -# =============================================================================
2132 -# INSTALACJA
2133 -# =============================================================================
2134 -
2135 -def _install_local_pkg_files(paths, install_succeeded):
2136 - """Instaluje lokalne pliki .pkg.tar.xz (bez repozytorium).
2137 - Zgodnie z _atomic_install każdy plik jest instalowany atomowo.
2138 - Zwraca (failed_count, installed_files)."""
2139 - failed = 0
2140 - all_files = []
2141 - for p in paths:
2142 - p = os.path.abspath(p)
2143 - if not os.path.isfile(p):
2144 - print(f" ❌ Nie znaleziono pakietu: {p}")
2145 - failed += 1
2146 - continue
2147 - try:
2148 - with tarfile.open(p, "r:xz") as tf:
2149 - meta = tf.extractfile("metadata.json")
2150 - if meta is None:
2151 - print(f" ❌ {p}: brak metadata.json")
2152 - failed += 1
2153 - continue
2154 - data = json.loads(meta.read())
2155 - except Exception as e:
2156 - print(f" ❌ {p}: nie udało się odczytać pakietu ({e})")
2157 - failed += 1
2158 - continue
2159 - pkg = PackageInfo(data, repo="local")
2160 - print(f" ↓ {pkg.name}-{pkg.version} (lokalny) ... ", end="", flush=True)
2161 - ok, files, _ = _atomic_install(p, pkg)
2162 - if ok:
2163 - install_succeeded(pkg, files)
2164 - all_files.extend(f["path"] for f in files)
2165 - print("✅")
2166 - else:
2167 - print("❌")
2168 - failed += 1
2169 - return failed, all_files
2170 -
2171 -
2172 -def _preflight_disk(total_bytes: int) -> bool:
2173 - """Pre-flight przed transakcją: wolne miejsce + mount read-only.
2174 -
2175 - Zwraca False (przerywa instalację) gdy na partycji docelowej brakuje
2176 - miejsca na pakiety albo katalog stagingu jest zamontowany read-only
2177 - (inaczej instalacja rwałaby się w połowie, zostawiając uszkodzony system).
2178 - """
2179 - target = PAG_ROOT or "/"
2180 - try:
2181 - st = os.statvfs(target)
2182 - free = st.f_bavail * st.f_frsize
2183 - except OSError:
2184 - return True # nie da się sprawdzić – nie blokuj
2185 - need_mb = total_bytes // 1048576
2186 - free_mb = free // 1048576
2187 - if free < total_bytes:
2188 - print(f" ❌ Za mało miejsca na dysku: potrzeba ~{need_mb} MB, "
2189 - f"wolne {free_mb} MB ({target})")
2190 - return False
2191 - if free < total_bytes * 3:
2192 - print(f" ⚠ Mało miejsca na dysku: wolne {free_mb} MB, "
2193 - f"pakiety ~{need_mb} MB (rozpakowane zajmą więcej)")
2194 - # Wykryj mount read-only (test zapisu w stagingu)
2195 - try:
2196 - probe = os.path.join(STAGING_DIR, ".pag-probe")
2197 - with open(probe, "w") as f:
2198 - f.write("x")
2199 - os.remove(probe)
2200 - except OSError:
2201 - print(f" ❌ {target} jest zamontowane tylko-do-odczytu – nie można instalować.")
2202 - return False
2203 - return True
2204 -
2205 -
2206 -def cmd_install(package_names, as_dep=False, upgrade=False):
2207 - ensure_dirs()
2208 - installed_db = load_json(INSTALLED_DB)
2209 - world = load_world()
2210 - pinned = load_json(PINNED_FILE)
2211 -
2212 - # Obsługa lokalnych plików .pkg.tar.xz (zbudowanych przez pagbuild) –
2213 - # nie wymaga repozytorium ani GPG.
2214 - local_files = [p for p in package_names if p.endswith(PKG_EXT) or
2215 - (os.sep in p and os.path.isfile(os.path.abspath(p)))]
2216 - if local_files:
2217 - _local_need = sum(
2218 - os.path.getsize(os.path.abspath(p))
2219 - for p in local_files if os.path.isfile(os.path.abspath(p))
2220 - )
2221 - if not _preflight_disk(_local_need):
2222 - return 1
2223 -
2224 - def _ok(pkg, files):
2225 - installed_db[pkg.name] = {
2226 - "version": pkg.version, "release": pkg.release, "description": pkg.description,
2227 - "dependencies": pkg.dependencies, "size_bytes": pkg.size_bytes,
2228 - "sha256": pkg.sha256, "installed_at": datetime.now().isoformat(),
2229 - "repo": "local",
2230 - "provides": getattr(pkg, "provides", None) or [],
2231 - "provides_so": getattr(pkg, "provides_so", None) or [],
2232 - "requires_so": getattr(pkg, "requires_so", None) or [],
2233 - }
2234 - world.add(pkg.name)
2235 - failed_local, _fl = _install_local_pkg_files(local_files, _ok)
2236 - save_json(INSTALLED_DB, installed_db)
2237 - save_world(world)
2238 - if failed_local:
2239 - return 1
2240 - _refresh_dynamic_linker_cache()
2241 - package_names = [n for n in package_names if n not in
2242 - [os.path.abspath(x) for x in local_files] and
2243 - n not in local_files]
2244 - to_install = []
2245 - if not package_names:
2246 - return 0
2247 - # pozostałe argumenty to nazwy pakietów z repo – kontynuuj
2248 -
2249 - repo_pkgs = fetch_all_packages()
2250 -
2251 - if not repo_pkgs:
2252 - print(f"❌ {_('no_index')}"); return 1
2253 -
2254 - for name in list(package_names):
2255 - if name in pinned:
2256 - print(f"⚠ {name} {_('pinned_to')} {pinned[name]} – skipping")
2257 - package_names.remove(name)
2258 -
2259 - to_install, missing_deps = _resolve_deps(package_names, repo_pkgs, installed_db)
2260 -
2261 - # ── Pakiety, których NIE MA w repo ani nie są zainstalowane ──
2262 - # Zgłoś od razu zamiast mylącego „Do zainstalowania: N (0.00 MB)”
2263 - # i prośby o potwierdzenie (np. `pag install steam` gdy steam nie istnieje).
2264 - not_found = []
2265 - for n in package_names:
2266 - real = _resolve_provides(n, repo_pkgs, installed_db)
2267 - if real not in repo_pkgs and real not in installed_db \
2268 - and not os.path.exists(os.path.abspath(n)):
2269 - not_found.append(n)
2270 - if not_found:
2271 - print(f"\n ❌ {_('pkg_not_found', ', '.join(not_found))}")
2272 - print(f" {_('not_found_hint')}")
2273 - return 1
2274 -
2275 - # --- Tryb upgrade: pakiety już zainstalowane MUSZĄ zostać ponownie
2276 - # zainstalowane z nowszej wersji (zastąpienie w tej samej transakcji).
2277 - if upgrade:
2278 - # `pag update` przekazuje tu tylko pakiety z NOWSZĄ wersją (już
2279 - # przefiltrowane w _pending_updates), a `pag install -f` wymusza
2280 - # reinstalację nawet tej SAMEJ wersji – dlatego nie filtrujemy po
2281 - # _version_newer.
2282 - upgrade_targets = [
2283 - name for name in package_names
2284 - if name in repo_pkgs
2285 - and name in installed_db
2286 - and name not in pinned
2287 - ]
2288 - for name in upgrade_targets:
2289 - if name not in to_install:
2290 - to_install.append(name)
2291 -
2292 - if not to_install and not missing_deps:
2293 - print(f"✅ {_('all_installed')}"); return 0
2294 -
2295 - # ── WERYFIKACJA ZALEŻNOŚCI ──────────────────────────────────────────
2296 - fatal_missing = _verify_dependencies(to_install, repo_pkgs, installed_db)
2297 -
2298 - if fatal_missing > 0:
2299 - print(f"❌ Nie można kontynuować – {fatal_missing} brakujących zależności.")
2300 - print(f" Zainstaluj brakujące pakiety lub dodaj repozytoria.")
2301 - return 1
2302 -
2303 - so_missing = _verify_so_deps(to_install, repo_pkgs, installed_db)
2304 - if so_missing > 0:
2305 - print(" Zainstaluj dostawcę biblioteki lub zaktualizuj repozytorium.")
2306 - return 1
2307 -
2308 - if not to_install:
2309 - print(f"✅ {_('all_installed')}"); return 0
2310 -
2311 - MAX_MB = MAX_PKG_SIZE // 1048576
2312 - for n in to_install:
2313 - if not _validate_pkg_name(n):
2314 - print(f" {_("sec_badname", name=n)}")
2315 - return 1
2316 - sz = repo_pkgs[n].size_bytes if n in repo_pkgs else 0
2317 - if sz > MAX_PKG_SIZE:
2318 - mb = sz // 1048576
2319 - print(f" {_("sec_toobig", size_mb=mb, max_mb=MAX_MB)}")
2320 - return 1
2321 - total_size = sum(repo_pkgs[n].size_bytes for n in to_install if n in repo_pkgs)
2322 - if not _preflight_disk(total_size):
2323 - return 1
2324 - print(f"\n📦 {_('to_install', len(to_install), total_size/1048576)}")
2325 - for name in to_install:
2326 - p = repo_pkgs.get(name)
2327 - if p:
2328 - if name in installed_db:
2329 - marker = " [upgrade]" if upgrade else ""
2330 - else:
2331 - marker = f" [{_('new')}]"
2332 - print(f" {name}-{p.version}{marker}")
2333 -
2334 - if not as_dep and not upgrade:
2335 - if not _ask_confirm():
2336 - print(_("cancelled")); return 0
2337 -
2338 - snapshot = json.loads(json.dumps(installed_db))
2339 - all_installed_files = []
2340 - failed = []
2341 - # Pary (pkg, stare_pliki, nowe_pliki) do usunięcia martwych plików po upgrade
2342 - stale_candidates = []
2343 - # Katalog backupów starych wersji (upgrade) – dla poprawnego rollbacku
2344 - backup_root = ""
2345 - all_backups: List[Tuple[str, str]] = [] # (backup_path, dst)
2346 - if upgrade and to_install:
2347 - backup_root = _new_upgrade_backup_root()
2348 -
2349 - # --- Dziennik transakcji (dla pełnej atomowości) ---
2350 - # Jeśli którykolwiek pakiet zawiedzie, cofamy WSZYSTKIE zainstalowane
2351 - # w tej transakcji przez _rollback_transaction().
2352 - transaction_journal: List[Tuple[str, str, str]] = [] # (op, src, dst)
2353 -
2354 - # --- Tryb immutable: utwórz nowy deployment ---
2355 - immutable = os.environ.get("PAG_IMMUTABLE", "") == "1"
2356 - deploy_dir = ""
2357 - deploy_id = ""
2358 - if immutable:
2359 - print(f"\n 🏗️ Tworzenie nowego deploymentu...")
2360 - deploy_dir, deploy_id = _create_deployment(to_install, "upgrade" if upgrade else "install")
2361 - target_root = deploy_dir
2362 - else:
2363 - target_root = ""
2364 -
2365 - # --- Faza 1: Równoległe pobieranie wszystkich pakietów ---
2366 - to_download = [repo_pkgs[name] for name in to_install if name in repo_pkgs]
2367 - if len(to_download) > 1:
2368 - print(f"\n ⏬ Pobieranie {len(to_download)} pakietów równolegle...")
2369 - downloaded = _download_packages_parallel(to_download)
2370 - else:
2371 - downloaded = {}
2372 -
2373 - # --- Faza 2: Instalacja z paskiem postępu ---
2374 - t0 = time.time()
2375 -
2376 - for name in to_install:
2377 - pkg = repo_pkgs.get(name)
2378 - if not pkg:
2379 - print(f" ❌ {name}: {_('not_found')}")
2380 - failed.append(name)
2381 - break
2382 -
2383 - # Pasek postępu na stderr (nie koliduje z download barem)
2384 - idx = len(all_installed_files) + 1
2385 - pct = (idx - 1) / len(to_install) * 100
2386 - fl = int(25 * pct / 100)
2387 - pbar = "█" * fl + "░" * (25 - fl)
2388 - elapsed = time.time() - t0
2389 - if idx > 1 and elapsed > 0:
2390 - avg = elapsed / (idx - 1)
2391 - remaining = avg * (len(to_install) - idx + 1)
2392 - if remaining < 60:
2393 - eta_s = f" ~{remaining:.0f}s"
2394 - else:
2395 - eta_s = f" ~{remaining/60:.1f}m"
2396 - else:
2397 - eta_s = ""
2398 - status = f" [{pbar}] {idx}/{len(to_install)} ({pct:.0f}%){eta_s}"
2399 - print(status, file=sys.stderr, flush=True)
2400 -
2401 - print(f" ↓ {name}-{pkg.version} ...", end=" ", flush=True)
2402 -
2403 - # Pobierz (z cache fazy 1 lub bezpośrednio)
2404 - pkg_path = downloaded.get(name) if name in downloaded else _download_pkg(pkg)
2405 - if not pkg_path:
2406 - print(f"❌ {_('download_fail')}")
2407 - failed.append(name)
2408 - break # przerwij transakcję
2409 -
2410 - # GPG
2411 - gpg_ok, gpg_msg = _verify_pkg_gpg(pkg_path, repo_url=pkg.repo_url)
2412 - if not gpg_ok:
2413 - print(f"❌ {_('gpg_fail')}: {gpg_msg[:60]}")
2414 - failed.append(name)
2415 - break # PRZERWIJ – niezaufany pakiet
2416 -
2417 - # SHA256 całego pakietu
2418 - if pkg.sha256 and _sha256_file(pkg_path) != pkg.sha256:
2419 - print(f"❌ {_('sha256_mismatch')}")
2420 - failed.append(name)
2421 - break # PRZERWIJ – uszkodzony pakiet
2422 -
2423 - # Przed instalacją zapamiętaj pliki starej wersji (potrzebne w upgrade)
2424 - old_files = _db_get_package_files(name) if name in installed_db else []
2425 -
2426 - # Atomowa instalacja (w upgrade backupuje nadpisywane pliki)
2427 - ok, files, backup_j = _atomic_install(pkg_path, pkg, deploy_dir,
2428 - backup_dir=backup_root)
2429 - if ok:
2430 - installed_db[name] = {
2431 - "version": pkg.version, "release": pkg.release, "description": pkg.description,
2432 - "dependencies": pkg.dependencies, "size_bytes": pkg.size_bytes,
2433 - "sha256": pkg.sha256, "installed_at": datetime.now().isoformat(),
2434 - "repo": pkg.repo_url,
2435 - "provides": getattr(pkg, "provides", None) or [],
2436 - "provides_so": getattr(pkg, "provides_so", None) or [],
2437 - "requires_so": getattr(pkg, "requires_so", None) or [],
2438 - }
2439 - if not as_dep and name in package_names:
2440 - world.add(name)
2441 - print("✅")
2442 - all_installed_files.extend(f["path"] for f in files)
2443 - all_backups.extend(backup_j)
2444 -
2445 - # Upgrade: zapamiętaj stare pliki, by po sukcesie usunąć te,
2446 - # których nie ma już w nowej wersji.
2447 - if upgrade and old_files:
2448 - stale_candidates.append((name, old_files, [f["path"] for f in files]))
2449 -
2450 - # Po instalacji kernela – przebuduj initramfs
2451 - if _is_kernel_package(name):
2452 - _rebuild_initramfs(deploy_dir)
2453 - else:
2454 - print("❌")
2455 - failed.append(name)
2456 - break # PRZERWIJ – błąd instalacji
2457 -
2458 - # --- Rollback całej transakcji jeśli cokolwiek zawiodło ---
2459 - if failed:
2460 - print(f"\n ↩ Cofanie transakcji ({len(failed)} błędów)...")
2461 - _rollback_transaction(installed_db, snapshot, all_installed_files,
2462 - deploy_dir, immutable, backups=all_backups)
2463 - if backup_root:
2464 - shutil.rmtree(backup_root, ignore_errors=True)
2465 - _record_transaction("upgrade" if upgrade else "install", to_install, False, snapshot)
2466 - return 1
2467 -
2468 - # --- Po sukcesie transakcji: usuń nieaktualne pliki starych wersji (upgrade).
2469 - # Usunięte pliki trafiają do backupu, aby `pag rollback` mógł je przywrócić.
2470 - for pkg_name, old_files, new_paths in stale_candidates:
2471 - _remove_stale_files(pkg_name, old_files, new_paths, installed_db, deploy_dir,
2472 - backup_root, all_backups)
2473 -
2474 - save_json(INSTALLED_DB, installed_db)
2475 - save_world(world)
2476 - _record_transaction("upgrade" if upgrade else "install", to_install, True, snapshot,
2477 - file_journal=all_installed_files,
2478 - upgrade_backups={dst: bp for bp, dst in all_backups} if all_backups else None,
2479 - upgrade_backup_root=backup_root)
2480 -
2481 - # Zachowaj backupy bieżącej transakcji (dla `pag rollback`), usuń starsze.
2482 - if backup_root:
2483 - _purge_old_backups(keep_root=backup_root)
2484 -
2485 - # --- Tryb immutable: przełącz na nowy deployment ---
2486 - if immutable and not failed:
2487 - _refresh_dynamic_linker_cache(deploy_dir)
2488 - print(f"\n 🔄 Przełączanie na deployment {deploy_id}...")
2489 - _switch_deployment(deploy_dir)
2490 - print(f" ✅ Aktywny deployment: {deploy_id}")
2491 - _update_grub_config()
2492 - cmd_deploy_cleanup(keep=5) # Zostawia 5 najnowszych deploymentów
2493 - print(f" 💡 Restart wymagany do przeładowania systemu.")
2494 - else:
2495 - _refresh_dynamic_linker_cache()
2496 - # Hooki zbiorcze – raz na transakcję (fc-cache itp.), tylko gdy pliki
2497 - # trafiły do realnego systemu (nie do deploymentu).
2498 - _process_triggers(all_installed_files)
2499 -
2500 - print(f"\n✅ {_('installed', len(to_install))}")
2501 - return 0
2502 -
2503 -
2504 -def _rollback_transaction(installed_db: dict, snapshot: dict,
2505 - installed_files: List[str],
2506 - deploy_dir: str, is_immutable: bool,
2507 - backups: Optional[List[Tuple[str, str]]] = None):
2508 - """
2509 - Cofa WSZYSTKIE pakiety zainstalowane w bieżącej transakcji.
2510 - Przywraca installed_db do stanu sprzed transakcji.
2511 - Usuwa fizyczne pliki z systemu (lub deploymentu w trybie immutable).
2512 - Jeśli podano `backups` (upgrade) – przywraca stare wersje nadpisanych plików.
2513 - """
2514 - # Przywróć installed_db
2515 - installed_db.clear()
2516 - installed_db.update(snapshot)
2517 -
2518 - root = deploy_dir if is_immutable else PAG_ROOT
2519 - backup_map = {dst: src for src, dst in (backups or [])}
2520 -
2521 - # Przywróć stare wersje z backupów (upgrade)
2522 - for dst, bpath in backup_map.items():
2523 - full = os.path.join(root, dst.lstrip("/"))
2524 - if os.path.lexists(bpath):
2525 - try:
2526 - os.makedirs(os.path.dirname(full), exist_ok=True)
2527 - os.replace(bpath, full)
2528 - except OSError:
2529 - pass
2530 -
2531 - # Usuń nowe pliki (które nie miały poprzedniej wersji)
2532 - for fpath in reversed(installed_files):
2533 - if fpath in backup_map:
2534 - continue
2535 - full = os.path.join(root, fpath.lstrip("/"))
2536 - if os.path.isfile(full) or os.path.islink(full):
2537 - try:
2538 - os.remove(full)
2539 - except OSError:
2540 - pass
2541 -
2542 - # Wyczyść puste katalogi
2543 - dirs_to_check = set()
2544 - for fpath in installed_files:
2545 - parent = os.path.dirname(fpath)
2546 - while parent and parent != "/":
2547 - dirs_to_check.add(parent)
2548 - parent = os.path.dirname(parent)
2549 - for d in sorted(dirs_to_check, key=len, reverse=True):
2550 - full_d = os.path.join(root, d.lstrip("/"))
2551 - if os.path.isdir(full_d):
2552 - try:
2553 - os.rmdir(full_d)
2554 - except OSError:
2555 - pass
2556 -
2557 - # W trybie immutable: usuń nieudany deployment
2558 - if is_immutable and deploy_dir:
2559 - shutil.rmtree(deploy_dir, ignore_errors=True)
2560 -
2561 - save_json(INSTALLED_DB, snapshot)
2562 -
2563 -
2564 -# =============================================================================
2565 -# USUWANIE
2566 -# =============================================================================
2567 -
2568 -def cmd_remove(package_names):
2569 - installed_db = load_json(INSTALLED_DB)
2570 - world = load_world()
2571 - snapshot = json.loads(json.dumps(installed_db))
2572 - removed = []
2573 - removed_files = []
2574 -
2575 - total = len(package_names)
2576 - for i, name in enumerate(package_names, 1):
2577 - if name not in installed_db:
2578 - print(f" ⚠ {name}: not installed"); continue
2579 -
2580 - # Pasek postępu
2581 - pct = (i - 1) / total * 100
2582 - filled = int(25 * pct / 100)
2583 - print(f" 🗑 [{'█' * filled + '░' * (25 - filled)}] {i}/{total} ({pct:.0f}%) ", end="\r", file=sys.stderr, flush=True)
2584 -
2585 - print(f"🗑 {name}-{installed_db[name]['version']} ...", end=" ", flush=True)
2586 -
2587 - # Pre-remove hook (jeśli dostępny w staging)
2588 - _run_hook_for_installed(name, "pre-remove")
2589 -
2590 - count, rm_files = _safe_remove_files(name, installed_db)
2591 - del installed_db[name]
2592 - world.discard(name)
2593 - removed.append(name)
2594 - removed_files.extend(rm_files)
2595 - print(f"✅ ({count} files)")
2596 -
2597 - # Post-remove hook + sprzątanie zapisanych hooków
2598 - _run_hook_for_installed(name, "post-remove")
2599 - shutil.rmtree(os.path.join(PAG_DB, "hooks", name), ignore_errors=True)
2600 -
2601 - save_json(INSTALLED_DB, installed_db)
2602 - save_world(world)
2603 - _record_transaction("remove", removed, True, snapshot)
2604 -
2605 - print(file=sys.stderr) # wyczyść linię paska postępu
2606 -
2607 - if not removed: return 0
2608 - print(f"\n✅ Removed {len(removed)}.")
2609 - _process_triggers(removed_files)
2610 -
2611 - orphans = _find_orphans(installed_db, world)
2612 - if orphans:
2613 - print(f"\n💡 {_('orphans_found', len(orphans), ', '.join(sorted(orphans)[:10]))}")
2614 - print(" 'pag remove-orphans' to clean up.")
2615 - return 0
2616 -
2617 -def _run_hook_for_installed(pkg_name, hook_name):
2618 - """Próbuje uruchomić hook z katalogu pakietu (jeśli został zapisany)."""
2619 - hook_dir = os.path.join(PAG_DB, "hooks", pkg_name)
2620 - if os.path.isdir(hook_dir):
2621 - ver = load_json(INSTALLED_DB).get(pkg_name, {}).get("version", "")
2622 - _run_hook(hook_dir, hook_name, PackageInfo({"name": pkg_name, "version": ver}))
2623 -
2624 -
2625 -# =============================================================================
2626 -# TRIGGERS – hooki zbiorcze (raz na transakcję, nie per pakiet)
2627 -# =============================================================================
2628 -# Wzorem pacman/dpkg: pakiet/administrator deklaruje zainteresowanie ścieżkami,
2629 -# a pasujący trigger uruchamia się DOKŁADNIE RAZ na końcu transakcji
2630 -# (np. fc-cache, glib-compile-schemas, update-desktop-database) zamiast po
2631 -# każdym pakiecie z osobna.
2632 -
2633 -TRIGGERS_DIR = PAG_CONF + "/triggers"
2634 -
2635 -DEFAULT_TRIGGERS = [
2636 - {"name": "font-cache", "paths": ["/usr/share/fonts/", "/usr/local/share/fonts/"],
2637 - "run": "fc-cache -fs"},
2638 - {"name": "glib-schemas", "paths": ["/usr/share/glib-2.0/schemas/"],
2639 - "run": "glib-compile-schemas /usr/share/glib-2.0/schemas"},
2640 - {"name": "desktop-database", "paths": ["/usr/share/applications/"],
2641 - "run": "update-desktop-database -q /usr/share/applications"},
2642 - {"name": "mime-database", "paths": ["/usr/share/mime/"],
2643 - "run": "update-mime-database /usr/share/mime"},
2644 -]
2645 -
2646 -def _load_triggers() -> List[dict]:
2647 - """Ładuje triggery: domyślne (tylko gdy binarka istnieje) + /etc/pag/triggers/*.json."""
2648 - out = []
2649 - for t in DEFAULT_TRIGGERS:
2650 - bin_name = t["run"].split()[0]
2651 - if shutil.which(bin_name):
2652 - out.append(dict(t))
2653 - if os.path.isdir(TRIGGERS_DIR):
2654 - for fn in sorted(os.listdir(TRIGGERS_DIR)):
2655 - if not fn.endswith(".json"):
2656 - continue
2657 - try:
2658 - with open(os.path.join(TRIGGERS_DIR, fn)) as f:
2659 - data = json.load(f)
2660 - except (OSError, json.JSONDecodeError):
2661 - continue
2662 - if isinstance(data, dict):
2663 - data = [data]
2664 - for t in data:
2665 - if isinstance(t, dict) and t.get("name") and t.get("paths") and t.get("run"):
2666 - out.append(t)
2667 - return out
2668 -
2669 -def _process_triggers(touched_paths: List[str]):
2670 - """Uruchamia pasujące triggery RAZ na końcu transakcji (best-effort)."""
2671 - if not touched_paths:
2672 - return
2673 - if os.environ.get("PAG_NO_HOOKS", "") == "1":
2674 - return
2675 - import shlex as _shlex
2676 - matched = []
2677 - for trig in _load_triggers():
2678 - if any(path.startswith(p) for p in trig["paths"] for path in touched_paths):
2679 - matched.append(trig)
2680 - for trig in matched:
2681 - run = trig["run"]
2682 - print(f" ⚡ Trigger: {trig['name']} ({run})")
2683 - try:
2684 - r = subprocess.run(_shlex.split(run), capture_output=True, text=True, timeout=120)
2685 - _audit(f"TRIGGER {trig['name']}: {run} rc={r.returncode}")
2686 - if r.returncode != 0:
2687 - print(f" ⚠ rc={r.returncode}: {(r.stderr or r.stdout or '').strip()[:160]}")
2688 - except subprocess.TimeoutExpired:
2689 - print(f" ⚠ trigger {trig['name']} przekroczył limit czasu (120 s)")
2690 - _audit(f"TRIGGER {trig['name']} TIMEOUT")
2691 - except Exception as e:
2692 - print(f" ⚠ trigger {trig['name']}: {e}")
2693 -
2694 -# =============================================================================
2695 -# UPDATE / UPGRADE / LIST / SEARCH / INFO / VERIFY
2696 -# =============================================================================
2697 -
2698 -def _cleanup_tmp_files(*paths):
2699 - """Usuwa tymczasowe pliki (np. .pag.new) po nieudanej operacji."""
2700 - for p in paths:
2701 - try:
2702 - if os.path.isfile(p):
2703 - os.remove(p)
2704 - except OSError:
2705 - pass
2706 -
2707 -
2708 -def cmd_self_update():
2709 - """Aktualizuje samego klienta pag z repo (podpisany /stable/pag).
2710 -
2711 - Kolejność: pobierz → weryfikacja GPG (+ fingerprint repo) → SHA256 →
2712 - kontrola składni (compile) → backup → atomowe os.replace. Nowa wersja
2713 - idzie do tego samego katalogu (/usr/local/bin/.pag.new), dzięki czemu
2714 - podmiana jest atomowa; jeśli system padnie w trakcie, stary pag zostaje.
2715 - """
2716 - repos = get_repos()
2717 - if not repos:
2718 - print("❌ Brak repozytoriów w konfiguracji.")
2719 - return 1
2720 - base = repos[0]
2721 - dst = "/usr/local/bin/pag"
2722 - dst_new = dst + ".new"
2723 - dst_bak = dst + ".bak"
2724 - print(f"🔄 Sprawdzam aktualizację pag z {base}...")
2725 - try:
2726 - with urlopen(Request(f"{base}/pag", headers={"User-Agent": "pag/3.0"}), timeout=20) as r:
2727 - data = r.read()
2728 - with urlopen(Request(f"{base}/pag.asc", headers={"User-Agent": "pag/3.0"}), timeout=20) as r:
2729 - sig = r.read()
2730 - except Exception as e:
2731 - print(f" ❌ Nie można pobrać pag: {e}")
2732 - return 1
2733 -
2734 - # Zapisz nową wersję w katalogu docelowym (ta sama partycja → atomowy rename)
2735 - with open(dst_new, "wb") as f:
2736 - f.write(data)
2737 - with open(dst_new + ".asc", "wb") as f:
2738 - f.write(sig)
2739 -
2740 - # --- 1. Weryfikacja podpisu GPG – bez tego nie instalujemy ---
2741 - insecure = os.environ.get("PAG_INSECURE", "") == "1"
2742 - ok, fp = _gpg_verify_fp(dst_new + ".asc", dst_new)
2743 - if not ok:
2744 - # Automatyczny import klucza (TOFU) – jak w _verify_repo_sig
2745 - res = _gpg_run("--verify", dst_new + ".asc", dst_new,
2746 - capture_output=True, text=True)
2747 - _stderr = res.stderr.decode(errors="replace") if isinstance(res.stderr, bytes) else (res.stderr or "")
2748 - if "public key" in _stderr.lower() and "no public key" in _stderr.lower():
2749 - try:
2750 - with urlopen(Request(f"{base}/paganos.asc", headers={"User-Agent": "pag/3.0"}), timeout=20) as r:
2751 - keydata = r.read()
2752 - with tempfile.NamedTemporaryFile(delete=False, suffix=".asc") as tmp:
2753 - tmp.write(keydata); tmp.flush()
2754 - _gpg_run("--import", tmp.name, capture_output=True, timeout=30)
2755 - os.unlink(tmp.name)
2756 - print(f" 🔑 Importowano klucz repo z {base}/paganos.asc")
2757 - ok, fp = _gpg_verify_fp(dst_new + ".asc", dst_new)
2758 - except Exception:
2759 - pass
2760 - if not ok:
2761 - if insecure:
2762 - print(" ⚠ Nieprawidłowy podpis aktualizacji (PAG_INSECURE – ignoruję)")
2763 - else:
2764 - print(" ❌ Nieprawidłowy podpis aktualizacji – nie aktualizuję.")
2765 - _cleanup_tmp_files(dst_new, dst_new + ".asc")
2766 - return 1
2767 - # Sprawdź fingerprint względem przypiętego klucza repo
2768 - pinned = _repo_pinned_fp(base)
2769 - if pinned:
2770 - if not fp:
2771 - print(" ❌ Nie można potwierdzić fingerprintu podpisu aktualizacji.")
2772 - _cleanup_tmp_files(dst_new, dst_new + ".asc")
2773 - return 1
2774 - if fp != pinned.upper():
2775 - if insecure:
2776 - print(" ⚠ Podpis aktualizacji innym kluczem (PAG_INSECURE – ignoruję)")
2777 - else:
2778 - print(" ❌ [SECURITY ERROR] Podpis aktualizacji innym kluczem niż repo!")
2779 - print(f" Oczekiwany: {pinned}, Otrzymany: {fp}")
2780 - _cleanup_tmp_files(dst_new, dst_new + ".asc")
2781 - return 1
2782 -
2783 - # --- 2. Weryfikacja SHA256 (jeśli repo publikuje pag.sha256) ---
2784 - try:
2785 - with urlopen(Request(f"{base}/pag.sha256", headers={"User-Agent": "pag/3.0"}), timeout=15) as r:
2786 - sha = r.read().decode().strip().split()[0]
2787 - if sha:
2788 - actual = hashlib.sha256(data).hexdigest()
2789 - if actual.lower() != sha.lower():
2790 - print(f" ❌ SHA256 niezgodny! Oczekiwano {sha}, jest {actual}")
2791 - _cleanup_tmp_files(dst_new, dst_new + ".asc")
2792 - return 1
2793 - print(" ✅ SHA256 zgodny")
2794 - except Exception:
2795 - # Brak pag.sha256 w repo – opcjonalne; nie blokuj aktualizacji.
2796 - pass
2797 -
2798 - # --- 3. Kontrola składni (nie uruchamiaj uszkodzonego/poddanego edycji pliku) ---
2799 - try:
2800 - compile(data, "pag", "exec")
2801 - except SyntaxError as e:
2802 - print(f" ❌ Błąd składni w nowym pag: {e}")
2803 - _cleanup_tmp_files(dst_new, dst_new + ".asc")
2804 - return 1
2805 -
2806 - m = (re.search(rb'PAG_VERSION\s*=\s*"(\d+\.\d+\.\d+[a-z]?)"', data[:3000])
2807 - or re.search(rb"v(\d+\.\d+\.\d+[a-z]?)", data[:3000]))
2808 - new_ver = m.group(1).decode() if m else "?"
2809 - print(f" ✅ Pobrano pag {new_ver} (obecny {PAG_VERSION}), podpis zweryfikowany")
2810 -
2811 - # --- 4. Backup + atomowa podmiana ---
2812 - if os.path.exists(dst):
2813 - shutil.copy2(dst, dst_bak)
2814 - os.chmod(dst_new, 0o755)
2815 - os.replace(dst_new, dst) # atomowe na tym samym FS
2816 - try:
2817 - if os.path.exists(dst_new + ".asc"):
2818 - os.remove(dst_new + ".asc")
2819 - except OSError:
2820 - pass
2821 - print(f" ✅ Zainstalowano nowy pag. Stary zachowany jako {dst_bak}")
2822 - print(" Uruchom ponownie pag, aby użyć nowej wersji.")
2823 - return 0
2824 -
2825 -
2826 -def _candidate_newer(rp, inst):
2827 - """Czy pakiet z repo jest nowszy od zainstalowanego.
2828 - Porównuje (version, release): sam bump pkgrel (np. auto-rebuild modułów
2829 - po aktualizacji jądra: nvidia-kernel-618 610.57.04-1 -> -2) też musi być
2830 - widziany przez `pag update`. Stare rekordy instalacji (bez pola release)
2831 - traktujemy jak release=1 – nie generują churnu, dopóki nie wrócą do
2832 - reinstalacji/zmiany wersji."""
2833 - rv = getattr(rp, "version", "0")
2834 - iv = inst.get("version", "0")
2835 - if _version_newer(rv, iv):
2836 - return True
2837 - if rv != iv:
2838 - return False
2839 - rr = int(getattr(rp, "release", 1) or 1)
2840 - ir = int(inst.get("release", 1) or 1)
2841 - return rr > ir
2842 -
2843 -
2844 -def _pending_updates() -> List[str]:
2845 - """Zainstalowane pakiety z nowszą wersją/release w repo (bez przypiętych)."""
2846 - installed = load_json(INSTALLED_DB)
2847 - pinned = load_json(PINNED_FILE)
2848 - repo = fetch_all_packages()
2849 - if not repo:
2850 - return []
2851 - return [n for n, i in installed.items()
2852 - if n not in pinned and (rp := repo.get(n)) and _candidate_newer(rp, i)]
2853 -
2854 -def cmd_update(do_upgrade: bool = False):
2855 - """`pag sync` / `pag update` – odświeżenie indeksów + raport aktualizacji.
2856 -
2857 - sync → tylko odświeżenie indeksów + info: „jest X pakietów do
2858 - zaktualizowania – wpisz: pag update".
2859 - update → odświeżenie indeksów + AKTUALIZACJA PAKIETÓW (pakiety, nie system).
2860 - Pomijamy cache TTL (inaczej nowe pakiety/aktualizacje są niewidoczne nawet
2861 - przez godzinę). Pełne pobranie + weryfikacja GPG przy każdym odświeżeniu.
2862 - """
2863 - force = True
2864 - print("🔄 Refreshing indexes...")
2865 - for repo_url in get_repos():
2866 - pkgs = fetch_repo_index(repo_url, force=force)
2867 - cp = _repo_cache_path(repo_url)
2868 - has_sig = os.path.exists(cp + ".sig")
2869 - print(f" {'✅' if pkgs is not None else '❌'} {repo_url}: {len(pkgs or [])} pkgs {'🔐' if has_sig else '⚠'}")
2870 - print(f"✅ {_('indexes_refreshed')}")
2871 -
2872 - # Powiadomienie o nowszej wersji pag (repo.json["pag_version"])
2873 - try:
2874 - for r in get_repos():
2875 - cp = _repo_cache_path(r)
2876 - if os.path.exists(cp):
2877 - d = json.load(open(cp))
2878 - rv = d.get("pag_version", "")
2879 - if rv and rv != PAG_VERSION:
2880 - print(f" ⚠ Nowa wersja pag {rv} dostępna – uruchom: pag self-update")
2881 - except Exception:
2882 - pass
2883 -
2884 - # Raport: pakiety do aktualizacji
2885 - pending = _pending_updates()
2886 - if not pending:
2887 - print(f"✅ {_('all_up_to_date')}")
2888 - return 0
2889 - print(f"{_('updates_available', len(pending))}")
2890 - installed = load_json(INSTALLED_DB)
2891 - repo = fetch_all_packages()
2892 - for n in pending:
2893 - print(f" {n}: {installed.get(n, {}).get('version', '?')} → {repo[n].version}")
2894 - if not do_upgrade:
2895 - return 0 # sync: tylko informacja
2896 - if not _ask_confirm():
2897 - return 0
2898 - return cmd_install(pending, upgrade=True)
2899 -
2900 -def _initramfs_stale() -> bool:
2901 - """Czy initramfs jest starszy niż najnowsze jądro (wymaga przebudowy)."""
2902 - try:
2903 - kernels = [k for k in os.listdir("/boot") if k.startswith("vmlinuz-")] if os.path.isdir("/boot") else []
2904 - if not kernels:
2905 - return False
2906 - newest = max(os.path.getmtime(os.path.join("/boot", k)) for k in kernels)
2907 - initrd = "/boot/initramfs.img"
2908 - return (not os.path.exists(initrd)) or os.path.getmtime(initrd) < newest
2909 - except Exception:
2910 - return False
2911 -
2912 -def cmd_upgrade():
2913 - """`pag upgrade` – aktualizacja SYSTEMU: pakiety + kernel/initramfs/GRUB."""
2914 - rc = cmd_update(do_upgrade=True)
2915 - if rc != 0:
2916 - return rc
2917 - # System: dopilnuj initramfs (gdyby kernel był nowszy) + GRUB (immutable)
2918 - if _initramfs_stale():
2919 - print(" 🐧 Przebudowa initramfs (nowsze jądro)...")
2920 - _rebuild_initramfs()
2921 - try:
2922 - if _load_deployments():
2923 - _update_grub_config()
2924 - except Exception:
2925 - pass
2926 - return 0
2927 -
2928 -def cmd_list(installed_only=False):
2929 - if installed_only:
2930 - db = load_json(INSTALLED_DB)
2931 - pinned = load_json(PINNED_FILE)
2932 - if not db: print("No packages installed."); return
2933 - print(f"Installed ({len(db)}):")
2934 - for n, i in sorted(db.items()):
2935 - pin = " 📌" if n in pinned else ""
2936 - print(f" {n}-{i['version']}{pin} – {i.get('description','')}")
2937 - else:
2938 - pkgs = fetch_all_packages()
2939 - installed = load_json(INSTALLED_DB)
2940 - pinned = load_json(PINNED_FILE)
2941 - print(f"Available ({len(pkgs)}):")
2942 - for n, p in sorted(pkgs.items()):
2943 - m = "✓" if n in installed else " "
2944 - extra = f" [installed: {installed[n]['version']}]" if n in installed else ""
2945 - if n in pinned: extra += " 📌"
2946 - print(f" [{m}] {n}-{p.version} – {p.description}{extra}")
2947 -
2948 -def cmd_search(query):
2949 - pkgs = fetch_all_packages()
2950 - results = [(n,p) for n,p in pkgs.items() if query.lower() in n.lower() or query.lower() in p.description.lower()]
2951 - if not results: print(f"❌ No results for: {query}"); return
2952 - installed = load_json(INSTALLED_DB)
2953 - print(f"Results for '{query}' ({len(results)}):")
2954 - for n,p in sorted(results):
2955 - print(f" [{'✓' if n in installed else ' '}] {n}-{p.version}")
2956 - print(f" {p.description}")
2957 -
2958 -
2959 -def _smart_search(query: str) -> int:
2960 - """
2961 - Inteligentne wyszukiwanie: repo PaganOS + Flathub.
2962 - Uruchamiane gdy użytkownik wpisze `pag <nazwa>` zamiast `pag install <nazwa>`.
2963 - Pokazuje dostępne źródła i sugeruje komendy instalacji.
2964 - """
2965 - # 1. Repo PaganOS
2966 - try:
2967 - pkgs = fetch_all_packages()
2968 - except Exception:
2969 - pkgs = {}
2970 - repo_lower = [(n, p) for n, p in pkgs.items()
2971 - if query.lower() in n.lower() or query.lower() in p.description.lower()]
2972 -
2973 - # 2. Flathub (jeśli dostępny)
2974 - flat = _flatpak_search_raw(query) if _check_flatpak(quiet=True) else []
2975 -
2976 - if not repo_lower and not flat:
2977 - print(f"\n ❌ '{query}' — nie znaleziono.")
2978 - print(f" Repo PaganOS: pag search {query}")
2979 - if _check_flatpak(quiet=True):
2980 - print(f" Flathub: pag flatpak search {query}")
2981 - print(f" Dodaj repo: pag repo-add <url>")
2982 - return 1
2983 -
2984 - installed = load_json(INSTALLED_DB)
2985 -
2986 - # ── Repo PaganOS ──
2987 - if repo_lower:
2988 - exact = [(n, p) for n, p in repo_lower if n.lower() == query.lower()]
2989 - show = (exact or repo_lower)[:6]
2990 - print(f"\n 📦 PaganOS — '{query}':")
2991 - for n, p in sorted(show):
2992 - mark = "✓" if n in installed else " "
2993 - desc = p.description[:70] if len(p.description) > 75 else p.description
2994 - print(f" [{mark}] {n}-{p.version}")
2995 - if desc:
2996 - print(f" {desc}")
2997 - if len(repo_lower) > 6:
2998 - print(f" ... i {len(repo_lower) - 6} więcej (pag search {query})")
2999 -
3000 - # ── Flathub ──
3001 - if flat:
3002 - print(f"\n 📦 Flathub — '{query}':")
3003 - for r in flat[:5]:
3004 - mark = "✓" if r.get("installed") else " "
3005 - name = r.get("name") or r.get("application", "?")
3006 - desc = (r.get("description") or "")[:65]
3007 - print(f" [{mark}] {name}")
3008 - if desc:
3009 - print(f" {desc}")
3010 - if len(flat) > 5:
3011 - print(f" ... i {len(flat) - 5} więcej (pag flatpak search {query})")
3012 -
3013 - # ── Sugestie instalacji ──
3014 - print()
3015 - if repo_lower:
3016 - 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]
3017 - if best in installed:
3018 - print(f" ✓ {best} jest już zainstalowany ({installed[best]['version']})")
3019 - else:
3020 - print(f" 💡 sudo pag install {best}")
3021 - if flat:
3022 - best_fp = flat[0].get("application") or flat[0].get("name", query)
3023 - print(f" 💡 pag flatpak install {best_fp}")
3024 -
3025 - return 0
3026 -
3027 -def cmd_info(name):
3028 - pkgs = fetch_all_packages()
3029 - p = pkgs.get(name)
3030 - info = load_json(INSTALLED_DB).get(name)
3031 - if not p and not info: print(f"❌ '{name}' not found."); return 1
3032 - print(f"📦 {name}")
3033 - if p:
3034 - print(f" Version (repo): {p.version}")
3035 - print(f" Description: {p.description}")
3036 - print(f" Size: {p.size_bytes/1048576:.1f} MB")
3037 - print(f" SHA256: {p.sha256[:32]}...")
3038 - print(f" GPG: {p.gpg_fp or 'none'}")
3039 - print(f" Dependencies: {', '.join(p.dependencies) if p.dependencies else '(none)'}")
3040 - if info:
3041 - print(f" Installed: {info['version']} ({info.get('installed_at','?')})")
3042 -
3043 -def cmd_files(name):
3044 - if name not in load_json(INSTALLED_DB):
3045 - print(f"❌ '{name}' not installed."); return 1
3046 - files = _db_get_package_files(name)
3047 - print(f"Files in {name} ({len(files)}):")
3048 - for f in sorted(files): print(f" {f}")
3049 -
3050 -def cmd_verify(deep=False):
3051 - installed = load_json(INSTALLED_DB)
3052 - if not installed: print("Nothing to verify."); return
3053 - errors = []
3054 -
3055 - for name in installed:
3056 - for fpath in _db_get_package_files(name):
3057 - full = os.path.join(PAG_ROOT, fpath.lstrip("/"))
3058 - if not (os.path.exists(full) or os.path.islink(full)):
3059 - errors.append(f" ❌ {name}: missing {fpath}")
3060 - elif deep:
3061 - checksums = _db_get_all_file_checksums()
3062 - expected = checksums.get(fpath, "")
3063 - if expected:
3064 - actual = _sha256_file(full)
3065 - if actual != expected:
3066 - errors.append(f" ❌ {name}: SHA256 mismatch {fpath}")
3067 -
3068 - if errors:
3069 - print(f"❌ {_('verify_errors', len(errors))}")
3070 - for e in errors[:50]: print(e)
3071 - return 1
3072 - total = _db_count_files()
3073 - print(f"✅ {_('verify_ok', total)}")
3074 -
3075 -# =============================================================================
3076 -# PINNING / CLEAN / ORPHANS / REPO / FLATPAK
3077 -# =============================================================================
3078 -
3079 -def cmd_pin(name, version=""):
3080 - pinned = load_json(PINNED_FILE)
3081 - if version:
3082 - pinned[name] = version
3083 - else:
3084 - info = load_json(INSTALLED_DB).get(name, {})
3085 - pinned[name] = info.get("version", "?")
3086 - save_json(PINNED_FILE, pinned)
3087 - print(f"📌 {name} {_('pinned_to')} {pinned[name]}")
3088 -
3089 -def cmd_unpin(name):
3090 - pinned = load_json(PINNED_FILE)
3091 - if name in pinned:
3092 - del pinned[name]; save_json(PINNED_FILE, pinned)
3093 - print(f"🔓 {name} {_('unpinned')}")
3094 - else:
3095 - print(f"⚠ {name} {_('not_pinned')}")
3096 -
3097 -def cmd_pinned():
3098 - pinned = load_json(PINNED_FILE)
3099 - if not pinned: print(_("no_pinned")); return
3100 - print(_("pinned_list", len(pinned)))
3101 - for n,v in sorted(pinned.items()): print(f" 📌 {n} = {v}")
3102 -
3103 -def cmd_clean():
3104 - if os.path.isdir(PAG_CACHE):
3105 - count = size = 0
3106 - for f in os.listdir(PAG_CACHE):
3107 - fp = os.path.join(PAG_CACHE, f)
3108 - if os.path.isfile(fp):
3109 - size += os.path.getsize(fp); os.remove(fp); count += 1
3110 - print(f"✅ {_('cache_cleared', count, size/1048576)}")
3111 -
3112 -def cmd_remove_orphans():
3113 - installed = load_json(INSTALLED_DB)
3114 - world = load_world()
3115 - orphans = _find_orphans(installed, world)
3116 - if not orphans: print("✅ No orphans."); return
3117 - print(f"Orphans ({len(orphans)}):")
3118 - for n in sorted(orphans): print(f" {n}-{installed[n]['version']}")
3119 - if not _ask_confirm():
3120 - return
3121 - cmd_remove(list(orphans))
3122 -
3123 -
3124 -# =============================================================================
3125 -# PROVIDES – PAKIETY WIRTUALNE
3126 -# =============================================================================
3127 -
3128 -PROVIDES_MAP = {
3129 - "pkgconfig(glib-2.0)": "glib",
3130 - "pkgconfig(gobject-introspection-1.0)": "gobject-introspection",
3131 - "pkgconfig(gtk+-3.0)": "gtk",
3132 - "pkgconfig(gtk4)": "gtk",
3133 - "pkgconfig(zlib)": "zlib",
3134 - "pkgconfig(libffi)": "libffi",
3135 - "pkgconfig(expat)": "expat",
3136 - "pkgconfig(libsystemd)": "systemd",
3137 - "pkgconfig(dbus-1)": "dbus",
3138 - "pkgconfig(mount)": "util-linux",
3139 - "pkgconfig(blkid)": "util-linux",
3140 - "pkgconfig(libcap)": "libcap",
3141 - "pkgconfig(liblzma)": "xz",
3142 - "pkgconfig(libzstd)": "zstd",
3143 - "pkgconfig(bzip2)": "bzip2",
3144 - "pkgconfig(libcurl)": "curl",
3145 - "pkgconfig(openssl)": "openssl",
3146 - "pkgconfig(libpcre2-8)": "pcre2",
3147 - "pkgconfig(libxml-2.0)": "libxml2",
3148 - "pkgconfig(libxslt)": "libxslt",
3149 - "pkgconfig(freetype2)": "freetype",
3150 - "pkgconfig(fontconfig)": "fontconfig",
3151 - "pkgconfig(harfbuzz)": "harfbuzz",
3152 - "pkgconfig(cairo)": "cairo",
3153 - "pkgconfig(pango)": "pango",
3154 - "pkgconfig(xt)": "xorg-libxt",
3155 - "pkgconfig(xmu)": "xorg-libxmu",
3156 - "pkgconfig(ice)": "xorg-libice",
3157 - "pkgconfig(sm)": "xorg-libsm",
3158 - "pkgconfig(x11)": "xorg-libx11",
3159 - "pkgconfig(xext)": "xorg-libxext",
3160 - "pkgconfig(xrandr)": "xorg-libxrandr",
3161 - "pkgconfig(xfixes)": "xorg-libxfixes",
3162 - "pkgconfig(xcursor)": "xorg-libxcursor",
3163 - "pkgconfig(xinerama)": "xorg-libxinerama",
3164 - "pkgconfig(xrender)": "xorg-libxrender",
3165 - "pkgconfig(xau)": "xorg-libxau",
3166 - "pkgconfig(xcb)": "xorg-libxcb",
3167 - "pkgconfig(xdamage)": "xorg-libxdamage",
3168 - "pkgconfig(xcomposite)": "xorg-libxcomposite",
3169 - "pkgconfig(xft)": "xorg-libxft",
3170 - "pkgconfig(xss)": "xorg-libxss",
3171 - "pkgconfig(libsoup-3.0)": "libsoup3",
3172 - "pkgconfig(libsoup-2.4)": "libsoup2",
3173 - "pkgconfig(gdk-pixbuf-2.0)": "gdk-pixbuf2",
3174 - "pkgconfig(libpng)": "libpng",
3175 - "pkgconfig(libjpeg)": "libjpeg-turbo",
3176 - "pkgconfig(libtiff-4)": "libtiff",
3177 - "pkgconfig(ffi)": "libffi",
3178 - # ── system / baza ──
3179 - "pkgconfig(libcrypto)": "openssl",
3180 - "pkgconfig(libssl)": "openssl",
3181 - "pkgconfig(libudev)": "systemd",
3182 - "pkgconfig(libmount)": "util-linux",
3183 - "pkgconfig(libblkid)": "util-linux",
3184 - "pkgconfig(uuid)": "util-linux",
3185 - "pkgconfig(libexpat)": "expat",
3186 - "pkgconfig(libpcre)": "pcre",
3187 - "pkgconfig(ncursesw)": "ncurses",
3188 - "pkgconfig(tinfo)": "ncurses",
3189 - "pkgconfig(panel)": "ncurses",
3190 - "pkgconfig(readline)": "readline",
3191 - "pkgconfig(libseccomp)": "libseccomp",
3192 - "pkgconfig(pam)": "linux-pam",
3193 - "pkgconfig(libxcrypt)": "libxcrypt",
3194 - "pkgconfig(libcrypt)": "libxcrypt",
3195 - "pkgconfig(libnsl)": "libnsl",
3196 - "pkgconfig(liblz4)": "lz4",
3197 - "pkgconfig(libevent)": "libevent",
3198 - "pkgconfig(libarchive)": "libarchive",
3199 - "pkgconfig(sqlite3)": "sqlite",
3200 - "pkgconfig(libpq)": "postgresql",
3201 - "pkgconfig(mysqlclient)": "mariadb",
3202 - "pkgconfig(json-c)": "json-c",
3203 - "pkgconfig(json-glib-1.0)": "json-glib",
3204 - "pkgconfig(libunistring)": "libunistring",
3205 - "pkgconfig(libidn2)": "libidn2",
3206 - "pkgconfig(libpsl)": "libpsl",
3207 - "pkgconfig(icu-uc)": "icu",
3208 - "pkgconfig(icu-i18n)": "icu",
3209 - "pkgconfig(icu-io)": "icu",
3210 - "pkgconfig(gnutls)": "gnutls",
3211 - "pkgconfig(nettle)": "nettle",
3212 - "pkgconfig(hogweed)": "nettle",
3213 - "pkgconfig(libgcrypt)": "libgcrypt",
3214 - "pkgconfig(libgpg-error)": "libgpg-error",
3215 - "pkgconfig(libassuan)": "libassuan",
3216 - "pkgconfig(libusb-1.0)": "libusb",
3217 - "pkgconfig(libusb)": "libusb",
3218 - "pkgconfig(libgudev-1.0)": "libgudev",
3219 - "pkgconfig(gudev-1.0)": "libgudev",
3220 - "pkgconfig(polkit-gobject-1)": "polkit",
3221 - "pkgconfig(polkit-agent-1)": "polkit",
3222 - "pkgconfig(libpciaccess)": "libpciaccess",
3223 - "pkgconfig(pixman-1)": "pixman",
3224 - "pkgconfig(libdrm)": "libdrm",
3225 - "pkgconfig(libva)": "libva",
3226 - "pkgconfig(libva-drm)": "libva",
3227 - "pkgconfig(libva-x11)": "libva",
3228 - "pkgconfig(libva-wayland)": "libva",
3229 - "pkgconfig(vdpau)": "libvdpau",
3230 - "pkgconfig(libvdpau)": "libvdpau",
3231 - "pkgconfig(libinput)": "libinput",
3232 - "pkgconfig(libevdev)": "libevdev",
3233 - "pkgconfig(mtdev)": "mtdev",
3234 - # ── grafika / GL / multimedia ──
3235 - "pkgconfig(gbm)": "mesa",
3236 - "pkgconfig(gl)": "libglvnd",
3237 - "pkgconfig(egl)": "libglvnd",
3238 - "pkgconfig(glesv2)": "libglvnd",
3239 - "pkgconfig(glx)": "libglvnd",
3240 - "pkgconfig(vulkan)": "vulkan-loader",
3241 - "pkgconfig(libxkbcommon)": "libxkbcommon",
3242 - "pkgconfig(xkbcommon)": "libxkbcommon",
3243 - "pkgconfig(xkbcommon-x11)": "libxkbcommon",
3244 - "pkgconfig(xcb)": "xorg-libxcb",
3245 - "pkgconfig(xcb-util)": "xcb-util",
3246 - "pkgconfig(xcb-keysyms)": "xcb-util-keysyms",
3247 - "pkgconfig(xcb-icccm)": "xcb-util-wm",
3248 - "pkgconfig(xcb-cursor)": "xcb-util-cursor",
3249 - "pkgconfig(xcb-renderutil)": "xcb-util-renderutil",
3250 - "pkgconfig(xcb-image)": "xcb-util-image",
3251 - "pkgconfig(xcb-errors)": "xcb-util-errors",
3252 - "pkgconfig(wayland-client)": "wayland",
3253 - "pkgconfig(wayland-server)": "wayland",
3254 - "pkgconfig(wayland-cursor)": "wayland",
3255 - "pkgconfig(wayland-egl)": "wayland",
3256 - "pkgconfig(wayland-protocols)": "wayland-protocols",
3257 - "pkgconfig(gstreamer-1.0)": "gstreamer",
3258 - "pkgconfig(gstreamer-base-1.0)": "gstreamer",
3259 - "pkgconfig(gstreamer-check-1.0)": "gstreamer",
3260 - "pkgconfig(gstreamer-controller-1.0)": "gstreamer",
3261 - "pkgconfig(gstreamer-app-1.0)": "gst-plugins-base",
3262 - "pkgconfig(gstreamer-video-1.0)": "gst-plugins-base",
3263 - "pkgconfig(gstreamer-audio-1.0)": "gst-plugins-base",
3264 - "pkgconfig(gstreamer-pbutils-1.0)": "gst-plugins-base",
3265 - "pkgconfig(gstreamer-fft-1.0)": "gst-plugins-base",
3266 - "pkgconfig(gstreamer-riff-1.0)": "gst-plugins-base",
3267 - "pkgconfig(gstreamer-rtp-1.0)": "gst-plugins-base",
3268 - "pkgconfig(gstreamer-rtsp-1.0)": "gst-plugins-base",
3269 - "pkgconfig(gstreamer-sdp-1.0)": "gst-plugins-base",
3270 - "pkgconfig(gstreamer-net-1.0)": "gst-plugins-base",
3271 - "pkgconfig(gstreamer-gl-1.0)": "gst-plugins-base",
3272 - "pkgconfig(libpulse)": "libpulse",
3273 - "pkgconfig(libpulse-simple)": "libpulse",
3274 - "pkgconfig(libpulse-mainloop-glib)": "libpulse",
3275 - "pkgconfig(alsa)": "alsa-lib",
3276 - "pkgconfig(jack)": "jack2",
3277 - "pkgconfig(libsamplerate)": "libsamplerate",
3278 - "pkgconfig(sndfile)": "libsndfile",
3279 - "pkgconfig(libavcodec)": "ffmpeg",
3280 - "pkgconfig(libavformat)": "ffmpeg",
3281 - "pkgconfig(libavutil)": "ffmpeg",
3282 - "pkgconfig(libavfilter)": "ffmpeg",
3283 - "pkgconfig(libswscale)": "ffmpeg",
3284 - "pkgconfig(libswresample)": "ffmpeg",
3285 - "pkgconfig(libpostproc)": "ffmpeg",
3286 - "pkgconfig(SDL2)": "sdl2",
3287 - "pkgconfig(SDL)": "sdl",
3288 - "pkgconfig(SDL2_image)": "sdl2-image",
3289 - "pkgconfig(SDL2_ttf)": "sdl2-ttf",
3290 - "pkgconfig(SDL2_mixer)": "sdl2-mixer",
3291 - "pkgconfig(SDL2_net)": "sdl2-net",
3292 - "pkgconfig(libpng16)": "libpng",
3293 - "pkgconfig(libwebp)": "libwebp",
3294 - "pkgconfig(libwebpmux)": "libwebp",
3295 - "pkgconfig(libwebpdemux)": "libwebp",
3296 - "pkgconfig(libopenjp2)": "openjpeg2",
3297 - "pkgconfig(lcms2)": "lcms2",
3298 - "pkgconfig(libheif)": "libheif",
3299 - "pkgconfig(libde265)": "libde265",
3300 - "pkgconfig(x264)": "x264",
3301 - "pkgconfig(x265)": "x265",
3302 - # ── glib / gio ──
3303 - "pkgconfig(gio-unix-2.0)": "glib",
3304 - "pkgconfig(gmodule-2.0)": "glib",
3305 - "pkgconfig(gthread-2.0)": "glib",
3306 - "pkgconfig(girepository-2.0)": "gobject-introspection",
3307 - "pkgconfig(girepository-1.0)": "gobject-introspection",
3308 - "pkgconfig(libglib-2.0)": "glib",
3309 - "pkgconfig(libgobject-2.0)": "glib",
3310 -}
3311 -
3312 -def _resolve_provides(name: str, repo: dict, installed: Optional[dict] = None) -> str:
3313 - """Rozwija wirtualną nazwę pakietu do rzeczywistej nazwy.
3314 -
3315 - Kolejność: repo → PROVIDES_MAP → wzorce → provides z repo.json →
3316 - provides ZAINSTALOWANYCH pakietów (lokalnie zbudowane poza repo też
3317 - dostarczają wirtualne zależności) → fallback pkgconfig (czyszczenie nazwy).
3318 - """
3319 - if name in repo:
3320 - return name
3321 - if name in PROVIDES_MAP:
3322 - real = PROVIDES_MAP[name]
3323 - if real in repo:
3324 - return real
3325 - # Wzorce: moduły Qt (Qt5Core/Qt6Widgets) i GStreamer (gstreamer-video-1.0)
3326 - if name.startswith("pkgconfig(Qt5"):
3327 - real = "qt5"
3328 - if real in repo:
3329 - return real
3330 - if name.startswith("pkgconfig(Qt6"):
3331 - real = "qt6"
3332 - if real in repo:
3333 - return real
3334 - if name.startswith("pkgconfig(gstreamer-") and name.endswith("-1.0)"):
3335 - real = "gstreamer"
3336 - if real in repo:
3337 - return real
3338 - if name.startswith("pkgconfig(gst-"):
3339 - real = "gst-plugins-base"
3340 - if real in repo:
3341 - return real
3342 - # Dynamiczne provides z repo.json (sekcja provides: w PAGBUILD.yaml)
3343 - for _pkg_name, _pkg in repo.items():
3344 - _provs = getattr(_pkg, "provides", None) or []
3345 - if name in _provs:
3346 - return _pkg_name
3347 - # provides ZAINSTALOWANYCH pakietów – lokalnie zbudowane (pagbuild, poza
3348 - # repo) też dostarczają wirtualne zależności i muszą być rozpoznawane.
3349 - if installed:
3350 - for _pkg_name, _meta in installed.items():
3351 - _provs = _meta.get("provides") or [] if isinstance(_meta, dict) else []
3352 - if name in _provs:
3353 - return _pkg_name
3354 - clean = name
3355 - if name.startswith("pkgconfig(") and ")" in name:
3356 - clean = name.split("(", 1)[1].rstrip(")")
3357 - elif name.startswith("pkgconfig32(") and ")" in name:
3358 - clean = name.split("(", 1)[1].rstrip(")")
3359 - if clean != name and clean in repo:
3360 - return clean
3361 - return name
3362 -
3363 -
3364 -def cmd_why(pkg_name: str):
3365 - """Pokazuje dlaczego pakiet jest zainstalowany."""
3366 - installed = load_json(INSTALLED_DB)
3367 - world = load_world()
3368 - if pkg_name not in installed:
3369 - print(f" {pkg_name}: {_('why_not_installed')}"); return 1
3370 - if pkg_name in world:
3371 - print(f" {pkg_name}-{installed[pkg_name]['version']}: {_('why_explicit')}")
3372 - return 0
3373 - parents = set()
3374 - for w in world:
3375 - _find_dep_path(w, pkg_name, installed, set(), [], parents)
3376 - if parents:
3377 - for pp in sorted(parents):
3378 - print(f" {pkg_name}: {_('why_dependency')} {' → '.join(pp)}")
3379 - else:
3380 - print(f" {pkg_name}: {_('why_dependency')} (unknown/orphan)")
3381 - return 0
3382 -
3383 -
3384 -def _find_dep_path(cur, target, installed, visited, path, results):
3385 - if cur in visited: return
3386 - visited.add(cur); path.append(cur)
3387 - if cur == target:
3388 - results.add(tuple(path))
3389 - else:
3390 - for dep in installed.get(cur, {}).get("dependencies", []):
3391 - _find_dep_path(dep, target, installed, visited, path, results)
3392 - path.pop(); visited.discard(cur)
3393 -
3394 -
3395 -def cmd_autoremove():
3396 - """Automatycznie usuwa osierocone zależności bez pytania."""
3397 - installed = load_json(INSTALLED_DB)
3398 - world = load_world()
3399 - orphans = _find_orphans(installed, world)
3400 - if not orphans: print(f"✅ {_('autoremove_none')}"); return 0
3401 - print(f"🗑 {_('orphans_found', len(orphans), ', '.join(sorted(orphans)[:10]))}")
3402 - return cmd_remove(list(orphans))
3403 -
3404 -
3405 -def cmd_download(package_names):
3406 - """Pobiera pakiety do cache bez instalowania."""
3407 - ensure_dirs()
3408 - repo = fetch_all_packages()
3409 - if not repo: print(f"❌ {_('no_index')}"); return 1
3410 - total_size = 0; downloaded = []
3411 - for name in package_names:
3412 - pkg = repo.get(name)
3413 - if not pkg:
3414 - print(f" ❌ {name}: {_('not_found')}"); continue
3415 - print(f" ↓ {name}-{pkg.version} ...", end=" ", flush=True)
3416 - path = _download_pkg(pkg)
3417 - if path:
3418 - total_size += os.path.getsize(path)
3419 - downloaded.append(name)
3420 - print(_c("green", "✓"))
3421 - else:
3422 - print(_c("red", "✗"))
3423 - if downloaded:
3424 - print(f"\n✅ {_('downloaded', len(downloaded), total_size/1048576)}")
3425 - return 0 if len(downloaded) == len(package_names) else 1
3426 -
3427 -
3428 -def cmd_stats():
3429 - """Wyświetla statystyki PAG."""
3430 - installed = load_json(INSTALLED_DB)
3431 - history = load_json(HISTORY_FILE) if os.path.exists(HISTORY_FILE) else []
3432 - total_size = sum(i.get("size_bytes", 0) for i in installed.values())
3433 - total_files = _db_count_files()
3434 - cache_size = sum(
3435 - os.path.getsize(os.path.join(PAG_CACHE, f))
3436 - for f in os.listdir(PAG_CACHE)
3437 - if os.path.isfile(os.path.join(PAG_CACHE, f))
3438 - ) if os.path.isdir(PAG_CACHE) else 0
3439 - last_update = "never"
3440 - for e in reversed(history):
3441 - if e.get("action") in ("install", "upgrade") and e.get("success"):
3442 - last_update = e.get("timestamp", "?")[:19]; break
3443 - print(f"\n {_c('bold', _('stats_title'))}")
3444 - print(f" {'─' * 40}")
3445 - print(f" {_('stats_packages'):<30} {len(installed)}")
3446 - print(f" {_('stats_files'):<30} {total_files}")
3447 - print(f" {_('stats_size'):<30} {total_size/1048576:.1f} MB")
3448 - print(f" {_('stats_cache'):<30} {cache_size/1048576:.1f} MB")
3449 - print(f" {_('stats_history'):<30} {len(history)}")
3450 - print(f" {_('stats_last_update'):<30} {last_update}")
3451 - by_size = sorted(installed.items(), key=lambda x: x[1].get("size_bytes", 0), reverse=True)[:5]
3452 - if by_size:
3453 - print(f"\n {_c('dim', 'Top 5:')}")
3454 - for n, i in by_size:
3455 - print(f" {n}-{i['version']} {i.get('size_bytes',0)/1048576:.1f} MB")
3456 - return 0
3457 -
3458 -
3459 -def cmd_repo_add(url, name=None):
3460 - if not url.startswith("https://") and not os.environ.get("PAG_INSECURE"):
3461 - print(f" {_('sec_https')}"); return 1
3462 - ensure_dirs()
3463 - url = url.rstrip("/")
3464 - repos = get_repos()
3465 - if url in repos: print(f"⚠ {_('repo_exists', url)}"); return
3466 - if name:
3467 - # Drop-in: /etc/pag/repos/<nazwa>.conf (jak `echo url > .../stable.conf`)
3468 - os.makedirs(REPOS_DIR, exist_ok=True)
3469 - target = os.path.join(REPOS_DIR, name.rstrip("/").replace("/", "_") + ".conf")
3470 - with open(target, "w") as f: f.write(f"{url}\n")
3471 - print(f"✅ {_('repo_added', url)} → {target}")
3472 - return
3473 - with open(REPOS_CONF, "a") as f: f.write(f"{url}\n")
3474 - print(f"✅ {_('repo_added', url)}")
3475 -
3476 -def cmd_repo_list():
3477 - for i, url in enumerate(get_repos(), 1): print(f" {i}. {url}")
3478 -
3479 -def _check_flatpak(quiet: bool = False):
3480 - if not shutil.which("flatpak"):
3481 - if not quiet:
3482 - print(f"❌ {_('flatpak_missing')}")
3483 - return False
3484 - r = subprocess.run(["flatpak","remotes"], capture_output=True, text=True)
3485 - if "flathub" not in r.stdout:
3486 - print(f"⚠ {_('flatpak_adding')}")
3487 - subprocess.run(["flatpak","remote-add","--if-not-exists","flathub",
3488 - "https://flathub.org/repo/flathub.flatpakrepo"], check=False)
3489 - return True
3490 -
3491 -def _spinner(msg: str):
3492 - """Prosty spinner „myślenia” w osobnym wątku. Zwraca funkcję stop()."""
3493 - stop = threading.Event()
3494 - def _spin():
3495 - for c in itertools.cycle("|/-\\"):
3496 - if stop.is_set():
3497 - break
3498 - sys.stdout.write(f"\r {msg} {c}")
3499 - sys.stdout.flush()
3500 - time.sleep(0.1)
3501 - t = threading.Thread(target=_spin, daemon=True)
3502 - t.start()
3503 - def _stop():
3504 - stop.set()
3505 - t.join(timeout=0.3)
3506 - sys.stdout.write("\r" + " " * (len(msg) + 4) + "\r")
3507 - sys.stdout.flush()
3508 - return _stop
3509 -
3510 -
3511 -def _flatpak_search_raw(query: str) -> List[dict]:
3512 - """Szuka we Flathub i zwraca listę wyników jako słowniki."""
3513 - if not _check_flatpak():
3514 - return []
3515 - stop = _spinner("Szukam we Flathub...")
3516 - try:
3517 - try:
3518 - r = subprocess.run(
3519 - ["flatpak", "search", "--columns=name,description,application,version,branch,remotes", query],
3520 - capture_output=True, text=True, timeout=120
3521 - )
3522 - finally:
3523 - stop()
3524 - if r.returncode != 0 and "No matches found" not in r.stdout and not r.stdout.strip():
3525 - print(f" ⚠ flatpak search: {r.stderr.strip()[:150]}")
3526 - results = []
3527 - for line in r.stdout.strip().split("\n"):
3528 - parts = line.split("\t")
3529 - if len(parts) >= 3:
3530 - results.append({
3531 - "name": parts[0].strip(),
3532 - "description": parts[1].strip() if len(parts) > 1 else "",
3533 - "app_id": parts[2].strip() if len(parts) > 2 else "",
3534 - "version": parts[3].strip() if len(parts) > 3 else "",
3535 - "branch": parts[4].strip() if len(parts) > 4 else "stable",
3536 - "origin": parts[5].strip() if len(parts) > 5 else "flathub",
3537 - })
3538 - return results
3539 - except Exception as e:
3540 - print(f" ⚠ Błąd wyszukiwania: {e}", file=sys.stderr)
3541 - return []
3542 -
3543 -def _flatpak_find_best(query: str) -> Optional[dict]:
3544 - """
3545 - Szuka we Flathub i próbuje znaleźć najlepsze dopasowanie.
3546 - - Jeśli query dokładnie pasuje do app_id → zwraca od razu
3547 - - Jeśli query pasuje do nazwy → zwraca pierwsze
3548 - - Jeśli wiele wyników → wyświetla listę i pyta użytkownika
3549 - - Jeśli brak → zwraca None
3550 - """
3551 - results = _flatpak_search_raw(query)
3552 - if not results:
3553 - return None
3554 -
3555 - # Dokładne dopasowanie app_id
3556 - exact = [r for r in results if r["app_id"].lower() == query.lower()]
3557 - if exact:
3558 - return exact[0]
3559 -
3560 - # Dokładne dopasowanie nazwy
3561 - exact_name = [r for r in results if r["name"].lower() == query.lower()]
3562 - if exact_name:
3563 - return exact_name[0]
3564 -
3565 - # Jednoznaczne dopasowanie (tylko 1 wynik)
3566 - if len(results) == 1:
3567 - return results[0]
3568 -
3569 - # Wiele wyników – pokaż użytkownikowi
3570 - print(f"\n {_('flatpak_found', len(results))}")
3571 - for i, r in enumerate(results):
3572 - print(f" {i+1}. {_c('bold', r['name'])} ({r['app_id']})")
3573 - if r["version"]:
3574 - print(f" {_('flatpak_info_version')}: {r['version']}")
3575 - if r["description"]:
3576 - desc = r["description"][:80] + ("..." if len(r["description"]) > 80 else "")
3577 - print(f" {desc}")
3578 -
3579 - try:
3580 - choice = input(f"\n Wybierz numer (1-{len(results)}) lub Enter aby anulować: ").strip()
3581 - if not choice:
3582 - return None
3583 - idx = int(choice) - 1
3584 - if 0 <= idx < len(results):
3585 - return results[idx]
3586 - except (EOFError, ValueError, IndexError):
3587 - pass
3588 - return None
3589 -
3590 -def _flatpak_get_installed_info(app_id: str) -> Optional[dict]:
3591 - """Zwraca info o zainstalowanym flatpaku lub None."""
3592 - try:
3593 - r = subprocess.run(
3594 - ["flatpak", "info", "--columns=name,version,branch,origin,installed-size,description", app_id],
3595 - capture_output=True, text=True, timeout=10
3596 - )
3597 - if r.returncode != 0:
3598 - return None
3599 - parts = r.stdout.strip().split("\t")
3600 - if len(parts) < 3:
3601 - return None
3602 - return {
3603 - "name": parts[0].strip(),
3604 - "version": parts[1].strip() if len(parts) > 1 else "",
3605 - "branch": parts[2].strip() if len(parts) > 2 else "",
3606 - "origin": parts[3].strip() if len(parts) > 3 else "",
3607 - "size": parts[4].strip() if len(parts) > 4 else "",
3608 - "description": parts[5].strip() if len(parts) > 5 else "",
3609 - }
3610 - except Exception:
3611 - return None
3612 -
3613 -def _flatpak_is_installed(app_id: str) -> bool:
3614 - """Sprawdza czy flatpak o danym ID jest zainstalowany."""
3615 - try:
3616 - r = subprocess.run(
3617 - ["flatpak", "info", app_id],
3618 - capture_output=True, text=True, timeout=10
3619 - )
3620 - return r.returncode == 0
3621 - except Exception:
3622 - return False
3623 -
3624 -# =============================================================================
3625 -# FLATPAK – KOMENDY GŁÓWNE (zunifikowany interfejs)
3626 -# =============================================================================
3627 -# pag flatpak <query> → szuka i proponuje instalację (jeśli nie zainstalowany)
3628 -# pag flatpak search <query> → tylko szuka
3629 -# pag flatpak install <query> → instaluje
3630 -# pag flatpak remove <id> → usuwa
3631 -# pag flatpak list → lista zainstalowanych
3632 -# pag flatpak update → aktualizuje wszystkie
3633 -# pag flatpak info <id> → szczegóły flatpaka
3634 -
3635 -def cmd_flatpak(args: list):
3636 - """
3637 - Główna komenda flatpak – inteligentnie rozpoznaje intencję:
3638 - pag flatpak firefox → szuka i instaluje (jeśli nieznaleziony → szuka)
3639 - pag flatpak search firefox → tylko wyszukiwanie
3640 - pag flatpak install ... → bezpośrednia instalacja
3641 - pag flatpak remove ... → odinstalowanie
3642 - pag flatpak list → lista
3643 - pag flatpak update → aktualizacja
3644 - pag flatpak info ... → szczegóły
3645 - """
3646 - if not _check_flatpak():
3647 - return 1
3648 -
3649 - if not args:
3650 - # Bez argumentów – domyślnie lista
3651 - return cmd_flatpak_list()
3652 -
3653 - subcmd = args[0].lower()
3654 - rest = args[1:]
3655 -
3656 - # ── Podkomendy jawne ────────────────────────────────────────────────
3657 - if subcmd == "search":
3658 - if not rest:
3659 - print(_("flatpak_usage")); return 1
3660 - return cmd_flatpak_search(" ".join(rest))
3661 -
3662 - elif subcmd == "install":
3663 - if not rest:
3664 - print(_("flatpak_usage")); return 1
3665 - return _flatpak_smart_install(rest)
3666 -
3667 - elif subcmd == "remove" or subcmd == "uninstall":
3668 - if not rest:
3669 - print(_("flatpak_usage")); return 1
3670 - return _flatpak_smart_remove(rest)
3671 -
3672 - elif subcmd == "list":
3673 - return cmd_flatpak_list()
3674 -
3675 - elif subcmd == "update":
3676 - return cmd_flatpak_update()
3677 -
3678 - elif subcmd == "info":
3679 - if not rest:
3680 - print(_("flatpak_usage")); return 1
3681 - return cmd_flatpak_info(rest[0])
3682 -
3683 - else:
3684 - # ── Inteligentne wykrywanie: pag flatpak <nazwa> ────────────────
3685 - # Sprawdź czy to zainstalowany flatpak → pokaż info
3686 - # Jeśli nie → szukaj i zaproponuj instalację
3687 - query = " ".join(args)
3688 -
3689 - # Najpierw sprawdź czy już zainstalowany
3690 - if _flatpak_is_installed(query):
3691 - print(f" 📦 {_c('green', query)} – already installed (use 'pag flatpak info {query}' for details)")
3692 - return cmd_flatpak_info(query)
3693 -
3694 - # Szukaj we Flathub
3695 - print(f" {_('flatpak_searching', query)}")
3696 - best = _flatpak_find_best(query)
3697 - if not best:
3698 - print(f" ❌ '{query}' – {_('flatpak_not_found')}")
3699 - return 1
3700 -
3701 - print(f"\n {_c('cyan', best['name'])} ({best['app_id']})")
3702 - if best["version"]:
3703 - print(f" {_('flatpak_info_version')}: {best['version']}")
3704 - if best["description"]:
3705 - print(f" {best['description']}")
3706 -
3707 - try:
3708 - ans = input(f"\n {_('flatpak_install_prompt', best['name'])}").strip().lower()
3709 - except (EOFError, KeyboardInterrupt):
3710 - print(f"\n ⚠ {_('no_tty')}")
3711 - return 0
3712 - if ans and ans not in ("t", "y"):
3713 - print(_("cancelled"))
3714 - return 0
3715 -
3716 - return _flatpak_do_install(best["app_id"])
3717 -
3718 -def _flatpak_smart_install(names: list) -> int:
3719 - """Instaluje flatpaki – obsługuje nazwy częściowe (wyszukuje przed instalacją)."""
3720 - failed = 0
3721 - for name in names:
3722 - if "." in name and "/" not in name:
3723 - # Wygląda na pełne app_id (np. org.mozilla.firefox)
3724 - app_id = name
3725 - else:
3726 - # Szukaj najlepszego dopasowania
3727 - best = _flatpak_find_best(name)
3728 - if not best:
3729 - print(f" ❌ '{name}' – {_('flatpak_not_found')}")
3730 - failed += 1
3731 - continue
3732 - app_id = best["app_id"]
3733 - print(f" → {best['name']} ({app_id})")
3734 -
3735 - if _flatpak_do_install(app_id) != 0:
3736 - failed += 1
3737 - return 1 if failed else 0
3738 -
3739 -def _flatpak_do_install(app_id: str) -> int:
3740 - """Wykonuje właściwą instalację flatpaka."""
3741 - print(f" {_('flatpak_installing', app_id)}")
3742 - result = subprocess.run(
3743 - ["flatpak", "install", "-y", "flathub", app_id],
3744 - check=False, timeout=600
3745 - )
3746 - if result.returncode == 0:
3747 - print(f" ✅ {_('flatpak_installed', app_id)}")
3748 - return 0
3749 - else:
3750 - print(f" ❌ {_('download_fail')}: {app_id}")
3751 - return 1
3752 -
3753 -def _flatpak_smart_remove(names: list) -> int:
3754 - """Usuwa flatpaki – obsługuje nazwy częściowe."""
3755 - # Pobierz listę zainstalowanych
3756 - try:
3757 - r = subprocess.run(
3758 - ["flatpak", "list", "--columns=application,name"],
3759 - capture_output=True, text=True, timeout=10
3760 - )
3761 - installed = {}
3762 - for line in r.stdout.strip().split("\n"):
3763 - parts = line.split("\t")
3764 - if len(parts) >= 2:
3765 - installed[parts[0].strip()] = parts[1].strip()
3766 - except Exception:
3767 - installed = {}
3768 -
3769 - failed = 0
3770 - for name in names:
3771 - app_id = name
3772 -
3773 - # Jeśli nie podano pełnego ID – spróbuj dopasować
3774 - if name not in installed:
3775 - matches = {aid: aname for aid, aname in installed.items()
3776 - if name.lower() in aid.lower() or name.lower() in aname.lower()}
3777 - if len(matches) == 0:
3778 - print(f" ❌ '{name}' – {_('flatpak_not_installed', name)}")
3779 - failed += 1
3780 - continue
3781 - elif len(matches) == 1:
3782 - app_id = list(matches.keys())[0]
3783 - print(f" → {matches[app_id]} ({app_id})")
3784 - else:
3785 - print(f"\n Wiele dopasowań dla '{name}':")
3786 - for i, (aid, aname) in enumerate(sorted(matches.items()), 1):
3787 - print(f" {i}. {aname} ({aid})")
3788 - try:
3789 - choice = input(f"\n Wybierz numer (1-{len(matches)}) lub Enter: ").strip()
3790 - if not choice:
3791 - failed += 1
3792 - continue
3793 - aid_list = sorted(matches.keys())
3794 - app_id = aid_list[int(choice) - 1]
3795 - except (EOFError, ValueError, IndexError):
3796 - failed += 1
3797 - continue
3798 -
3799 - print(f" 🗑 {app_id} ...", end=" ", flush=True)
3800 - result = subprocess.run(
3801 - ["flatpak", "uninstall", "-y", app_id],
3802 - capture_output=True, text=True, timeout=120
3803 - )
3804 - if result.returncode == 0:
3805 - print("✅")
3806 - print(f" {_('flatpak_removed', app_id)}")
3807 - else:
3808 - print("❌")
3809 - failed += 1
3810 - return 1 if failed else 0
3811 -
3812 -def cmd_flatpak_search(q: str):
3813 - """Wyszukuje we Flathub i wyświetla wyniki (z możliwością wyboru do instalacji)."""
3814 - if not _check_flatpak():
3815 - return 1
3816 - results = _flatpak_search_raw(q)
3817 - if not results:
3818 - print(f" ❌ '{q}' – {_('flatpak_not_found')}")
3819 - return 1
3820 - print(f"\n {_('flatpak_found', len(results))}")
3821 - shown = results[:30] # max 30 wyników
3822 - for i, r in enumerate(shown, 1):
3823 - installed = "📦 " if _flatpak_is_installed(r["app_id"]) else " "
3824 - print(f" {i:>2}. {installed}{_c('bold', r['name'])} ({r['app_id']})")
3825 - if r["version"]:
3826 - print(f" {_('flatpak_info_version')}: {r['version']} | {_('flatpak_info_branch')}: {r['branch']}")
3827 - if r["description"]:
3828 - desc = r["description"][:100] + ("..." if len(r["description"]) > 100 else "")
3829 - print(f" {_c('dim', desc)}")
3830 - if len(results) > 30:
3831 - print(f" ... i {len(results) - 30} więcej. Doprecyzuj zapytanie.")
3832 -
3833 - # Interaktywny wybór – wpisz numer, aby zainstalować (Enter = anuluj)
3834 - try:
3835 - ans = input(f"\n Wybierz numer do zainstalowania (1-{len(shown)}) lub Enter aby anulować: ").strip()
3836 - except (EOFError, KeyboardInterrupt):
3837 - return 0
3838 - if ans:
3839 - try:
3840 - idx = int(ans) - 1
3841 - if 0 <= idx < len(shown):
3842 - return _flatpak_do_install(shown[idx]["app_id"])
3843 - print(_("cancelled"))
3844 - except (ValueError, IndexError):
3845 - print(_("cancelled"))
3846 - return 0
3847 -
3848 -def cmd_flatpak_list():
3849 - """Wyświetla zainstalowane flatpaki."""
3850 - if not _check_flatpak():
3851 - return 1
3852 - r = subprocess.run(
3853 - ["flatpak", "list", "--columns=application,name,version,origin,installed-size"],
3854 - capture_output=True, text=True, timeout=10
3855 - )
3856 - lines = [l for l in r.stdout.strip().split("\n") if l.strip()]
3857 - if not lines:
3858 - print(" (brak zainstalowanych flatpaków)")
3859 - return 0
3860 - print(f" Zainstalowane flatpaki ({len(lines)}):")
3861 - for line in lines:
3862 - parts = line.split("\t")
3863 - if len(parts) >= 3:
3864 - app_id, name, version = parts[0], parts[1], parts[2]
3865 - size = parts[4] if len(parts) > 4 else ""
3866 - size_str = f" ({size})" if size else ""
3867 - print(f" 📦 {_c('bold', name)} {version}{size_str}")
3868 - print(f" {_c('dim', app_id)}")
3869 - return 0
3870 -
3871 -def cmd_flatpak_update():
3872 - """Aktualizuje wszystkie flatpaki."""
3873 - if not _check_flatpak():
3874 - return 1
3875 - print(" 🔄 Aktualizacja flatpaków...")
3876 - result = subprocess.run(["flatpak", "update", "-y"], check=False, timeout=600)
3877 - if result.returncode == 0:
3878 - print(f" ✅ {_('flatpak_updated')}")
3879 - return result.returncode
3880 -
3881 -def cmd_flatpak_info(app_id: str):
3882 - """Wyświetla szczegóły flatpaka (zainstalowanego lub z Flathub)."""
3883 - if not _check_flatpak():
3884 - return 1
3885 -
3886 - # Najpierw sprawdź zainstalowany
3887 - info = _flatpak_get_installed_info(app_id)
3888 - if info:
3889 - print(f"\n 📦 {_c('bold', info['name'])} {_c('green', '[zainstalowany]')}")
3890 - print(f" {'─' * 45}")
3891 - print(f" {_('flatpak_info_id'):<16} {app_id}")
3892 - print(f" {_('flatpak_info_version'):<16} {info['version']}")
3893 - print(f" {_('flatpak_info_branch'):<16} {info['branch']}")
3894 - print(f" {_('flatpak_info_origin'):<16} {info['origin']}")
3895 - if info["size"]:
3896 - print(f" {_('flatpak_info_size'):<16} {info['size']}")
3897 - if info["description"]:
3898 - print(f" {_('flatpak_info_desc'):<16} {info['description']}")
3899 - return 0
3900 -
3901 - # Szukaj we Flathub
3902 - results = _flatpak_search_raw(app_id)
3903 - exact = [r for r in results if r["app_id"].lower() == app_id.lower()]
3904 - if not exact:
3905 - # Spróbuj częściowego dopasowania
3906 - if results:
3907 - exact = [results[0]]
3908 - else:
3909 - print(f" ❌ '{app_id}' – {_('flatpak_not_found')}")
3910 - return 1
3911 -
3912 - r = exact[0]
3913 - print(f"\n 📦 {_c('bold', r['name'])} (Flathub)")
3914 - print(f" {'─' * 45}")
3915 - print(f" {_('flatpak_info_id'):<16} {r['app_id']}")
3916 - print(f" {_('flatpak_info_version'):<16} {r['version']}")
3917 - if r["description"]:
3918 - print(f" {_('flatpak_info_desc'):<16} {r['description']}")
3919 - print(f"\n 💡 Aby zainstalować: pag flatpak install {r['app_id']}")
3920 - return 0
3921 -
3922 -# =============================================================================
3923 -# IMMUTABLE OS – KOMENDY DEPLOYMENTOWE
3924 -# =============================================================================
3925 -
3926 -# Pakiety jądra – po ich instalacji trzeba przebudować initramfs
3927 -KERNEL_PACKAGE_PATTERNS = ["linux", "kernel", "linux-kernel", "linux-lts"]
3928 -
3929 -def _is_kernel_package(name: str) -> bool:
3930 - """Sprawdza czy pakiet to jądro (wymaga przebudowy initramfs)."""
3931 - name_lower = name.lower()
3932 - return any(pattern in name_lower for pattern in KERNEL_PACKAGE_PATTERNS)
3933 -
3934 -def _rebuild_initramfs(deploy_dir: str = "") -> bool:
3935 - """
3936 - Przebudowuje initramfs dla aktywnego (lub podanego) deploymentu.
3937 - Używa skryptu pag-initramfs lub ręcznego cpio.
3938 - """
3939 - if deploy_dir:
3940 - root = deploy_dir
3941 - else:
3942 - root = _get_deployment_root()
3943 -
3944 - if root == PAG_ROOT:
3945 - # Zwykły system – użyj dracut jeśli dostępny
3946 - if shutil.which("dracut"):
3947 - print(" 🔧 Przebudowa initramfs (dracut)...")
3948 - result = subprocess.run(
3949 - ["dracut", "--force", "/boot/initramfs.img"],
3950 - capture_output=True, text=True, timeout=120
3951 - )
3952 - return result.returncode == 0
3953 - elif shutil.which("mkinitcpio"):
3954 - print(" 🔧 Przebudowa initramfs (mkinitcpio)...")
3955 - result = subprocess.run(
3956 - ["mkinitcpio", "-g", "/boot/initramfs.img"],
3957 - capture_output=True, text=True, timeout=120
3958 - )
3959 - return result.returncode == 0
3960 - else:
3961 - print(" ⚠ Brak dracut/mkinitcpio – initramfs nie został przebudowany")
3962 - return False
3963 -
3964 - # Tryb immutable – budujemy initramfs dla deploymentu
3965 - print(" 🔧 Budowanie initramfs dla deploymentu...")
3966 -
3967 - # Sprawdź czy mamy nasz skrypt init
3968 - pag_init_script = "/usr/share/pag/initramfs-init"
3969 - if not os.path.exists(pag_init_script):
3970 - # Szukaj w źródłach (developerski fallback)
3971 - alt_paths = [
3972 - os.path.join(os.path.dirname(os.path.abspath(__file__)), "scripts", "initramfs-init"),
3973 - "/usr/share/pag/init",
3974 - ]
3975 - for p in alt_paths:
3976 - if os.path.exists(p):
3977 - pag_init_script = p
3978 - break
3979 -
3980 - if not os.path.exists(pag_init_script):
3981 - print(" ⚠ Nie znaleziono pag-initramfs-init – pomijam budowę initramfs")
3982 - return False
3983 -
3984 - boot_dir = os.path.join(root, "boot")
3985 - os.makedirs(boot_dir, exist_ok=True)
3986 -
3987 - # Znajdź jądro (vmlinuz-*)
3988 - kernels = sorted(
3989 - [f for f in os.listdir(boot_dir) if f.startswith("vmlinuz-")],
3990 - reverse=True
3991 - ) if os.path.exists(boot_dir) else []
3992 - if not kernels:
3993 - print(" ⚠ Nie znaleziono vmlinuz-* w /boot deploymentu")
3994 - return False
3995 -
3996 - kernel_ver = kernels[0].replace("vmlinuz-", "")
3997 - print(f" 🐧 Jądro: {kernel_ver}")
3998 -
3999 - # Buduj initramfs ręcznie (cpio)
4000 - tmpdir = tempfile.mkdtemp(prefix="pag-initramfs-")
4001 - try:
4002 - # Podstawowa struktura
4003 - for d in ["bin", "sbin", "dev", "proc", "sys", "run", "new_root",
4004 - "usr/bin", "usr/sbin", "lib", "lib64", "etc"]:
4005 - os.makedirs(os.path.join(tmpdir, d), exist_ok=True)
4006 -
4007 - # Skopiuj init
4008 - shutil.copy2(pag_init_script, os.path.join(tmpdir, "init"))
4009 - os.chmod(os.path.join(tmpdir, "init"), 0o755)
4010 -
4011 - # Skopiuj niezbędne binaria (busybox lub podstawowe narzędzia)
4012 - busybox_paths = [
4013 - os.path.join(root, "usr/bin/busybox"),
4014 - os.path.join(root, "bin/busybox"),
4015 - "/usr/bin/busybox",
4016 - "/bin/busybox",
4017 - ]
4018 - busybox = None
4019 - for bp in busybox_paths:
4020 - if os.path.exists(bp):
4021 - busybox = bp
4022 - break
4023 -
4024 - if busybox:
4025 - shutil.copy2(busybox, os.path.join(tmpdir, "bin/busybox"))
4026 - # Utwórz symlinki dla podstawowych komend
4027 - for cmd in ["sh", "mount", "umount", "ls", "cat", "echo", "sleep",
4028 - "readlink", "mkdir", "switch_root", "cp", "rm"]:
4029 - link = os.path.join(tmpdir, "bin", cmd)
4030 - if not os.path.exists(link):
4031 - os.symlink("busybox", link)
4032 - # /bin/sh → busybox
4033 - if not os.path.exists(os.path.join(tmpdir, "bin/sh")):
4034 - os.symlink("busybox", os.path.join(tmpdir, "bin/sh"))
4035 - else:
4036 - # Bez busybox – kopiuj podstawowe narzędzia z deploymentu
4037 - for tool in ["bash", "mount", "umount", "readlink", "mkdir", "cat", "sleep", "cp", "rm"]:
4038 - src = os.path.join(root, "usr/bin", tool)
4039 - if not os.path.exists(src):
4040 - src = os.path.join(root, "bin", tool)
4041 - if os.path.exists(src):
4042 - dest = os.path.join(tmpdir, "bin", os.path.basename(tool))
4043 - shutil.copy2(src, dest)
4044 - # Kopiuj zależności .so
4045 - _copy_libs_for_binary(src, tmpdir, root)
4046 -
4047 - # Dodaj moduły jądra (opcjonalnie – dla sterowników dyskowych)
4048 - modules_src = os.path.join(root, "lib/modules", kernel_ver)
4049 - if os.path.isdir(modules_src):
4050 - modules_dst = os.path.join(tmpdir, "lib/modules", kernel_ver)
4051 - # Kopiuj tylko niezbędne (fs, block, drivers/ata, drivers/nvme)
4052 - for sub in ["kernel/fs", "kernel/drivers/ata", "kernel/drivers/nvme",
4053 - "kernel/drivers/scsi", "kernel/drivers/virtio",
4054 - "modules.order", "modules.builtin"]:
4055 - src_sub = os.path.join(modules_src, sub)
4056 - if os.path.exists(src_sub):
4057 - dst_sub = os.path.join(modules_dst, sub)
4058 - os.makedirs(os.path.dirname(dst_sub), exist_ok=True)
4059 - if os.path.isdir(src_sub):
4060 - try:
4061 - shutil.copytree(src_sub, dst_sub, dirs_exist_ok=True, symlinks=True,
4062 - ignore_dangling_symlinks=True)
4063 - except (FileNotFoundError, PermissionError):
4064 - print(f" ⚠ Pomijam niedostępne pliki: {sub}")
4065 - else:
4066 - try:
4067 - shutil.copy2(src_sub, dst_sub)
4068 - except (FileNotFoundError, PermissionError):
4069 - print(f" ⚠ Pomijam niedostępny plik: {sub}")
4070 -
4071 - # Pakuj do initramfs.img
4072 - initramfs_path = os.path.join(boot_dir, "initramfs.img")
4073 - old_cwd = os.getcwd()
4074 - os.chdir(tmpdir)
4075 - try:
4076 - with open(initramfs_path + ".tmp", "wb") as out:
4077 - _run_cpio_pipeline(tmpdir, out)
4078 - os.rename(initramfs_path + ".tmp", initramfs_path)
4079 - finally:
4080 - os.chdir(old_cwd)
4081 -
4082 - size_mb = os.path.getsize(initramfs_path) / 1048576
4083 - print(f" ✅ initramfs.img ({size_mb:.1f} MB) → {initramfs_path}")
4084 - return True
4085 -
4086 - except Exception as e:
4087 - print(f" ❌ Błąd budowy initramfs: {e}")
4088 - return False
4089 - finally:
4090 - shutil.rmtree(tmpdir, ignore_errors=True)
4091 -
4092 -
4093 -def _run_cpio_pipeline(tmpdir: str, out):
4094 - """find . -print0 | cpio --null -oH newc | gzip — bez shell=True.
4095 -
4096 - Buduje pipeline przez subprocess.Popen, unikając pośrednika powłoki
4097 - (brak ryzyka injection i niepotrzebnego procesu sh). Wykonuje się w cwd=tmpdir.
4098 - Separatory NUL (\0): plik/katalog ze znakiem nowej linii w nazwie nie
4099 - rozjeżdża cpio (inaczej uszkodzone archiwum → kernel panic przy rozruchu).
4100 - """
4101 - find = subprocess.Popen(["find", ".", "-print0"], cwd=tmpdir, stdout=subprocess.PIPE)
4102 - cpio = subprocess.Popen(["cpio", "--null", "-oH", "newc"], cwd=tmpdir,
4103 - stdin=find.stdout, stdout=subprocess.PIPE)
4104 - find.stdout.close() # zwolnij uchwyt – cpio dostanie SIGPIPE po zakończeniu find
4105 - gzip = subprocess.Popen(["gzip"], stdin=cpio.stdout, stdout=out)
4106 - cpio.stdout.close()
4107 - try:
4108 - gzip.wait(timeout=120)
4109 - if gzip.returncode != 0:
4110 - raise subprocess.CalledProcessError(gzip.returncode, ["gzip"])
4111 - cpio.wait(timeout=30)
4112 - find.wait(timeout=30)
4113 - except subprocess.TimeoutExpired:
4114 - for p in (gzip, cpio, find):
4115 - p.kill()
4116 - raise
4117 - finally:
4118 - for p in (find, cpio, gzip):
4119 - if p.poll() is None:
4120 - p.kill()
4121 - # Skontroluj też kody procesów pośrednich (cpio/find mogą zawieść, a gzip zwrócić 0)
4122 - if cpio.returncode != 0:
4123 - raise subprocess.CalledProcessError(cpio.returncode, ["cpio"])
4124 - if find.returncode != 0:
4125 - raise subprocess.CalledProcessError(find.returncode, ["find"])
4126 -
4127 -
4128 -def _copy_libs_for_binary(binary: str, dest_dir: str, root: str):
4129 - """Kopiuje zależności .so dla binarki do initramfs (uproszczone ldd)."""
4130 - try:
4131 - result = subprocess.run(
4132 - ["ldd", binary], capture_output=True, text=True, timeout=10
4133 - )
4134 - for line in result.stdout.split("\n"):
4135 - m = re.search(r'=>\s+(/\S+)', line)
4136 - if m:
4137 - lib_path = m.group(1)
4138 - lib_rel = lib_path.lstrip("/")
4139 - lib_dest = os.path.join(dest_dir, lib_rel)
4140 - if not os.path.exists(lib_dest):
4141 - os.makedirs(os.path.dirname(lib_dest), exist_ok=True)
4142 - # Szukaj w deployment root lub systemie
4143 - if os.path.exists(lib_path):
4144 - shutil.copy2(lib_path, lib_dest)
4145 - else:
4146 - alt = os.path.join(root, lib_rel)
4147 - if os.path.exists(alt):
4148 - shutil.copy2(alt, lib_dest)
4149 - except Exception:
4150 - pass
4151 -
4152 -
4153 -def cmd_initramfs_update():
4154 - """Ręcznie przebudowuje initramfs dla bieżącego deploymentu."""
4155 - ensure_dirs()
4156 - deploy_dir = _get_deployment_root()
4157 - if deploy_dir != PAG_ROOT:
4158 - print(f"🏗️ Deployment: {os.path.basename(deploy_dir)}")
4159 - ok = _rebuild_initramfs(deploy_dir)
4160 - if ok:
4161 - print("✅ Initramfs zaktualizowany.")
4162 - # Po initramfs – zaktualizuj też GRUB
4163 - _update_grub_config()
4164 - else:
4165 - print("❌ Błąd aktualizacji initramfs.")
4166 - return 0 if ok else 1
4167 -
4168 -
4169 -def _update_grub_config():
4170 - """
4171 - Generuje wpisy GRUB dla wszystkich deploymentów.
4172 - Każdy deployment dostaje własny wpis – rollback możliwy z bootloadera.
4173 - """
4174 - grub_cfg = "/boot/grub/grub.cfg"
4175 - if not os.path.exists(os.path.dirname(grub_cfg)):
4176 - return # brak GRUB
4177 -
4178 - deployments = _load_deployments()
4179 - root_dev = _detect_root_device()
4180 -
4181 - lines = [
4182 - "# =====================================================================",
4183 - "# Pagan Linux – GRUB config (wygenerowane przez pag grub-update)",
4184 - f"# Data: {datetime.now().isoformat()}",
4185 - "# =====================================================================",
4186 - "",
4187 - ]
4188 -
4189 - # Domyślny – ostatni (najnowszy) deployment
4190 - if deployments:
4191 - latest = deployments[-1]["id"]
4192 - lines.append(f"set default=0")
4193 - lines.append(f"set timeout=5")
4194 - else:
4195 - lines.append("set default=0")
4196 - lines.append("set timeout=5")
4197 - lines.append("")
4198 -
4199 - # Wpisy dla każdego deploymentu (od najnowszego)
4200 - entry_num = 0
4201 - for d in reversed(deployments):
4202 - deploy_dir = os.path.join(DEPLOYMENTS_DIR, d["id"])
4203 - boot_dir = os.path.join(deploy_dir, "boot")
4204 - kernels = sorted(
4205 - [f for f in os.listdir(boot_dir) if f.startswith("vmlinuz-")],
4206 - reverse=True
4207 - ) if os.path.isdir(boot_dir) else []
4208 -
4209 - kernel_path = f"/.deployments/{d['id']}/boot/{kernels[0]}" if kernels else ""
4210 - initrd_path = f"/.deployments/{d['id']}/boot/initramfs.img"
4211 - initrd_line = f"initrd {initrd_path}" if os.path.exists(os.path.join(boot_dir, "initramfs.img")) else ""
4212 -
4213 - active_mark = " [AKTYWNY]" if d.get("active") else ""
4214 - pkg_list = ", ".join(d.get("packages", [])[:3])
4215 - label = f"Pagan Linux – {d['id']}{active_mark}"
4216 -
4217 - lines.append(f"menuentry '{label}' {{")
4218 - if kernel_path:
4219 - lines.append(f" linux {kernel_path} root={root_dev} rw quiet")
4220 - else:
4221 - lines.append(f" # Brak jądra w tym deploymencie")
4222 - if initrd_line:
4223 - lines.append(f" {initrd_line}")
4224 - lines.append("}")
4225 - lines.append("")
4226 - entry_num += 1
4227 -
4228 - # Wpis fallback: zwykły root (gdyby wszystko padło)
4229 - lines.append("menuentry 'Pagan Linux – fallback (zwykły root)' {")
4230 - lines.append(f" linux /boot/vmlinuz-* root={root_dev} rw quiet")
4231 - lines.append(f" initrd /boot/initramfs.img")
4232 - lines.append("}")
4233 - lines.append("")
4234 -
4235 - # Zapisz
4236 - os.makedirs(os.path.dirname(grub_cfg), exist_ok=True)
4237 - with open(grub_cfg, "w") as f:
4238 - f.write("\n".join(lines))
4239 -
4240 - print(" 📋 GRUB config zaktualizowany – wpisy dla każdego deploymentu")
4241 -
4242 -
4243 -def _detect_root_device() -> str:
4244 - """Wykrywa device partycji root (np. /dev/sda1)."""
4245 - try:
4246 - result = subprocess.run(
4247 - ["findmnt", "-n", "-o", "SOURCE", "/"],
4248 - capture_output=True, text=True, timeout=5
4249 - )
4250 - if result.returncode == 0 and result.stdout.strip():
4251 - return result.stdout.strip()
4252 - except Exception:
4253 - pass
4254 - return "/dev/sda1" # fallback
4255 -
4256 -
4257 -def cmd_grub_update():
4258 - """Ręcznie regeneruje konfigurację GRUB (wpisy dla deploymentów)."""
4259 - ensure_dirs()
4260 - print("📋 Aktualizacja konfiguracji GRUB...")
4261 - _update_grub_config()
4262 - print("✅ GRUB zaktualizowany.")
4263 - return 0
4264 -
4265 -def cmd_deploy_list():
4266 - """Wyświetla listę wszystkich deploymentów."""
4267 - deployments = _load_deployments()
4268 - if not deployments:
4269 - print(_("no_deployments")); return
4270 -
4271 - print(_("deployments_list", len(deployments)))
4272 - active = os.readlink(ACTIVE_LINK) if os.path.islink(ACTIVE_LINK) else ""
4273 -
4274 - for d in reversed(deployments):
4275 - marker = f" ◀ {_('active_deployment')}" if d.get("active") or d["id"] == os.path.basename(active) else ""
4276 - print(f" {d['id']}{marker}")
4277 - print(f" {d['action']}: {', '.join(d['packages'][:5])}")
4278 - if len(d.get('packages', [])) > 5:
4279 - print(f" +{len(d['packages']) - 5} więcej...")
4280 - print(f" {d['timestamp']}")
4281 -
4282 -
4283 -def cmd_deploy_rollback():
4284 - """Przełącza na poprzedni deployment."""
4285 - deployments = _load_deployments()
4286 - active_indices = [i for i, d in enumerate(deployments) if d.get("active")]
4287 -
4288 - if len(deployments) < 2:
4289 - print(f"❌ {_('deploy_rollback_fail')}"); return 1
4290 -
4291 - current_idx = active_indices[0] if active_indices else len(deployments) - 1
4292 - prev_idx = current_idx - 1 if current_idx > 0 else -1
4293 -
4294 - if prev_idx < 0:
4295 - print(f"❌ {_('deploy_rollback_fail')}"); return 1
4296 -
4297 - prev = deployments[prev_idx]
4298 - prev_dir = os.path.join(DEPLOYMENTS_DIR, prev["id"])
4299 -
4300 - if not os.path.isdir(prev_dir):
4301 - print(f"❌ Deployment {prev['id']} nie istnieje na dysku"); return 1
4302 -
4303 - print(f"⏪ Przywracanie deploymentu: {prev['id']}")
4304 - print(f" {prev['action']}: {', '.join(prev['packages'][:5])}")
4305 -
4306 - if not _ask_confirm():
4307 - return 0
4308 -
4309 - _switch_deployment(prev_dir)
4310 -
4311 - for d in deployments:
4312 - d["active"] = (d["id"] == prev["id"])
4313 - _save_deployments(deployments)
4314 -
4315 - _update_grub_config()
4316 - print(f"✅ {_('deploy_rollback_ok', prev['id'])}")
4317 - print(" 💡 Restart wymagany do przeładowania systemu.")
4318 - return 0
4319 -
4320 -
4321 -def cmd_deploy_cleanup(keep: int = 3):
4322 - """Usuwa stare deploymenty, zachowując ostatnie `keep`."""
4323 - deployments = _load_deployments()
4324 -
4325 - if len(deployments) <= keep:
4326 - print(f"✅ {_('deploy_cleanup_none', keep)}"); return 0
4327 -
4328 - to_remove = deployments[:-keep]
4329 - removed = 0
4330 -
4331 - for d in to_remove:
4332 - deploy_dir = os.path.join(DEPLOYMENTS_DIR, d["id"])
4333 - if os.path.isdir(deploy_dir):
4334 - shutil.rmtree(deploy_dir, ignore_errors=True)
4335 - removed += 1
4336 -
4337 - remaining = deployments[-keep:]
4338 - _save_deployments(remaining)
4339 -
4340 - print(f"✅ {_('deploy_cleanup_ok', removed)}")
4341 - return 0
4342 -
4343 -
4344 -# =============================================================================
4345 -# POMOCNICZE
4346 -# =============================================================================
4347 -
4348 -def _resolve_deps(names, repo, installed):
4349 - resolved, visited = [], set()
4350 - missing = [] # zależności których nie ma ani w repo ani zainstalowane
4351 -
4352 - def visit(name):
4353 - if name in visited: return
4354 -
4355 - # Rozwijanie wirtualnych zależności przez provides
4356 - target = _resolve_provides(name, repo, installed)
4357 -
4358 - if target in visited: return
4359 - visited.add(target)
4360 - if target in repo:
4361 - for dep in repo[target].dependencies:
4362 - real_dep = _resolve_provides(dep, repo, installed)
4363 - real_target = real_dep if real_dep in repo else dep
4364 -
4365 - # Sprawdź czy zależność jest dostępna
4366 - if real_target not in installed and real_target not in repo:
4367 - if dep not in missing:
4368 - missing.append(dep)
4369 -
4370 - if dep not in installed:
4371 - visit(real_target)
4372 - elif target not in installed:
4373 - # Pakiet nie istnieje ani w repo ani zainstalowany
4374 - if target not in missing:
4375 - missing.append(target)
4376 -
4377 - if target not in installed and target not in resolved:
4378 - resolved.append(target)
4379 -
4380 - for name in names:
4381 - visit(name)
4382 -
4383 - # Zwróć brakujące (do sprawdzenia przez wywołującego)
4384 - return resolved, missing
4385 -
4386 -def _verify_dependencies(to_install: list, repo: dict, installed: dict) -> int:
4387 - """
4388 - Sprawdza czy wszystkie zależności pakietów do instalacji są spełnione.
4389 - Zwraca liczbę brakujących zależności.
4390 - """
4391 - # Pakiety dostarczane przez bazowy system (zawsze "zainstalowane")
4392 - SYSTEM_BASE = {
4393 - "glibc", "libc", "gcc", "g++", "make", "binutils", "coreutils", "bash",
4394 - "linux-api-headers", "kernel-headers", "zlib", "pkg-config", "pkgconf",
4395 - "tar", "gzip", "xz", "bzip2", "findutils", "grep", "sed", "gawk", "awk",
4396 - "diffutils", "patch", "file", "m4", "perl", "python3", "sh",
4397 - }
4398 - all_missing = []
4399 - all_warnings = []
4400 -
4401 - for pkg_name in to_install:
4402 - pkg = repo.get(pkg_name)
4403 - if not pkg:
4404 - continue
4405 -
4406 - for dep in pkg.dependencies:
4407 - if dep in SYSTEM_BASE:
4408 - continue # bazowy system dostarcza tę zależność
4409 - real_dep = _resolve_provides(dep, repo, installed)
4410 - # Sprawdź czy zależność jest dostępna (w repo lub już zainstalowana)
4411 - in_repo = real_dep in repo
4412 - in_installed = real_dep in installed
4413 - will_be_installed = real_dep in to_install
4414 -
4415 - if not in_repo and not in_installed and not will_be_installed:
4416 - if dep not in all_missing:
4417 - all_missing.append((pkg_name, dep))
4418 - elif in_repo and not in_installed and not will_be_installed:
4419 - if dep not in [w[1] for w in all_warnings]:
4420 - all_warnings.append((pkg_name, dep, real_dep))
4421 -
4422 - if all_missing:
4423 - print(f"\n❌ {_c('red', 'BRAKUJĄCE ZALEŻNOŚCI')} – nie można zainstalować:")
4424 - for pkg, dep in all_missing:
4425 - print(f" {pkg} → potrzebuje {_c('red', dep)} (brak w repozytoriach)")
4426 - print()
4427 -
4428 - if all_warnings:
4429 - print(f"\n⚠ {_c('yellow', 'NIESPEŁNIONE ZALEŻNOŚCI')} – zostaną doinstalowane:")
4430 - for pkg, dep, real in all_warnings:
4431 - print(f" {pkg} → {dep} ({_c('green', real)} – będzie pobrane)")
4432 - print()
4433 -
4434 - return len(all_missing)
4435 -
4436 -# Biblioteki bazowe (glibc/gcc runtime) – zawsze dostępne, nie wymagają pakietu
4437 -BASE_SO = {
4438 - "libc.so.6", "libm.so.6", "libpthread.so.0", "libdl.so.2", "librt.so.1",
4439 - "libutil.so.1", "libresolv.so.2", "libnsl.so.1", "libcrypt.so.1",
4440 - "ld-linux.so.2", "ld-linux-x86-64.so.2", "ld-linux-aarch64.so.1",
4441 - "libgcc_s.so.1", "linux-vdso.so.1",
4442 -}
4443 -
4444 -def _verify_so_deps(to_install: list, repo: dict, installed: dict) -> int:
4445 - """Sprawdza wymagania ABI (provides_so / requires_so z metadata.json).
4446 -
4447 - Fail-closed TYLKO gdy metadata jawnie deklaruje requires_so, a żaden pakiet
4448 - (bazowy, zainstalowany lub instalowany w tej transakcji) nie dostarcza
4449 - wymaganej wersji biblioteki. Stare pakiety bez tych pól są pomijane.
4450 - """
4451 - provided = set(BASE_SO)
4452 - for n in to_install:
4453 - p = repo.get(n)
4454 - if p:
4455 - provided.update(p.provides_so or [])
4456 - for n, info in installed.items():
4457 - provided.update(info.get("provides_so", []) or [])
4458 -
4459 - missing = []
4460 - for n in sorted(to_install):
4461 - p = repo.get(n)
4462 - if not p:
4463 - continue
4464 - for so in (p.requires_so or []):
4465 - if so not in provided:
4466 - missing.append((n, so))
4467 -
4468 - if missing:
4469 - print(f"\n❌ {_c('red', 'BRAK WYMAGANYCH BIBLIOTEK (ABI so-name)')}:")
4470 - for n, so in missing:
4471 - print(f" {n} → wymaga {_c('red', so)} – żaden pakiet nie dostarcza tej wersji")
4472 - print()
4473 - return len(missing)
4474 -
4475 -def _download_pkg(pkg):
4476 - url = f"{pkg.repo_url}/{pkg.filename}"
4477 - dest = os.path.join(PAG_CACHE, pkg.filename)
4478 - if os.path.exists(dest) and (not pkg.sha256 or _sha256_file(dest) == pkg.sha256):
4479 - _download_pkg_sig(pkg, dest) # upewnij się, że sygnatura jest w cache
4480 - return dest
4481 - try:
4482 - req = Request(url, headers={"User-Agent":"pag/3.0"})
4483 - with urlopen(req, timeout=600) as resp:
4484 - total = int(resp.headers.get("Content-Length", 0))
4485 - bar = DownloadBar(pkg.filename, total)
4486 - with open(dest, "wb") as f:
4487 - while True:
4488 - chunk = resp.read(65536)
4489 - if not chunk:
4490 - break
4491 - f.write(chunk)
4492 - bar.update(len(chunk))
4493 - bar.close()
4494 - if pkg.sha256 and _sha256_file(dest) != pkg.sha256:
4495 - os.remove(dest); return None
4496 - _download_pkg_sig(pkg, dest)
4497 - return dest
4498 - except Exception as e:
4499 - print(f" ⚠ Błąd pobierania {pkg.filename}: {e}", file=sys.stderr)
4500 - return None
4501 -
4502 -def _download_pkg_sig(pkg, dest):
4503 - """Pobiera podpis pakietu (.asc, fallback .sig) obok paczki w cache."""
4504 - for ext in (".asc", ".sig"):
4505 - sig_dest = dest + ext
4506 - if os.path.exists(sig_dest):
4507 - return
4508 - try:
4509 - req = Request(f"{pkg.repo_url}/{pkg.filename}{ext}", headers={"User-Agent":"pag/3.0"})
4510 - with urlopen(req, timeout=30) as resp:
4511 - with open(sig_dest, "wb") as f:
4512 - f.write(resp.read())
4513 - return
4514 - except Exception:
4515 - continue
4516 -
4517 -def _download_packages_parallel(pkgs: List[PackageInfo], max_workers: int = 4) -> Dict[str, Optional[str]]:
4518 - """
4519 - Równoległe pobieranie wielu pakietów przez ThreadPoolExecutor.
4520 - Znacząco przyspiesza przy dużych aktualizacjach (50+ pakietów).
4521 - Zwraca słownik {nazwa_pakietu: ścieżka_lub_None}.
4522 - """
4523 - results = {}
4524 - total = len(pkgs)
4525 - completed = 0
4526 - with ThreadPoolExecutor(max_workers=max_workers) as executor:
4527 - future_to_pkg = {executor.submit(_download_pkg, pkg): pkg for pkg in pkgs}
4528 - for future in as_completed(future_to_pkg):
4529 - pkg = future_to_pkg[future]
4530 - try:
4531 - results[pkg.name] = future.result()
4532 - except Exception:
4533 - results[pkg.name] = None
4534 - completed += 1
4535 - # Pasek postępu
4536 - pct = completed / total * 100
4537 - filled = int(20 * pct / 100)
4538 - bar = "█" * filled + "░" * (20 - filled)
4539 - print(f"\r ⏬ [{bar}] {completed}/{total} ({pct:.0f}%)", end="", file=sys.stderr, flush=True)
4540 - print(file=sys.stderr) # nowa linia po zakończeniu
4541 - return results
4542 -
4543 -def load_world():
4544 - if not os.path.exists(WORLD_FILE): return set()
4545 - return {l.strip() for l in open(WORLD_FILE) if l.strip()}
4546 -
4547 -def save_world(w):
4548 - with open(WORLD_FILE,"w") as f:
4549 - for n in sorted(w): f.write(f"{n}\n")
4550 -
4551 -def _find_orphans(installed, world):
4552 - needed = set(world)
4553 - changed = True
4554 - while changed:
4555 - changed = False
4556 - for n in list(needed):
4557 - for dep in installed.get(n,{}).get("dependencies",[]):
4558 - if dep not in needed and dep in installed:
4559 - needed.add(dep); changed = True
4560 - return {n for n in installed if n not in needed}
4561 -
4562 -# =============================================================================
4563 -# MAIN
4564 -# =============================================================================
4565 -
4566 -def cmd_sbom(argv):
4567 - """pag sbom export [spdx|cyclonedx] – manifest SBOM zainstalowanych pakietów.
4568 -
4569 - Wypisuje na stdout JSON (SPDX 2.3 lub CycloneDX 1.5) z listą
4570 - zainstalowanych pakietów, wersji, licencji i sum SHA256.
4571 - """
4572 - fmt = (argv[0] if argv else "spdx").lower()
4573 - if fmt not in ("spdx", "cyclonedx"):
4574 - print("❌ Format: spdx | cyclonedx")
4575 - return 1
4576 - installed = load_json(INSTALLED_DB)
4577 - if not installed:
4578 - print("{}") if fmt == "cyclonedx" else print("{\"packages\": []}")
4579 - return 0
4580 - # metadata repo (licencje) – best-effort
4581 - try:
4582 - repo = fetch_all_packages()
4583 - except Exception:
4584 - repo = {}
4585 - names = sorted(installed)
4586 - created = datetime.now().astimezone().isoformat(timespec="seconds")
4587 -
4588 - def _license_of(name):
4589 - p = repo.get(name)
4590 - lic = getattr(p, "license", None) or []
4591 - if isinstance(lic, list):
4592 - lic = ", ".join(x for x in lic if x)
4593 - return lic or "NOASSERTION"
4594 -
4595 - if fmt == "spdx":
4596 - doc = {
4597 - "spdxVersion": "SPDX-2.3",
4598 - "dataLicense": "CC0-1.0",
4599 - "SPDXID": "SPDXRef-DOCUMENT",
4600 - "name": "PaganOS-installed",
4601 - "documentNamespace": f"https://repo.paganlinux.eu/sbom/installed-{int(time.time())}",
4602 - "creationInfo": {
4603 - "created": created,
4604 - "creators": [f"Tool: pag-{PAG_VERSION}"],
4605 - },
4606 - "packages": [],
4607 - }
4608 - for i, n in enumerate(names):
4609 - info = installed[n]
4610 - doc["packages"].append({
4611 - "SPDXID": f"SPDXRef-Package-{i+1}",
4612 - "name": n,
4613 - "versionInfo": info.get("version", ""),
4614 - "downloadLocation": info.get("repo", "NOASSERTION"),
4615 - "filesAnalyzed": False,
4616 - "licenseConcluded": _license_of(n),
4617 - "checksums": [{"algorithm": "SHA256", "checksumValue": info.get("sha256", "")}],
4618 - })
4619 - else: # cyclonedx
4620 - doc = {
4621 - "bomFormat": "CycloneDX",
4622 - "specVersion": "1.5",
4623 - "serialNumber": f"urn:uuid:{str(uuid.uuid4())}",
4624 - "version": 1,
4625 - "metadata": {
4626 - "timestamp": created,
4627 - "tools": [{"vendor": "PaganOS", "name": "pag", "version": PAG_VERSION}],
4628 - },
4629 - "components": [],
4630 - }
4631 - for n in names:
4632 - info = installed[n]
4633 - lic = _license_of(n)
4634 - comp = {
4635 - "type": "library",
4636 - "name": n,
4637 - "version": info.get("version", ""),
4638 - "hashes": [{"alg": "SHA-256", "content": info.get("sha256", "")}],
4639 - }
4640 - if lic != "NOASSERTION":
4641 - comp["licenses"] = [{"license": {"id": lic}}]
4642 - doc["components"].append(comp)
4643 - print(json.dumps(doc, indent=2, ensure_ascii=False))
4644 - return 0
4645 -
4646 -
4647 -USAGE_EN = """pag v3 – Pagan Linux Package Manager
4648 -
4649 -BASIC:
4650 - pag install <pkg>... Install packages
4651 - pag remove <pkg>... Remove packages
4652 - pag update Update PACKAGES (refreshes indexes first)
4653 - pag sync Refresh indexes + show pending package updates
4654 - pag upgrade Update SYSTEM (packages + kernel/initramfs/GRUB)
4655 - pag list [--installed] List available / installed
4656 - pag search <query> Search packages
4657 - pag info <pkg> Package details
4658 - pag files <pkg> List package files
4659 - pag verify [--deep] Verify integrity (--deep = SHA256 per file)
4660 - pag clean Clear download cache
4661 - pag stats System statistics
4662 - pag download <pkg>... Download packages to cache (offline prep)
4663 -
4664 -SECURITY:
4665 - pag key-add <url|file> Import GPG key
4666 - pag key-list List trusted keys
4667 - pag key-remove <id> Remove key
4668 - pag key-trust <repo> Pin repo signing key fingerprint (no TOFU)
4669 - pag key-untrust <repo> Forget repo fingerprint (back to TOFU)
4670 - pag key-trusted List pinned repo fingerprints
4671 -
4672 -ADVANCED:
4673 - pag why <pkg> Show why a package is installed
4674 - pag autoremove Auto-remove orphaned dependencies
4675 - pag pin <pkg> [ver] Pin package version
4676 - pag unpin <pkg> Unpin
4677 - pag pinned List pinned
4678 - pag history Transaction history
4679 - pag rollback Rollback last transaction
4680 - pag remove-orphans Remove orphaned deps
4681 - pag repo-add <url> [name] Add repository (drop-in /etc/pag/repos/)
4682 - pag repo-list List repositories
4683 - pag sbom export [fmt] SBOM manifest (spdx|cyclonedx)
4684 -
4685 -FLATPAK:
4686 - pag flatpak [<query>] Search & install (smart)
4687 - pag flatpak search <q> Search Flathub
4688 - pag flatpak install <id> Install flatpak
4689 - pag flatpak remove <id> Remove flatpak
4690 - pag flatpak list List installed flatpaks
4691 - pag flatpak update Update all flatpaks
4692 - pag flatpak info <id> Show flatpak details
4693 -
4694 -IMMUTABLE OS (PAG_IMMUTABLE=1):
4695 - pag deploy-list List all deployments
4696 - pag deploy-rollback Switch to previous deployment
4697 - pag deploy-cleanup [N] Remove old deployments (keep last N, default 3)
4698 - pag initramfs-update Rebuild initramfs for current kernel/deployment
4699 - pag grub-update Regenerate GRUB entries for all deployments
4700 -"""
4701 -
4702 -USAGE_PL = """pag v3 – Pagan Linux Package Manager
4703 -
4704 -PODSTAWOWE:
4705 - pag install <pkg>... Instalacja pakietów
4706 - pag remove <pkg>... Usuwanie pakietów
4707 - pag update Aktualizacja PAKIETÓW (odświeża indeksy)
4708 - pag sync Odśwież indeksy + info o aktualizacjach
4709 - pag upgrade Aktualizacja SYSTEMU (pakiety + kernel/initramfs/GRUB)
4710 - pag list [--installed] Lista dostępnych / zainstalowanych
4711 - pag search <query> Szukaj pakietów
4712 - pag info <pkg> Szczegóły pakietu
4713 - pag files <pkg> Lista plików pakietu
4714 - pag verify [--deep] Weryfikacja integralności
4715 - pag clean Wyczyść cache pobierania
4716 - pag stats Statystyki systemu
4717 - pag download <pkg>... Pobierz do cache (offline)
4718 -
4719 -BEZPIECZEŃSTWO:
4720 - pag key-add <url|file> Importuj klucz GPG
4721 - pag key-list Lista zaufanych kluczy
4722 - pag key-remove <id> Usuń klucz
4723 - pag key-trust <repo> Przypnij fingerprint klucza repo (bez TOFU)
4724 - pag key-untrust <repo> Zapomnij fingerprint repo (powrót do TOFU)
4725 - pag key-trusted Lista przypiętych fingerprintów repo
4726 -
4727 -ZAAWANSOWANE:
4728 - pag why <pkg> Dlaczego pakiet jest zainstalowany
4729 - pag autoremove Usuń osierocone zależności
4730 - pag pin <pkg> [ver] Przypnij wersję pakietu
4731 - pag unpin <pkg> Odepnij
4732 - pag pinned Lista przypiętych
4733 - pag history Historia transakcji
4734 - pag rollback Cofnij ostatnią transakcję
4735 - pag remove-orphans Usuń osierocone zależności
4736 - pag repo-add <url> [nazwa] Dodaj repozytorium (drop-in w /etc/pag/repos/)
4737 - pag repo-list Lista repozytoriów
4738 - pag sbom export [fmt] Manifest SBOM (spdx|cyclonedx)
4739 -
4740 -FLATPAK:
4741 - pag flatpak [<query>] Szukaj i instaluj
4742 - pag flatpak search <q> Szukaj na Flathub
4743 - pag flatpak install <id> Zainstaluj flatpak
4744 - pag flatpak remove <id> Usuń flatpak
4745 - pag flatpak list Lista zainstalowanych
4746 - pag flatpak update Aktualizuj wszystkie
4747 - pag flatpak info <id> Szczegóły flatpaka
4748 -
4749 -IMMUTABLE OS (PAG_IMMUTABLE=1):
4750 - pag deploy-list Lista wdrożeń
4751 - pag deploy-rollback Przełącz na poprzednie wdrożenie
4752 - pag deploy-cleanup [N] Usuń stare wdrożenia (zachowaj N, domyślnie 3)
4753 - pag initramfs-update Przebuduj initramfs
4754 - pag grub-update Regeneruj wpisy GRUB"""
4755 -
4756 -def _get_usage():
4757 - if LANG == "pl":
4758 - return USAGE_PL
4759 - return USAGE_EN
4760 -
4761 -
4762 -def main():
4763 - if len(sys.argv) >= 2 and sys.argv[1] in ("--version", "-V", "version"):
4764 - print(f"pag {PAG_VERSION}")
4765 - sys.exit(0)
4766 - if len(sys.argv) < 2:
4767 - print(_get_usage()); sys.exit(0)
4768 -
4769 - cmd = sys.argv[1]
4770 - args = sys.argv[2:]
4771 -
4772 - # --- Komendy TYLKO DO ODCZYTU (nie wymagają roota) ---
4773 - READ_ONLY = {
4774 - "list": lambda: cmd_list("--installed" in args),
4775 - "search": lambda: cmd_search(args[0]) if args else print("Usage: pag search <query>"),
4776 - "info": lambda: cmd_info(args[0]) if args else print("Usage: pag info <pkg>"),
4777 - "files": lambda: cmd_files(args[0]) if args else print("Usage: pag files <pkg>"),
4778 - "verify": lambda: cmd_verify("--deep" in args),
4779 - "why": lambda: cmd_why(args[0]) if args else print("Usage: pag why <pkg>"),
4780 - "stats": cmd_stats,
4781 - "pinned": cmd_pinned,
4782 - "history": cmd_history,
4783 - "repo-list": cmd_repo_list,
4784 - "key-list": cmd_key_list,
4785 - "key-trusted": cmd_key_trusted,
4786 - "flatpak": lambda: cmd_flatpak(args),
4787 - "flatpak-search": lambda: cmd_flatpak_search(args[0]) if args else print("Usage: pag flatpak-search <query>"),
4788 - "flatpak-list": cmd_flatpak_list,
4789 - "flatpak-info": lambda: cmd_flatpak_info(args[0]) if args else print("Usage: pag flatpak-info <id>"),
4790 - "deploy-list": cmd_deploy_list,
4791 - "deploy": cmd_deploy_list,
4792 - "sbom": lambda: cmd_sbom(args),
4793 - }
4794 -
4795 - if cmd in READ_ONLY:
4796 - sys.exit(READ_ONLY[cmd]() or 0)
4797 -
4798 - # --- Smart search: `pag <nazwa-pakietu>` → repo + Flathub + sugestie ---
4799 - WRITE_CMDS = {
4800 - "install", "remove", "update", "sync", "upgrade", "clean", "download",
4801 - "autoremove", "remove-orphans", "pin", "unpin", "rollback",
4802 - "repo-add", "key-add", "key-remove", "key-trust", "key-untrust",
4803 - "self-update",
4804 - "flatpak", "flatpak-install", "flatpak-remove", "flatpak-update",
4805 - "deploy-rollback", "deploy-cleanup", "initramfs-update", "grub-update",
4806 - }
4807 - if cmd not in WRITE_CMDS:
4808 - # Literówka komendy? (np. `pag instal steam` zamiast `pag install`) –
4809 - # zasugeruj poprawną komendę ZAMIAST wpadać w smart search (który
4810 - # potrafi wisieć na `flatpak search` aż do Ctrl-C).
4811 - _known = set(READ_ONLY) | set(WRITE_CMDS)
4812 - _close = difflib.get_close_matches(cmd, _known, n=1, cutoff=0.75)
4813 - if _close:
4814 - print(f"❌ Nieznana komenda: '{cmd}'. Czy chodziło o '{_close[0]}'?")
4815 - print(f" Uruchom 'pag' bez argumentów, aby zobaczyć listę komend.")
4816 - sys.exit(1)
4817 - sys.exit(_smart_search(" ".join([cmd] + args)) or 0)
4818 -
4819 - # Obsługa flag globalnych (-y/--yes)
4820 - global_args = []
4821 - for a in args:
4822 - if a in ("-y", "--yes"):
4823 - os.environ["PAG_YES"] = "1"
4824 - else:
4825 - global_args.append(a)
4826 - args = global_args
4827 -
4828 - # --- Komendy ZAPISU (wymagają roota) ---
4829 - if os.geteuid() != 0:
4830 - print(f"❌ {_('root_required')}", file=sys.stderr); sys.exit(1)
4831 -
4832 - ensure_dirs()
4833 -
4834 - with DatabaseLock():
4835 - WRITE_COMMANDS = {
4836 - "install": lambda: cmd_install(
4837 - [a for a in args if a not in ("-f", "--force")],
4838 - upgrade=("-f" in args or "--force" in args)),
4839 - "remove": lambda: cmd_remove(args),
4840 - "update": lambda: cmd_update(do_upgrade=True),
4841 - "sync": lambda: cmd_update(do_upgrade=False),
4842 - "upgrade": cmd_upgrade,
4843 - "clean": cmd_clean,
4844 - "download": lambda: cmd_download(args),
4845 - "autoremove": cmd_autoremove,
4846 - "remove-orphans": cmd_remove_orphans,
4847 - "pin": lambda: cmd_pin(args[0], args[1] if len(args)>1 else ""),
4848 - "unpin": lambda: cmd_unpin(args[0]) if args else print("Usage: pag unpin <pkg>"),
4849 - "rollback": cmd_rollback,
4850 - "repo-add": lambda: cmd_repo_add(args[0], args[1] if len(args) > 1 else "") if args else print("Usage: pag repo-add <url> [name]"),
4851 - "key-add": lambda: cmd_key_add(args[0]) if args else print("Usage: pag key-add <url|file>"),
4852 - "key-remove": lambda: cmd_key_remove(args[0]) if args else print("Usage: pag key-remove <id>"),
4853 - "key-trust": lambda: cmd_key_trust(args[0]) if args else print("Usage: pag key-trust <repo_url>"),
4854 - "key-untrust": lambda: cmd_key_untrust(args[0]) if args else print("Usage: pag key-untrust <repo_url>"),
4855 - "self-update": cmd_self_update,
4856 - "flatpak": lambda: cmd_flatpak(args),
4857 - "flatpak-install": lambda: _flatpak_smart_install(args) if args else print("Usage: pag flatpak-install <app>"),
4858 - "flatpak-remove": lambda: _flatpak_smart_remove(args) if args else print("Usage: pag flatpak-remove <app>"),
4859 - "flatpak-update": cmd_flatpak_update,
4860 - "deploy-rollback": cmd_deploy_rollback,
4861 - "deploy-cleanup": lambda: cmd_deploy_cleanup(int(args[0]) if args else 3),
4862 - "initramfs-update": cmd_initramfs_update,
4863 - "grub-update": cmd_grub_update,
4864 - }
4865 -
4866 - fn = WRITE_COMMANDS.get(cmd)
4867 - if fn:
4868 - sys.exit(fn() or 0)
4869 - # Should never reach here – _smart_search handles unknowns
4870 - sys.exit(_smart_search(" ".join([cmd] + args)) or 0)
4871 -
4872 -if __name__ == "__main__":
4873 - try:
4874 - main()
4875 - except KeyboardInterrupt:
4876 - # Ctrl-C (np. podczas flatpak search / pobierania) – bez tracebacka
4877 - print("\n ⚠ Przerwano (Ctrl-C).")
1 +#!/usr/bin/env python3
2 +"""
3 +╔══════════════════════════════════════════════════════════════════════════════╗
4 +║ PAG - Pagan Linux Package Manager v3.3.18 ║
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 + pinning fingerprintu
13 + - Hooki: pre/post-install, pre/post-remove (piaskownica env, timeout, audit)
14 + - Głęboka weryfikacja SHA256 per-plik
15 + - Pełny rollback – cofa fizyczne pliki
16 + - Blokada flock – tylko jedna instancja
17 + - Transakcje z migawkami + rejestr wykonanych hooków
18 + - Cache HTTP (ETag/If-Modified-Since)
19 + - Wielojęzyczność (i18n) – PL, EN
20 +
21 +FORMAT PAKIETU (.pag):
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 +MODEL ZAUFANIA / BEZPIECZEŃSTWO:
27 + - Repozytorium MUSI być zaufane: podpisy GPG zweryfikowane; fingerprint
28 + klucza przypiętego do repo (TOFU przy pierwszym użyciu, potem pinning).
29 + - Hooki uruchamiają dowolny plik z pakietu jako ROOT (jak apt/pacman).
30 + Ograniczamy je (czyste env, timeout, PAG_NO_HOOKS=1, log do
31 + /var/log/pag/audit.log) i rejestrujemy w transakcji, ale ostatecznie
32 + instalujesz kod, któremu ufasz.
33 + - self-update: weryfikacja podpisu + SHA256 + składnia, atomowa podmiana.
34 +"""
35 +
36 +import os, sys, json, shutil, hashlib, tarfile, tempfile, subprocess, time, fcntl, sqlite3, locale, re, difflib
37 +
38 +# Fix TLS trust inside the Pagan chroot: point Python at the CA bundle that
39 +# pag ships, otherwise urlopen() fails with "unable to get local issuer
40 +# certificate" (no default capath/cafile is resolved in the chroot).
41 +for _cafile in (
42 + "/etc/ssl/certs/ca-certificates.crt",
43 + "/etc/ssl/cert.pem",
44 +):
45 + if os.path.isfile(_cafile):
46 + os.environ["SSL_CERT_FILE"] = _cafile
47 + break
48 +
49 +from pathlib import Path
50 +from datetime import datetime, timezone
51 +from typing import Dict, List, Optional, Tuple, Set
52 +from concurrent.futures import ThreadPoolExecutor, as_completed
53 +from urllib.request import urlopen, Request
54 +import threading, itertools
55 +import uuid # serialNumber SBOM (CycloneDX)
56 +
57 +# Wersja klienta – do porównania z repo.json["pag_version"] (self-update)
58 +PAG_VERSION = "3.3.18"
59 +from urllib.error import URLError, HTTPError
60 +
61 +# =============================================================================
62 +# ProgressBar — minimalistyczny pasek postępu (bez zewnętrznych zależności)
63 +# =============================================================================
64 +
65 +class ProgressBar:
66 + """Czysty Python progress bar — działa z TTY i bez."""
67 + def __init__(self, total: int, desc: str = "", unit: str = "", width: int = 30):
68 + self.total = max(total, 1)
69 + self.desc = desc
70 + self.unit = unit
71 + self.width = width
72 + self.n = 0
73 + self.start = time.time()
74 + self.tty = sys.stderr.isatty()
75 + self._last_line_len = 0
76 +
77 + def update(self, n: Optional[int] = None, suffix: str = ""):
78 + if n is not None:
79 + self.n = n
80 + else:
81 + self.n += 1
82 + pct = self.n / self.total * 100
83 + elapsed = time.time() - self.start
84 + speed = self.n / elapsed if elapsed > 0 else 0
85 + if self.n >= self.total:
86 + eta_str = "done"
87 + elif speed > 0:
88 + eta = (self.total - self.n) / speed
89 + eta_str = f"{eta:.0f}s" if eta < 60 else f"{eta/60:.1f}m"
90 + else:
91 + eta_str = "?..."
92 + bar_len = int(self.width * pct / 100)
93 + bar = "█" * bar_len + "░" * (self.width - bar_len)
94 + line = f" {self.desc} [{bar}] {self.n}/{self.total} ({pct:.0f}%) ETA {eta_str}{suffix}"
95 + if self.tty:
96 + # Overwrite current line
97 + clear = " " * max(0, self._last_line_len - len(line))
98 + print(f"\r{line}{clear}", end="", file=sys.stderr, flush=True)
99 + self._last_line_len = len(line)
100 + else:
101 + # Print milestone lines only (every 10% or when done)
102 + if self.n == 1 or self.n >= self.total or self.n % max(1, self.total // 10) == 0:
103 + print(line, file=sys.stderr)
104 +
105 + def close(self):
106 + if self.tty:
107 + print(file=sys.stderr)
108 + self._last_line_len = 0
109 +
110 + def __enter__(self):
111 + return self
112 +
113 + def __exit__(self, *args):
114 + self.close()
115 +
116 +
117 +class DownloadBar:
118 + """Pasek postępu pobierania — na podstawie Content-Length."""
119 + def __init__(self, filename: str, total_bytes: int):
120 + self.filename = filename
121 + self.total = total_bytes
122 + self.downloaded = 0
123 + self.start = time.time()
124 + self.tty = sys.stderr.isatty()
125 + self._last_len = 0
126 +
127 + def update(self, chunk_size: int):
128 + self.downloaded += chunk_size
129 + if self.total <= 0:
130 + return
131 + pct = self.downloaded / self.total * 100
132 + elapsed = time.time() - self.start
133 + speed = self.downloaded / elapsed if elapsed > 0 else 0
134 + if speed > 0:
135 + eta = (self.total - self.downloaded) / speed
136 + eta_str = f"{eta:.0f}s" if eta < 60 else f"{eta/60:.1f}m"
137 + else:
138 + eta_str = "?..."
139 + bar_len = 25
140 + filled = int(bar_len * pct / 100)
141 + bar = "█" * filled + "░" * (bar_len - filled)
142 + sz = self._fmt_size(self.total)
143 + spd = self._fmt_size(int(speed))
144 + line = f" ↓ {self.filename} [{bar}] {pct:.0f}% {sz} {spd}/s ETA {eta_str}"
145 + if self.tty:
146 + clear = " " * max(0, self._last_len - len(line))
147 + print(f"\r{line}{clear}", end="", file=sys.stderr, flush=True)
148 + self._last_len = len(line)
149 +
150 + def close(self):
151 + if self.tty and self.total > 0:
152 + print(file=sys.stderr)
153 +
154 + @staticmethod
155 + def _fmt_size(n: int) -> str:
156 + for unit in ("B", "KB", "MB", "GB"):
157 + if n < 1024:
158 + return f"{n:.1f} {unit}"
159 + n /= 1024
160 + return f"{n:.1f} TB"
161 +
162 +# =============================================================================
163 +# GPG – BEZPIECZNE WYWOŁYWANIE (odporne na brak binarki gpg)
164 +# =============================================================================
165 +
166 +GPG_BINARY = shutil.which("gpg2") or shutil.which("gpg") or "gpg"
167 +GPG_HOME = "/etc/pag/gpg" # izolowany keyring (działa z keyboxd GPG 2.4+)
168 +
169 +def _gpg_run(*args, timeout: int = 30, **kwargs) -> subprocess.CompletedProcess:
170 + """
171 + Bezpieczne wywołanie GPG – przechwytuje FileNotFoundError,
172 + gdyby gpg/gpg2 nie było zainstalowane w minimalnym środowisku.
173 + Wymusza LC_ALL=C aby komunikaty GPG były zawsze po angielsku
174 + (niezależnie od locale systemu) – kluczowe dla parsowania stderr.
175 + """
176 + env = kwargs.pop("env", None) or os.environ.copy()
177 + env["LC_ALL"] = "C"
178 + env["GNUPGHOME"] = GPG_HOME
179 + try:
180 + return subprocess.run([GPG_BINARY, *args], timeout=timeout, env=env, **kwargs)
181 + except FileNotFoundError:
182 + # GPG nie jest dostępne – zwróć błąd z komunikatem
183 + # (szanuj text=True – inaczej caller dostaje bytes i może wybuchnąć TypeError)
184 + _text = bool(kwargs.get("text") or kwargs.get("universal_newlines"))
185 + _msg = f"GPG binary not found ({GPG_BINARY})"
186 + return subprocess.CompletedProcess(
187 + [GPG_BINARY, *args], 127,
188 + stdout=("" if _text else b""),
189 + stderr=(_msg if _text else _msg.encode()),
190 + )
191 + except subprocess.TimeoutExpired:
192 + return subprocess.CompletedProcess(
193 + [GPG_BINARY, *args], 124,
194 + stdout=b"", stderr=b"GPG operation timed out"
195 + )
196 +
197 +def _load_trust_db() -> dict:
198 + """Mapa repo_url → fingerprint klucza podpisującego (baza zaufania)."""
199 + try:
200 + with open(TRUST_DB) as f:
201 + return json.load(f)
202 + except (FileNotFoundError, json.JSONDecodeError):
203 + return {}
204 +
205 +
206 +def _save_trust_db(db: dict):
207 + os.makedirs(os.path.dirname(TRUST_DB), exist_ok=True)
208 + with open(TRUST_DB, "w") as f:
209 + json.dump(db, f, indent=2)
210 +
211 +
212 +def _gpg_verify_fp(sig_path: str, data_path: str, timeout: int = 30):
213 + """Weryfikuje podpis i odczytuje fingerprint podpisującego.
214 +
215 + Używa --status-fd=1 i linii VALIDSIG <fingerprint>. Zwraca (ok, fingerprint).
216 + """
217 + env = os.environ.copy()
218 + res = _gpg_run("--verify", "--status-fd", "1", sig_path, data_path,
219 + capture_output=True, text=True, timeout=timeout, env=env)
220 + if res.returncode != 0:
221 + return False, None
222 + m = re.search(r"\[GNUPG:\]\s+VALIDSIG\s+([0-9A-Fa-f]+)", res.stdout or "")
223 + if not m:
224 + m = re.search(r"VALIDSIG\s+([0-9A-Fa-f]{16,})", res.stdout or "")
225 + return True, (m.group(1).upper() if m else None)
226 +
227 +
228 +# =============================================================================
229 +# i18n – WIELOJĘZYCZNOŚĆ
230 +# =============================================================================
231 +
232 +LANG = os.environ.get("LANG", "en_US.UTF-8")[:2] # pl, en, de...
233 +COLOR = os.environ.get("NO_COLOR", "") == "" and sys.stdout.isatty()
234 +
235 +def _c(code: str, text: str) -> str:
236 + """Dodaje kody ANSI jeśli kolor jest włączony."""
237 + if not COLOR:
238 + return text
239 + colors = {
240 + "green": "\033[32m", "red": "\033[31m", "yellow": "\033[33m",
241 + "cyan": "\033[36m", "bold": "\033[1m", "dim": "\033[2m",
242 + "reset": "\033[0m",
243 + }
244 + return f"{colors.get(code,'')}{text}{colors['reset']}"
245 +
246 +T = {
247 + "en": {
248 + "root_required": "pag requires root privileges (sudo).",
249 + "db_locked": "Another pag instance is running.",
250 + "db_lock_hint": "If no other pag process is running, wait a moment and retry.",
251 + "no_index": "Cannot fetch repository indexes. Run 'pag update'.",
252 + "cache_ro": "Repo cache is read-only ({cache}) – using local index (may be outdated).\n Refresh as root: sudo pag sync",
253 + "all_installed": "All packages are already installed.",
254 + "to_install": "To install: {} packages ({:.2f} MB)",
255 + "new": "NEW",
256 + "continue_q": "Continue? [Y/n] ",
257 + "no_tty": "No TTY / stdin closed (EOF) – cancelling.",
258 + "cancelled": "Cancelled.",
259 + "not_found": "not found in repos",
260 + "pkg_not_found": "Package not found: {} (not in any repo)",
261 + "not_found_hint": "Check the spelling or run 'pag search <query>'.",
262 + "downloading": "Downloading",
263 + "download_fail": "download failed",
264 + "gpg_fail": "GPG verification failed",
265 + "sha256_mismatch": "SHA256 mismatch",
266 + "install_failed": "installation failed",
267 + "installed": "Installed {} packages.",
268 + "rollback_restored": "Restored previous state from snapshot.",
269 + "rollback_files": "Rolled back {} files.",
270 + "no_history": "No transaction history.",
271 + "pinned_list": "Pinned packages ({}):",
272 + "no_pinned": "No pinned packages.",
273 + "pinned_to": "pinned to",
274 + "unpinned": "unpinned.",
275 + "not_pinned": "was not pinned.",
276 + "repo_added": "Added repository: {}",
277 + "repo_exists": "Repository already exists: {}",
278 + "updated_done": "Index refresh complete. {} packages cached.",
279 + "indexes_refreshed": "Indexes refreshed.",
280 + "updates_available": "⚠ {} packages have updates – run: pag update",
281 + "upgrading": "Upgrading: {} packages",
282 + "all_up_to_date": "All packages are up to date.",
283 + "removing": "Removing",
284 + "orphans_found": "Orphaned dependencies ({}): {}",
285 + "flatpak_missing": "Flatpak is not installed.",
286 + "flatpak_adding": "Adding Flathub remote...",
287 + "flatpak_searching": "Searching Flathub for '{}'...",
288 + "flatpak_found": "Found {} results:",
289 + "flatpak_not_found": "not found on Flathub",
290 + "flatpak_install_prompt": "Install {}? [Y/n] ",
291 + "flatpak_installing": "Installing {}...",
292 + "flatpak_installed": "Flatpak {} installed.",
293 + "flatpak_removed": "Flatpak {} removed.",
294 + "flatpak_not_installed": "Flatpak {} is not installed.",
295 + "flatpak_info_id": "ID",
296 + "flatpak_info_version": "Version",
297 + "flatpak_info_branch": "Branch",
298 + "flatpak_info_origin": "Origin",
299 + "flatpak_info_size": "Installed size",
300 + "flatpak_info_desc": "Description",
301 + "flatpak_updated": "Flatpaks updated.",
302 + "flatpak_usage": "Usage: pag flatpak <search|install|remove|list|update|info> [args]",
303 + "key_imported": "Key imported successfully.",
304 + "key_removed": "Key removed: {}",
305 + "no_keys": "No trusted GPG keys.",
306 + "gpg_missing": "GNUPG MISSING – install gnupg and retry",
307 + "key_add_failed": "Key import failed (gpg error) – key was NOT added.",
308 + "verify_ok": "All {} files intact.",
309 + "verify_errors": "{} problems found:",
310 + "cache_cleared": "{} files ({:.2f} MB) cleared from cache.",
311 + "deployments_list": "Deployments ({}):",
312 + "no_deployments": "No deployments.",
313 + "active_deployment": "ACTIVE",
314 + "deploy_rollback_ok": "Switched to deployment: {}",
315 + "deploy_rollback_fail": "No previous deployment.",
316 + "deploy_cleanup_ok": "Removed {} old deployments.",
317 + "deploy_cleanup_none": "No deployments to clean (minimum {}).",
318 + "why_explicit": "explicitly installed",
319 + "why_dependency": "dependency of",
320 + "why_not_installed": "not installed",
321 + "autoremove_ok": "Removed {} orphaned packages.",
322 + "autoremove_none": "No orphaned packages.",
323 + "downloaded": "Downloaded {} to cache ({:.2f} MB).",
324 + "provides_mapped": "{} → {} (provides)",
325 + "stats_title": "PAG Statistics",
326 + "stats_packages": "Installed packages",
327 + "stats_files": "Tracked files",
328 + "stats_size": "Total size",
329 + "stats_cache": "Cache size",
330 + "stats_history": "Transactions",
331 + "stats_last_update": "Last update",
332 + # Komunikaty bezpieczeństwa (baza EN; PL w tabeli "pl" jako sec_*_pl)
333 + "sec_downgrade": "Downgrade blocked: {pkg} {new} < {old}",
334 + "sec_suid": "SUID stripped from {path}",
335 + "sec_https": "HTTPS required for repos",
336 + "sec_badname": "Invalid package name: {name}",
337 + "sec_toobig": "Package too large: {size_mb}MB > {max_mb}MB",
338 + "sec_conflict": "File conflict: {path} owned by {owner}",
339 + "sec_audit": "{pkg} installed by {user}",
340 + "sec_locked": "Another pag process is running",
341 + },
342 + "pl": {
343 + "root_required": "pag wymaga uprawnień root (sudo).",
344 + "db_locked": "Inna instancja pag jest uruchomiona.",
345 + "db_lock_hint": "Jeśli żaden inny proces pag nie działa, poczekaj chwilę i spróbuj ponownie.",
346 + "no_index": "Nie można pobrać indeksów repozytoriów. Uruchom 'pag update'.",
347 + "cache_ro": "Cache repozytoriów jest tylko-do-odczytu ({cache}) – używam lokalnego indeksu (może być nieaktualny).\n Odśwież jako root: sudo pag sync",
348 + "all_installed": "Wszystkie pakiety są już zainstalowane.",
349 + "to_install": "Do zainstalowania: {} pakietów ({:.2f} MB)",
350 + "new": "NOWY",
351 + "continue_q": "Kontynuować? [T/n] ",
352 + "no_tty": "Brak terminala (EOF) – anuluję.",
353 + "cancelled": "Anulowano.",
354 + "not_found": "brak w repozytoriach",
355 + "pkg_not_found": "Nie znaleziono pakietu: {} (brak w repozytoriach)",
356 + "not_found_hint": "Sprawdź pisownię lub uruchom 'pag search <fraza>'.",
357 + "downloading": "Pobieranie",
358 + "download_fail": "błąd pobierania",
359 + "gpg_fail": "błąd weryfikacji GPG",
360 + "sha256_mismatch": "niezgodność SHA256",
361 + "install_failed": "błąd instalacji",
362 + "installed": "Zainstalowano {} pakietów.",
363 + "rollback_restored": "Przywrócono poprzedni stan z migawki.",
364 + "rollback_files": "Wycofano {} plików.",
365 + "no_history": "Brak historii transakcji.",
366 + "pinned_list": "Przypięte pakiety ({}):",
367 + "no_pinned": "Brak przypiętych pakietów.",
368 + "pinned_to": "przypięty do",
369 + "unpinned": "odpięty.",
370 + "not_pinned": "nie był przypięty.",
371 + "repo_added": "Dodano repozytorium: {}",
372 + "repo_exists": "Repozytorium już istnieje: {}",
373 + "updated_done": "Odświeżanie zakończone. {} pakietów w cache.",
374 + "indexes_refreshed": "Indeksy odświeżone.",
375 + "updates_available": "⚠ jest {} pakietów do zaktualizowania – wpisz: pag update",
376 + "upgrading": "Aktualizacje: {} pakietów",
377 + "all_up_to_date": "Wszystkie pakiety są aktualne.",
378 + "removing": "Usuwanie",
379 + "orphans_found": "Osierocone zależności ({}): {}",
380 + "flatpak_missing": "Flatpak nie jest zainstalowany.",
381 + "flatpak_adding": "Dodaję zdalne repozytorium Flathub...",
382 + "flatpak_searching": "Szukam '{}' we Flathub...",
383 + "flatpak_found": "Znaleziono {} wyników:",
384 + "flatpak_not_found": "nie znaleziono we Flathub",
385 + "flatpak_install_prompt": "Zainstalować {}? [T/n] ",
386 + "flatpak_installing": "Instalowanie {}...",
387 + "flatpak_installed": "Flatpak {} zainstalowany.",
388 + "flatpak_removed": "Flatpak {} usunięty.",
389 + "flatpak_not_installed": "Flatpak {} nie jest zainstalowany.",
390 + "flatpak_info_id": "ID",
391 + "flatpak_info_version": "Wersja",
392 + "flatpak_info_branch": "Gałąź",
393 + "flatpak_info_origin": "Źródło",
394 + "flatpak_info_size": "Rozmiar",
395 + "flatpak_info_desc": "Opis",
396 + "flatpak_updated": "Flapaki zaktualizowane.",
397 + "flatpak_usage": "Użycie: pag flatpak <search|install|remove|list|update|info> [args]",
398 + "key_imported": "Klucz zaimportowany pomyślnie.",
399 + "key_removed": "Klucz usunięty: {}",
400 + "no_keys": "Brak zaufanych kluczy GPG.",
401 + "gpg_missing": "BRAK GNUPG – zainstaluj gnupg i spróbuj ponownie",
402 + "key_add_failed": "Import klucza nie powiódł się (błąd gpg) – klucz NIE został dodany.",
403 + "verify_ok": "Wszystkie {} plików sprawne.",
404 + "verify_errors": "Znaleziono {} problemów:",
405 + "cache_cleared": "{} plików ({:.2f} MB) usuniętych z cache.",
406 + "deployments_list": "Deploymenty ({}):",
407 + "no_deployments": "Brak deploymentów.",
408 + "active_deployment": "AKTYWNY",
409 + "deploy_rollback_ok": "Przełączono na deployment: {}",
410 + "deploy_rollback_fail": "Brak poprzedniego deploymentu.",
411 + "deploy_cleanup_ok": "Usunięto {} starych deploymentów.",
412 + "deploy_cleanup_none": "Nie ma deploymentów do wyczyszczenia (minimum {}).",
413 + "why_explicit": "zainstalowany jawnie",
414 + "why_dependency": "zależność od",
415 + "why_not_installed": "niezainstalowany",
416 + "autoremove_ok": "Usunięto {} osieroconych pakietów.",
417 + "autoremove_none": "Brak osieroconych pakietów.",
418 + "downloaded": "Pobrano {} do cache ({:.2f} MB).",
419 + "sec_downgrade": "Downgrade blocked: {pkg} {new} < {old}",
420 + "sec_suid": "SUID stripped from {path}",
421 + "sec_https": "HTTPS required for repos",
422 + "sec_badname": "Invalid package name: {name}",
423 + "sec_toobig": "Package too large: {size_mb}MB > {max_mb}MB",
424 + "sec_conflict": "File conflict: {path} owned by {owner}",
425 + "sec_audit": "{pkg} installed by {user}",
426 + "sec_locked": "Another pag process is running",
427 + "sec_downgrade_pl": "Blokada downgrade: {pkg} {new} < {old}",
428 + "sec_suid_pl": "SUID usuniety z {path}",
429 + "sec_https_pl": "Repozytorium wymaga HTTPS",
430 + "sec_badname_pl": "Nieprawidlowa nazwa pakietu: {name}",
431 + "sec_toobig_pl": "Paczka za duza: {size_mb}MB > {max_mb}MB",
432 + "sec_conflict_pl": "Konflikt plikow: {path} nalezy do {owner}",
433 + "sec_audit_pl": "{pkg} zainstalowany przez {user}",
434 + "sec_locked_pl": "Inny proces pag juz dziala",
435 +
436 + "provides_mapped": "{} → {} (provides)",
437 + "stats_title": "Statystyki PAG",
438 + "stats_packages": "Zainstalowane pakiety",
439 + "stats_files": "Śledzone pliki",
440 + "stats_size": "Całkowity rozmiar",
441 + "stats_cache": "Rozmiar cache",
442 + "stats_history": "Transakcje",
443 + "stats_last_update": "Ostatnia aktualizacja",
444 + },
445 +}
446 +
447 +# ── Tłumaczenia z PLIKÓW (nadpisują/rozszerzają wbudowane PL/EN) ─────────────
448 +# Kolejność: PAG_LANG_DIR (env) → /etc/pag/lang → /usr/share/pag/lang →
449 +# ./pag-lang obok binarki (dev). Brak plików NIE jest błędem – zostaje
450 +# wbudowany słownik T (fallback), więc pag zawsze działa.
451 +# Przykład pliku (pag-lang/pl.json): {"app_title": "...", "usage": "..."}.
452 +def _load_lang_files() -> None:
453 + # PAG_LANG_NO_FILES=1 → pomiń pliki (używane przy eksporcie --lang-extract,
454 + # żeby wyeksportować CZYSTE wbudowane słowniki, bez starych nadpisań).
455 + if os.environ.get("PAG_LANG_NO_FILES") == "1":
456 + return
457 + dirs = []
458 + _env = os.environ.get("PAG_LANG_DIR")
459 + if _env:
460 + dirs.append(_env)
461 + dirs += ["/etc/pag/lang", "/usr/share/pag/lang",
462 + os.path.join(os.path.dirname(os.path.abspath(__file__)), "pag-lang")]
463 + for _d in dirs:
464 + for _code in list(T.keys()) + ["pl", "en", "de"]:
465 + _p = os.path.join(_d, f"{_code}.json")
466 + try:
467 + with open(_p, "r", encoding="utf-8") as _fh:
468 + _data = json.load(_fh)
469 + if isinstance(_data, dict):
470 + T.setdefault(_code, {}).update(
471 + {str(k): str(v) for k, v in _data.items()})
472 + except (OSError, ValueError):
473 + continue
474 +
475 +
476 +_load_lang_files()
477 +
478 +def _(key: str, *args, **kwargs) -> str:
479 + """Tłumaczy klucz i formatuje argumenty.
480 +
481 + Dla LANG=pl preferuje wariant „<key>_pl” (np. komunikaty bezpieczeństwa
482 + mają krótkie wersje PL obok bazy EN), potem zwykły klucz, potem EN/klicz.
483 + """
484 + if LANG == "pl":
485 + _pl = T.get("pl", {})
486 + msg = _pl.get(key + "_pl") or _pl.get(key) or T["en"].get(key, key)
487 + else:
488 + msg = T.get(LANG, T["en"]).get(key, T["en"].get(key, key))
489 + if args or kwargs:
490 + return msg.format(*args, **kwargs)
491 + return msg
492 +
493 +
494 +def _ask_confirm() -> bool:
495 + """Pytanie potwierdzające (T/n). PAG_YES=1 → zawsze tak.
496 +
497 + EOF/brak terminala (stdin zamknięty, np. ssh bez TTY, cron, subprocess
498 + panelu webowego) → NIE – anuluj, nie wykonuj operacji bez potwierdzenia
499 + (inaczej input() rzuca EOFError i pag pada tracebackiem).
500 + Enter → tak (domyślne Y/n).
501 + """
502 + if os.environ.get("PAG_YES", "") == "1":
503 + print(_("continue_q") + " t (--yes)")
504 + return True
505 + try:
506 + ans = input(_("continue_q")).strip().lower()
507 + except (EOFError, KeyboardInterrupt):
508 + print(f"\n ⚠ {_('no_tty')}")
509 + return False
510 + return not ans or ans in ("t", "y")
511 +
512 +
513 +# =============================================================================
514 +# ŚCIEŻKI
515 +# =============================================================================
516 +PAG_ROOT = os.environ.get("PAG_ROOT", "/")
517 +PAG_DB = "/var/lib/pag"
518 +PAG_CACHE = "/var/cache/pag"
519 +PAG_CONF = "/etc/pag"
520 +REPO_CACHE = "/var/cache/pag/repos"
521 +REPOS_CONF = "/etc/pag/repos.conf"
522 +REPOS_DIR = PAG_CONF + "/repos" # drop-in: /etc/pag/repos/<nazwa>.conf
523 +INSTALLED_DB = "/var/lib/pag/installed.json"
524 +FILES_DB_SQL = "/var/lib/pag/files.db" # SQLite!
525 +WORLD_FILE = "/var/lib/pag/world"
526 +PINNED_FILE = "/var/lib/pag/pinned.json"
527 +HISTORY_FILE = "/var/lib/pag/history.json"
528 +LOCK_FILE = "/var/lib/pag/pag.lock"
529 +STAGING_DIR = "/.pag_staging" # na tej samej partycji co / (unikamy EXDEV)
530 +PKG_EXT = ".pag"
531 +REPO_CACHE_TTL = 3600
532 +MAX_PKG_SIZE = 2 * 1024 * 1024 * 1024 # 2 GB – maksymalny rozmiar paczki
533 +ALLOWED_PKG_RE = re.compile(r'^[a-zA-Z0-9][a-zA-Z0-9._+@-]*$')
534 +
535 +# Bezpieczeństwo / audyt
536 +AUDIT_LOG = "/var/log/pag/audit.log" # dziennik operacji krytycznych (hooki, self-update)
537 +TRUST_DB = "/etc/pag/trusted.json" # mapa repo_url → fingerprint klucza podpisującego
538 +HOOK_API_VERSION = "1" # wersjonowane API hooków (env PKG_HOOK_API)
539 +
540 +# =============================================================================
541 +# IMMUTABLE OS – DEPLOYMENTY
542 +# =============================================================================
543 +# Model: zamiast mutować /, każda operacja tworzy NOWY deployment.
544 +# /var, /etc, /home są współdzielone między deploymentami.
545 +#
546 +# STRUKTURA:
547 +# /.deployments/
548 +# active → 20260723T120000 (symlink do aktywnego)
549 +# 20260723T120000/
550 +# usr/ bin/ lib/ lib64/ ... (pełny system)
551 +# var → /var (symlink do współdzielonego)
552 +# etc → /etc
553 +# home → /home
554 +# ...
555 +#
556 +# Jak to działa:
557 +# 1. pag install → kopiuje active → nowy deployment + nakłada zmiany → switch symlinka
558 +# 2. pag remove → kopiuje active → nowy deployment - usuwa pliki → switch symlinka
559 +# 3. pag deploy-rollback → przełącza active symlink na poprzedni deployment
560 +# 4. Przy starcie systemu: initrd montuje /.deployments/active jako /
561 +# =============================================================================
562 +
563 +DEPLOYMENTS_DIR = "/.deployments"
564 +ACTIVE_LINK = "/.deployments/active"
565 +DEPLOYMENTS_DB = "/var/lib/pag/deployments.json"
566 +
567 +# Ścieżki współdzielone – NIE wchodzą do deploymentu (są symlinkami do /...)
568 +SHARED_PATHS = {
569 + "/var", "/etc", "/home", "/root", "/tmp", "/run",
570 + "/dev", "/proc", "/sys", "/mnt", "/media", "/srv",
571 + "/.deployments", "/.pag_staging",
572 +}
573 +
574 +def _is_shared_path(rel: str) -> bool:
575 + """Sprawdza czy ścieżka należy do katalogów współdzielonych (poza deploymentem)."""
576 + for sp in SHARED_PATHS:
577 + if rel == sp or rel.startswith(sp + "/"):
578 + return True
579 + return False
580 +
581 +def _get_deployment_root() -> str:
582 + """Zwraca ścieżkę do aktywnego deploymentu, lub PAG_ROOT jeśli tryb niemutowalny wyłączony."""
583 + if os.environ.get("PAG_IMMUTABLE", "") in ("0", "no", "false", ""):
584 + return PAG_ROOT
585 + if os.path.islink(ACTIVE_LINK):
586 + return os.readlink(ACTIVE_LINK)
587 + if os.path.isdir(ACTIVE_LINK):
588 + return ACTIVE_LINK
589 + # Brak deploymentów – użyj /
590 + return PAG_ROOT
591 +
592 +def _load_deployments() -> List[dict]:
593 + """Wczytuje historię deploymentów."""
594 + if not os.path.exists(DEPLOYMENTS_DB):
595 + return []
596 + try:
597 + return json.load(open(DEPLOYMENTS_DB))
598 + except Exception:
599 + return []
600 +
601 +def _save_deployments(deployments: List[dict]):
602 + os.makedirs(os.path.dirname(DEPLOYMENTS_DB), exist_ok=True)
603 + json.dump(deployments, open(DEPLOYMENTS_DB, "w"), indent=2)
604 +
605 +def _create_deployment(pkg_names: List[str], action: str) -> Tuple[str, str]:
606 + """
607 + Tworzy nowy deployment przez skopiowanie aktywnego (CoW) i zwraca jego ścieżkę.
608 + Zwraca (deployment_dir, deployment_id).
609 + """
610 + deploy_id = datetime.now().strftime("%Y%m%dT%H%M%S")
611 + deploy_dir = os.path.join(DEPLOYMENTS_DIR, deploy_id)
612 + os.makedirs(DEPLOYMENTS_DIR, exist_ok=True)
613 +
614 + active = _get_deployment_root()
615 +
616 + if os.path.isdir(active) and active != PAG_ROOT:
617 + # Trójstopniowa strategia kopiowania deploymentu:
618 + # 1. reflink (CoW – btrfs, xfs) → 0 MB kopiowane
619 + # 2. hardlink (linki twarde) → 0 MB kopiowane, tylko inody
620 + # 3. zwykłe cp (ostateczność) → pełna kopia
621 + print(f" ⚡ Kopiowanie aktywnego deploymentu...")
622 + copied = False
623 + for method, cmd, label in [
624 + ("reflink", ["cp", "--reflink=auto", "-a", active + "/.", deploy_dir + "/"], "CoW (reflink)"),
625 + ("hardlink", ["cp", "-al", active + "/.", deploy_dir + "/"], "hardlinki"),
626 + ("copy", ["cp", "-a", active + "/.", deploy_dir + "/"], "pełna kopia"),
627 + ]:
628 + try:
629 + subprocess.run(cmd, check=True, timeout=600, capture_output=True)
630 + print(f" ✅ Deployment: {deploy_id} ({label})")
631 + copied = True
632 + break
633 + except subprocess.CalledProcessError:
634 + if method == "copy":
635 + raise # ostatnia deska – niech leci wyjątek
636 + continue
637 + if not copied:
638 + raise RuntimeError("Nie udało się skopiować deploymentu żadną metodą")
639 + else:
640 + # Pierwszy deployment – tylko katalogi szkieletowe
641 + for d in ["/usr", "/lib", "/lib64", "/bin", "/sbin", "/boot", "/opt"]:
642 + if os.path.isdir(d):
643 + dest = os.path.join(deploy_dir, d.lstrip("/"))
644 + os.makedirs(dest, exist_ok=True)
645 + print(f" ✅ Pierwszy deployment: {deploy_id}")
646 +
647 + # Utwórz symlinki do współdzielonych katalogów
648 + for sp in SHARED_PATHS:
649 + link_dst = os.path.join(deploy_dir, sp.lstrip("/"))
650 + if not os.path.lexists(link_dst) and os.path.isdir(sp):
651 + os.symlink(sp, link_dst)
652 +
653 + # Zapisz w bazie deploymentów
654 + deployments = _load_deployments()
655 + deployments.append({
656 + "id": deploy_id,
657 + "action": action,
658 + "packages": pkg_names,
659 + "timestamp": datetime.now().isoformat(),
660 + "active": True,
661 + })
662 + # Oznacz poprzednie jako nieaktywne
663 + for d in deployments[:-1]:
664 + d["active"] = False
665 + _save_deployments(deployments)
666 +
667 + return deploy_dir, deploy_id
668 +
669 +def _switch_deployment(deploy_dir: str) -> bool:
670 + """Atomowo przełącza aktywny deployment przez podmianę symlinka."""
671 + tmp_link = ACTIVE_LINK + ".new"
672 + if os.path.lexists(tmp_link):
673 + os.remove(tmp_link)
674 + os.symlink(deploy_dir, tmp_link)
675 + os.rename(tmp_link, ACTIVE_LINK) # atomowe na tym samym FS
676 + return True
677 +
678 +DEFAULT_REPOS = [
679 + "https://repo.paganlinux.eu/stable/",
680 +]
681 +
682 +# =============================================================================
683 +# INICJALIZACJA
684 +# =============================================================================
685 +
686 +def ensure_dirs():
687 + for d in [PAG_DB, PAG_CACHE, PAG_CONF, REPO_CACHE, REPOS_DIR, STAGING_DIR, DEPLOYMENTS_DIR]:
688 + os.makedirs(d, exist_ok=True)
689 + for f, default in [
690 + (REPOS_CONF, "\n".join(DEFAULT_REPOS) + "\n"),
691 + (INSTALLED_DB, "{}"),
692 + (PINNED_FILE, "{}"),
693 + (HISTORY_FILE, "[]"),
694 + ]:
695 + if not os.path.exists(f):
696 + with open(f, "w") as fh: fh.write(default)
697 + if not os.path.exists(WORLD_FILE):
698 + Path(WORLD_FILE).touch()
699 + if not os.path.exists(GPG_HOME):
700 + os.makedirs(GPG_HOME, exist_ok=True)
701 + os.chmod(GPG_HOME, 0o700)
702 + _gpg_run("--list-keys", capture_output=True)
703 + # Inicjalizuj SQLite
704 + _db_init()
705 + # Wyczyść staging po poprzednim przerwanym buildzie/instalacji
706 + if os.path.isdir(STAGING_DIR):
707 + for entry in os.listdir(STAGING_DIR):
708 + if entry == "backups":
709 + continue # backupy starych wersji – potrzebne do `pag rollback`
710 + path = os.path.join(STAGING_DIR, entry)
711 + try:
712 + if os.path.isfile(path) or os.path.islink(path):
713 + os.unlink(path)
714 + elif os.path.isdir(path):
715 + shutil.rmtree(path, ignore_errors=True)
716 + except OSError:
717 + pass
718 +
719 +# =============================================================================
720 +# SQLITE – BAZA PLIKÓW (poprawne zarządzanie połączeniami)
721 +# =============================================================================
722 +
723 +from contextlib import contextmanager
724 +
725 +@contextmanager
726 +def _db_session():
727 + """Context manager – gwarantuje zamknięcie połączenia."""
728 + conn = sqlite3.connect(FILES_DB_SQL, timeout=15)
729 + conn.execute("PRAGMA journal_mode=WAL")
730 + conn.execute("PRAGMA synchronous=NORMAL")
731 + conn.execute("PRAGMA foreign_keys=ON")
732 + conn.execute("PRAGMA busy_timeout=15000")
733 + conn.row_factory = sqlite3.Row
734 + try:
735 + yield conn
736 + conn.commit()
737 + except Exception:
738 + conn.rollback()
739 + raise
740 + finally:
741 + conn.close()
742 +
743 +
744 +def _db_init():
745 + """Tworzy tabele SQLite jeśli nie istnieją."""
746 + with _db_session() as db:
747 + db.execute("""
748 + CREATE TABLE IF NOT EXISTS files (
749 + id INTEGER PRIMARY KEY AUTOINCREMENT,
750 + path TEXT NOT NULL,
751 + package TEXT NOT NULL,
752 + sha256 TEXT,
753 + size INTEGER,
754 + is_symlink INTEGER DEFAULT 0,
755 + symlink_target TEXT,
756 + UNIQUE(path, package)
757 + )
758 + """)
759 + db.execute("CREATE INDEX IF NOT EXISTS idx_files_path ON files(path)")
760 + db.execute("CREATE INDEX IF NOT EXISTS idx_files_pkg ON files(package)")
761 + db.execute("""
762 + CREATE TABLE IF NOT EXISTS file_checksums (
763 + path TEXT PRIMARY KEY,
764 + sha256 TEXT NOT NULL,
765 + installed_at TEXT
766 + )
767 + """)
768 + db.commit()
769 +
770 +def _db_record_files(pkg_name: str, files: List[dict]):
771 + """Zapisuje pliki do SQLite (obsługuje symlinki)."""
772 + with _db_session() as db:
773 + # Jawna transakcja – atomowość obu zapisów i szybsze wykrycie blokady
774 + try:
775 + db.execute("BEGIN IMMEDIATE")
776 + except sqlite3.OperationalError:
777 + pass # transakcja już otwarta (implicit)
778 + db.executemany(
779 + "INSERT OR REPLACE INTO files (path, package, sha256, size, is_symlink, symlink_target) "
780 + "VALUES (?,?,?,?,?,?)",
781 + [(f["path"], pkg_name, f.get("sha256",""), f.get("size",0),
782 + f.get("is_symlink", 0), f.get("symlink_target", ""))
783 + for f in files]
784 + )
785 + db.executemany(
786 + "INSERT OR REPLACE INTO file_checksums (path, sha256, installed_at) VALUES (?,?,?)",
787 + [(f["path"], f.get("sha256",""), datetime.now().isoformat())
788 + for f in files if f.get("sha256")]
789 + )
790 +
791 +def _db_get_package_files(pkg_name: str) -> List[str]:
792 + with _db_session() as db:
793 + return [r["path"] for r in db.execute(
794 + "SELECT DISTINCT path FROM files WHERE package=?", (pkg_name,)
795 + )]
796 +
797 +def _db_get_file_owners(filepath: str) -> List[str]:
798 + """Zwraca listę pakietów będących właścicielami pliku."""
799 + with _db_session() as db:
800 + return [r["package"] for r in db.execute(
801 + "SELECT package FROM files WHERE path=?", (filepath,)
802 + )]
803 +
804 +def _db_remove_package_files(pkg_name: str):
805 + with _db_session() as db:
806 + db.execute("DELETE FROM files WHERE package=?", (pkg_name,))
807 + db.commit()
808 +
809 +def _db_get_all_file_checksums() -> Dict[str, str]:
810 + with _db_session() as db:
811 + return {r["path"]: r["sha256"] for r in db.execute("SELECT path, sha256 FROM file_checksums")}
812 +
813 +def _db_count_files() -> int:
814 + with _db_session() as db:
815 + return db.execute("SELECT COUNT(*) FROM files").fetchone()[0]
816 +
817 +# =============================================================================
818 +# BLOKADA
819 +# =============================================================================
820 +
821 +class DatabaseLock:
822 + """Blokada plikowa (flock) – jądro zwalnia ją AUTOMATYCZNIE, gdy proces
823 + ginie (kill -9, twardy reset). Stary PID-file miał race condition: po
824 + śmierci pag PID mógł zostać przydzielony obcemu procesowi (PID reuse)
825 + i pag odmawiał działania na zawsze („baza zablokowana”).
826 + """
827 + def __init__(self):
828 + self._f = None
829 + def __enter__(self):
830 + os.makedirs(os.path.dirname(LOCK_FILE), exist_ok=True)
831 + self._f = open(LOCK_FILE, "w")
832 + try:
833 + # LOCK_NB: rzuca wyjątek zamiast czekać w nieskończoność
834 + fcntl.flock(self._f, fcntl.LOCK_EX | fcntl.LOCK_NB)
835 + except BlockingIOError:
836 + print(f"❌ {_('db_locked')}", file=sys.stderr)
837 + print(f" {_('db_lock_hint', LOCK_FILE)}", file=sys.stderr)
838 + sys.exit(1)
839 + self._f.write(str(os.getpid()))
840 + self._f.flush()
841 + return self
842 + def __exit__(self, *args):
843 + if self._f:
844 + try:
845 + fcntl.flock(self._f, fcntl.LOCK_UN)
846 + except OSError:
847 + pass
848 + self._f.close()
849 + self._f = None
850 + # Uwaga: NIE usuwamy pliku blokady. Stały plik + flock na inode to jedyny
851 + # bezpieczny wzorzec – os.remove(), gdy inny proces trzyma blokadę na starym
852 + # inode, otwiera wyścig (nowy proces blokowałby nowo utworzony inode).
853 +
854 +# =============================================================================
855 +# POMOCNICZE
856 +# =============================================================================
857 +
858 +
859 +_ALLOWED_PREFIXES = ("/usr/", "/etc/", "/var/", "/opt/",
860 + "/boot/", "/lib/", # kernel: vmlinuz/System.map + moduły (usrmerge: lib→usr/lib)
861 + # Pliki wewnętrzne paczki .pkg.tar.xz
862 + "metadata.json", "data.tar.xz", "hooks/",
863 + "sums.json")
864 +
865 +def _check_path_safety(name: str) -> bool:
866 + # Normalizuj – usuń leading ./
867 + if name.startswith("./"):
868 + name = name[2:]
869 + if name in (".", ""):
870 + return True
871 + # Porównuj z prefiksami BEZ wiodącego '/', by zarówno "/usr/bin/ls", jak i
872 + # wewnętrzne pliki pakietu ("hooks/pre-install", "data.tar.xz") przechodziły.
873 + norm = name.lstrip("/")
874 + for prefix in _ALLOWED_PREFIXES:
875 + p = prefix.lstrip("/").rstrip("/")
876 + if norm == p or norm.startswith(p + "/"):
877 + return True
878 + return False
879 +
880 +
881 +def _validate_pkg_name(name):
882 + return bool(ALLOWED_PKG_RE.match(name))
883 +
884 +
885 +
886 +def _audit(msg):
887 + from datetime import datetime, timezone
888 + os.makedirs(os.path.dirname(AUDIT_LOG), exist_ok=True)
889 + with open(AUDIT_LOG, "a") as f:
890 + f.write(datetime.now(timezone.utc).isoformat() + " " + msg + "\n")
891 +
892 +def _strip_suid(path):
893 + try:
894 + st = os.stat(path)
895 + if st.st_mode & 0o4000:
896 + os.chmod(path, st.st_mode & ~0o4000)
897 + print(f" {_("sec_suid", path=path)}")
898 + except OSError:
899 + pass
900 +
901 +def _check_downgrade(pkg_name, new_ver, installed_db):
902 + if pkg_name in installed_db:
903 + old = installed_db[pkg_name].get("version", "0")
904 + if new_ver < old:
905 + print(f" {_("sec_downgrade", pkg=pkg_name, new=new_ver, old=old)}")
906 + return False
907 + return True
908 +
909 +def _safe_extractall(tar: tarfile.TarFile, dest: str, *, preserve_perms: bool = True):
910 + """
911 + Bezpieczne rozpakowanie archiwum tar z ochroną przed Directory Traversal.
912 +
913 + Działa na Python < 3.12 (gdzie parametr 'filter' w extractall nie istnieje)
914 + oraz na Python 3.12+. W przeciwieństwie do filtra 'data' z Pythona 3.12,
915 + zachowuje bity uprawnień POSIX (SUID, SGID, sticky) – preserve_perms=True.
916 +
917 + Ochrona oparta jest na FINALNEJ ścieżce (os.path.realpath), nie tylko na
918 + prostym sprawdzaniu stringa:
919 + - Blokuje ścieżki absolutne i z '..' (path traversal)
920 + - Blokuje symlinki/hardlinki, których cel wychodzi poza dest
921 + - Blokuje zapis "przez" złośliwy symlink, który został wcześniej
922 + rozpakowany (np. katalog → /etc, potem zapis katalog/plik)
923 + - Zachowuje oryginalne uprawnienia plików
924 + """
925 + dest_real = os.path.realpath(dest)
926 + os.makedirs(dest_real, exist_ok=True)
927 +
928 + def _target_within(path: str) -> bool:
929 + try:
930 + return os.path.commonpath([dest_real, os.path.realpath(path)]) == dest_real
931 + except ValueError:
932 + # różne napędy / ścieżki nie da się wspólnie porównać → odrzuć
933 + return False
934 +
935 + for member in tar.getmembers():
936 + name = member.name
937 +
938 + # --- Ochrona przed Directory Traversal (szybkie string-checki) ---
939 + if name.startswith('/'):
940 + continue
941 + if '..' in name.split('/'):
942 + continue
943 + # Zablokuj bajt NUL i backslash (bugi/obejścia tarfile na niektórych platformach)
944 + if '\x00' in name or '\\' in name:
945 + continue
946 + if not _check_path_safety(name):
947 + print(f" BLOCKED: {name}")
948 + continue
949 +
950 + target = os.path.join(dest, name)
951 +
952 + # --- Ochrona na podstawie finalnej ścieżki ---
953 + # Jeśli którykolwiek komponent nadrzędny jest (złośliwym) symlinkiem
954 + # wskazującym poza dest, realpath to wykryje – zablokuj zapis.
955 + if not _target_within(target):
956 + print(f" BLOCKED (escape): {name}")
957 + continue
958 +
959 + # --- Ochrona dla symlinków i hardlinków ---
960 + if member.issym() or member.islnk():
961 + link = member.linkname
962 + # Szybkie odrzucenie linków absolutnych / z '..'
963 + if link.startswith('/') or '..' in link.split('/'):
964 + continue
965 + # Sprawdź, gdzie realnie prowadzi cel linku (względem katalogu linku)
966 + link_target = os.path.join(os.path.dirname(target), link)
967 + if not _target_within(link_target):
968 + print(f" BLOCKED (link escape): {name} -> {link}")
969 + continue
970 +
971 + # Rozpakuj z zachowaniem metadanych. Python 3.12+ wymaga jawnego
972 + # `filter=` (inaczej DeprecationWarning, w 3.14+ błąd) – nasza ręczna
973 + # walidacja powyżej już zabezpiecza ścieżki, więc 'fully_trusted'
974 + # (pomija filtr Pythona i zachowuje SUID/SGID/sticky z preserve_perms).
975 + try:
976 + if hasattr(tarfile, 'data_filter'):
977 + # Python 3.12+
978 + tar.extract(member, dest, set_attrs=preserve_perms, numeric_owner=False,
979 + filter='fully_trusted')
980 + else:
981 + tar.extract(member, dest, set_attrs=preserve_perms, numeric_owner=False)
982 + except Exception as e:
983 + print(f" ⚠ Nie rozpakowano {name}: {e}")
984 + continue
985 + _strip_suid(target)
986 +
987 +
988 +def _sha256_file(path: str) -> str:
989 + h = hashlib.sha256()
990 + with open(path, "rb") as f:
991 + for chunk in iter(lambda: f.read(65536), b""):
992 + h.update(chunk)
993 + return h.hexdigest()
994 +
995 +def _split_version(v: str):
996 + """Rozdziela wersję na (release_parts, prerelease_parts).
997 +
998 + Przykład: '1.2.0-rc1' → ([1,2,0], ['rc','1']).
999 + """
1000 + v = v.strip().lower().lstrip("v")
1001 + # build metadata po '+' jest ignorowane przy porównywaniu (semver)
1002 + v = v.split("+", 1)[0]
1003 + # prerelease po '-' lub '_' (np. 1.2.0-rc1, 1.2.0_rc1)
1004 + if "-" in v:
1005 + rel, pre = v.split("-", 1)
1006 + elif "_" in v:
1007 + rel, pre = v.split("_", 1)
1008 + else:
1009 + rel, pre = v, ""
1010 + nums = []
1011 + for part in rel.split("."):
1012 + m = re.match(r"(\d+)", part)
1013 + nums.append(int(m.group(1)) if m else 0)
1014 + pre_parts = [p for p in pre.split(".") if p]
1015 + return nums, pre_parts
1016 +
1017 +
1018 +def _cmp_pre(a, b):
1019 + """Porównuje ciągi identyfikatorów prerelease (reguły semver)."""
1020 + for i in range(max(len(a), len(b))):
1021 + if i >= len(a):
1022 + return -1 # krótszy prerelease jest niższy
1023 + if i >= len(b):
1024 + return 1
1025 + ia, ib = a[i], b[i]
1026 + if ia == ib:
1027 + continue
1028 + na, nb = ia.isdigit(), ib.isdigit()
1029 + if na and nb:
1030 + return 1 if int(ia) > int(ib) else -1
1031 + if na != nb:
1032 + return -1 if na else 1 # identyfikator liczbowy < alfanumeryczny
1033 + return 1 if ia > ib else -1
1034 + return 0
1035 +
1036 +
1037 +def _cmp_version(a: str, b: str) -> int:
1038 + """Porównuje dwie wersje; zwraca -1/0/1. Obsługuje prerelease (rc1, beta...)."""
1039 + a_rel, a_pre = _split_version(a)
1040 + b_rel, b_pre = _split_version(b)
1041 + # Porównaj część release (brakujące komponenty traktuj jako 0)
1042 + for i in range(max(len(a_rel), len(b_rel))):
1043 + xa = a_rel[i] if i < len(a_rel) else 0
1044 + xb = b_rel[i] if i < len(b_rel) else 0
1045 + if xa != xb:
1046 + return 1 if xa > xb else -1
1047 + # Część release równa → decyduje prerelease.
1048 + # Wersja finalna (bez prerelease) jest ZAWSZE nowsza od prerelease.
1049 + if not a_pre and not b_pre:
1050 + return 0
1051 + if not a_pre:
1052 + return 1
1053 + if not b_pre:
1054 + return -1
1055 + return _cmp_pre(a_pre, b_pre)
1056 +
1057 +
1058 +def _version_newer(a: str, b: str) -> bool:
1059 + """True gdy wersja a jest nowsza od b (z poprawną obsługą prerelease)."""
1060 + try:
1061 + return _cmp_version(a, b) > 0
1062 + except Exception:
1063 + return a != b
1064 +
1065 +def load_json(path):
1066 + try:
1067 + with open(path) as f:
1068 + return json.load(f)
1069 + except (FileNotFoundError, json.JSONDecodeError):
1070 + return {}
1071 +
1072 +def save_json(path, data):
1073 + with open(path, "w") as f:
1074 + json.dump(data, f, indent=2)
1075 +
1076 +class PackageInfo:
1077 + __slots__ = ("name","version","release","description","dependencies",
1078 + "size_bytes","sha256","gpg_fp","repo_url","filename","provides","license",
1079 + "provides_so","requires_so")
1080 + def __init__(self, d, repo=""):
1081 + self.name = d.get("name","?")
1082 + self.version = d.get("version","0")
1083 + self.release = d.get("release", 1)
1084 + self.description = d.get("description","")
1085 + self.dependencies = d.get("dependencies", d.get("depends", []))
1086 + self.size_bytes = d.get("size",0)
1087 + self.sha256 = d.get("sha256","")
1088 + self.gpg_fp = d.get("gpg_fingerprint","")
1089 + self.repo_url = repo
1090 + self.filename = d.get("filename", f"{self.name}-{self.version}{PKG_EXT}")
1091 + self.provides = d.get("provides", []) or []
1092 + self.license = d.get("license", []) or []
1093 + self.provides_so = d.get("provides_so", []) or []
1094 + self.requires_so = d.get("requires_so", []) or []
1095 +
1096 +# =============================================================================
1097 +# REPOZYTORIA (cache, ETag, GPG)
1098 +# =============================================================================
1099 +
1100 +def _parse_repos_config():
1101 + """Parsuje repozytoria z /etc/pag/repos.conf oraz /etc/pag/repos/*.conf.
1102 +
1103 + Format linii: <url> [fingerprint]
1104 + Opcjonalny `fingerprint` (40 znaków hex) pozwala przypiąć klucz
1105 + podpisujący repo do konkretnego adresu – wtedy TOFU (auto-zaufanie przy
1106 + pierwszym użyciu) nie jest potrzebne, a zmiana klucza = błąd bezpieczeństwa.
1107 +
1108 + Drop-iny (np. stable.conf) są czytane alfabetycznie – pozwalają na
1109 + wygodne dodawanie repo bez dotykania głównego repos.conf
1110 + (np. `echo 'https://repo.paganlinux.eu/stable' > /etc/pag/repos/stable.conf`).
1111 + """
1112 + entries = []
1113 +
1114 + def _read_lines(path):
1115 + if not os.path.exists(path):
1116 + return
1117 + for line in open(path):
1118 + line = line.strip()
1119 + if not line or line.startswith("#"):
1120 + continue
1121 + parts = line.split()
1122 + url = parts[0].rstrip("/")
1123 + fp = parts[1].lower() if len(parts) > 1 else ""
1124 + entries.append({"url": url, "fingerprint": fp or None})
1125 +
1126 + # 1) Legacy: pojedynczy plik /etc/pag/repos.conf
1127 + _read_lines(REPOS_CONF)
1128 + # 2) Drop-in: /etc/pag/repos/<nazwa>.conf (sortowane, stabilna kolejność)
1129 + if os.path.isdir(REPOS_DIR):
1130 + for drop in sorted(os.listdir(REPOS_DIR)):
1131 + if drop.endswith(".conf"):
1132 + _read_lines(os.path.join(REPOS_DIR, drop))
1133 +
1134 + # Dedupe po URL (zachowaj pierwszy wpis – może mieć fingerprint)
1135 + seen, unique = set(), []
1136 + for e in entries:
1137 + if e["url"] not in seen:
1138 + seen.add(e["url"])
1139 + unique.append(e)
1140 +
1141 + if not unique:
1142 + for url in DEFAULT_REPOS:
1143 + unique.append({"url": url, "fingerprint": None})
1144 + return unique
1145 +
1146 +
1147 +def get_repos():
1148 + return [e["url"] for e in _parse_repos_config()]
1149 +
1150 +
1151 +def _repo_pinned_fp(repo_url):
1152 + """Zwraca przypięty fingerprint klucza dla repo (z konfiguracji lub trust DB)."""
1153 + by_url = {e["url"]: e["fingerprint"] for e in _parse_repos_config()}
1154 + if by_url.get(repo_url):
1155 + return by_url[repo_url]
1156 + db = _load_trust_db()
1157 + fp = db.get(repo_url)
1158 + return fp.lower() if fp else None
1159 +
1160 +def _repo_cache_path(url):
1161 + return os.path.join(REPO_CACHE, url.replace("://","_").replace("/","_").replace(".","_") + ".json")
1162 +
1163 +def _repo_etag_path(url): return _repo_cache_path(url) + ".etag"
1164 +def _repo_ts_path(url): return _repo_cache_path(url) + ".ts"
1165 +
1166 +# Informacja (raz na uruchomienie), gdy cache repozytoriów jest tylko-do-odczytu –
1167 +# np. komendy read-only (`pag info`, `pag search`…) jako zwykły user: nie ma sensu
1168 +# ani prawa odświeżać /var/cache/pag/repos, więc używamy lokalnej kopii indeksu.
1169 +_cache_ro_notice_done = False
1170 +
1171 +def _cache_ro_notice():
1172 + global _cache_ro_notice_done
1173 + if _cache_ro_notice_done:
1174 + return
1175 + _cache_ro_notice_done = True
1176 + print(f" ⚠ {_('cache_ro', cache=REPO_CACHE)}", file=sys.stderr)
1177 +
1178 +def fetch_repo_index(repo_url, force=False):
1179 + cp = _repo_cache_path(repo_url)
1180 + ep = _repo_etag_path(repo_url)
1181 + tp = _repo_ts_path(repo_url)
1182 +
1183 + if not force and os.path.exists(cp) and os.path.exists(tp):
1184 + try:
1185 + if time.time() - float(open(tp).read().strip()) < REPO_CACHE_TTL:
1186 + return json.load(open(cp)).get("packages",[])
1187 + except: pass
1188 +
1189 + # --- Cache tylko-do-odczytu (np. `pag info` jako zwykły user) ---
1190 + # /var/cache/pag/repos należy do roota. Nie próbuj odświeżać ani pisać –
1191 + # zwykły user i tak nie zapisze indeksu; użyj lokalnej kopii (może być
1192 + # nieaktualna). Pełne odświeżenie indeksu: sudo pag sync
1193 + if not (os.path.isdir(REPO_CACHE) and os.access(REPO_CACHE, os.W_OK)):
1194 + if force:
1195 + print(f" ❌ {repo_url}: nie można odświeżyć indeksu – {REPO_CACHE} jest tylko-do-odczytu",
1196 + file=sys.stderr)
1197 + return None
1198 + _cache_ro_notice()
1199 + if os.path.exists(cp):
1200 + try:
1201 + return json.load(open(cp)).get("packages",[])
1202 + except Exception:
1203 + pass
1204 + return None
1205 +
1206 + headers = {"User-Agent": "pag/3.0"}
1207 + if os.path.exists(tp) and not force:
1208 + try:
1209 + lm = datetime.fromtimestamp(float(open(tp).read().strip()), tz=timezone.utc)
1210 + # Wymuś lokalizację C/POSIX dla nagłówków HTTP, aby unikać problemów z nazwami dni/miesięcy
1211 + try:
1212 + old_locale = locale.setlocale(locale.LC_TIME)
1213 + locale.setlocale(locale.LC_TIME, 'C')
1214 + headers["If-Modified-Since"] = lm.strftime("%a, %d %b %Y %H:%M:%S GMT")
1215 + locale.setlocale(locale.LC_TIME, old_locale)
1216 + except (locale.Error, ValueError):
1217 + # Jeśli ustawienie lokalizacji się nie powiedzie, użyj domyślnej
1218 + headers["If-Modified-Since"] = lm.strftime("%a, %d %b %Y %H:%M:%S GMT")
1219 + except: pass
1220 + if os.path.exists(ep) and not force:
1221 + try: headers["If-None-Match"] = open(ep).read().strip()
1222 + except: pass
1223 +
1224 + # --- Pobranie indeksu (błędy SIECI nie są błędami zapisu cache) ---
1225 + try:
1226 + req = Request(f"{repo_url}/repo.json", headers=headers)
1227 + with urlopen(req, timeout=30) as resp:
1228 + etag = resp.headers.get("ETag","")
1229 + raw = resp.read()
1230 + data = json.loads(raw.decode())
1231 + except HTTPError as e:
1232 + if e.code == 304:
1233 + # Serwer: indeks bez zmian – odśwież tylko znacznik czasu (best-effort)
1234 + try:
1235 + open(tp,"w").write(str(time.time()))
1236 + except OSError:
1237 + pass
1238 + if os.path.exists(cp):
1239 + try:
1240 + return json.load(open(cp)).get("packages",[])
1241 + except Exception:
1242 + pass # uszkodzona kopia – potraktuj jak brak (ostrzeżenie niżej)
1243 + print(f" ⚠ HTTP {e.code} dla {repo_url}", file=sys.stderr)
1244 + return None
1245 + except Exception as e:
1246 + print(f" ⚠ Błąd pobierania indeksu {repo_url}: {e}", file=sys.stderr)
1247 + if os.path.exists(cp):
1248 + try:
1249 + return json.load(open(cp)).get("packages",[])
1250 + except Exception:
1251 + pass
1252 + return None
1253 +
1254 + # Indeks pobrany – zapisz SUROWE bajty (nie re-serializuj! podpis GPG jest
1255 + # nad oryginalnymi bajtami repo.json z serwera) i zweryfikuj podpis.
1256 + # Najpierw zapis tymczasowy + weryfikacja GPG, dopiero potem podmiana cp:
1257 + # błąd zapisu (np. pełny dysk) nie niszczy starej, zweryfikowanej kopii
1258 + # i NIGDY nie zwracamy danych, które nie przeszły weryfikacji.
1259 + tmp_path = cp + ".tmp"
1260 + try:
1261 + with open(tmp_path, "wb") as f:
1262 + f.write(raw)
1263 + if not _verify_repo_sig(repo_url, tmp_path):
1264 + return None # weryfikacja nie powiodła się – stary cache zostaje
1265 + os.replace(tmp_path, cp)
1266 + # przenieś podpis obok docelowego pliku (marker „repo ma podpis")
1267 + for _ext in (".asc", ".sig"):
1268 + if os.path.exists(tmp_path + _ext):
1269 + try:
1270 + os.replace(tmp_path + _ext, cp + _ext)
1271 + except OSError:
1272 + pass
1273 + break
1274 + if etag:
1275 + try:
1276 + open(ep,"w").write(etag)
1277 + except OSError:
1278 + pass
1279 + try:
1280 + open(tp,"w").write(str(time.time()))
1281 + except OSError:
1282 + pass
1283 + return data.get("packages",[])
1284 + except OSError as e:
1285 + print(f" ⚠ Indeks pobrany, ale nie udało się zapisać cache ({REPO_CACHE}): {e}",
1286 + file=sys.stderr)
1287 + # cp nie został podmieniony (podmiana jest po weryfikacji) – lokalna kopia
1288 + # to wciąż stare, zweryfikowane dane
1289 + if os.path.exists(cp):
1290 + try:
1291 + return json.load(open(cp)).get("packages",[])
1292 + except Exception:
1293 + pass
1294 + return None
1295 + finally:
1296 + for _p in (tmp_path, tmp_path + ".asc", tmp_path + ".sig"):
1297 + try:
1298 + os.unlink(_p)
1299 + except OSError:
1300 + pass
1301 +
1302 +def _verify_repo_sig(repo_url, cache_path) -> bool:
1303 + """Weryfikuje podpis GPG indeksu repozytorium i przypina fingerprint.
1304 +
1305 + FAIL-CLOSED: brak/nieprawidłowy podpis = False (chyba że PAG_INSECURE=1).
1306 + Zwraca True jeśli indeks jest zaufany, False jeśli należy go odrzucić.
1307 +
1308 + Model zaufania (TOFU + pinning):
1309 + - Pierwszy raz (brak przypiętego fingerprintu) → klucz jest importowany,
1310 + a fingerprint zapisywany w /etc/pag/trusted.json z JAWNYM ostrzeżeniem.
1311 + To świadomy kompromis wygody i bezpieczeństwa.
1312 + - Kolejne uruchomienia: fingerprint jest porównywany z przypiętym.
1313 + Zmiana klucza = ❌ SECURITY ERROR (fail-closed), wymagane ręczne:
1314 + pag key-trust <repo_url> (po weryfikacji nowego klucza)
1315 + """
1316 + insecure = os.environ.get("PAG_INSECURE", "") == "1"
1317 +
1318 + if not os.path.exists(GPG_HOME):
1319 + if insecure:
1320 + return True # brak GPG home – tryb insecure, akceptuj
1321 + print(f" ❌ {repo_url}: brak kluczy GPG – weryfikacja niemożliwa!")
1322 + print(f" Ustaw PAG_INSECURE=1 aby pominąć (niezalecane)")
1323 + os.remove(cache_path)
1324 + return False
1325 +
1326 + sig_path = cache_path + ".sig"
1327 + # Podpisy generowane jako .asc (armored) – próbuj .asc, potem .sig
1328 + sig_data = None
1329 + sig_ext = ""
1330 + for ext in (".asc", ".sig"):
1331 + try:
1332 + req = Request(f"{repo_url}/repo.json{ext}", headers={"User-Agent":"pag/3.0"})
1333 + with urlopen(req, timeout=15) as resp:
1334 + sig_data = resp.read()
1335 + sig_ext = ext
1336 + break
1337 + except Exception:
1338 + continue
1339 + if not sig_data:
1340 + if insecure:
1341 + return True # tryb insecure – akceptuj bez podpisu
1342 + print(f" ❌ {repo_url}: NIE MOŻNA POBRAĆ PODPISU repo.json.asc/.sig!")
1343 + print(f" Ustaw PAG_INSECURE=1 aby pominąć (niezalecane)")
1344 + os.remove(cache_path)
1345 + return False
1346 + sig_path = cache_path + sig_ext
1347 + with open(sig_path, "wb") as f:
1348 + f.write(sig_data)
1349 +
1350 + ok, fingerprint = _gpg_verify_fp(sig_path, cache_path)
1351 + if not ok:
1352 + # Automatyczny import klucza repo przy pierwszym uruchomieniu (TOFU,
1353 + # jak apt) – gdy w keyringu brakuje klucza (No public key).
1354 + res = _gpg_run("--verify", sig_path, cache_path,
1355 + capture_output=True, text=True, timeout=30)
1356 + _stderr = res.stderr.decode(errors="replace") if isinstance(res.stderr, bytes) else (res.stderr or "")
1357 + if "public key" in _stderr.lower() and "no public key" in _stderr.lower():
1358 + try:
1359 + with urlopen(Request(f"{repo_url}/paganos.asc", headers={"User-Agent": "pag/3.0"}), timeout=20) as r:
1360 + keydata = r.read()
1361 + with tempfile.NamedTemporaryFile(delete=False, suffix=".asc") as tmp:
1362 + tmp.write(keydata)
1363 + tmp.flush()
1364 + _gpg_run("--import", tmp.name, capture_output=True, timeout=30)
1365 + os.unlink(tmp.name)
1366 + print(f" 🔑 Importowano klucz repo z {repo_url}/paganos.asc")
1367 + ok, fingerprint = _gpg_verify_fp(sig_path, cache_path)
1368 + except Exception:
1369 + pass
1370 + if not ok:
1371 + if insecure:
1372 + print(f" ⚠ {repo_url}: nieprawidłowy podpis GPG (PAG_INSECURE – ignoruję)")
1373 + return True
1374 + os.remove(cache_path)
1375 + if not shutil.which(GPG_BINARY):
1376 + print(f" ❌ {repo_url}: GPG nie jest zainstalowane – nie można zweryfikować podpisu!")
1377 + print(f" Zainstaluj gnupg lub ustaw PAG_INSECURE=1 (niezalecane)")
1378 + else:
1379 + print(f" ❌ {repo_url}: NIEPRAWIDŁOWY PODPIS GPG indeksu repozytorium!")
1380 + return False
1381 +
1382 + # --- Wymuś przypięty fingerprint (TOFU + pinning) ---
1383 + pinned = _repo_pinned_fp(repo_url)
1384 + if pinned:
1385 + if not fingerprint:
1386 + if insecure:
1387 + print(f" ⚠ {repo_url}: nie można odczytać fingerprintu (PAG_INSECURE – ignoruję)")
1388 + return True
1389 + os.remove(cache_path)
1390 + print(f" ❌ [SECURITY ERROR] {repo_url}: nie można odczytać fingerprintu podpisu!")
1391 + print(f" Przypięty klucz: {pinned} – odrzucam indeks.")
1392 + return False
1393 + if fingerprint != pinned.upper():
1394 + if insecure:
1395 + print(f" ⚠ {repo_url}: ZMIENIONY KLUCZ PODPISU (PAG_INSECURE – ignoruję)")
1396 + return True
1397 + os.remove(cache_path)
1398 + print(f" ❌ [SECURITY ERROR] {repo_url}: Klucz podpisujący repo uległ zmianie!")
1399 + print(f" Oczekiwany: {pinned}")
1400 + print(f" Otrzymany: {fingerprint}")
1401 + print(f" Jeśli to celowa rotacja klucza: pag key-trust {repo_url}")
1402 + return False
1403 + return True
1404 +
1405 + if fingerprint:
1406 + # Brak przypiętego fingerprintu → TOFU: zapisz go w bazie zaufania.
1407 + db = _load_trust_db()
1408 + if db.get(repo_url) != fingerprint:
1409 + _save_trust_db({**db, repo_url: fingerprint})
1410 + print(f" 🔐 Przypięto fingerprint repo {repo_url}: {fingerprint}")
1411 + print(f" (TOFU – pierwsze zaufanie. Gdy klucz się zmieni, pag odmówi aktualizacji.)")
1412 + print(f" Aby uniknąć TOFU, dopisz fingerprint w /etc/pag/repos.conf.")
1413 + return True
1414 +
1415 +def fetch_all_packages(force=False):
1416 + all_pkgs = {}
1417 + for repo_url in get_repos():
1418 + pkgs = fetch_repo_index(repo_url, force)
1419 + if pkgs:
1420 + for pdata in pkgs:
1421 + name = pdata.get("name", pdata.get("filename","?").split("-")[0])
1422 + pkg = PackageInfo(pdata, repo_url)
1423 + if name not in all_pkgs or _version_newer(pkg.version, all_pkgs[name].version):
1424 + all_pkgs[name] = pkg
1425 + return all_pkgs
1426 +
1427 +# =============================================================================
1428 +# GPG
1429 +# =============================================================================
1430 +
1431 +def _verify_pkg_gpg(pkg_path, repo_url=None):
1432 + """Weryfikuje podpis GPG pakietu i (jeśli znamy repo) przypięty fingerprint.
1433 +
1434 + FAIL-CLOSED: brak podpisu = odrzucenie (chyba że PAG_INSECURE=1).
1435 + Zwraca (passed: bool, message: str).
1436 + """
1437 + insecure = os.environ.get("PAG_INSECURE", "") == "1"
1438 + # Brak gnupg = weryfikacja niemożliwa. Bez tej gałęzi użytkownik dostawał
1439 + # mylące „NIEPRAWIDŁOWY PODPIS GPG”, mimo że paczka i podpis są w porządku.
1440 + if not shutil.which(GPG_BINARY):
1441 + if insecure:
1442 + return True, "(gpg missing – PAG_INSECURE)"
1443 + return False, _("gpg_missing")
1444 + sig_path = pkg_path + ".sig"
1445 + if not os.path.exists(sig_path) and os.path.exists(pkg_path + ".asc"):
1446 + sig_path = pkg_path + ".asc"
1447 +
1448 + if not os.path.exists(sig_path):
1449 + if insecure:
1450 + return True, "(no signature – PAG_INSECURE)"
1451 + return False, "BRAK PODPISU – pakiet odrzucony (ustaw PAG_INSECURE=1 aby pominąć)"
1452 +
1453 + ok, fp = _gpg_verify_fp(sig_path, pkg_path)
1454 + if not ok:
1455 + if insecure:
1456 + return True, "(invalid signature – PAG_INSECURE)"
1457 + return False, "NIEPRAWIDŁOWY PODPIS GPG"
1458 +
1459 + # Opcjonalnie: sprawdź, czy podpis pochodzi od klucza przypiętego dla repo.
1460 + if repo_url:
1461 + pinned = _repo_pinned_fp(repo_url)
1462 + if pinned and fp and fp != pinned.upper():
1463 + if insecure:
1464 + return True, "(pkg signer mismatch – PAG_INSECURE)"
1465 + return False, f"PAKIET PODPISANY INNYM KLUCZEM niż repo (oczekiwano {pinned})"
1466 +
1467 + return True, "GPG verified"
1468 +
1469 +def cmd_key_add(source):
1470 + ensure_dirs()
1471 + if not shutil.which(GPG_BINARY):
1472 + print(f"❌ {_('gpg_missing')}"); return 1
1473 + if source.startswith("http"):
1474 + try:
1475 + with urlopen(Request(source, headers={"User-Agent":"pag/3.0"}), timeout=30) as resp:
1476 + keydata = resp.read()
1477 + with tempfile.NamedTemporaryFile(delete=False, suffix=".gpg") as tmp:
1478 + tmp.write(keydata); tmp.flush()
1479 + res = _gpg_run("--import", tmp.name, capture_output=True, timeout=30)
1480 + os.unlink(tmp.name)
1481 + if res.returncode != 0:
1482 + print(f"❌ {_('key_add_failed')}"); return 1
1483 + except Exception as e:
1484 + print(f"❌ Download error: {e}"); return 1
1485 + else:
1486 + res = _gpg_run("--import", source, capture_output=True, timeout=30)
1487 + if res.returncode != 0:
1488 + print(f"❌ {_('key_add_failed')}"); return 1
1489 + print(f"✅ {_('key_imported')}")
1490 +
1491 +def cmd_key_list():
1492 + if not shutil.which(GPG_BINARY):
1493 + print(f"❌ {_('gpg_missing')}"); return
1494 + if not os.path.exists(GPG_HOME):
1495 + print(_("no_keys")); return
1496 + result = _gpg_run("--list-keys", "--keyid-format", "LONG",
1497 + capture_output=True, text=True, timeout=30)
1498 + if result.returncode != 0:
1499 + print(f"❌ {_('gpg_missing')}"); return
1500 + print(result.stdout or _("no_keys"))
1501 +
1502 +def cmd_key_remove(key_id):
1503 + _gpg_run("--batch", "--yes", "--delete-key", key_id,
1504 + capture_output=True, timeout=30)
1505 + print(f"✅ {_('key_removed', key_id)}")
1506 +
1507 +def _repo_signer_fp(repo_url):
1508 + """Pobiera repo.json + podpis i zwraca fingerprint podpisującego (bez pinningu)."""
1509 + repo_url = repo_url.rstrip("/")
1510 + try:
1511 + with urlopen(Request(f"{repo_url}/repo.json", headers={"User-Agent":"pag/3.0"}), timeout=30) as r:
1512 + data = r.read()
1513 + except Exception:
1514 + return None
1515 + sig = None
1516 + sig_ext = ".asc"
1517 + for ext in (".asc", ".sig"):
1518 + try:
1519 + with urlopen(Request(f"{repo_url}/repo.json{ext}", headers={"User-Agent":"pag/3.0"}), timeout=20) as r:
1520 + sig = r.read()
1521 + sig_ext = ext
1522 + break
1523 + except Exception:
1524 + continue
1525 + if not sig:
1526 + return None
1527 + with tempfile.NamedTemporaryFile(delete=False, suffix=".json") as tf:
1528 + tf.write(data); tf.flush()
1529 + data_path = tf.name
1530 + sig_path = data_path + sig_ext
1531 + try:
1532 + with open(sig_path, "wb") as f:
1533 + f.write(sig)
1534 + ok, fp = _gpg_verify_fp(sig_path, data_path)
1535 + finally:
1536 + for p in (data_path, sig_path):
1537 + try: os.unlink(p)
1538 + except OSError: pass
1539 + return fp if ok else None
1540 +
1541 +
1542 +def cmd_key_trust(repo_url):
1543 + """Przypina fingerprint klucza podpisującego repo (koniec z TOFU dla tego repo)."""
1544 + repo_url = repo_url.rstrip("/")
1545 + print(f"🔐 Przypinam klucz repo {repo_url}...")
1546 + fp = _repo_signer_fp(repo_url)
1547 + if not fp:
1548 + print(" ❌ Nie można odczytać fingerprintu podpisu (brak/nieudany).")
1549 + print(" Upewnij się, że klucz repo jest w keyringu (pag key-add <url|file>).")
1550 + return 1
1551 + db = _load_trust_db()
1552 + _save_trust_db({**db, repo_url: fp})
1553 + print(f" ✅ Przypięto {fp} dla {repo_url}")
1554 + print(" Od teraz zmiana klucza zostanie zgłoszona jako SECURITY ERROR.")
1555 + return 0
1556 +
1557 +
1558 +def cmd_key_untrust(repo_url):
1559 + """Usuwa przypięcie fingerprintu dla repo (wraca do TOFU)."""
1560 + repo_url = repo_url.rstrip("/")
1561 + db = _load_trust_db()
1562 + if repo_url not in db:
1563 + print(f" ℹ {repo_url} nie ma przypiętego fingerprintu.")
1564 + return 0
1565 + del db[repo_url]
1566 + _save_trust_db(db)
1567 + print(f" ✅ Usunięto przypięcie dla {repo_url}.")
1568 + return 0
1569 +
1570 +
1571 +def cmd_key_trusted():
1572 + """Listuje przypięte fingerprinty repozytoriów."""
1573 + db = _load_trust_db()
1574 + if not db:
1575 + print(_("no_keys"))
1576 + return
1577 + for url, fp in sorted(db.items()):
1578 + print(f" {url}\n {fp}")
1579 +
1580 +# =============================================================================
1581 +# ATOMOWA INSTALACJA (STAGING)
1582 +# =============================================================================
1583 +
1584 +def _safe_rename(src: str, dst: str) -> bool:
1585 + """
1586 + Atomowe przeniesienie pliku. Jeśli src i dst są na różnych
1587 + systemach plików (EXDEV), kopiuje + usuwa źródło.
1588 + """
1589 + try:
1590 + os.rename(src, dst)
1591 + return True
1592 + except OSError as e:
1593 + if e.errno == 18: # EXDEV – cross-device link
1594 + shutil.copy2(src, dst)
1595 + os.remove(src)
1596 + return True
1597 + raise
1598 +
1599 +
1600 +def _install_file(src: str, rel: str, data_staging: str, sums: dict,
1601 + staging: str, journal: list, installed_files: list,
1602 + deploy_dir: str = "", backup_dir: str = "",
1603 + backup_journal: Optional[list] = None) -> bool:
1604 + """
1605 + Instaluje pojedynczy plik (zwykły lub symlink).
1606 + Obsługuje: cross-device rename, symlinki, weryfikację SHA256.
1607 +
1608 + Jeśli deploy_dir jest podany (tryb immutable), pliki systemowe trafiają
1609 + do deploymentu, a współdzielone (/var, /etc, ...) bezpośrednio do /.
1610 +
1611 + Jeśli backup_dir jest podany, a pod dst istnieje już plik (upgrade/reinstall),
1612 + stara wersja jest przenoszona do backup_dir, by rollback mógł ją przywrócić.
1613 + """
1614 + # W trybie immutable: pliki współdzielone idą do /, reszta do deploymentu
1615 + if deploy_dir and _is_shared_path("/" + rel):
1616 + dst_root = PAG_ROOT
1617 + elif deploy_dir:
1618 + dst_root = deploy_dir
1619 + else:
1620 + dst_root = PAG_ROOT
1621 +
1622 + dst = os.path.join(dst_root, rel)
1623 +
1624 + # --- SYMLINK ---
1625 + if os.path.islink(src):
1626 + link_target = os.readlink(src)
1627 + # Weryfikuj sums.json dla symlinka (hash ścieżki docelowej)
1628 + expected = sums.get("/" + rel, "")
1629 + if expected:
1630 + link_hash = hashlib.sha256(link_target.encode()).hexdigest()
1631 + if expected and link_hash != expected:
1632 + return False
1633 +
1634 + os.makedirs(os.path.dirname(dst), exist_ok=True)
1635 + # Backup istniejącego symlinka (upgrade) – dla poprawnego rollbacku
1636 + if backup_dir and backup_journal is not None and os.path.lexists(dst):
1637 + try:
1638 + backup_path = os.path.join(backup_dir, rel)
1639 + os.makedirs(os.path.dirname(backup_path), exist_ok=True)
1640 + os.replace(dst, backup_path)
1641 + backup_journal.append((backup_path, "/" + rel))
1642 + journal.append(("backup", backup_path, dst))
1643 + except OSError:
1644 + pass
1645 + # Jeśli docelowy symlink już istnieje, usuń go
1646 + if os.path.islink(dst) or os.path.exists(dst):
1647 + os.remove(dst)
1648 + os.symlink(link_target, dst)
1649 + journal.append(("symlink", "", dst))
1650 + installed_files.append({
1651 + "path": "/" + rel,
1652 + "sha256": hashlib.sha256(link_target.encode()).hexdigest(),
1653 + "size": len(link_target),
1654 + "is_symlink": True,
1655 + "symlink_target": link_target,
1656 + })
1657 + return True
1658 +
1659 + # --- ZWYKŁY PLIK ---
1660 + # Oblicz SHA256
1661 + try:
1662 + file_sha = _sha256_file(src)
1663 + except Exception:
1664 + file_sha = ""
1665 +
1666 + # Weryfikuj sums.json
1667 + expected = sums.get("/" + rel, "")
1668 + if expected and file_sha and file_sha != expected:
1669 + return False
1670 +
1671 + # Utwórz katalog docelowy
1672 + os.makedirs(os.path.dirname(dst), exist_ok=True)
1673 +
1674 + # Backup istniejącego pliku (upgrade) – dla poprawnego rollbacku
1675 + if backup_dir and backup_journal is not None and os.path.lexists(dst):
1676 + try:
1677 + backup_path = os.path.join(backup_dir, rel)
1678 + os.makedirs(os.path.dirname(backup_path), exist_ok=True)
1679 + os.replace(dst, backup_path)
1680 + backup_journal.append((backup_path, "/" + rel))
1681 + journal.append(("backup", backup_path, dst))
1682 + except OSError:
1683 + pass
1684 +
1685 + # Atomowe przeniesienie (z fallbackiem dla cross-device).
1686 + # Zachowuje bity uprawnień (SUID/SGID/sticky) – NIE używamy filter='data'.
1687 + _safe_rename(src, dst)
1688 +
1689 + # Wymuś właściciela root:root. UWAGA: os.chown() NIE czyści bitów SUID/SGID.
1690 + try:
1691 + os.chown(dst, 0, 0)
1692 + except (OSError, PermissionError):
1693 + # Na niektórych systemach plików (tmpfs, fat) chown może się nie powieść
1694 + pass
1695 +
1696 + journal.append(("file", src, dst))
1697 + installed_files.append({
1698 + "path": "/" + rel,
1699 + "sha256": file_sha,
1700 + "size": os.path.getsize(dst),
1701 + "is_symlink": False,
1702 + })
1703 + return True
1704 +
1705 +
1706 +def _atomic_install(pkg_path: str, pkg: PackageInfo, deploy_dir: str = "",
1707 + backup_dir: str = "") -> Tuple[bool, List[dict], List[Tuple[str, str]]]:
1708 + """
1709 + Rozpakowuje do staging area, potem atomowo przenosi pliki.
1710 + Jeśli deploy_dir podany – instaluje do deploymentu (tryb immutable).
1711 + Zwraca (success, [lista plików z SHA256], [(backup_path, dst), ...]).
1712 + """
1713 + staging = tempfile.mkdtemp(dir=STAGING_DIR, prefix=f".staging-{pkg.name}-")
1714 + journal = []
1715 + installed_files = []
1716 + backup_journal: List[Tuple[str, str]] = []
1717 +
1718 + try:
1719 + # Rozpakuj .pkg.tar.xz → staging (bezpieczne – ochrona Directory Traversal)
1720 + with tarfile.open(pkg_path, "r:xz") as tf:
1721 + _safe_extractall(tf, staging)
1722 +
1723 + data_tar = os.path.join(staging, "data.tar.xz")
1724 + if not os.path.exists(data_tar):
1725 + shutil.rmtree(staging, ignore_errors=True)
1726 + return False, [], backup_journal
1727 +
1728 + # Rozpakuj data.tar.xz → staging/data (bezpieczne – ochrona Directory Traversal)
1729 + data_staging = os.path.join(staging, "data")
1730 + os.makedirs(data_staging, exist_ok=True)
1731 + with tarfile.open(data_tar, "r:xz") as tf:
1732 + _safe_extractall(tf, data_staging)
1733 +
1734 + # Wczytaj sums.json
1735 + sums_path = os.path.join(data_staging, "sums.json")
1736 + sums = json.load(open(sums_path)) if os.path.exists(sums_path) else {}
1737 +
1738 + # Hook pre-install (przed przeniesieniem plików do systemu)
1739 + _run_hook(os.path.join(staging, "hooks"), "pre-install", pkg)
1740 +
1741 + # Przenieś pliki: staging/data/* → /
1742 + for root, dirs, files in os.walk(data_staging):
1743 + # Odtwórz katalogi z pakietu – w tym PUSTE (np. /etc/pulse/default.pa.d).
1744 + # Pętla plików tworzy tylko rodziców instalowanych plików, przez co
1745 + # puste katalogi z data.tar.xz ginęły przy instalacji.
1746 + for d in dirs:
1747 + src_dir = os.path.join(root, d)
1748 + rel_dir = os.path.relpath(src_dir, data_staging)
1749 + if deploy_dir and _is_shared_path("/" + rel_dir):
1750 + dst_root = PAG_ROOT
1751 + elif deploy_dir:
1752 + dst_root = deploy_dir
1753 + else:
1754 + dst_root = PAG_ROOT
1755 + dst_dir = os.path.join(dst_root, rel_dir)
1756 + if not os.path.isdir(dst_dir):
1757 + try:
1758 + os.makedirs(dst_dir, exist_ok=True)
1759 + except OSError:
1760 + pass
1761 + for fname in files:
1762 + if fname == "sums.json":
1763 + continue
1764 + src = os.path.join(root, fname)
1765 + rel = os.path.relpath(src, data_staging)
1766 +
1767 + ok = _install_file(src, rel, data_staging, sums,
1768 + staging, journal, installed_files, deploy_dir,
1769 + backup_dir, backup_journal)
1770 + if not ok:
1771 + # Cofnij wszystkie operacje
1772 + _rollback_journal(journal, staging)
1773 + return False, [], backup_journal
1774 +
1775 + # Odbuduj cache ikon GTK dla motywów dotkniętych instalacją.
1776 + # Bez icon-theme.cache aplikacje GTK nie widzą ikon mimo obecności
1777 + # motywu (np. /usr/share/icons/Papirus). Pomijamy, gdy narzędzie
1778 + # nie jest zainstalowane.
1779 + _icon_dirs = set()
1780 + for f in installed_files:
1781 + fp = f.get("path", "") or ""
1782 + if fp.startswith("/usr/share/icons/"):
1783 + _rest = fp[len("/usr/share/icons/"):]
1784 + _theme = _rest.split("/", 1)[0]
1785 + if _theme:
1786 + _icon_dirs.add(os.path.join(PAG_ROOT, "usr/share/icons", _theme))
1787 + if _icon_dirs:
1788 + try:
1789 + subprocess.run(["gtk-update-icon-cache", "--version"],
1790 + capture_output=True, timeout=10)
1791 + for _d in sorted(_icon_dirs):
1792 + if os.path.isdir(_d):
1793 + subprocess.run(["gtk-update-icon-cache", "-f", "-q", _d],
1794 + capture_output=True, timeout=300)
1795 + except Exception:
1796 + pass
1797 +
1798 + # Uruchom hooki post-install
1799 + hooks_dir = os.path.join(staging, "hooks")
1800 + _run_hook(hooks_dir, "post-install", pkg)
1801 +
1802 + # Zachowaj hooki na wypadek usunięcia pakietu (pre/post-remove)
1803 + try:
1804 + if os.path.isdir(hooks_dir):
1805 + persisted = os.path.join(PAG_DB, "hooks", pkg.name)
1806 + shutil.rmtree(persisted, ignore_errors=True)
1807 + shutil.copytree(hooks_dir, persisted)
1808 + except Exception:
1809 + pass
1810 +
1811 + # Zapisz do SQLite
1812 + _db_record_files(pkg.name, installed_files)
1813 +
1814 + shutil.rmtree(staging, ignore_errors=True)
1815 + return True, installed_files, backup_journal
1816 +
1817 + except Exception as e:
1818 + _rollback_journal(journal, staging)
1819 + return False, [], backup_journal
1820 +
1821 +
1822 +def _refresh_dynamic_linker_cache(deploy_dir: str = "") -> bool:
1823 + """Odświeża cache ld.so po udanej instalacji pakietów."""
1824 + ldconfig = shutil.which("ldconfig")
1825 + if not ldconfig:
1826 + print(" ⚠ Nie znaleziono ldconfig — cache linkera nie został odświeżony.",
1827 + file=sys.stderr)
1828 + return False
1829 +
1830 + target_root = deploy_dir or PAG_ROOT
1831 + command = [ldconfig]
1832 + if target_root != "/":
1833 + command.extend(["-r", target_root])
1834 +
1835 + try:
1836 + subprocess.run(command, check=True, timeout=60,
1837 + stdout=subprocess.DEVNULL, stderr=subprocess.PIPE,
1838 + text=True)
1839 + return True
1840 + except (OSError, subprocess.CalledProcessError, subprocess.TimeoutExpired) as exc:
1841 + detail = getattr(exc, "stderr", None) or str(exc)
1842 + print(f" ⚠ Nie udało się odświeżyć cache'a ld.so: {detail.strip()}",
1843 + file=sys.stderr)
1844 + return False
1845 +
1846 +
1847 +def _rollback_journal(journal: list, staging_path: str):
1848 + """Cofa wszystkie operacje z journala (odwrotna kolejność)."""
1849 + for entry in reversed(journal):
1850 + op = entry[0]
1851 + if op == "file":
1852 + _, src, dst = entry
1853 + try:
1854 + if os.path.exists(dst) or os.path.islink(dst):
1855 + _safe_rename(dst, src)
1856 + except Exception:
1857 + pass
1858 + elif op == "symlink":
1859 + _, _, dst = entry
1860 + try:
1861 + if os.path.islink(dst) or os.path.exists(dst):
1862 + os.remove(dst)
1863 + except Exception:
1864 + pass
1865 + elif op == "backup":
1866 + # Przywróć starą wersję pliku z backupu (upgrade)
1867 + _, bpath, dst = entry
1868 + try:
1869 + if os.path.lexists(bpath):
1870 + os.replace(bpath, dst)
1871 + except Exception:
1872 + pass
1873 + shutil.rmtree(staging_path, ignore_errors=True)
1874 +
1875 +# =============================================================================
1876 +# BEZPIECZNE USUWANIE
1877 +# =============================================================================
1878 +
1879 +def _safe_remove_files(pkg_name: str, installed_db: dict) -> Tuple[int, List[str]]:
1880 + """
1881 + Usuwa pliki pakietu, ale tylko jeśli NIE są współdzielone z innym pakietem.
1882 + Zwraca (liczba usuniętych, [lista usuniętych ścieżek]).
1883 + """
1884 + pkg_files = _db_get_package_files(pkg_name)
1885 + removed = []
1886 + skipped_shared = []
1887 +
1888 + for fpath in pkg_files:
1889 + owners = _db_get_file_owners(fpath)
1890 + # Sprawdź czy inny ZAINSTALOWANY pakiet też jest właścicielem
1891 + other_owners = [o for o in owners if o != pkg_name and o in installed_db]
1892 +
1893 + if other_owners:
1894 + # Plik współdzielony – tylko usuń wpis w DB, nie kasuj pliku
1895 + skipped_shared.append(fpath)
1896 + continue
1897 +
1898 + full = os.path.join(PAG_ROOT, fpath.lstrip("/"))
1899 + if os.path.isfile(full) or os.path.islink(full):
1900 + os.remove(full)
1901 + removed.append(fpath)
1902 +
1903 + # Usuń puste katalogi (od najgłębszych)
1904 + dirs = set()
1905 + for fpath in removed + skipped_shared:
1906 + parent = os.path.dirname(fpath)
1907 + while parent and parent != "/":
1908 + dirs.add(parent)
1909 + parent = os.path.dirname(parent)
1910 +
1911 + for d in sorted(dirs, key=len, reverse=True):
1912 + full_d = os.path.join(PAG_ROOT, d.lstrip("/"))
1913 + if os.path.isdir(full_d):
1914 + try:
1915 + os.rmdir(full_d)
1916 + except OSError:
1917 + pass # nie jest pusty – OK
1918 +
1919 + # Usuń z SQLite
1920 + _db_remove_package_files(pkg_name)
1921 +
1922 + if skipped_shared:
1923 + print(f" ⚠ {len(skipped_shared)} plików współdzielonych zachowanych")
1924 +
1925 + return len(removed) + len(skipped_shared), removed
1926 +
1927 +
1928 +def _remove_stale_files(pkg_name: str, old_files: List[str], new_paths: List[str],
1929 + installed_db: dict, deploy_dir: str = "",
1930 + backup_dir: str = "", backup_journal: Optional[list] = None) -> Tuple[int, List[str]]:
1931 + """
1932 + Po upgrade usuwa pliki starej wersji, których nie ma w nowej.
1933 +
1934 + - Pliki współdzielone z innym zainstalowanym pakietem są ZACHOWYWANE
1935 + (usuwany jest tylko wpis z bazy `files` dla tego pakietu).
1936 + - Sprząta puste katalogi i wpisy SQLite starej wersji.
1937 + Zwraca (liczba usuniętych, [usunięte ścieżki]).
1938 + """
1939 + new_set = set(new_paths)
1940 + stale = [f for f in old_files if f not in new_set]
1941 + if not stale:
1942 + return 0, []
1943 +
1944 + root = deploy_dir or PAG_ROOT
1945 + removed = []
1946 + skipped = 0
1947 + for fpath in stale:
1948 + owners = _db_get_file_owners(fpath)
1949 + other_owners = [o for o in owners if o != pkg_name and o in installed_db]
1950 + if other_owners:
1951 + # Współdzielony z innym pakietem – tylko usuń wpis z DB dla tego pakietu
1952 + skipped += 1
1953 + else:
1954 + full = os.path.join(root, fpath.lstrip("/"))
1955 + if os.path.isfile(full) or os.path.islink(full):
1956 + try:
1957 + if backup_dir and backup_journal is not None:
1958 + backup_path = os.path.join(backup_dir, fpath.lstrip("/"))
1959 + os.makedirs(os.path.dirname(backup_path), exist_ok=True)
1960 + os.replace(full, backup_path) # przenieś do backupu (rollback)
1961 + backup_journal.append((backup_path, fpath))
1962 + else:
1963 + os.remove(full)
1964 + removed.append(fpath)
1965 + except OSError:
1966 + pass
1967 + # Usuń wpis `files` dla tego pakietu (stara wersja już go nie zawiera)
1968 + with _db_session() as db:
1969 + db.execute("DELETE FROM files WHERE package=? AND path=?", (pkg_name, fpath))
1970 +
1971 + # Usuń puste katalogi (od najgłębszych)
1972 + dirs = set()
1973 + for fpath in removed:
1974 + parent = os.path.dirname(fpath)
1975 + while parent and parent != "/":
1976 + dirs.add(parent)
1977 + parent = os.path.dirname(parent)
1978 + for d in sorted(dirs, key=len, reverse=True):
1979 + full_d = os.path.join(root, d.lstrip("/"))
1980 + if os.path.isdir(full_d):
1981 + try:
1982 + os.rmdir(full_d)
1983 + except OSError:
1984 + pass # nie jest pusty – OK
1985 +
1986 + if removed:
1987 + print(f" 🧹 Usunięto {len(removed)} nieaktualnych plików ({pkg_name})")
1988 + if skipped:
1989 + print(f" ⚠ {skipped} plików współdzielonych zachowanych")
1990 +
1991 + return len(removed), removed
1992 +
1993 +
1994 +def _new_upgrade_backup_root() -> str:
1995 + """Tworzy katalog na backupy starych wersji dla bieżącej transakcji upgrade."""
1996 + txn = datetime.now().strftime("%Y%m%dT%H%M%S") + "-" + str(os.getpid())
1997 + root = os.path.join(STAGING_DIR, "backups", txn)
1998 + os.makedirs(root, exist_ok=True)
1999 + return root
2000 +
2001 +
2002 +def _purge_old_backups(keep_root: str = ""):
2003 + """Usuwa backupy starszych transakcji (zostawia bieżący – dla `pag rollback`)."""
2004 + base = os.path.join(STAGING_DIR, "backups")
2005 + if not os.path.isdir(base):
2006 + return
2007 + for entry in os.listdir(base):
2008 + p = os.path.join(base, entry)
2009 + if p != keep_root and os.path.isdir(p):
2010 + shutil.rmtree(p, ignore_errors=True)
2011 +
2012 +# =============================================================================
2013 +# HOOKI
2014 +# =============================================================================
2015 +# Hooki uruchamiają dowolny plik z pakietu jako root — to naturalna cecha
2016 +# menedżera pakietów (apt/pacman też tak mają), dlatego MUSISZ ufać repozytorium.
2017 +# Aby ograniczyć ryzyko:
2018 +# - hook dostaje minimalne, "czyste" środowisko (bez LD_PRELOAD, BASH_ENV itp.)
2019 +# - hooki można wyłączyć (PAG_NO_HOOKS=1) i ustawić timeout (PAG_HOOK_TIMEOUT)
2020 +# - każde uruchomienie jest logowane do /var/log/pag/audit.log
2021 +# - hook ma wersjonowane API (PKG_HOOK_API)
2022 +# =============================================================================
2023 +
2024 +# Lista wykonanych hooków — trafia do wpisu transakcji (informacja w rejestrze).
2025 +_HOOKS_RUN: List[str] = []
2026 +
2027 +
2028 +def _hook_env(pkg: PackageInfo, hook_name: str) -> dict:
2029 + """Buduje minimalne środowisko dla hooka (bez niebezpiecznych zmiennych)."""
2030 + return {
2031 + "PATH": "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin",
2032 + "HOME": "/root",
2033 + "LANG": "C.UTF-8",
2034 + "LC_ALL": "C.UTF-8",
2035 + "PKG_NAME": pkg.name,
2036 + "PKG_VERSION": pkg.version,
2037 + "PKG_ACTION": hook_name,
2038 + "PKG_HOOK_API": HOOK_API_VERSION,
2039 + }
2040 +
2041 +
2042 +def _hook_timeout() -> int:
2043 + try:
2044 + return max(1, int(os.environ.get("PAG_HOOK_TIMEOUT", "60")))
2045 + except Exception:
2046 + return 60
2047 +
2048 +
2049 +def _run_hook(hooks_dir: str, hook_name: str, pkg: PackageInfo) -> bool:
2050 + """Uruchamia skrypt hooka jeśli istnieje.
2051 +
2052 + Zwraca True jeśli hook został WYKONANY (istniał i uruchomiono go), False w
2053 + pozostałych przypadkach (brak pliku, wyłączone hooki, błąd). Obsługuje
2054 + ograniczone środowisko, timeout, logowanie do audytu i rejestr w transakcji.
2055 + """
2056 + hook_path = os.path.join(hooks_dir, hook_name)
2057 + if not os.path.exists(hook_path):
2058 + return False
2059 +
2060 + if os.environ.get("PAG_NO_HOOKS", "") == "1":
2061 + print(f" ⚠ Hook pominięty (PAG_NO_HOOKS=1): {hook_name} dla {pkg.name}")
2062 + _audit(f"hook SKIP {hook_name} {pkg.name}-{pkg.version} (PAG_NO_HOOKS=1)")
2063 + return False
2064 +
2065 + os.chmod(hook_path, 0o755)
2066 + env = _hook_env(pkg, hook_name)
2067 + tag = f"{hook_name} {pkg.name}-{pkg.version}"
2068 + try:
2069 + result = subprocess.run([hook_path], env=env, timeout=_hook_timeout(),
2070 + check=False, capture_output=True, text=True,
2071 + cwd="/")
2072 + _HOOKS_RUN.append(tag)
2073 + if result.returncode != 0:
2074 + print(f" ⚠ Hook {hook_name} dla {pkg.name} zakończony z kodem {result.returncode}")
2075 + if result.stderr:
2076 + print(f" {result.stderr.strip()[-200:]}")
2077 + _audit(f"hook FAIL {tag} rc={result.returncode}")
2078 + else:
2079 + _audit(f"hook OK {tag}")
2080 + return True
2081 + except subprocess.TimeoutExpired:
2082 + print(f" ⚠ Hook {hook_name} dla {pkg.name} przekroczył timeout ({_hook_timeout()}s)")
2083 + _audit(f"hook TIMEOUT {tag}")
2084 + return False
2085 + except Exception as e:
2086 + print(f" ⚠ Hook {hook_name} dla {pkg.name}: {e}")
2087 + _audit(f"hook ERROR {tag}: {e}")
2088 + return False
2089 +
2090 +# =============================================================================
2091 +# TRANSAKCJE I ROLLBACK
2092 +# =============================================================================
2093 +
2094 +def _record_transaction(action, packages, success, snapshot, file_journal=None, hooks=None,
2095 + upgrade_backups=None, upgrade_backup_root=""):
2096 + history = load_json(HISTORY_FILE) if os.path.exists(HISTORY_FILE) else []
2097 + # Rejestr wykonanych hooków – informacja o tym, że uruchomiono kod pakietu
2098 + # jako root. Trafia do historii, by dało się później sprawdzić, co się działo.
2099 + executed_hooks = list(_HOOKS_RUN) if hooks is None else hooks
2100 + _HOOKS_RUN.clear()
2101 + entry = {
2102 + "action": action, "packages": packages, "success": success,
2103 + "timestamp": datetime.now().isoformat(),
2104 + "snapshot": snapshot,
2105 + "file_journal": file_journal, # lista plików do wycofania
2106 + "hooks": executed_hooks, # wykonane hooki (pre/post-install/remove)
2107 + }
2108 + if upgrade_backups:
2109 + entry["upgrade_backups"] = upgrade_backups # {dst: backup_path}
2110 + entry["upgrade_backup_root"] = upgrade_backup_root
2111 + history.append(entry)
2112 + if len(history) > 50:
2113 + history = history[-50:]
2114 + save_json(HISTORY_FILE, history)
2115 +
2116 +def cmd_history():
2117 + if not os.path.exists(HISTORY_FILE):
2118 + print(_("no_history")); return
2119 + history = load_json(HISTORY_FILE)
2120 + if not history:
2121 + print(_("no_history")); return
2122 + print(f"Ostatnie transakcje ({len(history)}):")
2123 + for i, e in enumerate(reversed(history), 1):
2124 + icon = "✅" if e["success"] else "❌"
2125 + pkgs = ", ".join(e["packages"][:5])
2126 + if len(e["packages"]) > 5: pkgs += f" (+{len(e['packages'])-5})"
2127 + print(f" {i}. {icon} {e['action']}: {pkgs}")
2128 + print(f" {e['timestamp']}")
2129 +
2130 +def cmd_rollback():
2131 + if not os.path.exists(HISTORY_FILE):
2132 + print(_("no_history")); return 1
2133 + history = load_json(HISTORY_FILE)
2134 + if not history:
2135 + print(_("no_history")); return 1
2136 +
2137 + last = None
2138 + for e in reversed(history):
2139 + if e["success"] and e.get("snapshot"):
2140 + last = e; break
2141 +
2142 + if not last:
2143 + print("❌ No snapshot to restore."); return 1
2144 +
2145 + print(f"⏪ Rolling back: {last['action']} ({last['timestamp']})")
2146 + print(f" Packages: {', '.join(last['packages'][:10])}")
2147 +
2148 + if not _ask_confirm():
2149 + return 0
2150 +
2151 + # Przywróć installed.json
2152 + save_json(INSTALLED_DB, last["snapshot"])
2153 +
2154 + # Wycofaj fizyczne pliki (jeśli zapisano journal)
2155 + file_journal = last.get("file_journal", [])
2156 + upgrade_backups = last.get("upgrade_backups", {}) or {}
2157 + backup_root = last.get("upgrade_backup_root", "")
2158 +
2159 + # Przywróć stare wersje z backupów (upgrade) – nadpisane i usunięte stale pliki
2160 + for dst, bpath in upgrade_backups.items():
2161 + full = os.path.join(PAG_ROOT, dst.lstrip("/"))
2162 + if bpath and os.path.lexists(bpath):
2163 + try:
2164 + os.makedirs(os.path.dirname(full), exist_ok=True)
2165 + os.replace(bpath, full)
2166 + except OSError:
2167 + pass
2168 +
2169 + # Usuń nowe pliki (które nie miały poprzedniej wersji)
2170 + backed = set(upgrade_backups)
2171 + if file_journal:
2172 + for fpath in reversed(file_journal):
2173 + if fpath in backed:
2174 + continue
2175 + full = os.path.join(PAG_ROOT, fpath.lstrip("/"))
2176 + if os.path.exists(full) or os.path.islink(full):
2177 + os.remove(full)
2178 + print(f" {_('rollback_files', len(file_journal))}")
2179 +
2180 + # Sprzątanie pustych katalogów + katalogu backupów
2181 + dirs = set()
2182 + for fpath in file_journal:
2183 + parent = os.path.dirname(fpath)
2184 + while parent and parent != "/":
2185 + dirs.add(parent)
2186 + parent = os.path.dirname(parent)
2187 + for d in sorted(dirs, key=len, reverse=True):
2188 + full_d = os.path.join(PAG_ROOT, d.lstrip("/"))
2189 + if os.path.isdir(full_d):
2190 + try:
2191 + os.rmdir(full_d)
2192 + except OSError:
2193 + pass
2194 + if backup_root:
2195 + shutil.rmtree(backup_root, ignore_errors=True)
2196 +
2197 + print(f"✅ {_('rollback_restored')}")
2198 + _record_transaction("rollback", last["packages"], True, None)
2199 + return 0
2200 +
2201 +# =============================================================================
2202 +# INSTALACJA
2203 +# =============================================================================
2204 +
2205 +def _install_local_pkg_files(paths, install_succeeded):
2206 + """Instaluje lokalne pliki .pkg.tar.xz (bez repozytorium).
2207 + Zgodnie z _atomic_install każdy plik jest instalowany atomowo.
2208 + Zwraca (failed_count, installed_files)."""
2209 + failed = 0
2210 + all_files = []
2211 + for p in paths:
2212 + p = os.path.abspath(p)
2213 + if not os.path.isfile(p):
2214 + print(f" ❌ Nie znaleziono pakietu: {p}")
2215 + failed += 1
2216 + continue
2217 + try:
2218 + with tarfile.open(p, "r:xz") as tf:
2219 + meta = tf.extractfile("metadata.json")
2220 + if meta is None:
2221 + print(f" ❌ {p}: brak metadata.json")
2222 + failed += 1
2223 + continue
2224 + data = json.loads(meta.read())
2225 + except Exception as e:
2226 + print(f" ❌ {p}: nie udało się odczytać pakietu ({e})")
2227 + failed += 1
2228 + continue
2229 + pkg = PackageInfo(data, repo="local")
2230 + print(f" ↓ {pkg.name}-{pkg.version} (lokalny) ... ", end="", flush=True)
2231 + ok, files, _ = _atomic_install(p, pkg)
2232 + if ok:
2233 + install_succeeded(pkg, files)
2234 + all_files.extend(f["path"] for f in files)
2235 + print("✅")
2236 + else:
2237 + print("❌")
2238 + failed += 1
2239 + return failed, all_files
2240 +
2241 +
2242 +def _preflight_disk(total_bytes: int) -> bool:
2243 + """Pre-flight przed transakcją: wolne miejsce + mount read-only.
2244 +
2245 + Zwraca False (przerywa instalację) gdy na partycji docelowej brakuje
2246 + miejsca na pakiety albo katalog stagingu jest zamontowany read-only
2247 + (inaczej instalacja rwałaby się w połowie, zostawiając uszkodzony system).
2248 + """
2249 + target = PAG_ROOT or "/"
2250 + try:
2251 + st = os.statvfs(target)
2252 + free = st.f_bavail * st.f_frsize
2253 + except OSError:
2254 + return True # nie da się sprawdzić – nie blokuj
2255 + need_mb = total_bytes // 1048576
2256 + free_mb = free // 1048576
2257 + if free < total_bytes:
2258 + print(f" ❌ Za mało miejsca na dysku: potrzeba ~{need_mb} MB, "
2259 + f"wolne {free_mb} MB ({target})")
2260 + return False
2261 + if free < total_bytes * 3:
2262 + print(f" ⚠ Mało miejsca na dysku: wolne {free_mb} MB, "
2263 + f"pakiety ~{need_mb} MB (rozpakowane zajmą więcej)")
2264 + # Wykryj mount read-only (test zapisu w stagingu)
2265 + try:
2266 + probe = os.path.join(STAGING_DIR, ".pag-probe")
2267 + with open(probe, "w") as f:
2268 + f.write("x")
2269 + os.remove(probe)
2270 + except OSError:
2271 + print(f" ❌ {target} jest zamontowane tylko-do-odczytu – nie można instalować.")
2272 + return False
2273 + return True
2274 +
2275 +
2276 +def cmd_install(package_names, as_dep=False, upgrade=False):
2277 + ensure_dirs()
2278 + installed_db = load_json(INSTALLED_DB)
2279 + world = load_world()
2280 + pinned = load_json(PINNED_FILE)
2281 +
2282 + # Obsługa lokalnych plików .pkg.tar.xz (zbudowanych przez pagbuild) –
2283 + # nie wymaga repozytorium ani GPG.
2284 + local_files = [p for p in package_names if p.endswith(PKG_EXT) or
2285 + (os.sep in p and os.path.isfile(os.path.abspath(p)))]
2286 + if local_files:
2287 + _local_need = sum(
2288 + os.path.getsize(os.path.abspath(p))
2289 + for p in local_files if os.path.isfile(os.path.abspath(p))
2290 + )
2291 + if not _preflight_disk(_local_need):
2292 + return 1
2293 +
2294 + def _ok(pkg, files):
2295 + installed_db[pkg.name] = {
2296 + "version": pkg.version, "release": pkg.release, "description": pkg.description,
2297 + "dependencies": pkg.dependencies, "size_bytes": pkg.size_bytes,
2298 + "sha256": pkg.sha256, "installed_at": datetime.now().isoformat(),
2299 + "repo": "local",
2300 + "provides": getattr(pkg, "provides", None) or [],
2301 + "provides_so": getattr(pkg, "provides_so", None) or [],
2302 + "requires_so": getattr(pkg, "requires_so", None) or [],
2303 + }
2304 + world.add(pkg.name)
2305 + failed_local, _fl = _install_local_pkg_files(local_files, _ok)
2306 + save_json(INSTALLED_DB, installed_db)
2307 + save_world(world)
2308 + if failed_local:
2309 + return 1
2310 + _refresh_dynamic_linker_cache()
2311 + package_names = [n for n in package_names if n not in
2312 + [os.path.abspath(x) for x in local_files] and
2313 + n not in local_files]
2314 + to_install = []
2315 + if not package_names:
2316 + return 0
2317 + # pozostałe argumenty to nazwy pakietów z repo – kontynuuj
2318 +
2319 + repo_pkgs = fetch_all_packages()
2320 +
2321 + if not repo_pkgs:
2322 + print(f"❌ {_('no_index')}"); return 1
2323 +
2324 + for name in list(package_names):
2325 + if name in pinned:
2326 + print(f"⚠ {name} {_('pinned_to')} {pinned[name]} – skipping")
2327 + package_names.remove(name)
2328 +
2329 + to_install, missing_deps = _resolve_deps(package_names, repo_pkgs, installed_db)
2330 +
2331 + # ── Pakiety, których NIE MA w repo ani nie są zainstalowane ──
2332 + # Zgłoś od razu zamiast mylącego „Do zainstalowania: N (0.00 MB)”
2333 + # i prośby o potwierdzenie (np. `pag install steam` gdy steam nie istnieje).
2334 + not_found = []
2335 + for n in package_names:
2336 + real = _resolve_provides(n, repo_pkgs, installed_db)
2337 + if real not in repo_pkgs and real not in installed_db \
2338 + and not os.path.exists(os.path.abspath(n)):
2339 + not_found.append(n)
2340 + if not_found:
2341 + print(f"\n ❌ {_('pkg_not_found', ', '.join(not_found))}")
2342 + print(f" {_('not_found_hint')}")
2343 + return 1
2344 +
2345 + # --- Tryb upgrade: pakiety już zainstalowane MUSZĄ zostać ponownie
2346 + # zainstalowane z nowszej wersji (zastąpienie w tej samej transakcji).
2347 + if upgrade:
2348 + # `pag update` przekazuje tu tylko pakiety z NOWSZĄ wersją (już
2349 + # przefiltrowane w _pending_updates), a `pag install -f` wymusza
2350 + # reinstalację nawet tej SAMEJ wersji – dlatego nie filtrujemy po
2351 + # _version_newer.
2352 + upgrade_targets = [
2353 + name for name in package_names
2354 + if name in repo_pkgs
2355 + and name in installed_db
2356 + and name not in pinned
2357 + ]
2358 + for name in upgrade_targets:
2359 + if name not in to_install:
2360 + to_install.append(name)
2361 +
2362 + if not to_install and not missing_deps:
2363 + print(f"✅ {_('all_installed')}"); return 0
2364 +
2365 + # ── WERYFIKACJA ZALEŻNOŚCI ──────────────────────────────────────────
2366 + fatal_missing = _verify_dependencies(to_install, repo_pkgs, installed_db)
2367 +
2368 + if fatal_missing > 0:
2369 + print(f"❌ Nie można kontynuować – {fatal_missing} brakujących zależności.")
2370 + print(f" Zainstaluj brakujące pakiety lub dodaj repozytoria.")
2371 + return 1
2372 +
2373 + so_missing = _verify_so_deps(to_install, repo_pkgs, installed_db)
2374 + if so_missing > 0:
2375 + print(" Zainstaluj dostawcę biblioteki lub zaktualizuj repozytorium.")
2376 + return 1
2377 +
2378 + if not to_install:
2379 + print(f"✅ {_('all_installed')}"); return 0
2380 +
2381 + MAX_MB = MAX_PKG_SIZE // 1048576
2382 + for n in to_install:
2383 + if not _validate_pkg_name(n):
2384 + print(f" {_("sec_badname", name=n)}")
2385 + return 1
2386 + sz = repo_pkgs[n].size_bytes if n in repo_pkgs else 0
2387 + if sz > MAX_PKG_SIZE:
2388 + mb = sz // 1048576
2389 + print(f" {_("sec_toobig", size_mb=mb, max_mb=MAX_MB)}")
2390 + return 1
2391 + total_size = sum(repo_pkgs[n].size_bytes for n in to_install if n in repo_pkgs)
2392 + if not _preflight_disk(total_size):
2393 + return 1
2394 + print(f"\n📦 {_('to_install', len(to_install), total_size/1048576)}")
2395 + for name in to_install:
2396 + p = repo_pkgs.get(name)
2397 + if p:
2398 + if name in installed_db:
2399 + marker = " [upgrade]" if upgrade else ""
2400 + else:
2401 + marker = f" [{_('new')}]"
2402 + print(f" {name}-{p.version}{marker}")
2403 +
2404 + if not as_dep and not upgrade:
2405 + if not _ask_confirm():
2406 + print(_("cancelled")); return 0
2407 +
2408 + snapshot = json.loads(json.dumps(installed_db))
2409 + all_installed_files = []
2410 + failed = []
2411 + # Pary (pkg, stare_pliki, nowe_pliki) do usunięcia martwych plików po upgrade
2412 + stale_candidates = []
2413 + # Katalog backupów starych wersji (upgrade) – dla poprawnego rollbacku
2414 + backup_root = ""
2415 + all_backups: List[Tuple[str, str]] = [] # (backup_path, dst)
2416 + if upgrade and to_install:
2417 + backup_root = _new_upgrade_backup_root()
2418 +
2419 + # --- Dziennik transakcji (dla pełnej atomowości) ---
2420 + # Jeśli którykolwiek pakiet zawiedzie, cofamy WSZYSTKIE zainstalowane
2421 + # w tej transakcji przez _rollback_transaction().
2422 + transaction_journal: List[Tuple[str, str, str]] = [] # (op, src, dst)
2423 +
2424 + # --- Tryb immutable: utwórz nowy deployment ---
2425 + immutable = os.environ.get("PAG_IMMUTABLE", "") == "1"
2426 + deploy_dir = ""
2427 + deploy_id = ""
2428 + if immutable:
2429 + print(f"\n 🏗️ Tworzenie nowego deploymentu...")
2430 + deploy_dir, deploy_id = _create_deployment(to_install, "upgrade" if upgrade else "install")
2431 + target_root = deploy_dir
2432 + else:
2433 + target_root = ""
2434 +
2435 + # --- Faza 1: Równoległe pobieranie wszystkich pakietów ---
2436 + to_download = [repo_pkgs[name] for name in to_install if name in repo_pkgs]
2437 + if len(to_download) > 1:
2438 + print(f"\n ⏬ Pobieranie {len(to_download)} pakietów równolegle...")
2439 + downloaded = _download_packages_parallel(to_download)
2440 + else:
2441 + downloaded = {}
2442 +
2443 + # --- Faza 2: Instalacja – JEDNA nadpisywana linia postępu (jak przy
2444 + # pobieraniu), bez ściany tekstu na każdy pakiet. W trybie
2445 + # nieinteraktywnym (logi, netinstall instalatora) wypisujemy linię na
2446 + # pakiet – tam to pożądane do logu.
2447 + t0 = time.time()
2448 + stderr_tty = sys.stderr.isatty()
2449 + stdout_tty = sys.stdout.isatty()
2450 + _bar_last = 0
2451 +
2452 + def _bar_draw(idx: int, name: str) -> None:
2453 + nonlocal _bar_last
2454 + n = len(to_install)
2455 + pct = (idx - 1) / n * 100.0
2456 + fl = int(25 * pct / 100)
2457 + pbar = "█" * fl + "░" * (25 - fl)
2458 + eta_s = ""
2459 + if idx > 1:
2460 + avg = (time.time() - t0) / (idx - 1)
2461 + rem = avg * (n - idx + 1)
2462 + eta_s = f" ~{rem:.0f}s" if rem < 60 else f" ~{rem/60:.1f}m"
2463 + line = f" 📦 [{pbar}] {idx}/{n} ({pct:.0f}%) {name}{eta_s}"
2464 + if stderr_tty:
2465 + clear = " " * max(0, _bar_last - len(line))
2466 + sys.stderr.write(f"\r{line}{clear}")
2467 + sys.stderr.flush()
2468 + _bar_last = len(line)
2469 + else:
2470 + print(line, file=sys.stderr, flush=True)
2471 +
2472 + def _bar_end() -> None:
2473 + nonlocal _bar_last
2474 + if stderr_tty and _bar_last:
2475 + sys.stderr.write("\r" + " " * _bar_last + "\r")
2476 + sys.stderr.flush()
2477 + _bar_last = 0
2478 +
2479 + _pkg_i = 0
2480 + for name in to_install:
2481 + pkg = repo_pkgs.get(name)
2482 + if not pkg:
2483 + _bar_end()
2484 + print(f" ❌ {name}: {_('not_found')}")
2485 + failed.append(name)
2486 + break
2487 +
2488 + _pkg_i += 1
2489 + _bar_draw(_pkg_i, f"{name}-{pkg.version}")
2490 +
2491 + # Pobierz (z cache fazy 1 lub bezpośrednio)
2492 + pkg_path = downloaded.get(name) if name in downloaded else _download_pkg(pkg)
2493 + if not pkg_path:
2494 + _bar_end()
2495 + print(f" ❌ {name}: {_('download_fail')}")
2496 + failed.append(name)
2497 + break # przerwij transakcję
2498 +
2499 + # GPG
2500 + gpg_ok, gpg_msg = _verify_pkg_gpg(pkg_path, repo_url=pkg.repo_url)
2501 + if not gpg_ok:
2502 + _bar_end()
2503 + print(f" ❌ {name}: {_('gpg_fail')}: {gpg_msg[:60]}")
2504 + failed.append(name)
2505 + break # PRZERWIJ – niezaufany pakiet
2506 +
2507 + # SHA256 całego pakietu
2508 + if pkg.sha256 and _sha256_file(pkg_path) != pkg.sha256:
2509 + _bar_end()
2510 + print(f" ❌ {name}: {_('sha256_mismatch')}")
2511 + failed.append(name)
2512 + break # PRZERWIJ – uszkodzony pakiet
2513 +
2514 + # Przed instalacją zapamiętaj pliki starej wersji (potrzebne w upgrade)
2515 + old_files = _db_get_package_files(name) if name in installed_db else []
2516 +
2517 + # Atomowa instalacja (w upgrade backupuje nadpisywane pliki)
2518 + ok, files, backup_j = _atomic_install(pkg_path, pkg, deploy_dir,
2519 + backup_dir=backup_root)
2520 + if ok:
2521 + installed_db[name] = {
2522 + "version": pkg.version, "release": pkg.release, "description": pkg.description,
2523 + "dependencies": pkg.dependencies, "size_bytes": pkg.size_bytes,
2524 + "sha256": pkg.sha256, "installed_at": datetime.now().isoformat(),
2525 + "repo": pkg.repo_url,
2526 + "provides": getattr(pkg, "provides", None) or [],
2527 + "provides_so": getattr(pkg, "provides_so", None) or [],
2528 + "requires_so": getattr(pkg, "requires_so", None) or [],
2529 + }
2530 + if not as_dep and name in package_names:
2531 + world.add(name)
2532 + if not stdout_tty:
2533 + print(f" ✓ {name}-{pkg.version}", flush=True)
2534 + all_installed_files.extend(f["path"] for f in files)
2535 + all_backups.extend(backup_j)
2536 +
2537 + # Upgrade: zapamiętaj stare pliki, by po sukcesie usunąć te,
2538 + # których nie ma już w nowej wersji.
2539 + if upgrade and old_files:
2540 + stale_candidates.append((name, old_files, [f["path"] for f in files]))
2541 +
2542 + # Po instalacji kernela – przebuduj initramfs
2543 + if _is_kernel_package(name):
2544 + _rebuild_initramfs(deploy_dir)
2545 + else:
2546 + _bar_end()
2547 + print(f" ❌ {name}: {_('install_failed')}")
2548 + failed.append(name)
2549 + break # PRZERWIJ – błąd instalacji
2550 +
2551 + # --- Rollback całej transakcji jeśli cokolwiek zawiodło ---
2552 + if failed:
2553 + _bar_end()
2554 + print(f"\n ↩ Cofanie transakcji ({len(failed)} błędów)...")
2555 + _rollback_transaction(installed_db, snapshot, all_installed_files,
2556 + deploy_dir, immutable, backups=all_backups)
2557 + if backup_root:
2558 + shutil.rmtree(backup_root, ignore_errors=True)
2559 + _record_transaction("upgrade" if upgrade else "install", to_install, False, snapshot)
2560 + return 1
2561 +
2562 + # --- Po sukcesie transakcji: usuń nieaktualne pliki starych wersji (upgrade).
2563 + # Usunięte pliki trafiają do backupu, aby `pag rollback` mógł je przywrócić.
2564 + for pkg_name, old_files, new_paths in stale_candidates:
2565 + _remove_stale_files(pkg_name, old_files, new_paths, installed_db, deploy_dir,
2566 + backup_root, all_backups)
2567 +
2568 + save_json(INSTALLED_DB, installed_db)
2569 + save_world(world)
2570 + _record_transaction("upgrade" if upgrade else "install", to_install, True, snapshot,
2571 + file_journal=all_installed_files,
2572 + upgrade_backups={dst: bp for bp, dst in all_backups} if all_backups else None,
2573 + upgrade_backup_root=backup_root)
2574 +
2575 + # Zachowaj backupy bieżącej transakcji (dla `pag rollback`), usuń starsze.
2576 + if backup_root:
2577 + _purge_old_backups(keep_root=backup_root)
2578 +
2579 + _bar_end()
2580 +
2581 + # --- Tryb immutable: przełącz na nowy deployment ---
2582 + if immutable and not failed:
2583 + _refresh_dynamic_linker_cache(deploy_dir)
2584 + print(f"\n 🔄 Przełączanie na deployment {deploy_id}...")
2585 + _switch_deployment(deploy_dir)
2586 + print(f" ✅ Aktywny deployment: {deploy_id}")
2587 + _update_grub_config()
2588 + cmd_deploy_cleanup(keep=5) # Zostawia 5 najnowszych deploymentów
2589 + print(f" 💡 Restart wymagany do przeładowania systemu.")
2590 + else:
2591 + _refresh_dynamic_linker_cache()
2592 + # Hooki zbiorcze – raz na transakcję (fc-cache itp.), tylko gdy pliki
2593 + # trafiły do realnego systemu (nie do deploymentu).
2594 + _process_triggers(all_installed_files)
2595 +
2596 + print(f"\n✅ {_('installed', len(to_install))} ({(time.time()-t0):.0f}s)")
2597 + return 0
2598 +
2599 +
2600 +def _rollback_transaction(installed_db: dict, snapshot: dict,
2601 + installed_files: List[str],
2602 + deploy_dir: str, is_immutable: bool,
2603 + backups: Optional[List[Tuple[str, str]]] = None):
2604 + """
2605 + Cofa WSZYSTKIE pakiety zainstalowane w bieżącej transakcji.
2606 + Przywraca installed_db do stanu sprzed transakcji.
2607 + Usuwa fizyczne pliki z systemu (lub deploymentu w trybie immutable).
2608 + Jeśli podano `backups` (upgrade) – przywraca stare wersje nadpisanych plików.
2609 + """
2610 + # Przywróć installed_db
2611 + installed_db.clear()
2612 + installed_db.update(snapshot)
2613 +
2614 + root = deploy_dir if is_immutable else PAG_ROOT
2615 + backup_map = {dst: src for src, dst in (backups or [])}
2616 +
2617 + # Przywróć stare wersje z backupów (upgrade)
2618 + for dst, bpath in backup_map.items():
2619 + full = os.path.join(root, dst.lstrip("/"))
2620 + if os.path.lexists(bpath):
2621 + try:
2622 + os.makedirs(os.path.dirname(full), exist_ok=True)
2623 + os.replace(bpath, full)
2624 + except OSError:
2625 + pass
2626 +
2627 + # Usuń nowe pliki (które nie miały poprzedniej wersji)
2628 + for fpath in reversed(installed_files):
2629 + if fpath in backup_map:
2630 + continue
2631 + full = os.path.join(root, fpath.lstrip("/"))
2632 + if os.path.isfile(full) or os.path.islink(full):
2633 + try:
2634 + os.remove(full)
2635 + except OSError:
2636 + pass
2637 +
2638 + # Wyczyść puste katalogi
2639 + dirs_to_check = set()
2640 + for fpath in installed_files:
2641 + parent = os.path.dirname(fpath)
2642 + while parent and parent != "/":
2643 + dirs_to_check.add(parent)
2644 + parent = os.path.dirname(parent)
2645 + for d in sorted(dirs_to_check, key=len, reverse=True):
2646 + full_d = os.path.join(root, d.lstrip("/"))
2647 + if os.path.isdir(full_d):
2648 + try:
2649 + os.rmdir(full_d)
2650 + except OSError:
2651 + pass
2652 +
2653 + # W trybie immutable: usuń nieudany deployment
2654 + if is_immutable and deploy_dir:
2655 + shutil.rmtree(deploy_dir, ignore_errors=True)
2656 +
2657 + save_json(INSTALLED_DB, snapshot)
2658 +
2659 +
2660 +# =============================================================================
2661 +# USUWANIE
2662 +# =============================================================================
2663 +
2664 +def cmd_remove(package_names):
2665 + installed_db = load_json(INSTALLED_DB)
2666 + world = load_world()
2667 + snapshot = json.loads(json.dumps(installed_db))
2668 + removed = []
2669 + removed_files = []
2670 +
2671 + total = len(package_names)
2672 + for i, name in enumerate(package_names, 1):
2673 + if name not in installed_db:
2674 + print(f" ⚠ {name}: not installed"); continue
2675 +
2676 + # Pasek postępu
2677 + pct = (i - 1) / total * 100
2678 + filled = int(25 * pct / 100)
2679 + print(f" 🗑 [{'█' * filled + '░' * (25 - filled)}] {i}/{total} ({pct:.0f}%) ", end="\r", file=sys.stderr, flush=True)
2680 +
2681 + print(f"🗑 {name}-{installed_db[name]['version']} ...", end=" ", flush=True)
2682 +
2683 + # Pre-remove hook (jeśli dostępny w staging)
2684 + _run_hook_for_installed(name, "pre-remove")
2685 +
2686 + count, rm_files = _safe_remove_files(name, installed_db)
2687 + del installed_db[name]
2688 + world.discard(name)
2689 + removed.append(name)
2690 + removed_files.extend(rm_files)
2691 + print(f"✅ ({count} files)")
2692 +
2693 + # Post-remove hook + sprzątanie zapisanych hooków
2694 + _run_hook_for_installed(name, "post-remove")
2695 + shutil.rmtree(os.path.join(PAG_DB, "hooks", name), ignore_errors=True)
2696 +
2697 + save_json(INSTALLED_DB, installed_db)
2698 + save_world(world)
2699 + _record_transaction("remove", removed, True, snapshot)
2700 +
2701 + print(file=sys.stderr) # wyczyść linię paska postępu
2702 +
2703 + if not removed: return 0
2704 + print(f"\n✅ Removed {len(removed)}.")
2705 + _process_triggers(removed_files)
2706 +
2707 + orphans = _find_orphans(installed_db, world)
2708 + if orphans:
2709 + print(f"\n💡 {_('orphans_found', len(orphans), ', '.join(sorted(orphans)[:10]))}")
2710 + print(" 'pag remove-orphans' to clean up.")
2711 + return 0
2712 +
2713 +def _run_hook_for_installed(pkg_name, hook_name):
2714 + """Próbuje uruchomić hook z katalogu pakietu (jeśli został zapisany)."""
2715 + hook_dir = os.path.join(PAG_DB, "hooks", pkg_name)
2716 + if os.path.isdir(hook_dir):
2717 + ver = load_json(INSTALLED_DB).get(pkg_name, {}).get("version", "")
2718 + _run_hook(hook_dir, hook_name, PackageInfo({"name": pkg_name, "version": ver}))
2719 +
2720 +
2721 +# =============================================================================
2722 +# TRIGGERS – hooki zbiorcze (raz na transakcję, nie per pakiet)
2723 +# =============================================================================
2724 +# Wzorem pacman/dpkg: pakiet/administrator deklaruje zainteresowanie ścieżkami,
2725 +# a pasujący trigger uruchamia się DOKŁADNIE RAZ na końcu transakcji
2726 +# (np. fc-cache, glib-compile-schemas, update-desktop-database) zamiast po
2727 +# każdym pakiecie z osobna.
2728 +
2729 +TRIGGERS_DIR = PAG_CONF + "/triggers"
2730 +
2731 +DEFAULT_TRIGGERS = [
2732 + {"name": "font-cache", "paths": ["/usr/share/fonts/", "/usr/local/share/fonts/"],
2733 + "run": "fc-cache -fs"},
2734 + {"name": "glib-schemas", "paths": ["/usr/share/glib-2.0/schemas/"],
2735 + "run": "glib-compile-schemas /usr/share/glib-2.0/schemas"},
2736 + {"name": "desktop-database", "paths": ["/usr/share/applications/"],
2737 + "run": "update-desktop-database -q /usr/share/applications"},
2738 + {"name": "mime-database", "paths": ["/usr/share/mime/"],
2739 + "run": "update-mime-database /usr/share/mime"},
2740 +]
2741 +
2742 +def _load_triggers() -> List[dict]:
2743 + """Ładuje triggery: domyślne (tylko gdy binarka istnieje) + /etc/pag/triggers/*.json."""
2744 + out = []
2745 + for t in DEFAULT_TRIGGERS:
2746 + bin_name = t["run"].split()[0]
2747 + if shutil.which(bin_name):
2748 + out.append(dict(t))
2749 + if os.path.isdir(TRIGGERS_DIR):
2750 + for fn in sorted(os.listdir(TRIGGERS_DIR)):
2751 + if not fn.endswith(".json"):
2752 + continue
2753 + try:
2754 + with open(os.path.join(TRIGGERS_DIR, fn)) as f:
2755 + data = json.load(f)
2756 + except (OSError, json.JSONDecodeError):
2757 + continue
2758 + if isinstance(data, dict):
2759 + data = [data]
2760 + for t in data:
2761 + if isinstance(t, dict) and t.get("name") and t.get("paths") and t.get("run"):
2762 + out.append(t)
2763 + return out
2764 +
2765 +def _process_triggers(touched_paths: List[str]):
2766 + """Uruchamia pasujące triggery RAZ na końcu transakcji (best-effort)."""
2767 + if not touched_paths:
2768 + return
2769 + if os.environ.get("PAG_NO_HOOKS", "") == "1":
2770 + return
2771 + import shlex as _shlex
2772 + matched = []
2773 + for trig in _load_triggers():
2774 + if any(path.startswith(p) for p in trig["paths"] for path in touched_paths):
2775 + matched.append(trig)
2776 + for trig in matched:
2777 + run = trig["run"]
2778 + print(f" ⚡ Trigger: {trig['name']} ({run})")
2779 + try:
2780 + r = subprocess.run(_shlex.split(run), capture_output=True, text=True, timeout=120)
2781 + _audit(f"TRIGGER {trig['name']}: {run} rc={r.returncode}")
2782 + if r.returncode != 0:
2783 + print(f" ⚠ rc={r.returncode}: {(r.stderr or r.stdout or '').strip()[:160]}")
2784 + except subprocess.TimeoutExpired:
2785 + print(f" ⚠ trigger {trig['name']} przekroczył limit czasu (120 s)")
2786 + _audit(f"TRIGGER {trig['name']} TIMEOUT")
2787 + except Exception as e:
2788 + print(f" ⚠ trigger {trig['name']}: {e}")
2789 +
2790 +# =============================================================================
2791 +# UPDATE / UPGRADE / LIST / SEARCH / INFO / VERIFY
2792 +# =============================================================================
2793 +
2794 +def _cleanup_tmp_files(*paths):
2795 + """Usuwa tymczasowe pliki (np. .pag.new) po nieudanej operacji."""
2796 + for p in paths:
2797 + try:
2798 + if os.path.isfile(p):
2799 + os.remove(p)
2800 + except OSError:
2801 + pass
2802 +
2803 +
2804 +def cmd_self_update():
2805 + """Aktualizuje samego klienta pag z repo (podpisany /stable/pag).
2806 +
2807 + Kolejność: pobierz → weryfikacja GPG (+ fingerprint repo) → SHA256 →
2808 + kontrola składni (compile) → backup → atomowe os.replace. Nowa wersja
2809 + idzie do tego samego katalogu (/usr/local/bin/.pag.new), dzięki czemu
2810 + podmiana jest atomowa; jeśli system padnie w trakcie, stary pag zostaje.
2811 + """
2812 + repos = get_repos()
2813 + if not repos:
2814 + print("❌ Brak repozytoriów w konfiguracji.")
2815 + return 1
2816 + base = repos[0]
2817 + dst = "/usr/local/bin/pag"
2818 + dst_new = dst + ".new"
2819 + dst_bak = dst + ".bak"
2820 + print(f"🔄 Sprawdzam aktualizację pag z {base}...")
2821 + try:
2822 + with urlopen(Request(f"{base}/pag", headers={"User-Agent": "pag/3.0"}), timeout=20) as r:
2823 + data = r.read()
2824 + with urlopen(Request(f"{base}/pag.asc", headers={"User-Agent": "pag/3.0"}), timeout=20) as r:
2825 + sig = r.read()
2826 + except Exception as e:
2827 + print(f" ❌ Nie można pobrać pag: {e}")
2828 + return 1
2829 +
2830 + # Zapisz nową wersję w katalogu docelowym (ta sama partycja → atomowy rename)
2831 + with open(dst_new, "wb") as f:
2832 + f.write(data)
2833 + with open(dst_new + ".asc", "wb") as f:
2834 + f.write(sig)
2835 +
2836 + # --- 1. Weryfikacja podpisu GPG – bez tego nie instalujemy ---
2837 + insecure = os.environ.get("PAG_INSECURE", "") == "1"
2838 + ok, fp = _gpg_verify_fp(dst_new + ".asc", dst_new)
2839 + if not ok:
2840 + # Automatyczny import klucza (TOFU) – jak w _verify_repo_sig
2841 + res = _gpg_run("--verify", dst_new + ".asc", dst_new,
2842 + capture_output=True, text=True)
2843 + _stderr = res.stderr.decode(errors="replace") if isinstance(res.stderr, bytes) else (res.stderr or "")
2844 + if "public key" in _stderr.lower() and "no public key" in _stderr.lower():
2845 + try:
2846 + with urlopen(Request(f"{base}/paganos.asc", headers={"User-Agent": "pag/3.0"}), timeout=20) as r:
2847 + keydata = r.read()
2848 + with tempfile.NamedTemporaryFile(delete=False, suffix=".asc") as tmp:
2849 + tmp.write(keydata); tmp.flush()
2850 + _gpg_run("--import", tmp.name, capture_output=True, timeout=30)
2851 + os.unlink(tmp.name)
2852 + print(f" 🔑 Importowano klucz repo z {base}/paganos.asc")
2853 + ok, fp = _gpg_verify_fp(dst_new + ".asc", dst_new)
2854 + except Exception:
2855 + pass
2856 + if not ok:
2857 + if insecure:
2858 + print(" ⚠ Nieprawidłowy podpis aktualizacji (PAG_INSECURE – ignoruję)")
2859 + else:
2860 + print(" ❌ Nieprawidłowy podpis aktualizacji – nie aktualizuję.")
2861 + _cleanup_tmp_files(dst_new, dst_new + ".asc")
2862 + return 1
2863 + # Sprawdź fingerprint względem przypiętego klucza repo
2864 + pinned = _repo_pinned_fp(base)
2865 + if pinned:
2866 + if not fp:
2867 + print(" ❌ Nie można potwierdzić fingerprintu podpisu aktualizacji.")
2868 + _cleanup_tmp_files(dst_new, dst_new + ".asc")
2869 + return 1
2870 + if fp != pinned.upper():
2871 + if insecure:
2872 + print(" ⚠ Podpis aktualizacji innym kluczem (PAG_INSECURE – ignoruję)")
2873 + else:
2874 + print(" ❌ [SECURITY ERROR] Podpis aktualizacji innym kluczem niż repo!")
2875 + print(f" Oczekiwany: {pinned}, Otrzymany: {fp}")
2876 + _cleanup_tmp_files(dst_new, dst_new + ".asc")
2877 + return 1
2878 +
2879 + # --- 2. Weryfikacja SHA256 (jeśli repo publikuje pag.sha256) ---
2880 + try:
2881 + with urlopen(Request(f"{base}/pag.sha256", headers={"User-Agent": "pag/3.0"}), timeout=15) as r:
2882 + sha = r.read().decode().strip().split()[0]
2883 + if sha:
2884 + actual = hashlib.sha256(data).hexdigest()
2885 + if actual.lower() != sha.lower():
2886 + print(f" ❌ SHA256 niezgodny! Oczekiwano {sha}, jest {actual}")
2887 + _cleanup_tmp_files(dst_new, dst_new + ".asc")
2888 + return 1
2889 + print(" ✅ SHA256 zgodny")
2890 + except Exception:
2891 + # Brak pag.sha256 w repo – opcjonalne; nie blokuj aktualizacji.
2892 + pass
2893 +
2894 + # --- 3. Kontrola składni (nie uruchamiaj uszkodzonego/poddanego edycji pliku) ---
2895 + try:
2896 + compile(data, "pag", "exec")
2897 + except SyntaxError as e:
2898 + print(f" ❌ Błąd składni w nowym pag: {e}")
2899 + _cleanup_tmp_files(dst_new, dst_new + ".asc")
2900 + return 1
2901 +
2902 + m = (re.search(rb'PAG_VERSION\s*=\s*"(\d+\.\d+\.\d+[a-z]?)"', data[:3000])
2903 + or re.search(rb"v(\d+\.\d+\.\d+[a-z]?)", data[:3000]))
2904 + new_ver = m.group(1).decode() if m else "?"
2905 + print(f" ✅ Pobrano pag {new_ver} (obecny {PAG_VERSION}), podpis zweryfikowany")
2906 +
2907 + # --- 4. Backup + atomowa podmiana ---
2908 + if os.path.exists(dst):
2909 + shutil.copy2(dst, dst_bak)
2910 + os.chmod(dst_new, 0o755)
2911 + os.replace(dst_new, dst) # atomowe na tym samym FS
2912 + try:
2913 + if os.path.exists(dst_new + ".asc"):
2914 + os.remove(dst_new + ".asc")
2915 + except OSError:
2916 + pass
2917 + print(f" ✅ Zainstalowano nowy pag. Stary zachowany jako {dst_bak}")
2918 + print(" Uruchom ponownie pag, aby użyć nowej wersji.")
2919 + return 0
2920 +
2921 +
2922 +def _candidate_newer(rp, inst):
2923 + """Czy pakiet z repo jest nowszy od zainstalowanego.
2924 + Porównuje (version, release): sam bump pkgrel (np. auto-rebuild modułów
2925 + po aktualizacji jądra: nvidia-kernel-618 610.57.04-1 -> -2) też musi być
2926 + widziany przez `pag update`. Stare rekordy instalacji (bez pola release)
2927 + traktujemy jak release=1 – nie generują churnu, dopóki nie wrócą do
2928 + reinstalacji/zmiany wersji."""
2929 + rv = getattr(rp, "version", "0")
2930 + iv = inst.get("version", "0")
2931 + if _version_newer(rv, iv):
2932 + return True
2933 + if rv != iv:
2934 + return False
2935 + rr = int(getattr(rp, "release", 1) or 1)
2936 + ir = int(inst.get("release", 1) or 1)
2937 + return rr > ir
2938 +
2939 +
2940 +def _pending_updates() -> List[str]:
2941 + """Zainstalowane pakiety z nowszą wersją/release w repo (bez przypiętych)."""
2942 + installed = load_json(INSTALLED_DB)
2943 + pinned = load_json(PINNED_FILE)
2944 + repo = fetch_all_packages()
2945 + if not repo:
2946 + return []
2947 + return [n for n, i in installed.items()
2948 + if n not in pinned and (rp := repo.get(n)) and _candidate_newer(rp, i)]
2949 +
2950 +def cmd_update(do_upgrade: bool = False):
2951 + """`pag sync` / `pag update` – odświeżenie indeksów + raport aktualizacji.
2952 +
2953 + sync → tylko odświeżenie indeksów + info: „jest X pakietów do
2954 + zaktualizowania – wpisz: pag update".
2955 + update → odświeżenie indeksów + AKTUALIZACJA PAKIETÓW (pakiety, nie system).
2956 + Pomijamy cache TTL (inaczej nowe pakiety/aktualizacje są niewidoczne nawet
2957 + przez godzinę). Pełne pobranie + weryfikacja GPG przy każdym odświeżeniu.
2958 + """
2959 + force = True
2960 + print("🔄 Refreshing indexes...")
2961 + for repo_url in get_repos():
2962 + pkgs = fetch_repo_index(repo_url, force=force)
2963 + cp = _repo_cache_path(repo_url)
2964 + has_sig = os.path.exists(cp + ".sig")
2965 + print(f" {'✅' if pkgs is not None else '❌'} {repo_url}: {len(pkgs or [])} pkgs {'🔐' if has_sig else '⚠'}")
2966 + print(f"✅ {_('indexes_refreshed')}")
2967 +
2968 + # Powiadomienie o nowszej wersji pag (repo.json["pag_version"])
2969 + try:
2970 + for r in get_repos():
2971 + cp = _repo_cache_path(r)
2972 + if os.path.exists(cp):
2973 + d = json.load(open(cp))
2974 + rv = d.get("pag_version", "")
2975 + if rv and rv != PAG_VERSION:
2976 + print(f" ⚠ Nowa wersja pag {rv} dostępna – uruchom: pag self-update")
2977 + except Exception:
2978 + pass
2979 +
2980 + # Raport: pakiety do aktualizacji
2981 + pending = _pending_updates()
2982 + if not pending:
2983 + print(f"✅ {_('all_up_to_date')}")
2984 + return 0
2985 + print(f"{_('updates_available', len(pending))}")
2986 + installed = load_json(INSTALLED_DB)
2987 + repo = fetch_all_packages()
2988 + for n in pending:
2989 + print(f" {n}: {installed.get(n, {}).get('version', '?')} → {repo[n].version}")
2990 + if not do_upgrade:
2991 + return 0 # sync: tylko informacja
2992 + if not _ask_confirm():
2993 + return 0
2994 + return cmd_install(pending, upgrade=True)
2995 +
2996 +def _initramfs_stale() -> bool:
2997 + """Czy initramfs jest starszy niż najnowsze jądro (wymaga przebudowy)."""
2998 + try:
2999 + kernels = [k for k in os.listdir("/boot") if k.startswith("vmlinuz-")] if os.path.isdir("/boot") else []
3000 + if not kernels:
3001 + return False
3002 + newest = max(os.path.getmtime(os.path.join("/boot", k)) for k in kernels)
3003 + initrd = "/boot/initramfs.img"
3004 + return (not os.path.exists(initrd)) or os.path.getmtime(initrd) < newest
3005 + except Exception:
3006 + return False
3007 +
3008 +def cmd_upgrade():
3009 + """`pag upgrade` – aktualizacja SYSTEMU: pakiety + kernel/initramfs/GRUB."""
3010 + rc = cmd_update(do_upgrade=True)
3011 + if rc != 0:
3012 + return rc
3013 + # System: dopilnuj initramfs (gdyby kernel był nowszy) + GRUB (immutable)
3014 + if _initramfs_stale():
3015 + print(" 🐧 Przebudowa initramfs (nowsze jądro)...")
3016 + _rebuild_initramfs()
3017 + try:
3018 + if _load_deployments():
3019 + _update_grub_config()
3020 + except Exception:
3021 + pass
3022 + return 0
3023 +
3024 +def cmd_list(installed_only=False):
3025 + if installed_only:
3026 + db = load_json(INSTALLED_DB)
3027 + pinned = load_json(PINNED_FILE)
3028 + if not db: print("No packages installed."); return
3029 + print(f"Installed ({len(db)}):")
3030 + for n, i in sorted(db.items()):
3031 + pin = " 📌" if n in pinned else ""
3032 + print(f" {n}-{i['version']}{pin} – {i.get('description','')}")
3033 + else:
3034 + pkgs = fetch_all_packages()
3035 + installed = load_json(INSTALLED_DB)
3036 + pinned = load_json(PINNED_FILE)
3037 + print(f"Available ({len(pkgs)}):")
3038 + for n, p in sorted(pkgs.items()):
3039 + m = "✓" if n in installed else " "
3040 + extra = f" [installed: {installed[n]['version']}]" if n in installed else ""
3041 + if n in pinned: extra += " 📌"
3042 + print(f" [{m}] {n}-{p.version} – {p.description}{extra}")
3043 +
3044 +def cmd_search(query):
3045 + pkgs = fetch_all_packages()
3046 + results = [(n,p) for n,p in pkgs.items() if query.lower() in n.lower() or query.lower() in p.description.lower()]
3047 + if not results: print(f"❌ No results for: {query}"); return
3048 + installed = load_json(INSTALLED_DB)
3049 + print(f"Results for '{query}' ({len(results)}):")
3050 + for n,p in sorted(results):
3051 + print(f" [{'✓' if n in installed else ' '}] {n}-{p.version}")
3052 + print(f" {p.description}")
3053 +
3054 +
3055 +def _smart_search(query: str) -> int:
3056 + """
3057 + Inteligentne wyszukiwanie: repo PaganOS + Flathub.
3058 + Uruchamiane gdy użytkownik wpisze `pag <nazwa>` zamiast `pag install <nazwa>`.
3059 + Pokazuje dostępne źródła i sugeruje komendy instalacji.
3060 + """
3061 + # 1. Repo PaganOS
3062 + try:
3063 + pkgs = fetch_all_packages()
3064 + except Exception:
3065 + pkgs = {}
3066 + repo_lower = [(n, p) for n, p in pkgs.items()
3067 + if query.lower() in n.lower() or query.lower() in p.description.lower()]
3068 +
3069 + # 2. Flathub (jeśli dostępny)
3070 + flat = _flatpak_search_raw(query) if _check_flatpak(quiet=True) else []
3071 +
3072 + if not repo_lower and not flat:
3073 + print(f"\n ❌ '{query}' — nie znaleziono.")
3074 + print(f" Repo PaganOS: pag search {query}")
3075 + if _check_flatpak(quiet=True):
3076 + print(f" Flathub: pag flatpak search {query}")
3077 + print(f" Dodaj repo: pag repo-add <url>")
3078 + return 1
3079 +
3080 + installed = load_json(INSTALLED_DB)
3081 +
3082 + # ── Repo PaganOS ──
3083 + if repo_lower:
3084 + exact = [(n, p) for n, p in repo_lower if n.lower() == query.lower()]
3085 + show = (exact or repo_lower)[:6]
3086 + print(f"\n 📦 PaganOS — '{query}':")
3087 + for n, p in sorted(show):
3088 + mark = "✓" if n in installed else " "
3089 + desc = p.description[:70] if len(p.description) > 75 else p.description
3090 + print(f" [{mark}] {n}-{p.version}")
3091 + if desc:
3092 + print(f" {desc}")
3093 + if len(repo_lower) > 6:
3094 + print(f" ... i {len(repo_lower) - 6} więcej (pag search {query})")
3095 +
3096 + # ── Flathub ──
3097 + if flat:
3098 + print(f"\n 📦 Flathub — '{query}':")
3099 + for r in flat[:5]:
3100 + mark = "✓" if r.get("installed") else " "
3101 + name = r.get("name") or r.get("application", "?")
3102 + desc = (r.get("description") or "")[:65]
3103 + print(f" [{mark}] {name}")
3104 + if desc:
3105 + print(f" {desc}")
3106 + if len(flat) > 5:
3107 + print(f" ... i {len(flat) - 5} więcej (pag flatpak search {query})")
3108 +
3109 + # ── Sugestie instalacji ──
3110 + print()
3111 + if repo_lower:
3112 + 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]
3113 + if best in installed:
3114 + print(f" ✓ {best} jest już zainstalowany ({installed[best]['version']})")
3115 + else:
3116 + print(f" 💡 sudo pag install {best}")
3117 + if flat:
3118 + best_fp = flat[0].get("application") or flat[0].get("name", query)
3119 + print(f" 💡 pag flatpak install {best_fp}")
3120 +
3121 + return 0
3122 +
3123 +def cmd_info(name):
3124 + pkgs = fetch_all_packages()
3125 + p = pkgs.get(name)
3126 + info = load_json(INSTALLED_DB).get(name)
3127 + if not p and not info: print(f"❌ '{name}' not found."); return 1
3128 + print(f"📦 {name}")
3129 + if p:
3130 + print(f" Version (repo): {p.version}")
3131 + print(f" Description: {p.description}")
3132 + print(f" Size: {p.size_bytes/1048576:.1f} MB")
3133 + print(f" SHA256: {p.sha256[:32]}...")
3134 + print(f" GPG: {p.gpg_fp or 'none'}")
3135 + print(f" Dependencies: {', '.join(p.dependencies) if p.dependencies else '(none)'}")
3136 + if info:
3137 + print(f" Installed: {info['version']} ({info.get('installed_at','?')})")
3138 +
3139 +def cmd_files(name):
3140 + if name not in load_json(INSTALLED_DB):
3141 + print(f"❌ '{name}' not installed."); return 1
3142 + files = _db_get_package_files(name)
3143 + print(f"Files in {name} ({len(files)}):")
3144 + for f in sorted(files): print(f" {f}")
3145 +
3146 +def cmd_verify(deep=False):
3147 + installed = load_json(INSTALLED_DB)
3148 + if not installed: print("Nothing to verify."); return
3149 + errors = []
3150 +
3151 + for name in installed:
3152 + for fpath in _db_get_package_files(name):
3153 + full = os.path.join(PAG_ROOT, fpath.lstrip("/"))
3154 + if not (os.path.exists(full) or os.path.islink(full)):
3155 + errors.append(f" ❌ {name}: missing {fpath}")
3156 + elif deep:
3157 + checksums = _db_get_all_file_checksums()
3158 + expected = checksums.get(fpath, "")
3159 + if expected:
3160 + actual = _sha256_file(full)
3161 + if actual != expected:
3162 + errors.append(f" ❌ {name}: SHA256 mismatch {fpath}")
3163 +
3164 + if errors:
3165 + print(f"❌ {_('verify_errors', len(errors))}")
3166 + for e in errors[:50]: print(e)
3167 + return 1
3168 + total = _db_count_files()
3169 + print(f"✅ {_('verify_ok', total)}")
3170 +
3171 +# =============================================================================
3172 +# PINNING / CLEAN / ORPHANS / REPO / FLATPAK
3173 +# =============================================================================
3174 +
3175 +def cmd_pin(name, version=""):
3176 + pinned = load_json(PINNED_FILE)
3177 + if version:
3178 + pinned[name] = version
3179 + else:
3180 + info = load_json(INSTALLED_DB).get(name, {})
3181 + pinned[name] = info.get("version", "?")
3182 + save_json(PINNED_FILE, pinned)
3183 + print(f"📌 {name} {_('pinned_to')} {pinned[name]}")
3184 +
3185 +def cmd_unpin(name):
3186 + pinned = load_json(PINNED_FILE)
3187 + if name in pinned:
3188 + del pinned[name]; save_json(PINNED_FILE, pinned)
3189 + print(f"🔓 {name} {_('unpinned')}")
3190 + else:
3191 + print(f"⚠ {name} {_('not_pinned')}")
3192 +
3193 +def cmd_pinned():
3194 + pinned = load_json(PINNED_FILE)
3195 + if not pinned: print(_("no_pinned")); return
3196 + print(_("pinned_list", len(pinned)))
3197 + for n,v in sorted(pinned.items()): print(f" 📌 {n} = {v}")
3198 +
3199 +def cmd_clean():
3200 + if os.path.isdir(PAG_CACHE):
3201 + count = size = 0
3202 + for f in os.listdir(PAG_CACHE):
3203 + fp = os.path.join(PAG_CACHE, f)
3204 + if os.path.isfile(fp):
3205 + size += os.path.getsize(fp); os.remove(fp); count += 1
3206 + print(f"✅ {_('cache_cleared', count, size/1048576)}")
3207 +
3208 +def cmd_remove_orphans():
3209 + installed = load_json(INSTALLED_DB)
3210 + world = load_world()
3211 + orphans = _find_orphans(installed, world)
3212 + if not orphans: print("✅ No orphans."); return
3213 + print(f"Orphans ({len(orphans)}):")
3214 + for n in sorted(orphans): print(f" {n}-{installed[n]['version']}")
3215 + if not _ask_confirm():
3216 + return
3217 + cmd_remove(list(orphans))
3218 +
3219 +
3220 +# =============================================================================
3221 +# PROVIDES – PAKIETY WIRTUALNE
3222 +# =============================================================================
3223 +
3224 +PROVIDES_MAP = {
3225 + "pkgconfig(glib-2.0)": "glib",
3226 + "pkgconfig(gobject-introspection-1.0)": "gobject-introspection",
3227 + "pkgconfig(gtk+-3.0)": "gtk",
3228 + "pkgconfig(gtk4)": "gtk",
3229 + "pkgconfig(zlib)": "zlib",
3230 + "pkgconfig(libffi)": "libffi",
3231 + "pkgconfig(expat)": "expat",
3232 + "pkgconfig(libsystemd)": "systemd",
3233 + "pkgconfig(dbus-1)": "dbus",
3234 + "pkgconfig(mount)": "util-linux",
3235 + "pkgconfig(blkid)": "util-linux",
3236 + "pkgconfig(libcap)": "libcap",
3237 + "pkgconfig(liblzma)": "xz",
3238 + "pkgconfig(libzstd)": "zstd",
3239 + "pkgconfig(bzip2)": "bzip2",
3240 + "pkgconfig(libcurl)": "curl",
3241 + "pkgconfig(openssl)": "openssl",
3242 + "pkgconfig(libpcre2-8)": "pcre2",
3243 + "pkgconfig(libxml-2.0)": "libxml2",
3244 + "pkgconfig(libxslt)": "libxslt",
3245 + "pkgconfig(freetype2)": "freetype",
3246 + "pkgconfig(fontconfig)": "fontconfig",
3247 + "pkgconfig(harfbuzz)": "harfbuzz",
3248 + "pkgconfig(cairo)": "cairo",
3249 + "pkgconfig(pango)": "pango",
3250 + "pkgconfig(xt)": "xorg-libxt",
3251 + "pkgconfig(xmu)": "xorg-libxmu",
3252 + "pkgconfig(ice)": "xorg-libice",
3253 + "pkgconfig(sm)": "xorg-libsm",
3254 + "pkgconfig(x11)": "xorg-libx11",
3255 + "pkgconfig(xext)": "xorg-libxext",
3256 + "pkgconfig(xrandr)": "xorg-libxrandr",
3257 + "pkgconfig(xfixes)": "xorg-libxfixes",
3258 + "pkgconfig(xcursor)": "xorg-libxcursor",
3259 + "pkgconfig(xinerama)": "xorg-libxinerama",
3260 + "pkgconfig(xrender)": "xorg-libxrender",
3261 + "pkgconfig(xau)": "xorg-libxau",
3262 + "pkgconfig(xcb)": "xorg-libxcb",
3263 + "pkgconfig(xdamage)": "xorg-libxdamage",
3264 + "pkgconfig(xcomposite)": "xorg-libxcomposite",
3265 + "pkgconfig(xft)": "xorg-libxft",
3266 + "pkgconfig(xss)": "xorg-libxss",
3267 + "pkgconfig(libsoup-3.0)": "libsoup3",
3268 + "pkgconfig(libsoup-2.4)": "libsoup2",
3269 + "pkgconfig(gdk-pixbuf-2.0)": "gdk-pixbuf2",
3270 + "pkgconfig(libpng)": "libpng",
3271 + "pkgconfig(libjpeg)": "libjpeg-turbo",
3272 + "pkgconfig(libtiff-4)": "libtiff",
3273 + "pkgconfig(ffi)": "libffi",
3274 + # ── system / baza ──
3275 + "pkgconfig(libcrypto)": "openssl",
3276 + "pkgconfig(libssl)": "openssl",
3277 + "pkgconfig(libudev)": "systemd",
3278 + "pkgconfig(libmount)": "util-linux",
3279 + "pkgconfig(libblkid)": "util-linux",
3280 + "pkgconfig(uuid)": "util-linux",
3281 + "pkgconfig(libexpat)": "expat",
3282 + "pkgconfig(libpcre)": "pcre",
3283 + "pkgconfig(ncursesw)": "ncurses",
3284 + "pkgconfig(tinfo)": "ncurses",
3285 + "pkgconfig(panel)": "ncurses",
3286 + "pkgconfig(readline)": "readline",
3287 + "pkgconfig(libseccomp)": "libseccomp",
3288 + "pkgconfig(pam)": "linux-pam",
3289 + "pkgconfig(libxcrypt)": "libxcrypt",
3290 + "pkgconfig(libcrypt)": "libxcrypt",
3291 + "pkgconfig(libnsl)": "libnsl",
3292 + "pkgconfig(liblz4)": "lz4",
3293 + "pkgconfig(libevent)": "libevent",
3294 + "pkgconfig(libarchive)": "libarchive",
3295 + "pkgconfig(sqlite3)": "sqlite",
3296 + "pkgconfig(libpq)": "postgresql",
3297 + "pkgconfig(mysqlclient)": "mariadb",
3298 + "pkgconfig(json-c)": "json-c",
3299 + "pkgconfig(json-glib-1.0)": "json-glib",
3300 + "pkgconfig(libunistring)": "libunistring",
3301 + "pkgconfig(libidn2)": "libidn2",
3302 + "pkgconfig(libpsl)": "libpsl",
3303 + "pkgconfig(icu-uc)": "icu",
3304 + "pkgconfig(icu-i18n)": "icu",
3305 + "pkgconfig(icu-io)": "icu",
3306 + "pkgconfig(gnutls)": "gnutls",
3307 + "pkgconfig(nettle)": "nettle",
3308 + "pkgconfig(hogweed)": "nettle",
3309 + "pkgconfig(libgcrypt)": "libgcrypt",
3310 + "pkgconfig(libgpg-error)": "libgpg-error",
3311 + "pkgconfig(libassuan)": "libassuan",
3312 + "pkgconfig(libusb-1.0)": "libusb",
3313 + "pkgconfig(libusb)": "libusb",
3314 + "pkgconfig(libgudev-1.0)": "libgudev",
3315 + "pkgconfig(gudev-1.0)": "libgudev",
3316 + "pkgconfig(polkit-gobject-1)": "polkit",
3317 + "pkgconfig(polkit-agent-1)": "polkit",
3318 + "pkgconfig(libpciaccess)": "libpciaccess",
3319 + "pkgconfig(pixman-1)": "pixman",
3320 + "pkgconfig(libdrm)": "libdrm",
3321 + "pkgconfig(libva)": "libva",
3322 + "pkgconfig(libva-drm)": "libva",
3323 + "pkgconfig(libva-x11)": "libva",
3324 + "pkgconfig(libva-wayland)": "libva",
3325 + "pkgconfig(vdpau)": "libvdpau",
3326 + "pkgconfig(libvdpau)": "libvdpau",
3327 + "pkgconfig(libinput)": "libinput",
3328 + "pkgconfig(libevdev)": "libevdev",
3329 + "pkgconfig(mtdev)": "mtdev",
3330 + # ── grafika / GL / multimedia ──
3331 + "pkgconfig(gbm)": "mesa",
3332 + "pkgconfig(gl)": "libglvnd",
3333 + "pkgconfig(egl)": "libglvnd",
3334 + "pkgconfig(glesv2)": "libglvnd",
3335 + "pkgconfig(glx)": "libglvnd",
3336 + "pkgconfig(vulkan)": "vulkan-loader",
3337 + "pkgconfig(libxkbcommon)": "libxkbcommon",
3338 + "pkgconfig(xkbcommon)": "libxkbcommon",
3339 + "pkgconfig(xkbcommon-x11)": "libxkbcommon",
3340 + "pkgconfig(xcb)": "xorg-libxcb",
3341 + "pkgconfig(xcb-util)": "xcb-util",
3342 + "pkgconfig(xcb-keysyms)": "xcb-util-keysyms",
3343 + "pkgconfig(xcb-icccm)": "xcb-util-wm",
3344 + "pkgconfig(xcb-cursor)": "xcb-util-cursor",
3345 + "pkgconfig(xcb-renderutil)": "xcb-util-renderutil",
3346 + "pkgconfig(xcb-image)": "xcb-util-image",
3347 + "pkgconfig(xcb-errors)": "xcb-util-errors",
3348 + "pkgconfig(wayland-client)": "wayland",
3349 + "pkgconfig(wayland-server)": "wayland",
3350 + "pkgconfig(wayland-cursor)": "wayland",
3351 + "pkgconfig(wayland-egl)": "wayland",
3352 + "pkgconfig(wayland-protocols)": "wayland-protocols",
3353 + "pkgconfig(gstreamer-1.0)": "gstreamer",
3354 + "pkgconfig(gstreamer-base-1.0)": "gstreamer",
3355 + "pkgconfig(gstreamer-check-1.0)": "gstreamer",
3356 + "pkgconfig(gstreamer-controller-1.0)": "gstreamer",
3357 + "pkgconfig(gstreamer-app-1.0)": "gst-plugins-base",
3358 + "pkgconfig(gstreamer-video-1.0)": "gst-plugins-base",
3359 + "pkgconfig(gstreamer-audio-1.0)": "gst-plugins-base",
3360 + "pkgconfig(gstreamer-pbutils-1.0)": "gst-plugins-base",
3361 + "pkgconfig(gstreamer-fft-1.0)": "gst-plugins-base",
3362 + "pkgconfig(gstreamer-riff-1.0)": "gst-plugins-base",
3363 + "pkgconfig(gstreamer-rtp-1.0)": "gst-plugins-base",
3364 + "pkgconfig(gstreamer-rtsp-1.0)": "gst-plugins-base",
3365 + "pkgconfig(gstreamer-sdp-1.0)": "gst-plugins-base",
3366 + "pkgconfig(gstreamer-net-1.0)": "gst-plugins-base",
3367 + "pkgconfig(gstreamer-gl-1.0)": "gst-plugins-base",
3368 + "pkgconfig(libpulse)": "libpulse",
3369 + "pkgconfig(libpulse-simple)": "libpulse",
3370 + "pkgconfig(libpulse-mainloop-glib)": "libpulse",
3371 + "pkgconfig(alsa)": "alsa-lib",
3372 + "pkgconfig(jack)": "jack2",
3373 + "pkgconfig(libsamplerate)": "libsamplerate",
3374 + "pkgconfig(sndfile)": "libsndfile",
3375 + "pkgconfig(libavcodec)": "ffmpeg",
3376 + "pkgconfig(libavformat)": "ffmpeg",
3377 + "pkgconfig(libavutil)": "ffmpeg",
3378 + "pkgconfig(libavfilter)": "ffmpeg",
3379 + "pkgconfig(libswscale)": "ffmpeg",
3380 + "pkgconfig(libswresample)": "ffmpeg",
3381 + "pkgconfig(libpostproc)": "ffmpeg",
3382 + "pkgconfig(SDL2)": "sdl2",
3383 + "pkgconfig(SDL)": "sdl",
3384 + "pkgconfig(SDL2_image)": "sdl2-image",
3385 + "pkgconfig(SDL2_ttf)": "sdl2-ttf",
3386 + "pkgconfig(SDL2_mixer)": "sdl2-mixer",
3387 + "pkgconfig(SDL2_net)": "sdl2-net",
3388 + "pkgconfig(libpng16)": "libpng",
3389 + "pkgconfig(libwebp)": "libwebp",
3390 + "pkgconfig(libwebpmux)": "libwebp",
3391 + "pkgconfig(libwebpdemux)": "libwebp",
3392 + "pkgconfig(libopenjp2)": "openjpeg2",
3393 + "pkgconfig(lcms2)": "lcms2",
3394 + "pkgconfig(libheif)": "libheif",
3395 + "pkgconfig(libde265)": "libde265",
3396 + "pkgconfig(x264)": "x264",
3397 + "pkgconfig(x265)": "x265",
3398 + # ── glib / gio ──
3399 + "pkgconfig(gio-unix-2.0)": "glib",
3400 + "pkgconfig(gmodule-2.0)": "glib",
3401 + "pkgconfig(gthread-2.0)": "glib",
3402 + "pkgconfig(girepository-2.0)": "gobject-introspection",
3403 + "pkgconfig(girepository-1.0)": "gobject-introspection",
3404 + "pkgconfig(libglib-2.0)": "glib",
3405 + "pkgconfig(libgobject-2.0)": "glib",
3406 +}
3407 +
3408 +def _resolve_provides(name: str, repo: dict, installed: Optional[dict] = None) -> str:
3409 + """Rozwija wirtualną nazwę pakietu do rzeczywistej nazwy.
3410 +
3411 + Kolejność: repo → PROVIDES_MAP → wzorce → provides z repo.json →
3412 + provides ZAINSTALOWANYCH pakietów (lokalnie zbudowane poza repo też
3413 + dostarczają wirtualne zależności) → fallback pkgconfig (czyszczenie nazwy).
3414 + """
3415 + if name in repo:
3416 + return name
3417 + if name in PROVIDES_MAP:
3418 + real = PROVIDES_MAP[name]
3419 + if real in repo:
3420 + return real
3421 + # Wzorce: moduły Qt (Qt5Core/Qt6Widgets) i GStreamer (gstreamer-video-1.0)
3422 + if name.startswith("pkgconfig(Qt5"):
3423 + real = "qt5"
3424 + if real in repo:
3425 + return real
3426 + if name.startswith("pkgconfig(Qt6"):
3427 + real = "qt6"
3428 + if real in repo:
3429 + return real
3430 + if name.startswith("pkgconfig(gstreamer-") and name.endswith("-1.0)"):
3431 + real = "gstreamer"
3432 + if real in repo:
3433 + return real
3434 + if name.startswith("pkgconfig(gst-"):
3435 + real = "gst-plugins-base"
3436 + if real in repo:
3437 + return real
3438 + # Dynamiczne provides z repo.json (sekcja provides: w PAGBUILD.yaml)
3439 + for _pkg_name, _pkg in repo.items():
3440 + _provs = getattr(_pkg, "provides", None) or []
3441 + if name in _provs:
3442 + return _pkg_name
3443 + # provides ZAINSTALOWANYCH pakietów – lokalnie zbudowane (pagbuild, poza
3444 + # repo) też dostarczają wirtualne zależności i muszą być rozpoznawane.
3445 + if installed:
3446 + for _pkg_name, _meta in installed.items():
3447 + _provs = _meta.get("provides") or [] if isinstance(_meta, dict) else []
3448 + if name in _provs:
3449 + return _pkg_name
3450 + clean = name
3451 + if name.startswith("pkgconfig(") and ")" in name:
3452 + clean = name.split("(", 1)[1].rstrip(")")
3453 + elif name.startswith("pkgconfig32(") and ")" in name:
3454 + clean = name.split("(", 1)[1].rstrip(")")
3455 + if clean != name and clean in repo:
3456 + return clean
3457 + return name
3458 +
3459 +
3460 +def cmd_why(pkg_name: str):
3461 + """Pokazuje dlaczego pakiet jest zainstalowany."""
3462 + installed = load_json(INSTALLED_DB)
3463 + world = load_world()
3464 + if pkg_name not in installed:
3465 + print(f" {pkg_name}: {_('why_not_installed')}"); return 1
3466 + if pkg_name in world:
3467 + print(f" {pkg_name}-{installed[pkg_name]['version']}: {_('why_explicit')}")
3468 + return 0
3469 + parents = set()
3470 + for w in world:
3471 + _find_dep_path(w, pkg_name, installed, set(), [], parents)
3472 + if parents:
3473 + for pp in sorted(parents):
3474 + print(f" {pkg_name}: {_('why_dependency')} {' → '.join(pp)}")
3475 + else:
3476 + print(f" {pkg_name}: {_('why_dependency')} (unknown/orphan)")
3477 + return 0
3478 +
3479 +
3480 +def _find_dep_path(cur, target, installed, visited, path, results):
3481 + if cur in visited: return
3482 + visited.add(cur); path.append(cur)
3483 + if cur == target:
3484 + results.add(tuple(path))
3485 + else:
3486 + for dep in installed.get(cur, {}).get("dependencies", []):
3487 + _find_dep_path(dep, target, installed, visited, path, results)
3488 + path.pop(); visited.discard(cur)
3489 +
3490 +
3491 +def cmd_autoremove():
3492 + """Automatycznie usuwa osierocone zależności bez pytania."""
3493 + installed = load_json(INSTALLED_DB)
3494 + world = load_world()
3495 + orphans = _find_orphans(installed, world)
3496 + if not orphans: print(f"✅ {_('autoremove_none')}"); return 0
3497 + print(f"🗑 {_('orphans_found', len(orphans), ', '.join(sorted(orphans)[:10]))}")
3498 + return cmd_remove(list(orphans))
3499 +
3500 +
3501 +def cmd_download(package_names):
3502 + """Pobiera pakiety do cache bez instalowania."""
3503 + ensure_dirs()
3504 + repo = fetch_all_packages()
3505 + if not repo: print(f"❌ {_('no_index')}"); return 1
3506 + total_size = 0; downloaded = []
3507 + for name in package_names:
3508 + pkg = repo.get(name)
3509 + if not pkg:
3510 + print(f" ❌ {name}: {_('not_found')}"); continue
3511 + print(f" ↓ {name}-{pkg.version} ...", end=" ", flush=True)
3512 + path = _download_pkg(pkg)
3513 + if path:
3514 + total_size += os.path.getsize(path)
3515 + downloaded.append(name)
3516 + print(_c("green", "✓"))
3517 + else:
3518 + print(_c("red", "✗"))
3519 + if downloaded:
3520 + print(f"\n✅ {_('downloaded', len(downloaded), total_size/1048576)}")
3521 + return 0 if len(downloaded) == len(package_names) else 1
3522 +
3523 +
3524 +def cmd_stats():
3525 + """Wyświetla statystyki PAG."""
3526 + installed = load_json(INSTALLED_DB)
3527 + history = load_json(HISTORY_FILE) if os.path.exists(HISTORY_FILE) else []
3528 + total_size = sum(i.get("size_bytes", 0) for i in installed.values())
3529 + total_files = _db_count_files()
3530 + cache_size = sum(
3531 + os.path.getsize(os.path.join(PAG_CACHE, f))
3532 + for f in os.listdir(PAG_CACHE)
3533 + if os.path.isfile(os.path.join(PAG_CACHE, f))
3534 + ) if os.path.isdir(PAG_CACHE) else 0
3535 + last_update = "never"
3536 + for e in reversed(history):
3537 + if e.get("action") in ("install", "upgrade") and e.get("success"):
3538 + last_update = e.get("timestamp", "?")[:19]; break
3539 + print(f"\n {_c('bold', _('stats_title'))}")
3540 + print(f" {'─' * 40}")
3541 + print(f" {_('stats_packages'):<30} {len(installed)}")
3542 + print(f" {_('stats_files'):<30} {total_files}")
3543 + print(f" {_('stats_size'):<30} {total_size/1048576:.1f} MB")
3544 + print(f" {_('stats_cache'):<30} {cache_size/1048576:.1f} MB")
3545 + print(f" {_('stats_history'):<30} {len(history)}")
3546 + print(f" {_('stats_last_update'):<30} {last_update}")
3547 + by_size = sorted(installed.items(), key=lambda x: x[1].get("size_bytes", 0), reverse=True)[:5]
3548 + if by_size:
3549 + print(f"\n {_c('dim', 'Top 5:')}")
3550 + for n, i in by_size:
3551 + print(f" {n}-{i['version']} {i.get('size_bytes',0)/1048576:.1f} MB")
3552 + return 0
3553 +
3554 +
3555 +def cmd_repo_add(url, name=None):
3556 + if not url.startswith("https://") and not os.environ.get("PAG_INSECURE"):
3557 + print(f" {_('sec_https')}"); return 1
3558 + ensure_dirs()
3559 + url = url.rstrip("/")
3560 + repos = get_repos()
3561 + if url in repos: print(f"⚠ {_('repo_exists', url)}"); return
3562 + if name:
3563 + # Drop-in: /etc/pag/repos/<nazwa>.conf (jak `echo url > .../stable.conf`)
3564 + os.makedirs(REPOS_DIR, exist_ok=True)
3565 + target = os.path.join(REPOS_DIR, name.rstrip("/").replace("/", "_") + ".conf")
3566 + with open(target, "w") as f: f.write(f"{url}\n")
3567 + print(f"✅ {_('repo_added', url)} → {target}")
3568 + return
3569 + with open(REPOS_CONF, "a") as f: f.write(f"{url}\n")
3570 + print(f"✅ {_('repo_added', url)}")
3571 +
3572 +def cmd_repo_list():
3573 + for i, url in enumerate(get_repos(), 1): print(f" {i}. {url}")
3574 +
3575 +def _check_flatpak(quiet: bool = False):
3576 + if not shutil.which("flatpak"):
3577 + if not quiet:
3578 + print(f"❌ {_('flatpak_missing')}")
3579 + return False
3580 + r = subprocess.run(["flatpak","remotes"], capture_output=True, text=True)
3581 + if "flathub" not in r.stdout:
3582 + print(f"⚠ {_('flatpak_adding')}")
3583 + subprocess.run(["flatpak","remote-add","--if-not-exists","flathub",
3584 + "https://flathub.org/repo/flathub.flatpakrepo"], check=False)
3585 + return True
3586 +
3587 +def _spinner(msg: str):
3588 + """Prosty spinner „myślenia” w osobnym wątku. Zwraca funkcję stop()."""
3589 + stop = threading.Event()
3590 + def _spin():
3591 + for c in itertools.cycle("|/-\\"):
3592 + if stop.is_set():
3593 + break
3594 + sys.stdout.write(f"\r {msg} {c}")
3595 + sys.stdout.flush()
3596 + time.sleep(0.1)
3597 + t = threading.Thread(target=_spin, daemon=True)
3598 + t.start()
3599 + def _stop():
3600 + stop.set()
3601 + t.join(timeout=0.3)
3602 + sys.stdout.write("\r" + " " * (len(msg) + 4) + "\r")
3603 + sys.stdout.flush()
3604 + return _stop
3605 +
3606 +
3607 +def _flatpak_search_raw(query: str) -> List[dict]:
3608 + """Szuka we Flathub i zwraca listę wyników jako słowniki."""
3609 + if not _check_flatpak():
3610 + return []
3611 + stop = _spinner("Szukam we Flathub...")
3612 + try:
3613 + try:
3614 + r = subprocess.run(
3615 + ["flatpak", "search", "--columns=name,description,application,version,branch,remotes", query],
3616 + capture_output=True, text=True, timeout=120
3617 + )
3618 + finally:
3619 + stop()
3620 + if r.returncode != 0 and "No matches found" not in r.stdout and not r.stdout.strip():
3621 + print(f" ⚠ flatpak search: {r.stderr.strip()[:150]}")
3622 + results = []
3623 + for line in r.stdout.strip().split("\n"):
3624 + parts = line.split("\t")
3625 + if len(parts) >= 3:
3626 + results.append({
3627 + "name": parts[0].strip(),
3628 + "description": parts[1].strip() if len(parts) > 1 else "",
3629 + "app_id": parts[2].strip() if len(parts) > 2 else "",
3630 + "version": parts[3].strip() if len(parts) > 3 else "",
3631 + "branch": parts[4].strip() if len(parts) > 4 else "stable",
3632 + "origin": parts[5].strip() if len(parts) > 5 else "flathub",
3633 + })
3634 + return results
3635 + except Exception as e:
3636 + print(f" ⚠ Błąd wyszukiwania: {e}", file=sys.stderr)
3637 + return []
3638 +
3639 +def _flatpak_find_best(query: str) -> Optional[dict]:
3640 + """
3641 + Szuka we Flathub i próbuje znaleźć najlepsze dopasowanie.
3642 + - Jeśli query dokładnie pasuje do app_id → zwraca od razu
3643 + - Jeśli query pasuje do nazwy → zwraca pierwsze
3644 + - Jeśli wiele wyników → wyświetla listę i pyta użytkownika
3645 + - Jeśli brak → zwraca None
3646 + """
3647 + results = _flatpak_search_raw(query)
3648 + if not results:
3649 + return None
3650 +
3651 + # Dokładne dopasowanie app_id
3652 + exact = [r for r in results if r["app_id"].lower() == query.lower()]
3653 + if exact:
3654 + return exact[0]
3655 +
3656 + # Dokładne dopasowanie nazwy
3657 + exact_name = [r for r in results if r["name"].lower() == query.lower()]
3658 + if exact_name:
3659 + return exact_name[0]
3660 +
3661 + # Jednoznaczne dopasowanie (tylko 1 wynik)
3662 + if len(results) == 1:
3663 + return results[0]
3664 +
3665 + # Wiele wyników – pokaż użytkownikowi
3666 + print(f"\n {_('flatpak_found', len(results))}")
3667 + for i, r in enumerate(results):
3668 + print(f" {i+1}. {_c('bold', r['name'])} ({r['app_id']})")
3669 + if r["version"]:
3670 + print(f" {_('flatpak_info_version')}: {r['version']}")
3671 + if r["description"]:
3672 + desc = r["description"][:80] + ("..." if len(r["description"]) > 80 else "")
3673 + print(f" {desc}")
3674 +
3675 + try:
3676 + choice = input(f"\n Wybierz numer (1-{len(results)}) lub Enter aby anulować: ").strip()
3677 + if not choice:
3678 + return None
3679 + idx = int(choice) - 1
3680 + if 0 <= idx < len(results):
3681 + return results[idx]
3682 + except (EOFError, ValueError, IndexError):
3683 + pass
3684 + return None
3685 +
3686 +def _flatpak_get_installed_info(app_id: str) -> Optional[dict]:
3687 + """Zwraca info o zainstalowanym flatpaku lub None."""
3688 + try:
3689 + r = subprocess.run(
3690 + ["flatpak", "info", "--columns=name,version,branch,origin,installed-size,description", app_id],
3691 + capture_output=True, text=True, timeout=10
3692 + )
3693 + if r.returncode != 0:
3694 + return None
3695 + parts = r.stdout.strip().split("\t")
3696 + if len(parts) < 3:
3697 + return None
3698 + return {
3699 + "name": parts[0].strip(),
3700 + "version": parts[1].strip() if len(parts) > 1 else "",
3701 + "branch": parts[2].strip() if len(parts) > 2 else "",
3702 + "origin": parts[3].strip() if len(parts) > 3 else "",
3703 + "size": parts[4].strip() if len(parts) > 4 else "",
3704 + "description": parts[5].strip() if len(parts) > 5 else "",
3705 + }
3706 + except Exception:
3707 + return None
3708 +
3709 +def _flatpak_is_installed(app_id: str) -> bool:
3710 + """Sprawdza czy flatpak o danym ID jest zainstalowany."""
3711 + try:
3712 + r = subprocess.run(
3713 + ["flatpak", "info", app_id],
3714 + capture_output=True, text=True, timeout=10
3715 + )
3716 + return r.returncode == 0
3717 + except Exception:
3718 + return False
3719 +
3720 +# =============================================================================
3721 +# FLATPAK – KOMENDY GŁÓWNE (zunifikowany interfejs)
3722 +# =============================================================================
3723 +# pag flatpak <query> → szuka i proponuje instalację (jeśli nie zainstalowany)
3724 +# pag flatpak search <query> → tylko szuka
3725 +# pag flatpak install <query> → instaluje
3726 +# pag flatpak remove <id> → usuwa
3727 +# pag flatpak list → lista zainstalowanych
3728 +# pag flatpak update → aktualizuje wszystkie
3729 +# pag flatpak info <id> → szczegóły flatpaka
3730 +
3731 +def cmd_flatpak(args: list):
3732 + """
3733 + Główna komenda flatpak – inteligentnie rozpoznaje intencję:
3734 + pag flatpak firefox → szuka i instaluje (jeśli nieznaleziony → szuka)
3735 + pag flatpak search firefox → tylko wyszukiwanie
3736 + pag flatpak install ... → bezpośrednia instalacja
3737 + pag flatpak remove ... → odinstalowanie
3738 + pag flatpak list → lista
3739 + pag flatpak update → aktualizacja
3740 + pag flatpak info ... → szczegóły
3741 + """
3742 + if not _check_flatpak():
3743 + return 1
3744 +
3745 + if not args:
3746 + # Bez argumentów – domyślnie lista
3747 + return cmd_flatpak_list()
3748 +
3749 + subcmd = args[0].lower()
3750 + rest = args[1:]
3751 +
3752 + # ── Podkomendy jawne ────────────────────────────────────────────────
3753 + if subcmd == "search":
3754 + if not rest:
3755 + print(_("flatpak_usage")); return 1
3756 + return cmd_flatpak_search(" ".join(rest))
3757 +
3758 + elif subcmd == "install":
3759 + if not rest:
3760 + print(_("flatpak_usage")); return 1
3761 + return _flatpak_smart_install(rest)
3762 +
3763 + elif subcmd == "remove" or subcmd == "uninstall":
3764 + if not rest:
3765 + print(_("flatpak_usage")); return 1
3766 + return _flatpak_smart_remove(rest)
3767 +
3768 + elif subcmd == "list":
3769 + return cmd_flatpak_list()
3770 +
3771 + elif subcmd == "update":
3772 + return cmd_flatpak_update()
3773 +
3774 + elif subcmd == "info":
3775 + if not rest:
3776 + print(_("flatpak_usage")); return 1
3777 + return cmd_flatpak_info(rest[0])
3778 +
3779 + else:
3780 + # ── Inteligentne wykrywanie: pag flatpak <nazwa> ────────────────
3781 + # Sprawdź czy to zainstalowany flatpak → pokaż info
3782 + # Jeśli nie → szukaj i zaproponuj instalację
3783 + query = " ".join(args)
3784 +
3785 + # Najpierw sprawdź czy już zainstalowany
3786 + if _flatpak_is_installed(query):
3787 + print(f" 📦 {_c('green', query)} – already installed (use 'pag flatpak info {query}' for details)")
3788 + return cmd_flatpak_info(query)
3789 +
3790 + # Szukaj we Flathub
3791 + print(f" {_('flatpak_searching', query)}")
3792 + best = _flatpak_find_best(query)
3793 + if not best:
3794 + print(f" ❌ '{query}' – {_('flatpak_not_found')}")
3795 + return 1
3796 +
3797 + print(f"\n {_c('cyan', best['name'])} ({best['app_id']})")
3798 + if best["version"]:
3799 + print(f" {_('flatpak_info_version')}: {best['version']}")
3800 + if best["description"]:
3801 + print(f" {best['description']}")
3802 +
3803 + try:
3804 + ans = input(f"\n {_('flatpak_install_prompt', best['name'])}").strip().lower()
3805 + except (EOFError, KeyboardInterrupt):
3806 + print(f"\n ⚠ {_('no_tty')}")
3807 + return 0
3808 + if ans and ans not in ("t", "y"):
3809 + print(_("cancelled"))
3810 + return 0
3811 +
3812 + return _flatpak_do_install(best["app_id"])
3813 +
3814 +def _flatpak_smart_install(names: list) -> int:
3815 + """Instaluje flatpaki – obsługuje nazwy częściowe (wyszukuje przed instalacją)."""
3816 + failed = 0
3817 + for name in names:
3818 + if "." in name and "/" not in name:
3819 + # Wygląda na pełne app_id (np. org.mozilla.firefox)
3820 + app_id = name
3821 + else:
3822 + # Szukaj najlepszego dopasowania
3823 + best = _flatpak_find_best(name)
3824 + if not best:
3825 + print(f" ❌ '{name}' – {_('flatpak_not_found')}")
3826 + failed += 1
3827 + continue
3828 + app_id = best["app_id"]
3829 + print(f" → {best['name']} ({app_id})")
3830 +
3831 + if _flatpak_do_install(app_id) != 0:
3832 + failed += 1
3833 + return 1 if failed else 0
3834 +
3835 +def _flatpak_do_install(app_id: str) -> int:
3836 + """Wykonuje właściwą instalację flatpaka."""
3837 + print(f" {_('flatpak_installing', app_id)}")
3838 + result = subprocess.run(
3839 + ["flatpak", "install", "-y", "flathub", app_id],
3840 + check=False, timeout=600
3841 + )
3842 + if result.returncode == 0:
3843 + print(f" ✅ {_('flatpak_installed', app_id)}")
3844 + return 0
3845 + else:
3846 + print(f" ❌ {_('download_fail')}: {app_id}")
3847 + return 1
3848 +
3849 +def _flatpak_smart_remove(names: list) -> int:
3850 + """Usuwa flatpaki – obsługuje nazwy częściowe."""
3851 + # Pobierz listę zainstalowanych
3852 + try:
3853 + r = subprocess.run(
3854 + ["flatpak", "list", "--columns=application,name"],
3855 + capture_output=True, text=True, timeout=10
3856 + )
3857 + installed = {}
3858 + for line in r.stdout.strip().split("\n"):
3859 + parts = line.split("\t")
3860 + if len(parts) >= 2:
3861 + installed[parts[0].strip()] = parts[1].strip()
3862 + except Exception:
3863 + installed = {}
3864 +
3865 + failed = 0
3866 + for name in names:
3867 + app_id = name
3868 +
3869 + # Jeśli nie podano pełnego ID – spróbuj dopasować
3870 + if name not in installed:
3871 + matches = {aid: aname for aid, aname in installed.items()
3872 + if name.lower() in aid.lower() or name.lower() in aname.lower()}
3873 + if len(matches) == 0:
3874 + print(f" ❌ '{name}' – {_('flatpak_not_installed', name)}")
3875 + failed += 1
3876 + continue
3877 + elif len(matches) == 1:
3878 + app_id = list(matches.keys())[0]
3879 + print(f" → {matches[app_id]} ({app_id})")
3880 + else:
3881 + print(f"\n Wiele dopasowań dla '{name}':")
3882 + for i, (aid, aname) in enumerate(sorted(matches.items()), 1):
3883 + print(f" {i}. {aname} ({aid})")
3884 + try:
3885 + choice = input(f"\n Wybierz numer (1-{len(matches)}) lub Enter: ").strip()
3886 + if not choice:
3887 + failed += 1
3888 + continue
3889 + aid_list = sorted(matches.keys())
3890 + app_id = aid_list[int(choice) - 1]
3891 + except (EOFError, ValueError, IndexError):
3892 + failed += 1
3893 + continue
3894 +
3895 + print(f" 🗑 {app_id} ...", end=" ", flush=True)
3896 + result = subprocess.run(
3897 + ["flatpak", "uninstall", "-y", app_id],
3898 + capture_output=True, text=True, timeout=120
3899 + )
3900 + if result.returncode == 0:
3901 + print("✅")
3902 + print(f" {_('flatpak_removed', app_id)}")
3903 + else:
3904 + print("❌")
3905 + failed += 1
3906 + return 1 if failed else 0
3907 +
3908 +def cmd_flatpak_search(q: str):
3909 + """Wyszukuje we Flathub i wyświetla wyniki (z możliwością wyboru do instalacji)."""
3910 + if not _check_flatpak():
3911 + return 1
3912 + results = _flatpak_search_raw(q)
3913 + if not results:
3914 + print(f" ❌ '{q}' – {_('flatpak_not_found')}")
3915 + return 1
3916 + print(f"\n {_('flatpak_found', len(results))}")
3917 + shown = results[:30] # max 30 wyników
3918 + for i, r in enumerate(shown, 1):
3919 + installed = "📦 " if _flatpak_is_installed(r["app_id"]) else " "
3920 + print(f" {i:>2}. {installed}{_c('bold', r['name'])} ({r['app_id']})")
3921 + if r["version"]:
3922 + print(f" {_('flatpak_info_version')}: {r['version']} | {_('flatpak_info_branch')}: {r['branch']}")
3923 + if r["description"]:
3924 + desc = r["description"][:100] + ("..." if len(r["description"]) > 100 else "")
3925 + print(f" {_c('dim', desc)}")
3926 + if len(results) > 30:
3927 + print(f" ... i {len(results) - 30} więcej. Doprecyzuj zapytanie.")
3928 +
3929 + # Interaktywny wybór – wpisz numer, aby zainstalować (Enter = anuluj)
3930 + try:
3931 + ans = input(f"\n Wybierz numer do zainstalowania (1-{len(shown)}) lub Enter aby anulować: ").strip()
3932 + except (EOFError, KeyboardInterrupt):
3933 + return 0
3934 + if ans:
3935 + try:
3936 + idx = int(ans) - 1
3937 + if 0 <= idx < len(shown):
3938 + return _flatpak_do_install(shown[idx]["app_id"])
3939 + print(_("cancelled"))
3940 + except (ValueError, IndexError):
3941 + print(_("cancelled"))
3942 + return 0
3943 +
3944 +def cmd_flatpak_list():
3945 + """Wyświetla zainstalowane flatpaki."""
3946 + if not _check_flatpak():
3947 + return 1
3948 + r = subprocess.run(
3949 + ["flatpak", "list", "--columns=application,name,version,origin,installed-size"],
3950 + capture_output=True, text=True, timeout=10
3951 + )
3952 + lines = [l for l in r.stdout.strip().split("\n") if l.strip()]
3953 + if not lines:
3954 + print(" (brak zainstalowanych flatpaków)")
3955 + return 0
3956 + print(f" Zainstalowane flatpaki ({len(lines)}):")
3957 + for line in lines:
3958 + parts = line.split("\t")
3959 + if len(parts) >= 3:
3960 + app_id, name, version = parts[0], parts[1], parts[2]
3961 + size = parts[4] if len(parts) > 4 else ""
3962 + size_str = f" ({size})" if size else ""
3963 + print(f" 📦 {_c('bold', name)} {version}{size_str}")
3964 + print(f" {_c('dim', app_id)}")
3965 + return 0
3966 +
3967 +def cmd_flatpak_update():
3968 + """Aktualizuje wszystkie flatpaki."""
3969 + if not _check_flatpak():
3970 + return 1
3971 + print(" 🔄 Aktualizacja flatpaków...")
3972 + result = subprocess.run(["flatpak", "update", "-y"], check=False, timeout=600)
3973 + if result.returncode == 0:
3974 + print(f" ✅ {_('flatpak_updated')}")
3975 + return result.returncode
3976 +
3977 +def cmd_flatpak_info(app_id: str):
3978 + """Wyświetla szczegóły flatpaka (zainstalowanego lub z Flathub)."""
3979 + if not _check_flatpak():
3980 + return 1
3981 +
3982 + # Najpierw sprawdź zainstalowany
3983 + info = _flatpak_get_installed_info(app_id)
3984 + if info:
3985 + print(f"\n 📦 {_c('bold', info['name'])} {_c('green', '[zainstalowany]')}")
3986 + print(f" {'─' * 45}")
3987 + print(f" {_('flatpak_info_id'):<16} {app_id}")
3988 + print(f" {_('flatpak_info_version'):<16} {info['version']}")
3989 + print(f" {_('flatpak_info_branch'):<16} {info['branch']}")
3990 + print(f" {_('flatpak_info_origin'):<16} {info['origin']}")
3991 + if info["size"]:
3992 + print(f" {_('flatpak_info_size'):<16} {info['size']}")
3993 + if info["description"]:
3994 + print(f" {_('flatpak_info_desc'):<16} {info['description']}")
3995 + return 0
3996 +
3997 + # Szukaj we Flathub
3998 + results = _flatpak_search_raw(app_id)
3999 + exact = [r for r in results if r["app_id"].lower() == app_id.lower()]
4000 + if not exact:
4001 + # Spróbuj częściowego dopasowania
4002 + if results:
4003 + exact = [results[0]]
4004 + else:
4005 + print(f" ❌ '{app_id}' – {_('flatpak_not_found')}")
4006 + return 1
4007 +
4008 + r = exact[0]
4009 + print(f"\n 📦 {_c('bold', r['name'])} (Flathub)")
4010 + print(f" {'─' * 45}")
4011 + print(f" {_('flatpak_info_id'):<16} {r['app_id']}")
4012 + print(f" {_('flatpak_info_version'):<16} {r['version']}")
4013 + if r["description"]:
4014 + print(f" {_('flatpak_info_desc'):<16} {r['description']}")
4015 + print(f"\n 💡 Aby zainstalować: pag flatpak install {r['app_id']}")
4016 + return 0
4017 +
4018 +# =============================================================================
4019 +# IMMUTABLE OS – KOMENDY DEPLOYMENTOWE
4020 +# =============================================================================
4021 +
4022 +# Pakiety jądra – po ich instalacji trzeba przebudować initramfs
4023 +KERNEL_PACKAGE_PATTERNS = ["linux", "kernel", "linux-kernel", "linux-lts"]
4024 +
4025 +def _is_kernel_package(name: str) -> bool:
4026 + """Sprawdza czy pakiet to jądro (wymaga przebudowy initramfs)."""
4027 + name_lower = name.lower()
4028 + return any(pattern in name_lower for pattern in KERNEL_PACKAGE_PATTERNS)
4029 +
4030 +def _rebuild_initramfs(deploy_dir: str = "") -> bool:
4031 + """
4032 + Przebudowuje initramfs dla aktywnego (lub podanego) deploymentu.
4033 + Używa skryptu pag-initramfs lub ręcznego cpio.
4034 + """
4035 + if deploy_dir:
4036 + root = deploy_dir
4037 + else:
4038 + root = _get_deployment_root()
4039 +
4040 + if root == PAG_ROOT:
4041 + # Zwykły system – użyj dracut jeśli dostępny
4042 + if shutil.which("dracut"):
4043 + print(" 🔧 Przebudowa initramfs (dracut)...")
4044 + result = subprocess.run(
4045 + ["dracut", "--force", "/boot/initramfs.img"],
4046 + capture_output=True, text=True, timeout=120
4047 + )
4048 + return result.returncode == 0
4049 + elif shutil.which("mkinitcpio"):
4050 + print(" 🔧 Przebudowa initramfs (mkinitcpio)...")
4051 + result = subprocess.run(
4052 + ["mkinitcpio", "-g", "/boot/initramfs.img"],
4053 + capture_output=True, text=True, timeout=120
4054 + )
4055 + return result.returncode == 0
4056 + else:
4057 + print(" ⚠ Brak dracut/mkinitcpio – initramfs nie został przebudowany")
4058 + return False
4059 +
4060 + # Tryb immutable – budujemy initramfs dla deploymentu
4061 + print(" 🔧 Budowanie initramfs dla deploymentu...")
4062 +
4063 + # Sprawdź czy mamy nasz skrypt init
4064 + pag_init_script = "/usr/share/pag/initramfs-init"
4065 + if not os.path.exists(pag_init_script):
4066 + # Szukaj w źródłach (developerski fallback)
4067 + alt_paths = [
4068 + os.path.join(os.path.dirname(os.path.abspath(__file__)), "scripts", "initramfs-init"),
4069 + "/usr/share/pag/init",
4070 + ]
4071 + for p in alt_paths:
4072 + if os.path.exists(p):
4073 + pag_init_script = p
4074 + break
4075 +
4076 + if not os.path.exists(pag_init_script):
4077 + print(" ⚠ Nie znaleziono pag-initramfs-init – pomijam budowę initramfs")
4078 + return False
4079 +
4080 + boot_dir = os.path.join(root, "boot")
4081 + os.makedirs(boot_dir, exist_ok=True)
4082 +
4083 + # Znajdź jądro (vmlinuz-*)
4084 + kernels = sorted(
4085 + [f for f in os.listdir(boot_dir) if f.startswith("vmlinuz-")],
4086 + reverse=True
4087 + ) if os.path.exists(boot_dir) else []
4088 + if not kernels:
4089 + print(" ⚠ Nie znaleziono vmlinuz-* w /boot deploymentu")
4090 + return False
4091 +
4092 + kernel_ver = kernels[0].replace("vmlinuz-", "")
4093 + print(f" 🐧 Jądro: {kernel_ver}")
4094 +
4095 + # Buduj initramfs ręcznie (cpio)
4096 + tmpdir = tempfile.mkdtemp(prefix="pag-initramfs-")
4097 + try:
4098 + # Podstawowa struktura
4099 + for d in ["bin", "sbin", "dev", "proc", "sys", "run", "new_root",
4100 + "usr/bin", "usr/sbin", "lib", "lib64", "etc"]:
4101 + os.makedirs(os.path.join(tmpdir, d), exist_ok=True)
4102 +
4103 + # Skopiuj init
4104 + shutil.copy2(pag_init_script, os.path.join(tmpdir, "init"))
4105 + os.chmod(os.path.join(tmpdir, "init"), 0o755)
4106 +
4107 + # Skopiuj niezbędne binaria (busybox lub podstawowe narzędzia)
4108 + busybox_paths = [
4109 + os.path.join(root, "usr/bin/busybox"),
4110 + os.path.join(root, "bin/busybox"),
4111 + "/usr/bin/busybox",
4112 + "/bin/busybox",
4113 + ]
4114 + busybox = None
4115 + for bp in busybox_paths:
4116 + if os.path.exists(bp):
4117 + busybox = bp
4118 + break
4119 +
4120 + if busybox:
4121 + shutil.copy2(busybox, os.path.join(tmpdir, "bin/busybox"))
4122 + # Utwórz symlinki dla podstawowych komend
4123 + for cmd in ["sh", "mount", "umount", "ls", "cat", "echo", "sleep",
4124 + "readlink", "mkdir", "switch_root", "cp", "rm"]:
4125 + link = os.path.join(tmpdir, "bin", cmd)
4126 + if not os.path.exists(link):
4127 + os.symlink("busybox", link)
4128 + # /bin/sh → busybox
4129 + if not os.path.exists(os.path.join(tmpdir, "bin/sh")):
4130 + os.symlink("busybox", os.path.join(tmpdir, "bin/sh"))
4131 + else:
4132 + # Bez busybox – kopiuj podstawowe narzędzia z deploymentu
4133 + for tool in ["bash", "mount", "umount", "readlink", "mkdir", "cat", "sleep", "cp", "rm"]:
4134 + src = os.path.join(root, "usr/bin", tool)
4135 + if not os.path.exists(src):
4136 + src = os.path.join(root, "bin", tool)
4137 + if os.path.exists(src):
4138 + dest = os.path.join(tmpdir, "bin", os.path.basename(tool))
4139 + shutil.copy2(src, dest)
4140 + # Kopiuj zależności .so
4141 + _copy_libs_for_binary(src, tmpdir, root)
4142 +
4143 + # Dodaj moduły jądra (opcjonalnie – dla sterowników dyskowych)
4144 + modules_src = os.path.join(root, "lib/modules", kernel_ver)
4145 + if os.path.isdir(modules_src):
4146 + modules_dst = os.path.join(tmpdir, "lib/modules", kernel_ver)
4147 + # Kopiuj tylko niezbędne (fs, block, drivers/ata, drivers/nvme)
4148 + for sub in ["kernel/fs", "kernel/drivers/ata", "kernel/drivers/nvme",
4149 + "kernel/drivers/scsi", "kernel/drivers/virtio",
4150 + "modules.order", "modules.builtin"]:
4151 + src_sub = os.path.join(modules_src, sub)
4152 + if os.path.exists(src_sub):
4153 + dst_sub = os.path.join(modules_dst, sub)
4154 + os.makedirs(os.path.dirname(dst_sub), exist_ok=True)
4155 + if os.path.isdir(src_sub):
4156 + try:
4157 + shutil.copytree(src_sub, dst_sub, dirs_exist_ok=True, symlinks=True,
4158 + ignore_dangling_symlinks=True)
4159 + except (FileNotFoundError, PermissionError):
4160 + print(f" ⚠ Pomijam niedostępne pliki: {sub}")
4161 + else:
4162 + try:
4163 + shutil.copy2(src_sub, dst_sub)
4164 + except (FileNotFoundError, PermissionError):
4165 + print(f" ⚠ Pomijam niedostępny plik: {sub}")
4166 +
4167 + # Pakuj do initramfs.img
4168 + initramfs_path = os.path.join(boot_dir, "initramfs.img")
4169 + old_cwd = os.getcwd()
4170 + os.chdir(tmpdir)
4171 + try:
4172 + with open(initramfs_path + ".tmp", "wb") as out:
4173 + _run_cpio_pipeline(tmpdir, out)
4174 + os.rename(initramfs_path + ".tmp", initramfs_path)
4175 + finally:
4176 + os.chdir(old_cwd)
4177 +
4178 + size_mb = os.path.getsize(initramfs_path) / 1048576
4179 + print(f" ✅ initramfs.img ({size_mb:.1f} MB) → {initramfs_path}")
4180 + return True
4181 +
4182 + except Exception as e:
4183 + print(f" ❌ Błąd budowy initramfs: {e}")
4184 + return False
4185 + finally:
4186 + shutil.rmtree(tmpdir, ignore_errors=True)
4187 +
4188 +
4189 +def _run_cpio_pipeline(tmpdir: str, out):
4190 + """find . -print0 | cpio --null -oH newc | gzip — bez shell=True.
4191 +
4192 + Buduje pipeline przez subprocess.Popen, unikając pośrednika powłoki
4193 + (brak ryzyka injection i niepotrzebnego procesu sh). Wykonuje się w cwd=tmpdir.
4194 + Separatory NUL (\0): plik/katalog ze znakiem nowej linii w nazwie nie
4195 + rozjeżdża cpio (inaczej uszkodzone archiwum → kernel panic przy rozruchu).
4196 + """
4197 + find = subprocess.Popen(["find", ".", "-print0"], cwd=tmpdir, stdout=subprocess.PIPE)
4198 + cpio = subprocess.Popen(["cpio", "--null", "-oH", "newc"], cwd=tmpdir,
4199 + stdin=find.stdout, stdout=subprocess.PIPE)
4200 + find.stdout.close() # zwolnij uchwyt – cpio dostanie SIGPIPE po zakończeniu find
4201 + gzip = subprocess.Popen(["gzip"], stdin=cpio.stdout, stdout=out)
4202 + cpio.stdout.close()
4203 + try:
4204 + gzip.wait(timeout=120)
4205 + if gzip.returncode != 0:
4206 + raise subprocess.CalledProcessError(gzip.returncode, ["gzip"])
4207 + cpio.wait(timeout=30)
4208 + find.wait(timeout=30)
4209 + except subprocess.TimeoutExpired:
4210 + for p in (gzip, cpio, find):
4211 + p.kill()
4212 + raise
4213 + finally:
4214 + for p in (find, cpio, gzip):
4215 + if p.poll() is None:
4216 + p.kill()
4217 + # Skontroluj też kody procesów pośrednich (cpio/find mogą zawieść, a gzip zwrócić 0)
4218 + if cpio.returncode != 0:
4219 + raise subprocess.CalledProcessError(cpio.returncode, ["cpio"])
4220 + if find.returncode != 0:
4221 + raise subprocess.CalledProcessError(find.returncode, ["find"])
4222 +
4223 +
4224 +def _copy_libs_for_binary(binary: str, dest_dir: str, root: str):
4225 + """Kopiuje zależności .so dla binarki do initramfs (uproszczone ldd)."""
4226 + try:
4227 + result = subprocess.run(
4228 + ["ldd", binary], capture_output=True, text=True, timeout=10
4229 + )
4230 + for line in result.stdout.split("\n"):
4231 + m = re.search(r'=>\s+(/\S+)', line)
4232 + if m:
4233 + lib_path = m.group(1)
4234 + lib_rel = lib_path.lstrip("/")
4235 + lib_dest = os.path.join(dest_dir, lib_rel)
4236 + if not os.path.exists(lib_dest):
4237 + os.makedirs(os.path.dirname(lib_dest), exist_ok=True)
4238 + # Szukaj w deployment root lub systemie
4239 + if os.path.exists(lib_path):
4240 + shutil.copy2(lib_path, lib_dest)
4241 + else:
4242 + alt = os.path.join(root, lib_rel)
4243 + if os.path.exists(alt):
4244 + shutil.copy2(alt, lib_dest)
4245 + except Exception:
4246 + pass
4247 +
4248 +
4249 +def cmd_initramfs_update():
4250 + """Ręcznie przebudowuje initramfs dla bieżącego deploymentu."""
4251 + ensure_dirs()
4252 + deploy_dir = _get_deployment_root()
4253 + if deploy_dir != PAG_ROOT:
4254 + print(f"🏗️ Deployment: {os.path.basename(deploy_dir)}")
4255 + ok = _rebuild_initramfs(deploy_dir)
4256 + if ok:
4257 + print("✅ Initramfs zaktualizowany.")
4258 + # Po initramfs – zaktualizuj też GRUB
4259 + _update_grub_config()
4260 + else:
4261 + print("❌ Błąd aktualizacji initramfs.")
4262 + return 0 if ok else 1
4263 +
4264 +
4265 +def _update_grub_config():
4266 + """
4267 + Generuje wpisy GRUB dla wszystkich deploymentów.
4268 + Każdy deployment dostaje własny wpis – rollback możliwy z bootloadera.
4269 + """
4270 + grub_cfg = "/boot/grub/grub.cfg"
4271 + if not os.path.exists(os.path.dirname(grub_cfg)):
4272 + return # brak GRUB
4273 +
4274 + deployments = _load_deployments()
4275 + root_dev = _detect_root_device()
4276 +
4277 + lines = [
4278 + "# =====================================================================",
4279 + "# Pagan Linux – GRUB config (wygenerowane przez pag grub-update)",
4280 + f"# Data: {datetime.now().isoformat()}",
4281 + "# =====================================================================",
4282 + "",
4283 + ]
4284 +
4285 + # Domyślny – ostatni (najnowszy) deployment
4286 + if deployments:
4287 + latest = deployments[-1]["id"]
4288 + lines.append(f"set default=0")
4289 + lines.append(f"set timeout=5")
4290 + else:
4291 + lines.append("set default=0")
4292 + lines.append("set timeout=5")
4293 + lines.append("")
4294 +
4295 + # Wpisy dla każdego deploymentu (od najnowszego)
4296 + entry_num = 0
4297 + for d in reversed(deployments):
4298 + deploy_dir = os.path.join(DEPLOYMENTS_DIR, d["id"])
4299 + boot_dir = os.path.join(deploy_dir, "boot")
4300 + kernels = sorted(
4301 + [f for f in os.listdir(boot_dir) if f.startswith("vmlinuz-")],
4302 + reverse=True
4303 + ) if os.path.isdir(boot_dir) else []
4304 +
4305 + kernel_path = f"/.deployments/{d['id']}/boot/{kernels[0]}" if kernels else ""
4306 + initrd_path = f"/.deployments/{d['id']}/boot/initramfs.img"
4307 + initrd_line = f"initrd {initrd_path}" if os.path.exists(os.path.join(boot_dir, "initramfs.img")) else ""
4308 +
4309 + active_mark = " [AKTYWNY]" if d.get("active") else ""
4310 + pkg_list = ", ".join(d.get("packages", [])[:3])
4311 + label = f"Pagan Linux – {d['id']}{active_mark}"
4312 +
4313 + lines.append(f"menuentry '{label}' {{")
4314 + if kernel_path:
4315 + lines.append(f" linux {kernel_path} root={root_dev} rw quiet")
4316 + else:
4317 + lines.append(f" # Brak jądra w tym deploymencie")
4318 + if initrd_line:
4319 + lines.append(f" {initrd_line}")
4320 + lines.append("}")
4321 + lines.append("")
4322 + entry_num += 1
4323 +
4324 + # Wpis fallback: zwykły root (gdyby wszystko padło)
4325 + lines.append("menuentry 'Pagan Linux – fallback (zwykły root)' {")
4326 + lines.append(f" linux /boot/vmlinuz-* root={root_dev} rw quiet")
4327 + lines.append(f" initrd /boot/initramfs.img")
4328 + lines.append("}")
4329 + lines.append("")
4330 +
4331 + # Zapisz
4332 + os.makedirs(os.path.dirname(grub_cfg), exist_ok=True)
4333 + with open(grub_cfg, "w") as f:
4334 + f.write("\n".join(lines))
4335 +
4336 + print(" 📋 GRUB config zaktualizowany – wpisy dla każdego deploymentu")
4337 +
4338 +
4339 +def _detect_root_device() -> str:
4340 + """Wykrywa device partycji root (np. /dev/sda1)."""
4341 + try:
4342 + result = subprocess.run(
4343 + ["findmnt", "-n", "-o", "SOURCE", "/"],
4344 + capture_output=True, text=True, timeout=5
4345 + )
4346 + if result.returncode == 0 and result.stdout.strip():
4347 + return result.stdout.strip()
4348 + except Exception:
4349 + pass
4350 + return "/dev/sda1" # fallback
4351 +
4352 +
4353 +def cmd_grub_update():
4354 + """Ręcznie regeneruje konfigurację GRUB (wpisy dla deploymentów)."""
4355 + ensure_dirs()
4356 + print("📋 Aktualizacja konfiguracji GRUB...")
4357 + _update_grub_config()
4358 + print("✅ GRUB zaktualizowany.")
4359 + return 0
4360 +
4361 +def cmd_deploy_list():
4362 + """Wyświetla listę wszystkich deploymentów."""
4363 + deployments = _load_deployments()
4364 + if not deployments:
4365 + print(_("no_deployments")); return
4366 +
4367 + print(_("deployments_list", len(deployments)))
4368 + active = os.readlink(ACTIVE_LINK) if os.path.islink(ACTIVE_LINK) else ""
4369 +
4370 + for d in reversed(deployments):
4371 + marker = f" ◀ {_('active_deployment')}" if d.get("active") or d["id"] == os.path.basename(active) else ""
4372 + print(f" {d['id']}{marker}")
4373 + print(f" {d['action']}: {', '.join(d['packages'][:5])}")
4374 + if len(d.get('packages', [])) > 5:
4375 + print(f" +{len(d['packages']) - 5} więcej...")
4376 + print(f" {d['timestamp']}")
4377 +
4378 +
4379 +def cmd_deploy_rollback():
4380 + """Przełącza na poprzedni deployment."""
4381 + deployments = _load_deployments()
4382 + active_indices = [i for i, d in enumerate(deployments) if d.get("active")]
4383 +
4384 + if len(deployments) < 2:
4385 + print(f"❌ {_('deploy_rollback_fail')}"); return 1
4386 +
4387 + current_idx = active_indices[0] if active_indices else len(deployments) - 1
4388 + prev_idx = current_idx - 1 if current_idx > 0 else -1
4389 +
4390 + if prev_idx < 0:
4391 + print(f"❌ {_('deploy_rollback_fail')}"); return 1
4392 +
4393 + prev = deployments[prev_idx]
4394 + prev_dir = os.path.join(DEPLOYMENTS_DIR, prev["id"])
4395 +
4396 + if not os.path.isdir(prev_dir):
4397 + print(f"❌ Deployment {prev['id']} nie istnieje na dysku"); return 1
4398 +
4399 + print(f"⏪ Przywracanie deploymentu: {prev['id']}")
4400 + print(f" {prev['action']}: {', '.join(prev['packages'][:5])}")
4401 +
4402 + if not _ask_confirm():
4403 + return 0
4404 +
4405 + _switch_deployment(prev_dir)
4406 +
4407 + for d in deployments:
4408 + d["active"] = (d["id"] == prev["id"])
4409 + _save_deployments(deployments)
4410 +
4411 + _update_grub_config()
4412 + print(f"✅ {_('deploy_rollback_ok', prev['id'])}")
4413 + print(" 💡 Restart wymagany do przeładowania systemu.")
4414 + return 0
4415 +
4416 +
4417 +def cmd_deploy_cleanup(keep: int = 3):
4418 + """Usuwa stare deploymenty, zachowując ostatnie `keep`."""
4419 + deployments = _load_deployments()
4420 +
4421 + if len(deployments) <= keep:
4422 + print(f"✅ {_('deploy_cleanup_none', keep)}"); return 0
4423 +
4424 + to_remove = deployments[:-keep]
4425 + removed = 0
4426 +
4427 + for d in to_remove:
4428 + deploy_dir = os.path.join(DEPLOYMENTS_DIR, d["id"])
4429 + if os.path.isdir(deploy_dir):
4430 + shutil.rmtree(deploy_dir, ignore_errors=True)
4431 + removed += 1
4432 +
4433 + remaining = deployments[-keep:]
4434 + _save_deployments(remaining)
4435 +
4436 + print(f"✅ {_('deploy_cleanup_ok', removed)}")
4437 + return 0
4438 +
4439 +
4440 +# =============================================================================
4441 +# POMOCNICZE
4442 +# =============================================================================
4443 +
4444 +def _resolve_deps(names, repo, installed):
4445 + resolved, visited = [], set()
4446 + missing = [] # zależności których nie ma ani w repo ani zainstalowane
4447 +
4448 + def visit(name):
4449 + if name in visited: return
4450 +
4451 + # Rozwijanie wirtualnych zależności przez provides
4452 + target = _resolve_provides(name, repo, installed)
4453 +
4454 + if target in visited: return
4455 + visited.add(target)
4456 + if target in repo:
4457 + for dep in repo[target].dependencies:
4458 + real_dep = _resolve_provides(dep, repo, installed)
4459 + real_target = real_dep if real_dep in repo else dep
4460 +
4461 + # Sprawdź czy zależność jest dostępna
4462 + if real_target not in installed and real_target not in repo:
4463 + if dep not in missing:
4464 + missing.append(dep)
4465 +
4466 + if dep not in installed:
4467 + visit(real_target)
4468 + elif target not in installed:
4469 + # Pakiet nie istnieje ani w repo ani zainstalowany
4470 + if target not in missing:
4471 + missing.append(target)
4472 +
4473 + if target not in installed and target not in resolved:
4474 + resolved.append(target)
4475 +
4476 + for name in names:
4477 + visit(name)
4478 +
4479 + # Zwróć brakujące (do sprawdzenia przez wywołującego)
4480 + return resolved, missing
4481 +
4482 +def _verify_dependencies(to_install: list, repo: dict, installed: dict) -> int:
4483 + """
4484 + Sprawdza czy wszystkie zależności pakietów do instalacji są spełnione.
4485 + Zwraca liczbę brakujących zależności.
4486 + """
4487 + # Pakiety dostarczane przez bazowy system (zawsze "zainstalowane")
4488 + SYSTEM_BASE = {
4489 + "glibc", "libc", "gcc", "g++", "make", "binutils", "coreutils", "bash",
4490 + "linux-api-headers", "kernel-headers", "zlib", "pkg-config", "pkgconf",
4491 + "tar", "gzip", "xz", "bzip2", "findutils", "grep", "sed", "gawk", "awk",
4492 + "diffutils", "patch", "file", "m4", "perl", "python3", "sh",
4493 + }
4494 + all_missing = []
4495 + all_warnings = []
4496 +
4497 + for pkg_name in to_install:
4498 + pkg = repo.get(pkg_name)
4499 + if not pkg:
4500 + continue
4501 +
4502 + for dep in pkg.dependencies:
4503 + if dep in SYSTEM_BASE:
4504 + continue # bazowy system dostarcza tę zależność
4505 + real_dep = _resolve_provides(dep, repo, installed)
4506 + # Sprawdź czy zależność jest dostępna (w repo lub już zainstalowana)
4507 + in_repo = real_dep in repo
4508 + in_installed = real_dep in installed
4509 + will_be_installed = real_dep in to_install
4510 +
4511 + if not in_repo and not in_installed and not will_be_installed:
4512 + if dep not in all_missing:
4513 + all_missing.append((pkg_name, dep))
4514 + elif in_repo and not in_installed and not will_be_installed:
4515 + if dep not in [w[1] for w in all_warnings]:
4516 + all_warnings.append((pkg_name, dep, real_dep))
4517 +
4518 + if all_missing:
4519 + print(f"\n❌ {_c('red', 'BRAKUJĄCE ZALEŻNOŚCI')} – nie można zainstalować:")
4520 + for pkg, dep in all_missing:
4521 + print(f" {pkg} → potrzebuje {_c('red', dep)} (brak w repozytoriach)")
4522 + print()
4523 +
4524 + if all_warnings:
4525 + print(f"\n⚠ {_c('yellow', 'NIESPEŁNIONE ZALEŻNOŚCI')} – zostaną doinstalowane:")
4526 + for pkg, dep, real in all_warnings:
4527 + print(f" {pkg} → {dep} ({_c('green', real)} – będzie pobrane)")
4528 + print()
4529 +
4530 + return len(all_missing)
4531 +
4532 +# Biblioteki bazowe (glibc/gcc runtime) – zawsze dostępne, nie wymagają pakietu
4533 +BASE_SO = {
4534 + "libc.so.6", "libm.so.6", "libpthread.so.0", "libdl.so.2", "librt.so.1",
4535 + "libutil.so.1", "libresolv.so.2", "libnsl.so.1", "libcrypt.so.1",
4536 + "ld-linux.so.2", "ld-linux-x86-64.so.2", "ld-linux-aarch64.so.1",
4537 + "libgcc_s.so.1", "linux-vdso.so.1",
4538 +}
4539 +
4540 +def _verify_so_deps(to_install: list, repo: dict, installed: dict) -> int:
4541 + """Sprawdza wymagania ABI (provides_so / requires_so z metadata.json).
4542 +
4543 + Fail-closed TYLKO gdy metadata jawnie deklaruje requires_so, a żaden pakiet
4544 + (bazowy, zainstalowany lub instalowany w tej transakcji) nie dostarcza
4545 + wymaganej wersji biblioteki. Stare pakiety bez tych pól są pomijane.
4546 + """
4547 + provided = set(BASE_SO)
4548 + for n in to_install:
4549 + p = repo.get(n)
4550 + if p:
4551 + provided.update(p.provides_so or [])
4552 + for n, info in installed.items():
4553 + provided.update(info.get("provides_so", []) or [])
4554 +
4555 + missing = []
4556 + for n in sorted(to_install):
4557 + p = repo.get(n)
4558 + if not p:
4559 + continue
4560 + for so in (p.requires_so or []):
4561 + if so not in provided:
4562 + missing.append((n, so))
4563 +
4564 + if missing:
4565 + print(f"\n❌ {_c('red', 'BRAK WYMAGANYCH BIBLIOTEK (ABI so-name)')}:")
4566 + for n, so in missing:
4567 + print(f" {n} → wymaga {_c('red', so)} – żaden pakiet nie dostarcza tej wersji")
4568 + print()
4569 + return len(missing)
4570 +
4571 +def _download_pkg(pkg):
4572 + url = f"{pkg.repo_url}/{pkg.filename}"
4573 + dest = os.path.join(PAG_CACHE, pkg.filename)
4574 + if os.path.exists(dest) and (not pkg.sha256 or _sha256_file(dest) == pkg.sha256):
4575 + _download_pkg_sig(pkg, dest) # upewnij się, że sygnatura jest w cache
4576 + return dest
4577 + try:
4578 + req = Request(url, headers={"User-Agent":"pag/3.0"})
4579 + with urlopen(req, timeout=600) as resp:
4580 + total = int(resp.headers.get("Content-Length", 0))
4581 + bar = DownloadBar(pkg.filename, total)
4582 + with open(dest, "wb") as f:
4583 + while True:
4584 + chunk = resp.read(65536)
4585 + if not chunk:
4586 + break
4587 + f.write(chunk)
4588 + bar.update(len(chunk))
4589 + bar.close()
4590 + if pkg.sha256 and _sha256_file(dest) != pkg.sha256:
4591 + os.remove(dest); return None
4592 + _download_pkg_sig(pkg, dest)
4593 + return dest
4594 + except Exception as e:
4595 + print(f" ⚠ Błąd pobierania {pkg.filename}: {e}", file=sys.stderr)
4596 + return None
4597 +
4598 +def _download_pkg_sig(pkg, dest):
4599 + """Zapewnia AKTUALNY podpis pakietu (.asc, fallback .sig) w cache.
4600 +
4601 + Istniejący podpis jest używany tylko wtedy, gdy faktycznie weryfikuje TĘ
4602 + paczkę. Inaczej po przebudowie tej samej wersji (ten sam plik, nowy sha256)
4603 + stary podpis zostawał obok nowej paczki i weryfikacja dawała fałszywe
4604 + „NIEPRAWIDŁOWY PODPIS GPG”.
4605 + """
4606 + for ext in (".asc", ".sig"):
4607 + sig_dest = dest + ext
4608 + if os.path.exists(sig_dest):
4609 + ok, _fp = _gpg_verify_fp(sig_dest, dest)
4610 + if ok:
4611 + return
4612 + for ext in (".asc", ".sig"):
4613 + sig_dest = dest + ext
4614 + try:
4615 + req = Request(f"{pkg.repo_url}/{pkg.filename}{ext}", headers={"User-Agent":"pag/3.0"})
4616 + with urlopen(req, timeout=30) as resp:
4617 + data = resp.read()
4618 + except Exception:
4619 + continue
4620 + # nie mieszaj rozszerzeń – zostaje tylko ten wariant podpisu
4621 + for other in (".asc", ".sig"):
4622 + if other != ext:
4623 + try:
4624 + os.remove(dest + other)
4625 + except OSError:
4626 + pass
4627 + with open(sig_dest, "wb") as f:
4628 + f.write(data)
4629 + return
4630 + # Nie udało się pobrać podpisu – usuń nieaktualny z cache, żeby weryfikacja
4631 + # nie porównywała paczki z podpisem od innej wersji (czytelny „BRAK PODPISU”).
4632 + for other in (".asc", ".sig"):
4633 + try:
4634 + os.remove(dest + other)
4635 + except OSError:
4636 + pass
4637 +
4638 +def _download_packages_parallel(pkgs: List[PackageInfo], max_workers: int = 4) -> Dict[str, Optional[str]]:
4639 + """
4640 + Równoległe pobieranie wielu pakietów przez ThreadPoolExecutor.
4641 + Znacząco przyspiesza przy dużych aktualizacjach (50+ pakietów).
4642 + Zwraca słownik {nazwa_pakietu: ścieżka_lub_None}.
4643 + """
4644 + results = {}
4645 + total = len(pkgs)
4646 + completed = 0
4647 + with ThreadPoolExecutor(max_workers=max_workers) as executor:
4648 + future_to_pkg = {executor.submit(_download_pkg, pkg): pkg for pkg in pkgs}
4649 + for future in as_completed(future_to_pkg):
4650 + pkg = future_to_pkg[future]
4651 + try:
4652 + results[pkg.name] = future.result()
4653 + except Exception:
4654 + results[pkg.name] = None
4655 + completed += 1
4656 + # Pasek postępu
4657 + pct = completed / total * 100
4658 + filled = int(20 * pct / 100)
4659 + bar = "█" * filled + "░" * (20 - filled)
4660 + print(f"\r ⏬ [{bar}] {completed}/{total} ({pct:.0f}%)", end="", file=sys.stderr, flush=True)
4661 + print(file=sys.stderr) # nowa linia po zakończeniu
4662 + return results
4663 +
4664 +def load_world():
4665 + if not os.path.exists(WORLD_FILE): return set()
4666 + return {l.strip() for l in open(WORLD_FILE) if l.strip()}
4667 +
4668 +def save_world(w):
4669 + with open(WORLD_FILE,"w") as f:
4670 + for n in sorted(w): f.write(f"{n}\n")
4671 +
4672 +def _find_orphans(installed, world):
4673 + needed = set(world)
4674 + changed = True
4675 + while changed:
4676 + changed = False
4677 + for n in list(needed):
4678 + for dep in installed.get(n,{}).get("dependencies",[]):
4679 + if dep not in needed and dep in installed:
4680 + needed.add(dep); changed = True
4681 + return {n for n in installed if n not in needed}
4682 +
4683 +# =============================================================================
4684 +# MAIN
4685 +# =============================================================================
4686 +
4687 +def cmd_sbom(argv):
4688 + """pag sbom export [spdx|cyclonedx] – manifest SBOM zainstalowanych pakietów.
4689 +
4690 + Wypisuje na stdout JSON (SPDX 2.3 lub CycloneDX 1.5) z listą
4691 + zainstalowanych pakietów, wersji, licencji i sum SHA256.
4692 + """
4693 + fmt = (argv[0] if argv else "spdx").lower()
4694 + if fmt not in ("spdx", "cyclonedx"):
4695 + print("❌ Format: spdx | cyclonedx")
4696 + return 1
4697 + installed = load_json(INSTALLED_DB)
4698 + if not installed:
4699 + print("{}") if fmt == "cyclonedx" else print("{\"packages\": []}")
4700 + return 0
4701 + # metadata repo (licencje) – best-effort
4702 + try:
4703 + repo = fetch_all_packages()
4704 + except Exception:
4705 + repo = {}
4706 + names = sorted(installed)
4707 + created = datetime.now().astimezone().isoformat(timespec="seconds")
4708 +
4709 + def _license_of(name):
4710 + p = repo.get(name)
4711 + lic = getattr(p, "license", None) or []
4712 + if isinstance(lic, list):
4713 + lic = ", ".join(x for x in lic if x)
4714 + return lic or "NOASSERTION"
4715 +
4716 + if fmt == "spdx":
4717 + doc = {
4718 + "spdxVersion": "SPDX-2.3",
4719 + "dataLicense": "CC0-1.0",
4720 + "SPDXID": "SPDXRef-DOCUMENT",
4721 + "name": "PaganOS-installed",
4722 + "documentNamespace": f"https://repo.paganlinux.eu/sbom/installed-{int(time.time())}",
4723 + "creationInfo": {
4724 + "created": created,
4725 + "creators": [f"Tool: pag-{PAG_VERSION}"],
4726 + },
4727 + "packages": [],
4728 + }
4729 + for i, n in enumerate(names):
4730 + info = installed[n]
4731 + doc["packages"].append({
4732 + "SPDXID": f"SPDXRef-Package-{i+1}",
4733 + "name": n,
4734 + "versionInfo": info.get("version", ""),
4735 + "downloadLocation": info.get("repo", "NOASSERTION"),
4736 + "filesAnalyzed": False,
4737 + "licenseConcluded": _license_of(n),
4738 + "checksums": [{"algorithm": "SHA256", "checksumValue": info.get("sha256", "")}],
4739 + })
4740 + else: # cyclonedx
4741 + doc = {
4742 + "bomFormat": "CycloneDX",
4743 + "specVersion": "1.5",
4744 + "serialNumber": f"urn:uuid:{str(uuid.uuid4())}",
4745 + "version": 1,
4746 + "metadata": {
4747 + "timestamp": created,
4748 + "tools": [{"vendor": "PaganOS", "name": "pag", "version": PAG_VERSION}],
4749 + },
4750 + "components": [],
4751 + }
4752 + for n in names:
4753 + info = installed[n]
4754 + lic = _license_of(n)
4755 + comp = {
4756 + "type": "library",
4757 + "name": n,
4758 + "version": info.get("version", ""),
4759 + "hashes": [{"alg": "SHA-256", "content": info.get("sha256", "")}],
4760 + }
4761 + if lic != "NOASSERTION":
4762 + comp["licenses"] = [{"license": {"id": lic}}]
4763 + doc["components"].append(comp)
4764 + print(json.dumps(doc, indent=2, ensure_ascii=False))
4765 + return 0
4766 +
4767 +
4768 +USAGE_EN = """pag v3 – Pagan Linux Package Manager
4769 +
4770 +BASIC:
4771 + pag install <pkg>... Install packages
4772 + pag remove <pkg>... Remove packages
4773 + pag update Update PACKAGES (refreshes indexes first)
4774 + pag sync Refresh indexes + show pending package updates
4775 + pag upgrade Update SYSTEM (packages + kernel/initramfs/GRUB)
4776 + pag list [--installed] List available / installed
4777 + pag search <query> Search packages
4778 + pag info <pkg> Package details
4779 + pag files <pkg> List package files
4780 + pag verify [--deep] Verify integrity (--deep = SHA256 per file)
4781 + pag clean Clear download cache
4782 + pag stats System statistics
4783 + pag download <pkg>... Download packages to cache (offline prep)
4784 +
4785 +SECURITY:
4786 + pag key-add <url|file> Import GPG key
4787 + pag key-list List trusted keys
4788 + pag key-remove <id> Remove key
4789 + pag key-trust <repo> Pin repo signing key fingerprint (no TOFU)
4790 + pag key-untrust <repo> Forget repo fingerprint (back to TOFU)
4791 + pag key-trusted List pinned repo fingerprints
4792 +
4793 +ADVANCED:
4794 + pag why <pkg> Show why a package is installed
4795 + pag autoremove Auto-remove orphaned dependencies
4796 + pag pin <pkg> [ver] Pin package version
4797 + pag unpin <pkg> Unpin
4798 + pag pinned List pinned
4799 + pag history Transaction history
4800 + pag rollback Rollback last transaction
4801 + pag remove-orphans Remove orphaned deps
4802 + pag repo-add <url> [name] Add repository (drop-in /etc/pag/repos/)
4803 + pag repo-list List repositories
4804 + pag sbom export [fmt] SBOM manifest (spdx|cyclonedx)
4805 +
4806 +FLATPAK:
4807 + pag flatpak [<query>] Search & install (smart)
4808 + pag flatpak search <q> Search Flathub
4809 + pag flatpak install <id> Install flatpak
4810 + pag flatpak remove <id> Remove flatpak
4811 + pag flatpak list List installed flatpaks
4812 + pag flatpak update Update all flatpaks
4813 + pag flatpak info <id> Show flatpak details
4814 +
4815 +IMMUTABLE OS (PAG_IMMUTABLE=1):
4816 + pag deploy-list List all deployments
4817 + pag deploy-rollback Switch to previous deployment
4818 + pag deploy-cleanup [N] Remove old deployments (keep last N, default 3)
4819 + pag initramfs-update Rebuild initramfs for current kernel/deployment
4820 + pag grub-update Regenerate GRUB entries for all deployments
4821 +"""
4822 +
4823 +USAGE_PL = """pag v3 – Pagan Linux Package Manager
4824 +
4825 +PODSTAWOWE:
4826 + pag install <pkg>... Instalacja pakietów
4827 + pag remove <pkg>... Usuwanie pakietów
4828 + pag update Aktualizacja PAKIETÓW (odświeża indeksy)
4829 + pag sync Odśwież indeksy + info o aktualizacjach
4830 + pag upgrade Aktualizacja SYSTEMU (pakiety + kernel/initramfs/GRUB)
4831 + pag list [--installed] Lista dostępnych / zainstalowanych
4832 + pag search <query> Szukaj pakietów
4833 + pag info <pkg> Szczegóły pakietu
4834 + pag files <pkg> Lista plików pakietu
4835 + pag verify [--deep] Weryfikacja integralności
4836 + pag clean Wyczyść cache pobierania
4837 + pag stats Statystyki systemu
4838 + pag download <pkg>... Pobierz do cache (offline)
4839 +
4840 +BEZPIECZEŃSTWO:
4841 + pag key-add <url|file> Importuj klucz GPG
4842 + pag key-list Lista zaufanych kluczy
4843 + pag key-remove <id> Usuń klucz
4844 + pag key-trust <repo> Przypnij fingerprint klucza repo (bez TOFU)
4845 + pag key-untrust <repo> Zapomnij fingerprint repo (powrót do TOFU)
4846 + pag key-trusted Lista przypiętych fingerprintów repo
4847 +
4848 +ZAAWANSOWANE:
4849 + pag why <pkg> Dlaczego pakiet jest zainstalowany
4850 + pag autoremove Usuń osierocone zależności
4851 + pag pin <pkg> [ver] Przypnij wersję pakietu
4852 + pag unpin <pkg> Odepnij
4853 + pag pinned Lista przypiętych
4854 + pag history Historia transakcji
4855 + pag rollback Cofnij ostatnią transakcję
4856 + pag remove-orphans Usuń osierocone zależności
4857 + pag repo-add <url> [nazwa] Dodaj repozytorium (drop-in w /etc/pag/repos/)
4858 + pag repo-list Lista repozytoriów
4859 + pag sbom export [fmt] Manifest SBOM (spdx|cyclonedx)
4860 +
4861 +FLATPAK:
4862 + pag flatpak [<query>] Szukaj i instaluj
4863 + pag flatpak search <q> Szukaj na Flathub
4864 + pag flatpak install <id> Zainstaluj flatpak
4865 + pag flatpak remove <id> Usuń flatpak
4866 + pag flatpak list Lista zainstalowanych
4867 + pag flatpak update Aktualizuj wszystkie
4868 + pag flatpak info <id> Szczegóły flatpaka
4869 +
4870 +IMMUTABLE OS (PAG_IMMUTABLE=1):
4871 + pag deploy-list Lista wdrożeń
4872 + pag deploy-rollback Przełącz na poprzednie wdrożenie
4873 + pag deploy-cleanup [N] Usuń stare wdrożenia (zachowaj N, domyślnie 3)
4874 + pag initramfs-update Przebuduj initramfs
4875 + pag grub-update Regeneruj wpisy GRUB"""
4876 +
4877 +def _get_usage():
4878 + # Plik językowy może dostarczyć klucz "usage" – wtedy wygrywa z wbudowanym.
4879 + _u = T.get(LANG, {}).get("usage")
4880 + if _u:
4881 + return _u
4882 + if LANG == "pl":
4883 + return USAGE_PL
4884 + return USAGE_EN
4885 +
4886 +
4887 +def _extract_lang(outdir: str) -> int:
4888 + """Eksport wbudowanych tłumaczeń do outdir/{pl,en}.json (+ klucz "usage").
4889 +
4890 + Używane przez recepturę pakietu (pag.pag), żeby tłumaczenia jechały RAZEM
4891 + z wersją paga – po `pag install/upgrade pag` i self-update są zawsze zgodne.
4892 + """
4893 + os.makedirs(outdir, exist_ok=True)
4894 + for _code in ("pl", "en"):
4895 + _d = dict(T.get(_code, {}))
4896 + _u = globals().get(f"USAGE_{_code.upper()}", "")
4897 + if _u:
4898 + _d["usage"] = _u
4899 + _p = os.path.join(outdir, f"{_code}.json")
4900 + with open(_p, "w", encoding="utf-8") as _fh:
4901 + json.dump(_d, _fh, ensure_ascii=False, indent=2, sort_keys=True)
4902 + print(f" ✓ {_p} ({len(_d)} kluczy)")
4903 + return 0
4904 +
4905 +
4906 +def main():
4907 + # Ukryte (używane przy budowie pakietu): eksport tłumaczeń do plików
4908 + if len(sys.argv) >= 3 and sys.argv[1] == "--lang-extract":
4909 + sys.exit(_extract_lang(sys.argv[2]))
4910 + if len(sys.argv) >= 2 and sys.argv[1] in ("--version", "-V", "version"):
4911 + print(f"pag {PAG_VERSION}")
4912 + sys.exit(0)
4913 + if len(sys.argv) == 2 and sys.argv[1] in ("--help", "-h", "help"):
4914 + print(_get_usage()); sys.exit(0)
4915 + if len(sys.argv) < 2:
4916 + print(_get_usage()); sys.exit(0)
4917 +
4918 + cmd = sys.argv[1]
4919 + args = sys.argv[2:]
4920 +
4921 + # --- Komendy TYLKO DO ODCZYTU (nie wymagają roota) ---
4922 + READ_ONLY = {
4923 + "list": lambda: cmd_list("--installed" in args),
4924 + "search": lambda: cmd_search(args[0]) if args else print("Usage: pag search <query>"),
4925 + "info": lambda: cmd_info(args[0]) if args else print("Usage: pag info <pkg>"),
4926 + "files": lambda: cmd_files(args[0]) if args else print("Usage: pag files <pkg>"),
4927 + "verify": lambda: cmd_verify("--deep" in args),
4928 + "why": lambda: cmd_why(args[0]) if args else print("Usage: pag why <pkg>"),
4929 + "stats": cmd_stats,
4930 + "pinned": cmd_pinned,
4931 + "history": cmd_history,
4932 + "repo-list": cmd_repo_list,
4933 + "key-list": cmd_key_list,
4934 + "key-trusted": cmd_key_trusted,
4935 + "flatpak": lambda: cmd_flatpak(args),
4936 + "flatpak-search": lambda: cmd_flatpak_search(args[0]) if args else print("Usage: pag flatpak-search <query>"),
4937 + "flatpak-list": cmd_flatpak_list,
4938 + "flatpak-info": lambda: cmd_flatpak_info(args[0]) if args else print("Usage: pag flatpak-info <id>"),
4939 + "deploy-list": cmd_deploy_list,
4940 + "deploy": cmd_deploy_list,
4941 + "sbom": lambda: cmd_sbom(args),
4942 + }
4943 +
4944 + if cmd in READ_ONLY:
4945 + sys.exit(READ_ONLY[cmd]() or 0)
4946 +
4947 + # --- Smart search: `pag <nazwa-pakietu>` → repo + Flathub + sugestie ---
4948 + WRITE_CMDS = {
4949 + "install", "remove", "update", "sync", "upgrade", "clean", "download",
4950 + "autoremove", "remove-orphans", "pin", "unpin", "rollback",
4951 + "repo-add", "key-add", "key-remove", "key-trust", "key-untrust",
4952 + "self-update",
4953 + "flatpak", "flatpak-install", "flatpak-remove", "flatpak-update",
4954 + "deploy-rollback", "deploy-cleanup", "initramfs-update", "grub-update",
4955 + }
4956 + if cmd not in WRITE_CMDS:
4957 + # Literówka komendy? (np. `pag instal steam` zamiast `pag install`) –
4958 + # zasugeruj poprawną komendę ZAMIAST wpadać w smart search (który
4959 + # potrafi wisieć na `flatpak search` aż do Ctrl-C).
4960 + _known = set(READ_ONLY) | set(WRITE_CMDS)
4961 + _close = difflib.get_close_matches(cmd, _known, n=1, cutoff=0.75)
4962 + if _close:
4963 + print(f"❌ Nieznana komenda: '{cmd}'. Czy chodziło o '{_close[0]}'?")
4964 + print(f" Uruchom 'pag' bez argumentów, aby zobaczyć listę komend.")
4965 + sys.exit(1)
4966 + sys.exit(_smart_search(" ".join([cmd] + args)) or 0)
4967 +
4968 + # Obsługa flag globalnych (-y/--yes)
4969 + global_args = []
4970 + for a in args:
4971 + if a in ("-y", "--yes"):
4972 + os.environ["PAG_YES"] = "1"
4973 + else:
4974 + global_args.append(a)
4975 + args = global_args
4976 +
4977 + # --- Komendy ZAPISU (wymagają roota) ---
4978 + if os.geteuid() != 0:
4979 + print(f"❌ {_('root_required')}", file=sys.stderr); sys.exit(1)
4980 +
4981 + ensure_dirs()
4982 +
4983 + with DatabaseLock():
4984 + WRITE_COMMANDS = {
4985 + "install": lambda: cmd_install(
4986 + [a for a in args if a not in ("-f", "--force")],
4987 + upgrade=("-f" in args or "--force" in args)),
4988 + "remove": lambda: cmd_remove(args),
4989 + "update": lambda: cmd_update(do_upgrade=True),
4990 + "sync": lambda: cmd_update(do_upgrade=False),
4991 + "upgrade": cmd_upgrade,
4992 + "clean": cmd_clean,
4993 + "download": lambda: cmd_download(args),
4994 + "autoremove": cmd_autoremove,
4995 + "remove-orphans": cmd_remove_orphans,
4996 + "pin": lambda: cmd_pin(args[0], args[1] if len(args)>1 else ""),
4997 + "unpin": lambda: cmd_unpin(args[0]) if args else print("Usage: pag unpin <pkg>"),
4998 + "rollback": cmd_rollback,
4999 + "repo-add": lambda: cmd_repo_add(args[0], args[1] if len(args) > 1 else "") if args else print("Usage: pag repo-add <url> [name]"),
5000 + "key-add": lambda: cmd_key_add(args[0]) if args else print("Usage: pag key-add <url|file>"),
5001 + "key-remove": lambda: cmd_key_remove(args[0]) if args else print("Usage: pag key-remove <id>"),
5002 + "key-trust": lambda: cmd_key_trust(args[0]) if args else print("Usage: pag key-trust <repo_url>"),
5003 + "key-untrust": lambda: cmd_key_untrust(args[0]) if args else print("Usage: pag key-untrust <repo_url>"),
5004 + "self-update": cmd_self_update,
5005 + "flatpak": lambda: cmd_flatpak(args),
5006 + "flatpak-install": lambda: _flatpak_smart_install(args) if args else print("Usage: pag flatpak-install <app>"),
5007 + "flatpak-remove": lambda: _flatpak_smart_remove(args) if args else print("Usage: pag flatpak-remove <app>"),
5008 + "flatpak-update": cmd_flatpak_update,
5009 + "deploy-rollback": cmd_deploy_rollback,
5010 + "deploy-cleanup": lambda: cmd_deploy_cleanup(int(args[0]) if args else 3),
5011 + "initramfs-update": cmd_initramfs_update,
5012 + "grub-update": cmd_grub_update,
5013 + }
5014 +
5015 + fn = WRITE_COMMANDS.get(cmd)
5016 + if fn:
5017 + sys.exit(fn() or 0)
5018 + # Should never reach here – _smart_search handles unknowns
5019 + sys.exit(_smart_search(" ".join([cmd] + args)) or 0)
5020 +
5021 +if __name__ == "__main__":
5022 + try:
5023 + main()
5024 + except KeyboardInterrupt:
5025 + # Ctrl-C (np. podczas flatpak search / pobierania) – bez tracebacka
5026 + print("\n ⚠ Przerwano (Ctrl-C).")
4878 5027 sys.exit(130)