← pag

Commit ac59b92

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