← pag

Commit 935308d

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