← pag

Commit 2d9af31

0
plików
+0
dodanych
-0
usuniętych
@@ -1,3320 +1,4181 @@
1 -#!/usr/bin/env python3
2 -"""
3 -╔══════════════════════════════════════════════════════════════════════════════╗
4 -║ PAG - Pagan Linux Package Manager v3.3.4 ║
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.4"
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 -MAX_PKG_SIZE = 2 * 1024 * 1024 * 1024 # 2 GB – maksymalny rozmiar paczki
388 -ALLOWED_PKG_RE = re.compile(r'^[a-zA-Z0-9][a-zA-Z0-9._+@-]*$')
389 -MAX_PKG_SIZE = 2 * 1024 * 1024 * 1024 # 2 GB – maksymalny rozmiar paczki
390 -
391 -# =============================================================================
392 -# IMMUTABLE OS – DEPLOYMENTY
393 -# =============================================================================
394 -# Model: zamiast mutować /, każda operacja tworzy NOWY deployment.
395 -# /var, /etc, /home są współdzielone między deploymentami.
396 -#
397 -# STRUKTURA:
398 -# /.deployments/
399 -# active → 20260723T120000 (symlink do aktywnego)
400 -# 20260723T120000/
401 -# usr/ bin/ lib/ lib64/ ... (pełny system)
402 -# var → /var (symlink do współdzielonego)
403 -# etc → /etc
404 -# home → /home
405 -# ...
406 -#
407 -# Jak to działa:
408 -# 1. pag install → kopiuje active → nowy deployment + nakłada zmiany → switch symlinka
409 -# 2. pag remove → kopiuje active → nowy deployment - usuwa pliki → switch symlinka
410 -# 3. pag deploy-rollback → przełącza active symlink na poprzedni deployment
411 -# 4. Przy starcie systemu: initrd montuje /.deployments/active jako /
412 -# =============================================================================
413 -
414 -DEPLOYMENTS_DIR = "/.deployments"
415 -ACTIVE_LINK = "/.deployments/active"
416 -DEPLOYMENTS_DB = "/var/lib/pag/deployments.json"
417 -
418 -# Ścieżki współdzielone – NIE wchodzą do deploymentu (są symlinkami do /...)
419 -SHARED_PATHS = {
420 - "/var", "/etc", "/home", "/root", "/tmp", "/run",
421 - "/dev", "/proc", "/sys", "/mnt", "/media", "/srv",
422 - "/.deployments", "/.pag_staging",
423 -}
424 -
425 -def _is_shared_path(rel: str) -> bool:
426 - """Sprawdza czy ścieżka należy do katalogów współdzielonych (poza deploymentem)."""
427 - for sp in SHARED_PATHS:
428 - if rel == sp or rel.startswith(sp + "/"):
429 - return True
430 - return False
431 -
432 -def _get_deployment_root() -> str:
433 - """Zwraca ścieżkę do aktywnego deploymentu, lub PAG_ROOT jeśli tryb niemutowalny wyłączony."""
434 - if os.environ.get("PAG_IMMUTABLE", "") in ("0", "no", "false", ""):
435 - return PAG_ROOT
436 - if os.path.islink(ACTIVE_LINK):
437 - return os.readlink(ACTIVE_LINK)
438 - if os.path.isdir(ACTIVE_LINK):
439 - return ACTIVE_LINK
440 - # Brak deploymentów – użyj /
441 - return PAG_ROOT
442 -
443 -def _load_deployments() -> List[dict]:
444 - """Wczytuje historię deploymentów."""
445 - if not os.path.exists(DEPLOYMENTS_DB):
446 - return []
447 - try:
448 - return json.load(open(DEPLOYMENTS_DB))
449 - except Exception:
450 - return []
451 -
452 -def _save_deployments(deployments: List[dict]):
453 - os.makedirs(os.path.dirname(DEPLOYMENTS_DB), exist_ok=True)
454 - json.dump(deployments, open(DEPLOYMENTS_DB, "w"), indent=2)
455 -
456 -def _create_deployment(pkg_names: List[str], action: str) -> Tuple[str, str]:
457 - """
458 - Tworzy nowy deployment przez skopiowanie aktywnego (CoW) i zwraca jego ścieżkę.
459 - Zwraca (deployment_dir, deployment_id).
460 - """
461 - deploy_id = datetime.now().strftime("%Y%m%dT%H%M%S")
462 - deploy_dir = os.path.join(DEPLOYMENTS_DIR, deploy_id)
463 - os.makedirs(DEPLOYMENTS_DIR, exist_ok=True)
464 -
465 - active = _get_deployment_root()
466 -
467 - if os.path.isdir(active) and active != PAG_ROOT:
468 - # Trójstopniowa strategia kopiowania deploymentu:
469 - # 1. reflink (CoW – btrfs, xfs) → 0 MB kopiowane
470 - # 2. hardlink (linki twarde) → 0 MB kopiowane, tylko inody
471 - # 3. zwykłe cp (ostateczność) → pełna kopia
472 - print(f" ⚡ Kopiowanie aktywnego deploymentu...")
473 - copied = False
474 - for method, cmd, label in [
475 - ("reflink", ["cp", "--reflink=auto", "-a", active + "/.", deploy_dir + "/"], "CoW (reflink)"),
476 - ("hardlink", ["cp", "-al", active + "/.", deploy_dir + "/"], "hardlinki"),
477 - ("copy", ["cp", "-a", active + "/.", deploy_dir + "/"], "pełna kopia"),
478 - ]:
479 - try:
480 - subprocess.run(cmd, check=True, timeout=600, capture_output=True)
481 - print(f" ✅ Deployment: {deploy_id} ({label})")
482 - copied = True
483 - break
484 - except subprocess.CalledProcessError:
485 - if method == "copy":
486 - raise # ostatnia deska – niech leci wyjątek
487 - continue
488 - if not copied:
489 - raise RuntimeError("Nie udało się skopiować deploymentu żadną metodą")
490 - else:
491 - # Pierwszy deployment – tylko katalogi szkieletowe
492 - for d in ["/usr", "/lib", "/lib64", "/bin", "/sbin", "/boot", "/opt"]:
493 - if os.path.isdir(d):
494 - dest = os.path.join(deploy_dir, d.lstrip("/"))
495 - os.makedirs(dest, exist_ok=True)
496 - print(f" ✅ Pierwszy deployment: {deploy_id}")
497 -
498 - # Utwórz symlinki do współdzielonych katalogów
499 - for sp in SHARED_PATHS:
500 - link_dst = os.path.join(deploy_dir, sp.lstrip("/"))
501 - if not os.path.lexists(link_dst) and os.path.isdir(sp):
502 - os.symlink(sp, link_dst)
503 -
504 - # Zapisz w bazie deploymentów
505 - deployments = _load_deployments()
506 - deployments.append({
507 - "id": deploy_id,
508 - "action": action,
509 - "packages": pkg_names,
510 - "timestamp": datetime.now().isoformat(),
511 - "active": True,
512 - })
513 - # Oznacz poprzednie jako nieaktywne
514 - for d in deployments[:-1]:
515 - d["active"] = False
516 - _save_deployments(deployments)
517 -
518 - return deploy_dir, deploy_id
519 -
520 -def _switch_deployment(deploy_dir: str) -> bool:
521 - """Atomowo przełącza aktywny deployment przez podmianę symlinka."""
522 - tmp_link = ACTIVE_LINK + ".new"
523 - if os.path.lexists(tmp_link):
524 - os.remove(tmp_link)
525 - os.symlink(deploy_dir, tmp_link)
526 - os.rename(tmp_link, ACTIVE_LINK) # atomowe na tym samym FS
527 - return True
528 -
529 -DEFAULT_REPOS = [
530 - "https://repo.paganlinux.eu/stable/",
531 -]
532 -
533 -# =============================================================================
534 -# INICJALIZACJA
535 -# =============================================================================
536 -
537 -def ensure_dirs():
538 - for d in [PAG_DB, PAG_CACHE, PAG_CONF, REPO_CACHE, STAGING_DIR, DEPLOYMENTS_DIR]:
539 - os.makedirs(d, exist_ok=True)
540 - for f, default in [
541 - (REPOS_CONF, "\n".join(DEFAULT_REPOS) + "\n"),
542 - (INSTALLED_DB, "{}"),
543 - (PINNED_FILE, "{}"),
544 - (HISTORY_FILE, "[]"),
545 - ]:
546 - if not os.path.exists(f):
547 - with open(f, "w") as fh: fh.write(default)
548 - if not os.path.exists(WORLD_FILE):
549 - Path(WORLD_FILE).touch()
550 - if not os.path.exists(GPG_KEYRING):
551 - _gpg_run("--no-default-keyring", "--keyring", GPG_KEYRING,
552 - "--fingerprint", capture_output=True)
553 - # Inicjalizuj SQLite
554 - _db_init()
555 - # Wyczyść staging po poprzednim przerwanym buildzie/instalacji
556 - if os.path.isdir(STAGING_DIR):
557 - for entry in os.listdir(STAGING_DIR):
558 - path = os.path.join(STAGING_DIR, entry)
559 - try:
560 - if os.path.isfile(path) or os.path.islink(path):
561 - os.unlink(path)
562 - elif os.path.isdir(path):
563 - shutil.rmtree(path, ignore_errors=True)
564 - except OSError:
565 - pass
566 -
567 -# =============================================================================
568 -# SQLITE – BAZA PLIKÓW (poprawne zarządzanie połączeniami)
569 -# =============================================================================
570 -
571 -from contextlib import contextmanager
572 -
573 -@contextmanager
574 -def _db_session():
575 - """Context manager – gwarantuje zamknięcie połączenia."""
576 - conn = sqlite3.connect(FILES_DB_SQL)
577 - conn.execute("PRAGMA journal_mode=WAL")
578 - conn.execute("PRAGMA synchronous=NORMAL")
579 - conn.execute("PRAGMA foreign_keys=ON")
580 - conn.row_factory = sqlite3.Row
581 - try:
582 - yield conn
583 - conn.commit()
584 - except Exception:
585 - conn.rollback()
586 - raise
587 - finally:
588 - conn.close()
589 -
590 -
591 -def _db_init():
592 - """Tworzy tabele SQLite jeśli nie istnieją."""
593 - with _db_session() as db:
594 - db.execute("""
595 - CREATE TABLE IF NOT EXISTS files (
596 - id INTEGER PRIMARY KEY AUTOINCREMENT,
597 - path TEXT NOT NULL,
598 - package TEXT NOT NULL,
599 - sha256 TEXT,
600 - size INTEGER,
601 - is_symlink INTEGER DEFAULT 0,
602 - symlink_target TEXT,
603 - UNIQUE(path, package)
604 - )
605 - """)
606 - db.execute("CREATE INDEX IF NOT EXISTS idx_files_path ON files(path)")
607 - db.execute("CREATE INDEX IF NOT EXISTS idx_files_pkg ON files(package)")
608 - db.execute("""
609 - CREATE TABLE IF NOT EXISTS file_checksums (
610 - path TEXT PRIMARY KEY,
611 - sha256 TEXT NOT NULL,
612 - installed_at TEXT
613 - )
614 - """)
615 - db.commit()
616 -
617 -def _db_record_files(pkg_name: str, files: List[dict]):
618 - """Zapisuje pliki do SQLite (obsługuje symlinki)."""
619 - with _db_session() as db:
620 - # Context manager sam zarządza transakcją atomowo
621 - db.executemany(
622 - "INSERT OR REPLACE INTO files (path, package, sha256, size, is_symlink, symlink_target) "
623 - "VALUES (?,?,?,?,?,?)",
624 - [(f["path"], pkg_name, f.get("sha256",""), f.get("size",0),
625 - f.get("is_symlink", 0), f.get("symlink_target", ""))
626 - for f in files]
627 - )
628 - db.executemany(
629 - "INSERT OR REPLACE INTO file_checksums (path, sha256, installed_at) VALUES (?,?,?)",
630 - [(f["path"], f.get("sha256",""), datetime.now().isoformat())
631 - for f in files if f.get("sha256")]
632 - )
633 -
634 -def _db_get_package_files(pkg_name: str) -> List[str]:
635 - with _db_session() as db:
636 - return [r["path"] for r in db.execute(
637 - "SELECT DISTINCT path FROM files WHERE package=?", (pkg_name,)
638 - )]
639 -
640 -def _db_get_file_owners(filepath: str) -> List[str]:
641 - """Zwraca listę pakietów będących właścicielami pliku."""
642 - with _db_session() as db:
643 - return [r["package"] for r in db.execute(
644 - "SELECT package FROM files WHERE path=?", (filepath,)
645 - )]
646 -
647 -def _db_remove_package_files(pkg_name: str):
648 - with _db_session() as db:
649 - db.execute("DELETE FROM files WHERE package=?", (pkg_name,))
650 - db.commit()
651 -
652 -def _db_get_all_file_checksums() -> Dict[str, str]:
653 - with _db_session() as db:
654 - return {r["path"]: r["sha256"] for r in db.execute("SELECT path, sha256 FROM file_checksums")}
655 -
656 -def _db_count_files() -> int:
657 - with _db_session() as db:
658 - return db.execute("SELECT COUNT(*) FROM files").fetchone()[0]
659 -
660 -# =============================================================================
661 -# BLOKADA
662 -# =============================================================================
663 -
664 -class DatabaseLock:
665 - """Blokada oparta na PID-file – niezawodna, bez flock."""
666 - def __init__(self):
667 - self._acquired = False
668 - def __enter__(self):
669 - os.makedirs(os.path.dirname(LOCK_FILE), exist_ok=True)
670 - if os.path.exists(LOCK_FILE):
671 - try:
672 - old_pid = int(open(LOCK_FILE).read().strip())
673 - os.kill(old_pid, 0) # sygnał 0 = sprawdź czy proces żyje
674 - except (ValueError, OSError, ProcessLookupError):
675 - # Stary PID nie żyje – usuwamy nieświeżą blokadę
676 - try:
677 - os.remove(LOCK_FILE)
678 - except OSError:
679 - pass
680 - else:
681 - print(f"❌ {_('db_locked')}", file=sys.stderr)
682 - print(f" {_('db_lock_hint', LOCK_FILE)}", file=sys.stderr)
683 - sys.exit(1)
684 - with open(LOCK_FILE, "w") as f:
685 - f.write(str(os.getpid()))
686 - self._acquired = True
687 - return self
688 - def __exit__(self, *args):
689 - if self._acquired:
690 - try:
691 - os.remove(LOCK_FILE)
692 - except OSError:
693 - pass
694 -
695 -# =============================================================================
696 -# POMOCNICZE
697 -# =============================================================================
698 -
699 -
700 -_ALLOWED_PREFIXES = ("/usr/", "/etc/", "/var/", "/opt/",
701 - # Pliki wewnętrzne paczki .pkg.tar.xz
702 - "metadata.json", "data.tar.xz", "hooks/",
703 - "sums.json")
704 -
705 -def _check_path_safety(name: str) -> bool:
706 - for prefix in _ALLOWED_PREFIXES:
707 - if name == prefix.rstrip("/") or name.startswith(prefix):
708 - return True
709 - return False
710 -
711 -
712 -def _validate_pkg_name(name):
713 - return bool(ALLOWED_PKG_RE.match(name))
714 -
715 -
716 -
717 -def _audit(msg):
718 - from datetime import datetime, timezone
719 - os.makedirs(os.path.dirname(AUDIT_LOG), exist_ok=True)
720 - with open(AUDIT_LOG, "a") as f:
721 - f.write(datetime.now(timezone.utc).isoformat() + " " + msg + "\n")
722 -
723 -def _strip_suid(path):
724 - try:
725 - st = os.stat(path)
726 - if st.st_mode & 0o4000:
727 - os.chmod(path, st.st_mode & ~0o4000)
728 - print(f" {_("sec_suid", path=path)}")
729 - except OSError:
730 - pass
731 -
732 -def _check_downgrade(pkg_name, new_ver, installed_db):
733 - if pkg_name in installed_db:
734 - old = installed_db[pkg_name].get("version", "0")
735 - if new_ver < old:
736 - print(f" {_("sec_downgrade", pkg=pkg_name, new=new_ver, old=old)}")
737 - return False
738 - return True
739 -
740 -def _safe_extractall(tar: tarfile.TarFile, dest: str, *, preserve_perms: bool = True):
741 - """
742 - Bezpieczne rozpakowanie archiwum tar z ochroną przed Directory Traversal.
743 -
744 - Działa na Python < 3.12 (gdzie parametr 'filter' w extractall nie istnieje)
745 - oraz na Python 3.12+. W przeciwieństwie do filtra 'data' z Pythona 3.12,
746 - zachowuje bity uprawnień POSIX (SUID, SGID, sticky) – preserve_perms=True.
747 -
748 - Ochrona:
749 - - Blokuje ścieżki absolutne i z '..' (path traversal)
750 - - Blokuje niebezpieczne symlinki
751 - - Zachowuje oryginalne uprawnienia plików
752 - """
753 - for member in tar.getmembers():
754 - name = member.name
755 -
756 - # --- Ochrona przed Directory Traversal ---
757 - # Blokuj ścieżki absolutne (zaczynające się od /)
758 - if name.startswith('/'):
759 - continue
760 - # Blokuj ścieżki zawierające '..'
761 - if '..' in name.split('/'):
762 - continue
763 - if not _check_path_safety(name):
764 - print(f" BLOCKED: {name}")
765 - continue
766 -
767 - # --- Ochrona dla symlinków i hardlinków ---
768 - if member.issym() or member.islnk():
769 - link = member.linkname
770 - # Blokuj linki do ścieżek absolutnych
771 - if link.startswith('/'):
772 - continue
773 - # Blokuj linki z '..'
774 - if '..' in link.split('/'):
775 - continue
776 -
777 - # Rozpakuj z zachowaniem metadanych
778 - target = os.path.join(dest, name)
779 - tar.extract(member, dest, set_attrs=preserve_perms, numeric_owner=False)
780 - _strip_suid(target)
781 -
782 -
783 -def _sha256_file(path: str) -> str:
784 - h = hashlib.sha256()
785 - with open(path, "rb") as f:
786 - for chunk in iter(lambda: f.read(65536), b""):
787 - h.update(chunk)
788 - return h.hexdigest()
789 -
790 -def _version_newer(a: str, b: str) -> bool:
791 - def parse(v):
792 - parts = []
793 - for p in v.replace("-",".").replace("_",".").split("."):
794 - try: parts.append((0, int(p)))
795 - except ValueError: parts.append((1, p))
796 - return parts
797 - try: return parse(a) > parse(b)
798 - except: return a != b
799 -
800 -def load_json(path):
801 - try:
802 - with open(path) as f:
803 - return json.load(f)
804 - except (FileNotFoundError, json.JSONDecodeError):
805 - return {}
806 -
807 -def save_json(path, data):
808 - with open(path, "w") as f:
809 - json.dump(data, f, indent=2)
810 -
811 -class PackageInfo:
812 - __slots__ = ("name","version","description","dependencies",
813 - "size_bytes","sha256","gpg_fp","repo_url","filename","provides")
814 - def __init__(self, d, repo=""):
815 - self.name = d.get("name","?")
816 - self.version = d.get("version","0")
817 - self.description = d.get("description","")
818 - self.dependencies = d.get("dependencies",[])
819 - self.size_bytes = d.get("size",0)
820 - self.sha256 = d.get("sha256","")
821 - self.gpg_fp = d.get("gpg_fingerprint","")
822 - self.repo_url = repo
823 - self.filename = d.get("filename", f"{self.name}-{self.version}{PKG_EXT}")
824 - self.provides = d.get("provides", []) or []
825 -
826 -# =============================================================================
827 -# REPOZYTORIA (cache, ETag, GPG)
828 -# =============================================================================
829 -
830 -def get_repos():
831 - repos = []
832 - if os.path.exists(REPOS_CONF):
833 - for line in open(REPOS_CONF):
834 - line = line.strip()
835 - if line and not line.startswith("#"):
836 - repos.append(line.rstrip("/"))
837 - return repos or DEFAULT_REPOS
838 -
839 -def _repo_cache_path(url):
840 - return os.path.join(REPO_CACHE, url.replace("://","_").replace("/","_").replace(".","_") + ".json")
841 -
842 -def _repo_etag_path(url): return _repo_cache_path(url) + ".etag"
843 -def _repo_ts_path(url): return _repo_cache_path(url) + ".ts"
844 -
845 -def fetch_repo_index(repo_url, force=False):
846 - cp = _repo_cache_path(repo_url)
847 - ep = _repo_etag_path(repo_url)
848 - tp = _repo_ts_path(repo_url)
849 -
850 - if not force and os.path.exists(cp) and os.path.exists(tp):
851 - try:
852 - if time.time() - float(open(tp).read().strip()) < REPO_CACHE_TTL:
853 - return json.load(open(cp)).get("packages",[])
854 - except: pass
855 -
856 - headers = {"User-Agent": "pag/3.0"}
857 - if os.path.exists(tp) and not force:
858 - try:
859 - lm = datetime.fromtimestamp(float(open(tp).read().strip()), tz=timezone.utc)
860 - # Wymuś lokalizację C/POSIX dla nagłówków HTTP, aby unikać problemów z nazwami dni/miesięcy
861 - try:
862 - old_locale = locale.setlocale(locale.LC_TIME)
863 - locale.setlocale(locale.LC_TIME, 'C')
864 - headers["If-Modified-Since"] = lm.strftime("%a, %d %b %Y %H:%M:%S GMT")
865 - locale.setlocale(locale.LC_TIME, old_locale)
866 - except (locale.Error, ValueError):
867 - # Jeśli ustawienie lokalizacji się nie powiedzie, użyj domyślnej
868 - headers["If-Modified-Since"] = lm.strftime("%a, %d %b %Y %H:%M:%S GMT")
869 - except: pass
870 - if os.path.exists(ep) and not force:
871 - try: headers["If-None-Match"] = open(ep).read().strip()
872 - except: pass
873 -
874 - try:
875 - req = Request(f"{repo_url}/repo.json", headers=headers)
876 - with urlopen(req, timeout=30) as resp:
877 - etag = resp.headers.get("ETag","")
878 - if etag: open(ep,"w").write(etag)
879 - raw = resp.read()
880 - data = json.loads(raw.decode())
881 - # Zapisuj SUROWE bajty (nie re-serializuj!) – podpis GPG jest nad
882 - # oryginalnymi bajtami repo.json z serwera
883 - with open(cp,"wb") as f: f.write(raw)
884 - open(tp,"w").write(str(time.time()))
885 - # SPRAWDŹ WYNIK WERYFIKACJI – nie ignoruj!
886 - if not _verify_repo_sig(repo_url, cp):
887 - return None # weryfikacja nie powiodła się, cache usunięty
888 - return data.get("packages",[])
889 - except HTTPError as e:
890 - if e.code == 304:
891 - open(tp,"w").write(str(time.time()))
892 - if os.path.exists(cp):
893 - return json.load(open(cp)).get("packages",[])
894 - print(f" ⚠ HTTP {e.code} dla {repo_url}", file=sys.stderr)
895 - return None
896 - except Exception as e:
897 - print(f" ⚠ Błąd pobierania indeksu {repo_url}: {e}", file=sys.stderr)
898 - if os.path.exists(cp):
899 - try: return json.load(open(cp)).get("packages",[])
900 - except Exception: pass
901 - return None
902 -
903 -def _verify_repo_sig(repo_url, cache_path) -> bool:
904 - """Weryfikuje podpis GPG indeksu repozytorium.
905 -
906 - FAIL-CLOSED: brak/nieprawidłowy podpis = False (chyba że PAG_INSECURE=1).
907 - Zwraca True jeśli indeks jest zaufany, False jeśli należy go odrzucić.
908 - """
909 - insecure = os.environ.get("PAG_INSECURE", "") == "1"
910 -
911 - if not os.path.exists(GPG_KEYRING):
912 - if insecure:
913 - return True # brak GPG keyring – tryb insecure, akceptuj
914 - print(f" ❌ {repo_url}: brak kluczy GPG – weryfikacja niemożliwa!")
915 - print(f" Ustaw PAG_INSECURE=1 aby pominąć (niezalecane)")
916 - os.remove(cache_path)
917 - return False
918 -
919 - sig_path = cache_path + ".sig"
920 - # Podpisy generowane jako .asc (armored) – próbuj .asc, potem .sig
921 - sig_data = None
922 - sig_ext = ""
923 - for ext in (".asc", ".sig"):
924 - try:
925 - req = Request(f"{repo_url}/repo.json{ext}", headers={"User-Agent":"pag/3.0"})
926 - with urlopen(req, timeout=15) as resp:
927 - sig_data = resp.read()
928 - sig_ext = ext
929 - break
930 - except Exception:
931 - continue
932 - if not sig_data:
933 - if insecure:
934 - return True # tryb insecure – akceptuj bez podpisu
935 - print(f" ❌ {repo_url}: NIE MOŻNA POBRAĆ PODPISU repo.json.asc/.sig!")
936 - print(f" Ustaw PAG_INSECURE=1 aby pominąć (niezalecane)")
937 - os.remove(cache_path)
938 - return False
939 - sig_path = cache_path + sig_ext
940 - with open(sig_path, "wb") as f:
941 - f.write(sig_data)
942 -
943 - result = _gpg_run("--no-default-keyring", "--keyring", GPG_KEYRING,
944 - "--verify", sig_path, cache_path,
945 - capture_output=True, text=True, timeout=30)
946 - if result.returncode != 0:
947 - # Automatyczny import klucza repo przy pierwszym uruchomieniu (TOFU,
948 - # jak apt) – gdy w keyringu brakuje klucza (No public key).
949 - _stderr = (result.stderr or "")
950 - if "public key" in _stderr.lower() and "no public key" in _stderr.lower():
951 - try:
952 - with urlopen(Request(f"{repo_url}/paganos.asc", headers={"User-Agent": "pag/3.0"}), timeout=20) as r:
953 - keydata = r.read()
954 - with tempfile.NamedTemporaryFile(delete=False, suffix=".asc") as tmp:
955 - tmp.write(keydata)
956 - tmp.flush()
957 - _gpg_run("--no-default-keyring", "--keyring", GPG_KEYRING,
958 - "--import", tmp.name, capture_output=True, timeout=30)
959 - os.unlink(tmp.name)
960 - print(f" 🔑 Importowano klucz repo z {repo_url}/paganos.asc")
961 - result = _gpg_run("--no-default-keyring", "--keyring", GPG_KEYRING,
962 - "--verify", sig_path, cache_path,
963 - capture_output=True, text=True, timeout=30)
964 - except Exception:
965 - pass
966 - if result.returncode != 0:
967 - if insecure:
968 - print(f" ⚠ {repo_url}: nieprawidłowy podpis GPG (PAG_INSECURE – ignoruję)")
969 - return True
970 - os.remove(cache_path)
971 - print(f" ❌ {repo_url}: NIEPRAWIDŁOWY PODPIS GPG indeksu repozytorium!")
972 - return False
973 -
974 - return True
975 -
976 -def fetch_all_packages(force=False):
977 - all_pkgs = {}
978 - for repo_url in get_repos():
979 - pkgs = fetch_repo_index(repo_url, force)
980 - if pkgs:
981 - for pdata in pkgs:
982 - name = pdata.get("name", pdata.get("filename","?").split("-")[0])
983 - pkg = PackageInfo(pdata, repo_url)
984 - if name not in all_pkgs or _version_newer(pkg.version, all_pkgs[name].version):
985 - all_pkgs[name] = pkg
986 - return all_pkgs
987 -
988 -# =============================================================================
989 -# GPG
990 -# =============================================================================
991 -
992 -def _verify_pkg_gpg(pkg_path):
993 - """Weryfikuje podpis GPG pakietu.
994 -
995 - FAIL-CLOSED: brak podpisu = odrzucenie (chyba że PAG_INSECURE=1).
996 - Zwraca (passed: bool, message: str).
997 - """
998 - insecure = os.environ.get("PAG_INSECURE", "") == "1"
999 - sig_path = pkg_path + ".sig"
1000 - if not os.path.exists(sig_path) and os.path.exists(pkg_path + ".asc"):
1001 - sig_path = pkg_path + ".asc"
1002 -
1003 - if not os.path.exists(sig_path):
1004 - if insecure:
1005 - return True, "(no signature – PAG_INSECURE)"
1006 - return False, "BRAK PODPISU – pakiet odrzucony (ustaw PAG_INSECURE=1 aby pominąć)"
1007 -
1008 - result = _gpg_run("--no-default-keyring", "--keyring", GPG_KEYRING,
1009 - "--verify", sig_path, pkg_path,
1010 - capture_output=True, text=True, timeout=30)
1011 - if result.returncode != 0:
1012 - if insecure:
1013 - return True, f"(invalid signature – PAG_INSECURE: {result.stderr[:80]})"
1014 - return False, f"NIEPRAWIDŁOWY PODPIS GPG: {result.stderr[:80]}"
1015 -
1016 - return True, "GPG verified"
1017 -
1018 -def cmd_key_add(source):
1019 - ensure_dirs()
1020 - if source.startswith("http"):
1021 - try:
1022 - with urlopen(Request(source, headers={"User-Agent":"pag/3.0"}), timeout=30) as resp:
1023 - keydata = resp.read()
1024 - with tempfile.NamedTemporaryFile(delete=False, suffix=".gpg") as tmp:
1025 - tmp.write(keydata); tmp.flush()
1026 - _gpg_run("--no-default-keyring", "--keyring", GPG_KEYRING,
1027 - "--import", tmp.name, capture_output=True, timeout=30)
1028 - os.unlink(tmp.name)
1029 - except Exception as e:
1030 - print(f"❌ Download error: {e}"); return 1
1031 - else:
1032 - _gpg_run("--no-default-keyring", "--keyring", GPG_KEYRING,
1033 - "--import", source, capture_output=True, timeout=30)
1034 - print(f"✅ {_('key_imported')}")
1035 -
1036 -def cmd_key_list():
1037 - if not os.path.exists(GPG_KEYRING):
1038 - print(_("no_keys")); return
1039 - result = _gpg_run("--no-default-keyring", "--keyring", GPG_KEYRING,
1040 - "--list-keys", "--keyid-format", "LONG",
1041 - capture_output=True, text=True, timeout=30)
1042 - print(result.stdout or _("no_keys"))
1043 -
1044 -def cmd_key_remove(key_id):
1045 - _gpg_run("--no-default-keyring", "--keyring", GPG_KEYRING,
1046 - "--batch", "--yes", "--delete-key", key_id,
1047 - capture_output=True, timeout=30)
1048 - print(f"✅ {_('key_removed', key_id)}")
1049 -
1050 -# =============================================================================
1051 -# ATOMOWA INSTALACJA (STAGING)
1052 -# =============================================================================
1053 -
1054 -def _safe_rename(src: str, dst: str) -> bool:
1055 - """
1056 - Atomowe przeniesienie pliku. Jeśli src i dst są na różnych
1057 - systemach plików (EXDEV), kopiuje + usuwa źródło.
1058 - """
1059 - try:
1060 - os.rename(src, dst)
1061 - return True
1062 - except OSError as e:
1063 - if e.errno == 18: # EXDEV – cross-device link
1064 - shutil.copy2(src, dst)
1065 - os.remove(src)
1066 - return True
1067 - raise
1068 -
1069 -
1070 -def _install_file(src: str, rel: str, data_staging: str, sums: dict,
1071 - staging: str, journal: list, installed_files: list,
1072 - deploy_dir: str = "") -> bool:
1073 - """
1074 - Instaluje pojedynczy plik (zwykły lub symlink).
1075 - Obsługuje: cross-device rename, symlinki, weryfikację SHA256.
1076 -
1077 - Jeśli deploy_dir jest podany (tryb immutable), pliki systemowe trafiają
1078 - do deploymentu, a współdzielone (/var, /etc, ...) bezpośrednio do /.
1079 - """
1080 - # W trybie immutable: pliki współdzielone idą do /, reszta do deploymentu
1081 - if deploy_dir and _is_shared_path("/" + rel):
1082 - dst_root = PAG_ROOT
1083 - elif deploy_dir:
1084 - dst_root = deploy_dir
1085 - else:
1086 - dst_root = PAG_ROOT
1087 -
1088 - dst = os.path.join(dst_root, rel)
1089 -
1090 - # --- SYMLINK ---
1091 - if os.path.islink(src):
1092 - link_target = os.readlink(src)
1093 - # Weryfikuj sums.json dla symlinka (hash ścieżki docelowej)
1094 - expected = sums.get("/" + rel, "")
1095 - if expected:
1096 - link_hash = hashlib.sha256(link_target.encode()).hexdigest()
1097 - if expected and link_hash != expected:
1098 - return False
1099 -
1100 - os.makedirs(os.path.dirname(dst), exist_ok=True)
1101 - # Jeśli docelowy symlink już istnieje, usuń go
1102 - if os.path.islink(dst) or os.path.exists(dst):
1103 - os.remove(dst)
1104 - os.symlink(link_target, dst)
1105 - journal.append(("symlink", "", dst))
1106 - installed_files.append({
1107 - "path": "/" + rel,
1108 - "sha256": hashlib.sha256(link_target.encode()).hexdigest(),
1109 - "size": len(link_target),
1110 - "is_symlink": True,
1111 - "symlink_target": link_target,
1112 - })
1113 - return True
1114 -
1115 - # --- ZWYKŁY PLIK ---
1116 - # Oblicz SHA256
1117 - try:
1118 - file_sha = _sha256_file(src)
1119 - except Exception:
1120 - file_sha = ""
1121 -
1122 - # Weryfikuj sums.json
1123 - expected = sums.get("/" + rel, "")
1124 - if expected and file_sha and file_sha != expected:
1125 - return False
1126 -
1127 - # Utwórz katalog docelowy
1128 - os.makedirs(os.path.dirname(dst), exist_ok=True)
1129 -
1130 - # Atomowe przeniesienie (z fallbackiem dla cross-device).
1131 - # Zachowuje bity uprawnień (SUID/SGID/sticky) – NIE używamy filter='data'.
1132 - _safe_rename(src, dst)
1133 -
1134 - # Wymuś właściciela root:root. UWAGA: os.chown() NIE czyści bitów SUID/SGID.
1135 - try:
1136 - os.chown(dst, 0, 0)
1137 - except (OSError, PermissionError):
1138 - # Na niektórych systemach plików (tmpfs, fat) chown może się nie powieść
1139 - pass
1140 -
1141 - journal.append(("file", src, dst))
1142 - installed_files.append({
1143 - "path": "/" + rel,
1144 - "sha256": file_sha,
1145 - "size": os.path.getsize(dst),
1146 - "is_symlink": False,
1147 - })
1148 - return True
1149 -
1150 -
1151 -def _atomic_install(pkg_path: str, pkg: PackageInfo, deploy_dir: str = "") -> Tuple[bool, List[dict]]:
1152 - """
1153 - Rozpakowuje do staging area, potem atomowo przenosi pliki.
1154 - Jeśli deploy_dir podany – instaluje do deploymentu (tryb immutable).
1155 - Zwraca (success, [lista plików z SHA256]).
1156 - """
1157 - staging = tempfile.mkdtemp(dir=STAGING_DIR, prefix=f".staging-{pkg.name}-")
1158 - journal = []
1159 - installed_files = []
1160 -
1161 - try:
1162 - # Rozpakuj .pkg.tar.xz → staging (bezpieczne – ochrona Directory Traversal)
1163 - with tarfile.open(pkg_path, "r:xz") as tf:
1164 - _safe_extractall(tf, staging)
1165 -
1166 - data_tar = os.path.join(staging, "data.tar.xz")
1167 - if not os.path.exists(data_tar):
1168 - shutil.rmtree(staging, ignore_errors=True)
1169 - return False, []
1170 -
1171 - # Rozpakuj data.tar.xz → staging/data (bezpieczne – ochrona Directory Traversal)
1172 - data_staging = os.path.join(staging, "data")
1173 - os.makedirs(data_staging, exist_ok=True)
1174 - with tarfile.open(data_tar, "r:xz") as tf:
1175 - _safe_extractall(tf, data_staging)
1176 -
1177 - # Wczytaj sums.json
1178 - sums_path = os.path.join(data_staging, "sums.json")
1179 - sums = json.load(open(sums_path)) if os.path.exists(sums_path) else {}
1180 -
1181 - # Przenieś pliki: staging/data/* → /
1182 - for root, dirs, files in os.walk(data_staging):
1183 - for fname in files:
1184 - if fname == "sums.json":
1185 - continue
1186 - src = os.path.join(root, fname)
1187 - rel = os.path.relpath(src, data_staging)
1188 -
1189 - ok = _install_file(src, rel, data_staging, sums,
1190 - staging, journal, installed_files, deploy_dir)
1191 - if not ok:
1192 - # Cofnij wszystkie operacje
1193 - _rollback_journal(journal, staging)
1194 - return False, []
1195 -
1196 - # Uruchom hooki post-install
1197 - hooks_dir = os.path.join(staging, "hooks")
1198 - _run_hook(hooks_dir, "post-install", pkg)
1199 -
1200 - # Zapisz do SQLite
1201 - _db_record_files(pkg.name, installed_files)
1202 -
1203 - shutil.rmtree(staging, ignore_errors=True)
1204 - return True, installed_files
1205 -
1206 - except Exception as e:
1207 - _rollback_journal(journal, staging)
1208 - return False, []
1209 -
1210 -
1211 -def _rollback_journal(journal: list, staging_path: str):
1212 - """Cofa wszystkie operacje z journala (odwrotna kolejność)."""
1213 - for entry in reversed(journal):
1214 - op = entry[0]
1215 - if op == "file":
1216 - _, src, dst = entry
1217 - try:
1218 - if os.path.exists(dst) or os.path.islink(dst):
1219 - _safe_rename(dst, src)
1220 - except Exception:
1221 - pass
1222 - elif op == "symlink":
1223 - _, _, dst = entry
1224 - try:
1225 - if os.path.islink(dst) or os.path.exists(dst):
1226 - os.remove(dst)
1227 - except Exception:
1228 - pass
1229 - shutil.rmtree(staging_path, ignore_errors=True)
1230 -
1231 -# =============================================================================
1232 -# BEZPIECZNE USUWANIE
1233 -# =============================================================================
1234 -
1235 -def _safe_remove_files(pkg_name: str, installed_db: dict) -> Tuple[int, List[str]]:
1236 - """
1237 - Usuwa pliki pakietu, ale tylko jeśli NIE są współdzielone z innym pakietem.
1238 - Zwraca (liczba usuniętych, [lista usuniętych ścieżek]).
1239 - """
1240 - pkg_files = _db_get_package_files(pkg_name)
1241 - removed = []
1242 - skipped_shared = []
1243 -
1244 - for fpath in pkg_files:
1245 - owners = _db_get_file_owners(fpath)
1246 - # Sprawdź czy inny ZAINSTALOWANY pakiet też jest właścicielem
1247 - other_owners = [o for o in owners if o != pkg_name and o in installed_db]
1248 -
1249 - if other_owners:
1250 - # Plik współdzielony – tylko usuń wpis w DB, nie kasuj pliku
1251 - skipped_shared.append(fpath)
1252 - continue
1253 -
1254 - full = os.path.join(PAG_ROOT, fpath.lstrip("/"))
1255 - if os.path.isfile(full) or os.path.islink(full):
1256 - os.remove(full)
1257 - removed.append(fpath)
1258 -
1259 - # Usuń puste katalogi (od najgłębszych)
1260 - dirs = set()
1261 - for fpath in removed + skipped_shared:
1262 - parent = os.path.dirname(fpath)
1263 - while parent and parent != "/":
1264 - dirs.add(parent)
1265 - parent = os.path.dirname(parent)
1266 -
1267 - for d in sorted(dirs, key=len, reverse=True):
1268 - full_d = os.path.join(PAG_ROOT, d.lstrip("/"))
1269 - if os.path.isdir(full_d):
1270 - try:
1271 - os.rmdir(full_d)
1272 - except OSError:
1273 - pass # nie jest pusty – OK
1274 -
1275 - # Usuń z SQLite
1276 - _db_remove_package_files(pkg_name)
1277 -
1278 - if skipped_shared:
1279 - print(f" ⚠ {len(skipped_shared)} plików współdzielonych zachowanych")
1280 -
1281 - return len(removed) + len(skipped_shared), removed
1282 -
1283 -# =============================================================================
1284 -# HOOKI
1285 -# =============================================================================
1286 -
1287 -def _run_hook(hooks_dir: str, hook_name: str, pkg: PackageInfo):
1288 - """Uruchamia skrypt hooka jeśli istnieje."""
1289 - hook_path = os.path.join(hooks_dir, hook_name)
1290 - if not os.path.exists(hook_path):
1291 - return
1292 - os.chmod(hook_path, 0o755)
1293 - env = os.environ.copy()
1294 - env["PKG_NAME"] = pkg.name
1295 - env["PKG_VERSION"] = pkg.version
1296 - env["PKG_ACTION"] = hook_name
1297 - try:
1298 - subprocess.run([hook_path], env=env, timeout=60, check=False)
1299 - except Exception:
1300 - pass
1301 -
1302 -# =============================================================================
1303 -# TRANSAKCJE I ROLLBACK
1304 -# =============================================================================
1305 -
1306 -def _record_transaction(action, packages, success, snapshot, file_journal=None):
1307 - history = load_json(HISTORY_FILE) if os.path.exists(HISTORY_FILE) else []
1308 - entry = {
1309 - "action": action, "packages": packages, "success": success,
1310 - "timestamp": datetime.now().isoformat(),
1311 - "snapshot": snapshot,
1312 - "file_journal": file_journal, # lista plików do wycofania
1313 - }
1314 - history.append(entry)
1315 - if len(history) > 50:
1316 - history = history[-50:]
1317 - save_json(HISTORY_FILE, history)
1318 -
1319 -def cmd_history():
1320 - if not os.path.exists(HISTORY_FILE):
1321 - print(_("no_history")); return
1322 - history = load_json(HISTORY_FILE)
1323 - if not history:
1324 - print(_("no_history")); return
1325 - print(f"Ostatnie transakcje ({len(history)}):")
1326 - for i, e in enumerate(reversed(history), 1):
1327 - icon = "✅" if e["success"] else "❌"
1328 - pkgs = ", ".join(e["packages"][:5])
1329 - if len(e["packages"]) > 5: pkgs += f" (+{len(e['packages'])-5})"
1330 - print(f" {i}. {icon} {e['action']}: {pkgs}")
1331 - print(f" {e['timestamp']}")
1332 -
1333 -def cmd_rollback():
1334 - if not os.path.exists(HISTORY_FILE):
1335 - print(_("no_history")); return 1
1336 - history = load_json(HISTORY_FILE)
1337 - if not history:
1338 - print(_("no_history")); return 1
1339 -
1340 - last = None
1341 - for e in reversed(history):
1342 - if e["success"] and e.get("snapshot"):
1343 - last = e; break
1344 -
1345 - if not last:
1346 - print("❌ No snapshot to restore."); return 1
1347 -
1348 - print(f"⏪ Rolling back: {last['action']} ({last['timestamp']})")
1349 - print(f" Packages: {', '.join(last['packages'][:10])}")
1350 -
1351 - ans = input(_("continue_q")).strip().lower()
1352 - if ans and ans not in ("t","y"):
1353 - return 0
1354 -
1355 - # Przywróć installed.json
1356 - save_json(INSTALLED_DB, last["snapshot"])
1357 -
1358 - # Wycofaj fizyczne pliki (jeśli zapisano journal)
1359 - file_journal = last.get("file_journal", [])
1360 - if file_journal:
1361 - for fpath in reversed(file_journal):
1362 - full = os.path.join(PAG_ROOT, fpath.lstrip("/"))
1363 - if os.path.exists(full) or os.path.islink(full):
1364 - os.remove(full)
1365 - print(f" {_('rollback_files', len(file_journal))}")
1366 -
1367 - print(f"✅ {_('rollback_restored')}")
1368 - _record_transaction("rollback", last["packages"], True, None)
1369 - return 0
1370 -
1371 -# =============================================================================
1372 -# INSTALACJA
1373 -# =============================================================================
1374 -
1375 -def cmd_install(package_names, as_dep=False):
1376 - ensure_dirs()
1377 - installed_db = load_json(INSTALLED_DB)
1378 - world = load_world()
1379 - pinned = load_json(PINNED_FILE)
1380 - repo_pkgs = fetch_all_packages()
1381 -
1382 - if not repo_pkgs:
1383 - print(f"❌ {_('no_index')}"); return 1
1384 -
1385 - for name in list(package_names):
1386 - if name in pinned:
1387 - print(f"⚠ {name} {_('pinned_to')} {pinned[name]} – skipping")
1388 - package_names.remove(name)
1389 -
1390 - to_install, missing_deps = _resolve_deps(package_names, repo_pkgs, installed_db)
1391 -
1392 - if not to_install and not missing_deps:
1393 - print(f"✅ {_('all_installed')}"); return 0
1394 -
1395 - # ── WERYFIKACJA ZALEŻNOŚCI ──────────────────────────────────────────
1396 - fatal_missing = _verify_dependencies(to_install, repo_pkgs, installed_db)
1397 -
1398 - if fatal_missing > 0:
1399 - print(f"❌ Nie można kontynuować – {fatal_missing} brakujących zależności.")
1400 - print(f" Zainstaluj brakujące pakiety lub dodaj repozytoria.")
1401 - return 1
1402 -
1403 - if not to_install:
1404 - print(f"✅ {_('all_installed')}"); return 0
1405 -
1406 - MAX_MB = MAX_PKG_SIZE // 1048576
1407 - for n in to_install:
1408 - if not _validate_pkg_name(n):
1409 - print(f" {_("sec_badname", name=n)}")
1410 - return 1
1411 - sz = repo_pkgs[n].size_bytes if n in repo_pkgs else 0
1412 - if sz > MAX_PKG_SIZE:
1413 - mb = sz // 1048576
1414 - print(f" {_("sec_toobig", size_mb=mb, max_mb=MAX_MB)}")
1415 - return 1
1416 - total_size = sum(repo_pkgs[n].size_bytes for n in to_install if n in repo_pkgs)
1417 - print(f"\n📦 {_('to_install', len(to_install), total_size/1048576)}")
1418 - for name in to_install:
1419 - p = repo_pkgs.get(name)
1420 - if p:
1421 - marker = f" [{_('new')}]" if name not in installed_db else ""
1422 - print(f" {name}-{p.version}{marker}")
1423 -
1424 - if not as_dep:
1425 - ans = input(_("continue_q")).strip().lower()
1426 - if ans and ans not in ("t","y"):
1427 - print(_("cancelled")); return 0
1428 -
1429 - snapshot = json.loads(json.dumps(installed_db))
1430 - all_installed_files = []
1431 - failed = []
1432 -
1433 - # --- Dziennik transakcji (dla pełnej atomowości) ---
1434 - # Jeśli którykolwiek pakiet zawiedzie, cofamy WSZYSTKIE zainstalowane
1435 - # w tej transakcji przez _rollback_transaction().
1436 - transaction_journal: List[Tuple[str, str, str]] = [] # (op, src, dst)
1437 -
1438 - # --- Tryb immutable: utwórz nowy deployment ---
1439 - immutable = os.environ.get("PAG_IMMUTABLE", "") == "1"
1440 - deploy_dir = ""
1441 - deploy_id = ""
1442 - if immutable:
1443 - print(f"\n 🏗️ Tworzenie nowego deploymentu...")
1444 - deploy_dir, deploy_id = _create_deployment(to_install, "install")
1445 - target_root = deploy_dir
1446 - else:
1447 - target_root = ""
1448 -
1449 - # --- Faza 1: Równoległe pobieranie wszystkich pakietów ---
1450 - to_download = [repo_pkgs[name] for name in to_install if name in repo_pkgs]
1451 - if len(to_download) > 1:
1452 - print(f"\n ⏬ Pobieranie {len(to_download)} pakietów równolegle...")
1453 - downloaded = _download_packages_parallel(to_download)
1454 - else:
1455 - downloaded = {}
1456 -
1457 - # --- Faza 2: Instalacja z paskiem postępu ---
1458 - t0 = time.time()
1459 -
1460 - for name in to_install:
1461 - pkg = repo_pkgs.get(name)
1462 - if not pkg:
1463 - print(f" ❌ {name}: {_('not_found')}")
1464 - failed.append(name)
1465 - break
1466 -
1467 - # Pasek postępu na stderr (nie koliduje z download barem)
1468 - idx = len(all_installed_files) + 1
1469 - pct = (idx - 1) / len(to_install) * 100
1470 - fl = int(25 * pct / 100)
1471 - pbar = "█" * fl + "░" * (25 - fl)
1472 - elapsed = time.time() - t0
1473 - if idx > 1 and elapsed > 0:
1474 - avg = elapsed / (idx - 1)
1475 - remaining = avg * (len(to_install) - idx + 1)
1476 - if remaining < 60:
1477 - eta_s = f" ~{remaining:.0f}s"
1478 - else:
1479 - eta_s = f" ~{remaining/60:.1f}m"
1480 - else:
1481 - eta_s = ""
1482 - status = f" [{pbar}] {idx}/{len(to_install)} ({pct:.0f}%){eta_s}"
1483 - print(status, file=sys.stderr, flush=True)
1484 -
1485 - print(f" ↓ {name}-{pkg.version} ...", end=" ", flush=True)
1486 -
1487 - # Pobierz (z cache fazy 1 lub bezpośrednio)
1488 - pkg_path = downloaded.get(name) if name in downloaded else _download_pkg(pkg)
1489 - if not pkg_path:
1490 - print(f"❌ {_('download_fail')}")
1491 - failed.append(name)
1492 - break # przerwij transakcję
1493 -
1494 - # GPG
1495 - gpg_ok, gpg_msg = _verify_pkg_gpg(pkg_path)
1496 - if not gpg_ok:
1497 - print(f"❌ {_('gpg_fail')}: {gpg_msg[:60]}")
1498 - failed.append(name)
1499 - break # PRZERWIJ – niezaufany pakiet
1500 -
1501 - # SHA256 całego pakietu
1502 - if pkg.sha256 and _sha256_file(pkg_path) != pkg.sha256:
1503 - print(f"❌ {_('sha256_mismatch')}")
1504 - failed.append(name)
1505 - break # PRZERWIJ – uszkodzony pakiet
1506 -
1507 - # Atomowa instalacja
1508 - ok, files = _atomic_install(pkg_path, pkg, deploy_dir)
1509 - if ok:
1510 - installed_db[name] = {
1511 - "version": pkg.version, "description": pkg.description,
1512 - "dependencies": pkg.dependencies, "size_bytes": pkg.size_bytes,
1513 - "sha256": pkg.sha256, "installed_at": datetime.now().isoformat(),
1514 - "repo": pkg.repo_url,
1515 - }
1516 - if not as_dep and name in package_names:
1517 - world.add(name)
1518 - print("✅")
1519 - all_installed_files.extend(f["path"] for f in files)
1520 -
1521 - # Po instalacji kernela – przebuduj initramfs
1522 - if _is_kernel_package(name):
1523 - _rebuild_initramfs(deploy_dir)
1524 - else:
1525 - print("❌")
1526 - failed.append(name)
1527 - break # PRZERWIJ – błąd instalacji
1528 -
1529 - # --- Rollback całej transakcji jeśli cokolwiek zawiodło ---
1530 - if failed:
1531 - print(f"\n ↩ Cofanie transakcji ({len(failed)} błędów)...")
1532 - _rollback_transaction(installed_db, snapshot, all_installed_files,
1533 - deploy_dir, immutable)
1534 - _record_transaction("install", to_install, False, snapshot)
1535 - return 1
1536 -
1537 - save_json(INSTALLED_DB, installed_db)
1538 - save_world(world)
1539 - _record_transaction("install", to_install, True, snapshot,
1540 - file_journal=all_installed_files)
1541 -
1542 - # --- Tryb immutable: przełącz na nowy deployment ---
1543 - if immutable and not failed:
1544 - print(f"\n 🔄 Przełączanie na deployment {deploy_id}...")
1545 - _switch_deployment(deploy_dir)
1546 - print(f" ✅ Aktywny deployment: {deploy_id}")
1547 - _update_grub_config()
1548 - cmd_deploy_cleanup(keep=5) # Zostawia 5 najnowszych deploymentów
1549 - print(f" 💡 Restart wymagany do przeładowania systemu.")
1550 -
1551 - print(f"\n✅ {_('installed', len(to_install))}")
1552 - return 0
1553 -
1554 -
1555 -def _rollback_transaction(installed_db: dict, snapshot: dict,
1556 - installed_files: List[str],
1557 - deploy_dir: str, is_immutable: bool):
1558 - """
1559 - Cofa WSZYSTKIE pakiety zainstalowane w bieżącej transakcji.
1560 - Przywraca installed_db do stanu sprzed transakcji.
1561 - Usuwa fizyczne pliki z systemu (lub deploymentu w trybie immutable).
1562 - """
1563 - # Przywróć installed_db
1564 - installed_db.clear()
1565 - installed_db.update(snapshot)
1566 -
1567 - # Usuń fizyczne pliki (odwrotna kolejność)
1568 - root = deploy_dir if is_immutable else PAG_ROOT
1569 - for fpath in reversed(installed_files):
1570 - full = os.path.join(root, fpath.lstrip("/"))
1571 - if os.path.isfile(full) or os.path.islink(full):
1572 - try:
1573 - os.remove(full)
1574 - except OSError:
1575 - pass
1576 -
1577 - # Wyczyść puste katalogi
1578 - dirs_to_check = set()
1579 - for fpath in installed_files:
1580 - parent = os.path.dirname(fpath)
1581 - while parent and parent != "/":
1582 - dirs_to_check.add(parent)
1583 - parent = os.path.dirname(parent)
1584 - for d in sorted(dirs_to_check, key=len, reverse=True):
1585 - full_d = os.path.join(root, d.lstrip("/"))
1586 - if os.path.isdir(full_d):
1587 - try:
1588 - os.rmdir(full_d)
1589 - except OSError:
1590 - pass
1591 -
1592 - # W trybie immutable: usuń nieudany deployment
1593 - if is_immutable and deploy_dir:
1594 - shutil.rmtree(deploy_dir, ignore_errors=True)
1595 -
1596 - save_json(INSTALLED_DB, snapshot)
1597 -
1598 -
1599 -# =============================================================================
1600 -# USUWANIE
1601 -# =============================================================================
1602 -
1603 -def cmd_remove(package_names):
1604 - installed_db = load_json(INSTALLED_DB)
1605 - world = load_world()
1606 - snapshot = json.loads(json.dumps(installed_db))
1607 - removed = []
1608 -
1609 - total = len(package_names)
1610 - for i, name in enumerate(package_names, 1):
1611 - if name not in installed_db:
1612 - print(f" ⚠ {name}: not installed"); continue
1613 -
1614 - # Pasek postępu
1615 - pct = (i - 1) / total * 100
1616 - filled = int(25 * pct / 100)
1617 - print(f" 🗑 [{'█' * filled + '░' * (25 - filled)}] {i}/{total} ({pct:.0f}%) ", end="\r", file=sys.stderr, flush=True)
1618 -
1619 - print(f"🗑 {name}-{installed_db[name]['version']} ...", end=" ", flush=True)
1620 -
1621 - # Pre-remove hook (jeśli dostępny w staging)
1622 - _run_hook_for_installed(name, "pre-remove")
1623 -
1624 - count, _ = _safe_remove_files(name, installed_db)
1625 - del installed_db[name]
1626 - world.discard(name)
1627 - removed.append(name)
1628 - print(f"✅ ({count} files)")
1629 -
1630 - save_json(INSTALLED_DB, installed_db)
1631 - save_world(world)
1632 - _record_transaction("remove", removed, True, snapshot)
1633 -
1634 - print(file=sys.stderr) # wyczyść linię paska postępu
1635 -
1636 - if not removed: return 0
1637 - print(f"\n✅ Removed {len(removed)}.")
1638 -
1639 - orphans = _find_orphans(installed_db, world)
1640 - if orphans:
1641 - print(f"\n💡 {_('orphans_found', len(orphans), ', '.join(sorted(orphans)[:10]))}")
1642 - print(" 'pag remove-orphans' to clean up.")
1643 - return 0
1644 -
1645 -def _run_hook_for_installed(pkg_name, hook_name):
1646 - """Próbuje uruchomić hook z katalogu pakietu (jeśli został zapisany)."""
1647 - hook_dir = os.path.join(PAG_DB, "hooks", pkg_name)
1648 - if os.path.isdir(hook_dir):
1649 - _run_hook(hook_dir, hook_name, PackageInfo({"name": pkg_name}))
1650 -
1651 -# =============================================================================
1652 -# UPDATE / UPGRADE / LIST / SEARCH / INFO / VERIFY
1653 -# =============================================================================
1654 -
1655 -def cmd_self_update():
1656 - """Aktualizuje samego klienta pag z repo (podpisany /stable/pag)."""
1657 - repos = get_repos()
1658 - if not repos:
1659 - print("❌ Brak repozytoriów w konfiguracji.")
1660 - return 1
1661 - base = repos[0]
1662 - print(f"🔄 Sprawdzam aktualizację pag z {base}...")
1663 - tmp_pag = "/tmp/pag.new"
1664 - tmp_sig = "/tmp/pag.new.asc"
1665 - try:
1666 - with urlopen(Request(f"{base}/pag", headers={"User-Agent": "pag/3.0"}), timeout=20) as r:
1667 - data = r.read()
1668 - with urlopen(Request(f"{base}/pag.asc", headers={"User-Agent": "pag/3.0"}), timeout=20) as r:
1669 - sig = r.read()
1670 - except Exception as e:
1671 - print(f" ❌ Nie można pobrać pag: {e}")
1672 - return 1
1673 - with open(tmp_pag, "wb") as f:
1674 - f.write(data)
1675 - with open(tmp_sig, "wb") as f:
1676 - f.write(sig)
1677 -
1678 - # Weryfikacja podpisu GPG – bez tego nie instalujemy
1679 - insecure = os.environ.get("PAG_INSECURE", "") == "1"
1680 - res = _gpg_run("--no-default-keyring", "--keyring", GPG_KEYRING,
1681 - "--verify", tmp_sig, tmp_pag, capture_output=True, text=True)
1682 - if res.returncode != 0:
1683 - # Automatyczny import klucza (TOFU) – jak w _verify_repo_sig
1684 - _stderr = (res.stderr or "")
1685 - if "public key" in _stderr.lower() and "no public key" in _stderr.lower():
1686 - try:
1687 - with urlopen(Request(f"{base}/paganos.asc", headers={"User-Agent": "pag/3.0"}), timeout=20) as r:
1688 - keydata = r.read()
1689 - with tempfile.NamedTemporaryFile(delete=False, suffix=".asc") as tmp:
1690 - tmp.write(keydata)
1691 - tmp.flush()
1692 - _gpg_run("--no-default-keyring", "--keyring", GPG_KEYRING,
1693 - "--import", tmp.name, capture_output=True, timeout=30)
1694 - os.unlink(tmp.name)
1695 - print(f" 🔑 Importowano klucz repo z {base}/paganos.asc")
1696 - res = _gpg_run("--no-default-keyring", "--keyring", GPG_KEYRING,
1697 - "--verify", tmp_sig, tmp_pag, capture_output=True, text=True)
1698 - except Exception:
1699 - pass
1700 - if res.returncode != 0:
1701 - if insecure:
1702 - print(" ⚠ Nieprawidłowy podpis aktualizacji (PAG_INSECURE – ignoruję)")
1703 - else:
1704 - print(" ❌ Nieprawidłowy podpis aktualizacji – nie aktualizuję.")
1705 - return 1
1706 -
1707 - m = re.search(rb"v\d+\.\d+\.\d+", data[:3000])
1708 - new_ver = m.group(0).decode().lstrip("v") if m else "?"
1709 - print(f" ✅ Pobrano pag {new_ver} (obecny {PAG_VERSION}), podpis zweryfikowany")
1710 -
1711 - dst = "/usr/local/bin/pag"
1712 - if os.path.exists(dst):
1713 - shutil.copy2(dst, dst + ".bak")
1714 - shutil.copy2(tmp_pag, dst)
1715 - os.chmod(dst, 0o755)
1716 - print(f" ✅ Zainstalowano nowy pag. Stary zachowany jako {dst}.bak")
1717 - print(" Uruchom ponownie pag, aby użyć nowej wersji.")
1718 - return 0
1719 -
1720 -
1721 -def cmd_update():
1722 - force = "--force" in sys.argv
1723 - print(f"🔄 {'Forced refresh' if force else 'Updating'} indexes...")
1724 - for repo_url in get_repos():
1725 - pkgs = fetch_repo_index(repo_url, force=force)
1726 - cp = _repo_cache_path(repo_url)
1727 - has_sig = os.path.exists(cp + ".sig")
1728 - print(f" {'✅' if pkgs is not None else '❌'} {repo_url}: {len(pkgs or [])} pkgs {'🔐' if has_sig else '⚠'}")
1729 - total = 0
1730 - for r in get_repos():
1731 - cp = _repo_cache_path(r)
1732 - if os.path.exists(cp):
1733 - try:
1734 - total += len(json.load(open(cp)).get("packages", []))
1735 - except Exception:
1736 - pass
1737 - print(f"✅ {_('updated_done', total)}")
1738 -
1739 - # Powiadomienie o nowszej wersji pag (repo.json["pag_version"])
1740 - try:
1741 - for r in get_repos():
1742 - cp = _repo_cache_path(r)
1743 - if os.path.exists(cp):
1744 - d = json.load(open(cp))
1745 - rv = d.get("pag_version", "")
1746 - if rv and rv != PAG_VERSION:
1747 - print(f" ⚠ Nowa wersja pag {rv} dostępna – uruchom: pag self-update")
1748 - except Exception:
1749 - pass
1750 -
1751 -def cmd_upgrade():
1752 - ensure_dirs()
1753 - installed = load_json(INSTALLED_DB)
1754 - pinned = load_json(PINNED_FILE)
1755 - repo = fetch_all_packages()
1756 - upgrades = [n for n, i in installed.items()
1757 - if n not in pinned and (rp := repo.get(n)) and _version_newer(rp.version, i["version"])]
1758 - if not upgrades:
1759 - print(f"✅ {_('all_up_to_date')}"); return 0
1760 - print(f"📦 {_('upgrading', len(upgrades))}")
1761 - for n in upgrades:
1762 - print(f" {n}: {installed[n]['version']} → {repo[n].version}")
1763 - ans = input(_("continue_q")).strip().lower()
1764 - if ans and ans not in ("t","y"): return 0
1765 - return cmd_install(upgrades)
1766 -
1767 -def cmd_list(installed_only=False):
1768 - if installed_only:
1769 - db = load_json(INSTALLED_DB)
1770 - pinned = load_json(PINNED_FILE)
1771 - if not db: print("No packages installed."); return
1772 - print(f"Installed ({len(db)}):")
1773 - for n, i in sorted(db.items()):
1774 - pin = " 📌" if n in pinned else ""
1775 - print(f" {n}-{i['version']}{pin} – {i.get('description','')}")
1776 - else:
1777 - pkgs = fetch_all_packages()
1778 - installed = load_json(INSTALLED_DB)
1779 - pinned = load_json(PINNED_FILE)
1780 - print(f"Available ({len(pkgs)}):")
1781 - for n, p in sorted(pkgs.items()):
1782 - m = "✓" if n in installed else " "
1783 - extra = f" [installed: {installed[n]['version']}]" if n in installed else ""
1784 - if n in pinned: extra += " 📌"
1785 - print(f" [{m}] {n}-{p.version} – {p.description}{extra}")
1786 -
1787 -def cmd_search(query):
1788 - pkgs = fetch_all_packages()
1789 - results = [(n,p) for n,p in pkgs.items() if query.lower() in n.lower() or query.lower() in p.description.lower()]
1790 - if not results: print(f"❌ No results for: {query}"); return
1791 - installed = load_json(INSTALLED_DB)
1792 - print(f"Results for '{query}' ({len(results)}):")
1793 - for n,p in sorted(results):
1794 - print(f" [{'✓' if n in installed else ' '}] {n}-{p.version}")
1795 - print(f" {p.description}")
1796 -
1797 -
1798 -def _smart_search(query: str) -> int:
1799 - """
1800 - Inteligentne wyszukiwanie: repo PaganOS + Flathub.
1801 - Uruchamiane gdy użytkownik wpisze `pag <nazwa>` zamiast `pag install <nazwa>`.
1802 - Pokazuje dostępne źródła i sugeruje komendy instalacji.
1803 - """
1804 - # 1. Repo PaganOS
1805 - try:
1806 - pkgs = fetch_all_packages()
1807 - except Exception:
1808 - pkgs = {}
1809 - repo_lower = [(n, p) for n, p in pkgs.items()
1810 - if query.lower() in n.lower() or query.lower() in p.description.lower()]
1811 -
1812 - # 2. Flathub (jeśli dostępny)
1813 - flat = _flatpak_search_raw(query) if _check_flatpak() else []
1814 -
1815 - if not repo_lower and not flat:
1816 - print(f"\n ❌ '{query}' — nie znaleziono.")
1817 - print(f" Repo PaganOS: pag search {query}")
1818 - if _check_flatpak():
1819 - print(f" Flathub: pag flatpak search {query}")
1820 - print(f" Dodaj repo: pag repo-add <url>")
1821 - return 1
1822 -
1823 - installed = load_json(INSTALLED_DB)
1824 -
1825 - # ── Repo PaganOS ──
1826 - if repo_lower:
1827 - exact = [(n, p) for n, p in repo_lower if n.lower() == query.lower()]
1828 - show = (exact or repo_lower)[:6]
1829 - print(f"\n 📦 PaganOS — '{query}':")
1830 - for n, p in sorted(show):
1831 - mark = "✓" if n in installed else " "
1832 - desc = p.description[:70] if len(p.description) > 75 else p.description
1833 - print(f" [{mark}] {n}-{p.version}")
1834 - if desc:
1835 - print(f" {desc}")
1836 - if len(repo_lower) > 6:
1837 - print(f" ... i {len(repo_lower) - 6} więcej (pag search {query})")
1838 -
1839 - # ── Flathub ──
1840 - if flat:
1841 - print(f"\n 📦 Flathub — '{query}':")
1842 - for r in flat[:5]:
1843 - mark = "✓" if r.get("installed") else " "
1844 - name = r.get("name") or r.get("application", "?")
1845 - desc = (r.get("description") or "")[:65]
1846 - print(f" [{mark}] {name}")
1847 - if desc:
1848 - print(f" {desc}")
1849 - if len(flat) > 5:
1850 - print(f" ... i {len(flat) - 5} więcej (pag flatpak search {query})")
1851 -
1852 - # ── Sugestie instalacji ──
1853 - print()
1854 - if repo_lower:
1855 - 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]
1856 - if best in installed:
1857 - print(f" ✓ {best} jest już zainstalowany ({installed[best]['version']})")
1858 - else:
1859 - print(f" 💡 sudo pag install {best}")
1860 - if flat:
1861 - best_fp = flat[0].get("application") or flat[0].get("name", query)
1862 - print(f" 💡 pag flatpak install {best_fp}")
1863 -
1864 - return 0
1865 -
1866 -def cmd_info(name):
1867 - pkgs = fetch_all_packages()
1868 - p = pkgs.get(name)
1869 - info = load_json(INSTALLED_DB).get(name)
1870 - if not p and not info: print(f"❌ '{name}' not found."); return 1
1871 - print(f"📦 {name}")
1872 - if p:
1873 - print(f" Version (repo): {p.version}")
1874 - print(f" Description: {p.description}")
1875 - print(f" Size: {p.size_bytes/1048576:.1f} MB")
1876 - print(f" SHA256: {p.sha256[:32]}...")
1877 - print(f" GPG: {p.gpg_fp or 'none'}")
1878 - print(f" Dependencies: {', '.join(p.dependencies) if p.dependencies else '(none)'}")
1879 - if info:
1880 - print(f" Installed: {info['version']} ({info.get('installed_at','?')})")
1881 -
1882 -def cmd_files(name):
1883 - if name not in load_json(INSTALLED_DB):
1884 - print(f"❌ '{name}' not installed."); return 1
1885 - files = _db_get_package_files(name)
1886 - print(f"Files in {name} ({len(files)}):")
1887 - for f in sorted(files): print(f" {f}")
1888 -
1889 -def cmd_verify(deep=False):
1890 - installed = load_json(INSTALLED_DB)
1891 - if not installed: print("Nothing to verify."); return
1892 - errors = []
1893 -
1894 - for name in installed:
1895 - for fpath in _db_get_package_files(name):
1896 - full = os.path.join(PAG_ROOT, fpath.lstrip("/"))
1897 - if not (os.path.exists(full) or os.path.islink(full)):
1898 - errors.append(f" ❌ {name}: missing {fpath}")
1899 - elif deep:
1900 - checksums = _db_get_all_file_checksums()
1901 - expected = checksums.get(fpath, "")
1902 - if expected:
1903 - actual = _sha256_file(full)
1904 - if actual != expected:
1905 - errors.append(f" ❌ {name}: SHA256 mismatch {fpath}")
1906 -
1907 - if errors:
1908 - print(f"❌ {_('verify_errors', len(errors))}")
1909 - for e in errors[:50]: print(e)
1910 - return 1
1911 - total = _db_count_files()
1912 - print(f"✅ {_('verify_ok', total)}")
1913 -
1914 -# =============================================================================
1915 -# PINNING / CLEAN / ORPHANS / REPO / FLATPAK
1916 -# =============================================================================
1917 -
1918 -def cmd_pin(name, version=""):
1919 - pinned = load_json(PINNED_FILE)
1920 - if version:
1921 - pinned[name] = version
1922 - else:
1923 - info = load_json(INSTALLED_DB).get(name, {})
1924 - pinned[name] = info.get("version", "?")
1925 - save_json(PINNED_FILE, pinned)
1926 - print(f"📌 {name} {_('pinned_to')} {pinned[name]}")
1927 -
1928 -def cmd_unpin(name):
1929 - pinned = load_json(PINNED_FILE)
1930 - if name in pinned:
1931 - del pinned[name]; save_json(PINNED_FILE, pinned)
1932 - print(f"🔓 {name} {_('unpinned')}")
1933 - else:
1934 - print(f"⚠ {name} {_('not_pinned')}")
1935 -
1936 -def cmd_pinned():
1937 - pinned = load_json(PINNED_FILE)
1938 - if not pinned: print(_("no_pinned")); return
1939 - print(_("pinned_list", len(pinned)))
1940 - for n,v in sorted(pinned.items()): print(f" 📌 {n} = {v}")
1941 -
1942 -def cmd_clean():
1943 - if os.path.isdir(PAG_CACHE):
1944 - count = size = 0
1945 - for f in os.listdir(PAG_CACHE):
1946 - fp = os.path.join(PAG_CACHE, f)
1947 - if os.path.isfile(fp):
1948 - size += os.path.getsize(fp); os.remove(fp); count += 1
1949 - print(f"✅ {_('cache_cleared', count, size/1048576)}")
1950 -
1951 -def cmd_remove_orphans():
1952 - installed = load_json(INSTALLED_DB)
1953 - world = load_world()
1954 - orphans = _find_orphans(installed, world)
1955 - if not orphans: print("✅ No orphans."); return
1956 - print(f"Orphans ({len(orphans)}):")
1957 - for n in sorted(orphans): print(f" {n}-{installed[n]['version']}")
1958 - ans = input(_("continue_q")).strip().lower()
1959 - if ans and ans not in ("t","y"): return
1960 - cmd_remove(list(orphans))
1961 -
1962 -
1963 -# =============================================================================
1964 -# PROVIDES – PAKIETY WIRTUALNE
1965 -# =============================================================================
1966 -
1967 -PROVIDES_MAP = {
1968 - "pkgconfig(glib-2.0)": "glib",
1969 - "pkgconfig(gobject-introspection-1.0)": "gobject-introspection",
1970 - "pkgconfig(gtk+-3.0)": "gtk",
1971 - "pkgconfig(gtk4)": "gtk",
1972 - "pkgconfig(zlib)": "zlib",
1973 - "pkgconfig(libffi)": "libffi",
1974 - "pkgconfig(expat)": "expat",
1975 - "pkgconfig(libsystemd)": "systemd",
1976 - "pkgconfig(dbus-1)": "dbus",
1977 - "pkgconfig(mount)": "util-linux",
1978 - "pkgconfig(blkid)": "util-linux",
1979 - "pkgconfig(libcap)": "libcap",
1980 - "pkgconfig(liblzma)": "xz",
1981 - "pkgconfig(libzstd)": "zstd",
1982 - "pkgconfig(bzip2)": "bzip2",
1983 - "pkgconfig(libcurl)": "curl",
1984 - "pkgconfig(openssl)": "openssl",
1985 - "pkgconfig(libpcre2-8)": "pcre2",
1986 - "pkgconfig(libxml-2.0)": "libxml2",
1987 - "pkgconfig(libxslt)": "libxslt",
1988 - "pkgconfig(freetype2)": "freetype",
1989 - "pkgconfig(fontconfig)": "fontconfig",
1990 - "pkgconfig(harfbuzz)": "harfbuzz",
1991 - "pkgconfig(cairo)": "cairo",
1992 - "pkgconfig(pango)": "pango",
1993 -}
1994 -
1995 -def _resolve_provides(name: str, repo: dict) -> str:
1996 - """Rozwija wirtualną nazwę pakietu do rzeczywistej nazwy z repo."""
1997 - if name in repo:
1998 - return name
1999 - if name in PROVIDES_MAP:
2000 - real = PROVIDES_MAP[name]
2001 - if real in repo:
2002 - return real
2003 - # Dynamiczne provides z repo.json (sekcja provides: w PAGBUILD.yaml)
2004 - for _pkg_name, _pkg in repo.items():
2005 - _provs = getattr(_pkg, "provides", None) or []
2006 - if name in _provs:
2007 - return _pkg_name
2008 - clean = name
2009 - if name.startswith("pkgconfig(") and ")" in name:
2010 - clean = name.split("(", 1)[1].rstrip(")")
2011 - elif name.startswith("pkgconfig32(") and ")" in name:
2012 - clean = name.split("(", 1)[1].rstrip(")")
2013 - if clean != name and clean in repo:
2014 - return clean
2015 - return name
2016 -
2017 -
2018 -def cmd_why(pkg_name: str):
2019 - """Pokazuje dlaczego pakiet jest zainstalowany."""
2020 - installed = load_json(INSTALLED_DB)
2021 - world = load_world()
2022 - if pkg_name not in installed:
2023 - print(f" {pkg_name}: {_('why_not_installed')}"); return 1
2024 - if pkg_name in world:
2025 - print(f" {pkg_name}-{installed[pkg_name]['version']}: {_('why_explicit')}")
2026 - return 0
2027 - parents = set()
2028 - for w in world:
2029 - _find_dep_path(w, pkg_name, installed, set(), [], parents)
2030 - if parents:
2031 - for pp in sorted(parents):
2032 - print(f" {pkg_name}: {_('why_dependency')} {' → '.join(pp)}")
2033 - else:
2034 - print(f" {pkg_name}: {_('why_dependency')} (unknown/orphan)")
2035 - return 0
2036 -
2037 -
2038 -def _find_dep_path(cur, target, installed, visited, path, results):
2039 - if cur in visited: return
2040 - visited.add(cur); path.append(cur)
2041 - if cur == target:
2042 - results.add(tuple(path))
2043 - else:
2044 - for dep in installed.get(cur, {}).get("dependencies", []):
2045 - _find_dep_path(dep, target, installed, visited, path, results)
2046 - path.pop(); visited.discard(cur)
2047 -
2048 -
2049 -def cmd_autoremove():
2050 - """Automatycznie usuwa osierocone zależności bez pytania."""
2051 - installed = load_json(INSTALLED_DB)
2052 - world = load_world()
2053 - orphans = _find_orphans(installed, world)
2054 - if not orphans: print(f"✅ {_('autoremove_none')}"); return 0
2055 - print(f"🗑 {_('orphans_found', len(orphans), ', '.join(sorted(orphans)[:10]))}")
2056 - return cmd_remove(list(orphans))
2057 -
2058 -
2059 -def cmd_download(package_names):
2060 - """Pobiera pakiety do cache bez instalowania."""
2061 - ensure_dirs()
2062 - repo = fetch_all_packages()
2063 - if not repo: print(f"❌ {_('no_index')}"); return 1
2064 - total_size = 0; downloaded = []
2065 - for name in package_names:
2066 - pkg = repo.get(name)
2067 - if not pkg:
2068 - print(f" ❌ {name}: {_('not_found')}"); continue
2069 - print(f" ↓ {name}-{pkg.version} ...", end=" ", flush=True)
2070 - path = _download_pkg(pkg)
2071 - if path:
2072 - total_size += os.path.getsize(path)
2073 - downloaded.append(name)
2074 - print(_c("green", "✓"))
2075 - else:
2076 - print(_c("red", "✗"))
2077 - if downloaded:
2078 - print(f"\n✅ {_('downloaded', len(downloaded), total_size/1048576)}")
2079 - return 0 if len(downloaded) == len(package_names) else 1
2080 -
2081 -
2082 -def cmd_stats():
2083 - """Wyświetla statystyki PAG."""
2084 - installed = load_json(INSTALLED_DB)
2085 - history = load_json(HISTORY_FILE) if os.path.exists(HISTORY_FILE) else []
2086 - total_size = sum(i.get("size_bytes", 0) for i in installed.values())
2087 - total_files = _db_count_files()
2088 - cache_size = sum(
2089 - os.path.getsize(os.path.join(PAG_CACHE, f))
2090 - for f in os.listdir(PAG_CACHE)
2091 - if os.path.isfile(os.path.join(PAG_CACHE, f))
2092 - ) if os.path.isdir(PAG_CACHE) else 0
2093 - last_update = "never"
2094 - for e in reversed(history):
2095 - if e.get("action") in ("install", "upgrade") and e.get("success"):
2096 - last_update = e.get("timestamp", "?")[:19]; break
2097 - print(f"\n {_c('bold', _('stats_title'))}")
2098 - print(f" {'─' * 40}")
2099 - print(f" {_('stats_packages'):<30} {len(installed)}")
2100 - print(f" {_('stats_files'):<30} {total_files}")
2101 - print(f" {_('stats_size'):<30} {total_size/1048576:.1f} MB")
2102 - print(f" {_('stats_cache'):<30} {cache_size/1048576:.1f} MB")
2103 - print(f" {_('stats_history'):<30} {len(history)}")
2104 - print(f" {_('stats_last_update'):<30} {last_update}")
2105 - by_size = sorted(installed.items(), key=lambda x: x[1].get("size_bytes", 0), reverse=True)[:5]
2106 - if by_size:
2107 - print(f"\n {_c('dim', 'Top 5:')}")
2108 - for n, i in by_size:
2109 - print(f" {n}-{i['version']} {i.get('size_bytes',0)/1048576:.1f} MB")
2110 - return 0
2111 -
2112 -
2113 -def cmd_repo_add(url):
2114 - if not url.startswith("https://") and not os.environ.get("PAG_INSECURE"):
2115 - print(f" {_("sec_https")}"); return 1
2116 - repos = get_repos()
2117 - url = url.rstrip("/")
2118 - if url in repos: print(f"⚠ {_('repo_exists', url)}"); return
2119 - with open(REPOS_CONF, "a") as f: f.write(f"{url}\n")
2120 - print(f"✅ {_('repo_added', url)}")
2121 -
2122 -def cmd_repo_list():
2123 - for i, url in enumerate(get_repos(), 1): print(f" {i}. {url}")
2124 -
2125 -def _check_flatpak():
2126 - if not shutil.which("flatpak"):
2127 - print(f"❌ {_('flatpak_missing')}"); return False
2128 - r = subprocess.run(["flatpak","remotes"], capture_output=True, text=True)
2129 - if "flathub" not in r.stdout:
2130 - print(f"⚠ {_('flatpak_adding')}")
2131 - subprocess.run(["flatpak","remote-add","--if-not-exists","flathub",
2132 - "https://flathub.org/repo/flathub.flatpakrepo"], check=False)
2133 - return True
2134 -
2135 -def _spinner(msg: str):
2136 - """Prosty spinner „myślenia” w osobnym wątku. Zwraca funkcję stop()."""
2137 - stop = threading.Event()
2138 - def _spin():
2139 - for c in itertools.cycle("|/-\\"):
2140 - if stop.is_set():
2141 - break
2142 - sys.stdout.write(f"\r {msg} {c}")
2143 - sys.stdout.flush()
2144 - time.sleep(0.1)
2145 - t = threading.Thread(target=_spin, daemon=True)
2146 - t.start()
2147 - def _stop():
2148 - stop.set()
2149 - t.join(timeout=0.3)
2150 - sys.stdout.write("\r" + " " * (len(msg) + 4) + "\r")
2151 - sys.stdout.flush()
2152 - return _stop
2153 -
2154 -
2155 -def _flatpak_search_raw(query: str) -> List[dict]:
2156 - """Szuka we Flathub i zwraca listę wyników jako słowniki."""
2157 - if not _check_flatpak():
2158 - return []
2159 - stop = _spinner("Szukam we Flathub...")
2160 - try:
2161 - try:
2162 - r = subprocess.run(
2163 - ["flatpak", "search", "--columns=name,description,application,version,branch,remotes", query],
2164 - capture_output=True, text=True, timeout=120
2165 - )
2166 - finally:
2167 - stop()
2168 - if r.returncode != 0 and "No matches found" not in r.stdout and not r.stdout.strip():
2169 - print(f" ⚠ flatpak search: {r.stderr.strip()[:150]}")
2170 - results = []
2171 - for line in r.stdout.strip().split("\n"):
2172 - parts = line.split("\t")
2173 - if len(parts) >= 3:
2174 - results.append({
2175 - "name": parts[0].strip(),
2176 - "description": parts[1].strip() if len(parts) > 1 else "",
2177 - "app_id": parts[2].strip() if len(parts) > 2 else "",
2178 - "version": parts[3].strip() if len(parts) > 3 else "",
2179 - "branch": parts[4].strip() if len(parts) > 4 else "stable",
2180 - "origin": parts[5].strip() if len(parts) > 5 else "flathub",
2181 - })
2182 - return results
2183 - except Exception as e:
2184 - print(f" ⚠ Błąd wyszukiwania: {e}", file=sys.stderr)
2185 - return []
2186 -
2187 -def _flatpak_find_best(query: str) -> Optional[dict]:
2188 - """
2189 - Szuka we Flathub i próbuje znaleźć najlepsze dopasowanie.
2190 - - Jeśli query dokładnie pasuje do app_id → zwraca od razu
2191 - - Jeśli query pasuje do nazwy → zwraca pierwsze
2192 - - Jeśli wiele wyników → wyświetla listę i pyta użytkownika
2193 - - Jeśli brak → zwraca None
2194 - """
2195 - results = _flatpak_search_raw(query)
2196 - if not results:
2197 - return None
2198 -
2199 - # Dokładne dopasowanie app_id
2200 - exact = [r for r in results if r["app_id"].lower() == query.lower()]
2201 - if exact:
2202 - return exact[0]
2203 -
2204 - # Dokładne dopasowanie nazwy
2205 - exact_name = [r for r in results if r["name"].lower() == query.lower()]
2206 - if exact_name:
2207 - return exact_name[0]
2208 -
2209 - # Jednoznaczne dopasowanie (tylko 1 wynik)
2210 - if len(results) == 1:
2211 - return results[0]
2212 -
2213 - # Wiele wyników – pokaż użytkownikowi
2214 - print(f"\n {_('flatpak_found', len(results))}")
2215 - for i, r in enumerate(results):
2216 - print(f" {i+1}. {_c('bold', r['name'])} ({r['app_id']})")
2217 - if r["version"]:
2218 - print(f" {_('flatpak_info_version')}: {r['version']}")
2219 - if r["description"]:
2220 - desc = r["description"][:80] + ("..." if len(r["description"]) > 80 else "")
2221 - print(f" {desc}")
2222 -
2223 - try:
2224 - choice = input(f"\n Wybierz numer (1-{len(results)}) lub Enter aby anulować: ").strip()
2225 - if not choice:
2226 - return None
2227 - idx = int(choice) - 1
2228 - if 0 <= idx < len(results):
2229 - return results[idx]
2230 - except (ValueError, IndexError):
2231 - pass
2232 - return None
2233 -
2234 -def _flatpak_get_installed_info(app_id: str) -> Optional[dict]:
2235 - """Zwraca info o zainstalowanym flatpaku lub None."""
2236 - try:
2237 - r = subprocess.run(
2238 - ["flatpak", "info", "--columns=name,version,branch,origin,installed-size,description", app_id],
2239 - capture_output=True, text=True, timeout=10
2240 - )
2241 - if r.returncode != 0:
2242 - return None
2243 - parts = r.stdout.strip().split("\t")
2244 - if len(parts) < 3:
2245 - return None
2246 - return {
2247 - "name": parts[0].strip(),
2248 - "version": parts[1].strip() if len(parts) > 1 else "",
2249 - "branch": parts[2].strip() if len(parts) > 2 else "",
2250 - "origin": parts[3].strip() if len(parts) > 3 else "",
2251 - "size": parts[4].strip() if len(parts) > 4 else "",
2252 - "description": parts[5].strip() if len(parts) > 5 else "",
2253 - }
2254 - except Exception:
2255 - return None
2256 -
2257 -def _flatpak_is_installed(app_id: str) -> bool:
2258 - """Sprawdza czy flatpak o danym ID jest zainstalowany."""
2259 - try:
2260 - r = subprocess.run(
2261 - ["flatpak", "info", app_id],
2262 - capture_output=True, text=True, timeout=10
2263 - )
2264 - return r.returncode == 0
2265 - except Exception:
2266 - return False
2267 -
2268 -# =============================================================================
2269 -# FLATPAK – KOMENDY GŁÓWNE (zunifikowany interfejs)
2270 -# =============================================================================
2271 -# pag flatpak <query> → szuka i proponuje instalację (jeśli nie zainstalowany)
2272 -# pag flatpak search <query> → tylko szuka
2273 -# pag flatpak install <query> → instaluje
2274 -# pag flatpak remove <id> → usuwa
2275 -# pag flatpak list → lista zainstalowanych
2276 -# pag flatpak update → aktualizuje wszystkie
2277 -# pag flatpak info <id> → szczegóły flatpaka
2278 -
2279 -def cmd_flatpak(args: list):
2280 - """
2281 - Główna komenda flatpak – inteligentnie rozpoznaje intencję:
2282 - pag flatpak firefox → szuka i instaluje (jeśli nieznaleziony → szuka)
2283 - pag flatpak search firefox → tylko wyszukiwanie
2284 - pag flatpak install ... → bezpośrednia instalacja
2285 - pag flatpak remove ... → odinstalowanie
2286 - pag flatpak list → lista
2287 - pag flatpak update → aktualizacja
2288 - pag flatpak info ... → szczegóły
2289 - """
2290 - if not _check_flatpak():
2291 - return 1
2292 -
2293 - if not args:
2294 - # Bez argumentów – domyślnie lista
2295 - return cmd_flatpak_list()
2296 -
2297 - subcmd = args[0].lower()
2298 - rest = args[1:]
2299 -
2300 - # ── Podkomendy jawne ────────────────────────────────────────────────
2301 - if subcmd == "search":
2302 - if not rest:
2303 - print(_("flatpak_usage")); return 1
2304 - return cmd_flatpak_search(" ".join(rest))
2305 -
2306 - elif subcmd == "install":
2307 - if not rest:
2308 - print(_("flatpak_usage")); return 1
2309 - return _flatpak_smart_install(rest)
2310 -
2311 - elif subcmd == "remove" or subcmd == "uninstall":
2312 - if not rest:
2313 - print(_("flatpak_usage")); return 1
2314 - return _flatpak_smart_remove(rest)
2315 -
2316 - elif subcmd == "list":
2317 - return cmd_flatpak_list()
2318 -
2319 - elif subcmd == "update":
2320 - return cmd_flatpak_update()
2321 -
2322 - elif subcmd == "info":
2323 - if not rest:
2324 - print(_("flatpak_usage")); return 1
2325 - return cmd_flatpak_info(rest[0])
2326 -
2327 - else:
2328 - # ── Inteligentne wykrywanie: pag flatpak <nazwa> ────────────────
2329 - # Sprawdź czy to zainstalowany flatpak → pokaż info
2330 - # Jeśli nie → szukaj i zaproponuj instalację
2331 - query = " ".join(args)
2332 -
2333 - # Najpierw sprawdź czy już zainstalowany
2334 - if _flatpak_is_installed(query):
2335 - print(f" 📦 {_c('green', query)} – already installed (use 'pag flatpak info {query}' for details)")
2336 - return cmd_flatpak_info(query)
2337 -
2338 - # Szukaj we Flathub
2339 - print(f" {_('flatpak_searching', query)}")
2340 - best = _flatpak_find_best(query)
2341 - if not best:
2342 - print(f" ❌ '{query}' – {_('flatpak_not_found')}")
2343 - return 1
2344 -
2345 - print(f"\n {_c('cyan', best['name'])} ({best['app_id']})")
2346 - if best["version"]:
2347 - print(f" {_('flatpak_info_version')}: {best['version']}")
2348 - if best["description"]:
2349 - print(f" {best['description']}")
2350 -
2351 - ans = input(f"\n {_('flatpak_install_prompt', best['name'])}").strip().lower()
2352 - if ans and ans not in ("t", "y"):
2353 - print(_("cancelled"))
2354 - return 0
2355 -
2356 - return _flatpak_do_install(best["app_id"])
2357 -
2358 -def _flatpak_smart_install(names: list) -> int:
2359 - """Instaluje flatpaki – obsługuje nazwy częściowe (wyszukuje przed instalacją)."""
2360 - failed = 0
2361 - for name in names:
2362 - if "." in name and "/" not in name:
2363 - # Wygląda na pełne app_id (np. org.mozilla.firefox)
2364 - app_id = name
2365 - else:
2366 - # Szukaj najlepszego dopasowania
2367 - best = _flatpak_find_best(name)
2368 - if not best:
2369 - print(f" ❌ '{name}' – {_('flatpak_not_found')}")
2370 - failed += 1
2371 - continue
2372 - app_id = best["app_id"]
2373 - print(f" → {best['name']} ({app_id})")
2374 -
2375 - if _flatpak_do_install(app_id) != 0:
2376 - failed += 1
2377 - return 1 if failed else 0
2378 -
2379 -def _flatpak_do_install(app_id: str) -> int:
2380 - """Wykonuje właściwą instalację flatpaka."""
2381 - print(f" {_('flatpak_installing', app_id)}")
2382 - result = subprocess.run(
2383 - ["flatpak", "install", "-y", "flathub", app_id],
2384 - check=False, timeout=600
2385 - )
2386 - if result.returncode == 0:
2387 - print(f" ✅ {_('flatpak_installed', app_id)}")
2388 - return 0
2389 - else:
2390 - print(f" ❌ {_('download_fail')}: {app_id}")
2391 - return 1
2392 -
2393 -def _flatpak_smart_remove(names: list) -> int:
2394 - """Usuwa flatpaki – obsługuje nazwy częściowe."""
2395 - # Pobierz listę zainstalowanych
2396 - try:
2397 - r = subprocess.run(
2398 - ["flatpak", "list", "--columns=application,name"],
2399 - capture_output=True, text=True, timeout=10
2400 - )
2401 - installed = {}
2402 - for line in r.stdout.strip().split("\n"):
2403 - parts = line.split("\t")
2404 - if len(parts) >= 2:
2405 - installed[parts[0].strip()] = parts[1].strip()
2406 - except Exception:
2407 - installed = {}
2408 -
2409 - failed = 0
2410 - for name in names:
2411 - app_id = name
2412 -
2413 - # Jeśli nie podano pełnego ID – spróbuj dopasować
2414 - if name not in installed:
2415 - matches = {aid: aname for aid, aname in installed.items()
2416 - if name.lower() in aid.lower() or name.lower() in aname.lower()}
2417 - if len(matches) == 0:
2418 - print(f" ❌ '{name}' – {_('flatpak_not_installed', name)}")
2419 - failed += 1
2420 - continue
2421 - elif len(matches) == 1:
2422 - app_id = list(matches.keys())[0]
2423 - print(f" → {matches[app_id]} ({app_id})")
2424 - else:
2425 - print(f"\n Wiele dopasowań dla '{name}':")
2426 - for i, (aid, aname) in enumerate(sorted(matches.items()), 1):
2427 - print(f" {i}. {aname} ({aid})")
2428 - try:
2429 - choice = input(f"\n Wybierz numer (1-{len(matches)}) lub Enter: ").strip()
2430 - if not choice:
2431 - failed += 1
2432 - continue
2433 - aid_list = sorted(matches.keys())
2434 - app_id = aid_list[int(choice) - 1]
2435 - except (ValueError, IndexError):
2436 - failed += 1
2437 - continue
2438 -
2439 - print(f" 🗑 {app_id} ...", end=" ", flush=True)
2440 - result = subprocess.run(
2441 - ["flatpak", "uninstall", "-y", app_id],
2442 - capture_output=True, text=True, timeout=120
2443 - )
2444 - if result.returncode == 0:
2445 - print("✅")
2446 - print(f" {_('flatpak_removed', app_id)}")
2447 - else:
2448 - print("❌")
2449 - failed += 1
2450 - return 1 if failed else 0
2451 -
2452 -def cmd_flatpak_search(q: str):
2453 - """Wyszukuje we Flathub i wyświetla wyniki (z możliwością wyboru do instalacji)."""
2454 - if not _check_flatpak():
2455 - return 1
2456 - results = _flatpak_search_raw(q)
2457 - if not results:
2458 - print(f" ❌ '{q}' – {_('flatpak_not_found')}")
2459 - return 1
2460 - print(f"\n {_('flatpak_found', len(results))}")
2461 - shown = results[:30] # max 30 wyników
2462 - for i, r in enumerate(shown, 1):
2463 - installed = "📦 " if _flatpak_is_installed(r["app_id"]) else " "
2464 - print(f" {i:>2}. {installed}{_c('bold', r['name'])} ({r['app_id']})")
2465 - if r["version"]:
2466 - print(f" {_('flatpak_info_version')}: {r['version']} | {_('flatpak_info_branch')}: {r['branch']}")
2467 - if r["description"]:
2468 - desc = r["description"][:100] + ("..." if len(r["description"]) > 100 else "")
2469 - print(f" {_c('dim', desc)}")
2470 - if len(results) > 30:
2471 - print(f" ... i {len(results) - 30} więcej. Doprecyzuj zapytanie.")
2472 -
2473 - # Interaktywny wybór – wpisz numer, aby zainstalować (Enter = anuluj)
2474 - try:
2475 - ans = input(f"\n Wybierz numer do zainstalowania (1-{len(shown)}) lub Enter aby anulować: ").strip()
2476 - except (EOFError, KeyboardInterrupt):
2477 - return 0
2478 - if ans:
2479 - try:
2480 - idx = int(ans) - 1
2481 - if 0 <= idx < len(shown):
2482 - return _flatpak_do_install(shown[idx]["app_id"])
2483 - print(_("cancelled"))
2484 - except (ValueError, IndexError):
2485 - print(_("cancelled"))
2486 - return 0
2487 -
2488 -def cmd_flatpak_list():
2489 - """Wyświetla zainstalowane flatpaki."""
2490 - if not _check_flatpak():
2491 - return 1
2492 - r = subprocess.run(
2493 - ["flatpak", "list", "--columns=application,name,version,origin,installed-size"],
2494 - capture_output=True, text=True, timeout=10
2495 - )
2496 - lines = [l for l in r.stdout.strip().split("\n") if l.strip()]
2497 - if not lines:
2498 - print(" (brak zainstalowanych flatpaków)")
2499 - return 0
2500 - print(f" Zainstalowane flatpaki ({len(lines)}):")
2501 - for line in lines:
2502 - parts = line.split("\t")
2503 - if len(parts) >= 3:
2504 - app_id, name, version = parts[0], parts[1], parts[2]
2505 - size = parts[4] if len(parts) > 4 else ""
2506 - size_str = f" ({size})" if size else ""
2507 - print(f" 📦 {_c('bold', name)} {version}{size_str}")
2508 - print(f" {_c('dim', app_id)}")
2509 - return 0
2510 -
2511 -def cmd_flatpak_update():
2512 - """Aktualizuje wszystkie flatpaki."""
2513 - if not _check_flatpak():
2514 - return 1
2515 - print(" 🔄 Aktualizacja flatpaków...")
2516 - result = subprocess.run(["flatpak", "update", "-y"], check=False, timeout=600)
2517 - if result.returncode == 0:
2518 - print(f" ✅ {_('flatpak_updated')}")
2519 - return result.returncode
2520 -
2521 -def cmd_flatpak_info(app_id: str):
2522 - """Wyświetla szczegóły flatpaka (zainstalowanego lub z Flathub)."""
2523 - if not _check_flatpak():
2524 - return 1
2525 -
2526 - # Najpierw sprawdź zainstalowany
2527 - info = _flatpak_get_installed_info(app_id)
2528 - if info:
2529 - print(f"\n 📦 {_c('bold', info['name'])} {_c('green', '[zainstalowany]')}")
2530 - print(f" {'─' * 45}")
2531 - print(f" {_('flatpak_info_id'):<16} {app_id}")
2532 - print(f" {_('flatpak_info_version'):<16} {info['version']}")
2533 - print(f" {_('flatpak_info_branch'):<16} {info['branch']}")
2534 - print(f" {_('flatpak_info_origin'):<16} {info['origin']}")
2535 - if info["size"]:
2536 - print(f" {_('flatpak_info_size'):<16} {info['size']}")
2537 - if info["description"]:
2538 - print(f" {_('flatpak_info_desc'):<16} {info['description']}")
2539 - return 0
2540 -
2541 - # Szukaj we Flathub
2542 - results = _flatpak_search_raw(app_id)
2543 - exact = [r for r in results if r["app_id"].lower() == app_id.lower()]
2544 - if not exact:
2545 - # Spróbuj częściowego dopasowania
2546 - if results:
2547 - exact = [results[0]]
2548 - else:
2549 - print(f" ❌ '{app_id}' – {_('flatpak_not_found')}")
2550 - return 1
2551 -
2552 - r = exact[0]
2553 - print(f"\n 📦 {_c('bold', r['name'])} (Flathub)")
2554 - print(f" {'─' * 45}")
2555 - print(f" {_('flatpak_info_id'):<16} {r['app_id']}")
2556 - print(f" {_('flatpak_info_version'):<16} {r['version']}")
2557 - if r["description"]:
2558 - print(f" {_('flatpak_info_desc'):<16} {r['description']}")
2559 - print(f"\n 💡 Aby zainstalować: pag flatpak install {r['app_id']}")
2560 - return 0
2561 -
2562 -# =============================================================================
2563 -# IMMUTABLE OS – KOMENDY DEPLOYMENTOWE
2564 -# =============================================================================
2565 -
2566 -# Pakiety jądra – po ich instalacji trzeba przebudować initramfs
2567 -KERNEL_PACKAGE_PATTERNS = ["linux", "kernel", "linux-kernel", "linux-lts"]
2568 -
2569 -def _is_kernel_package(name: str) -> bool:
2570 - """Sprawdza czy pakiet to jądro (wymaga przebudowy initramfs)."""
2571 - name_lower = name.lower()
2572 - return any(pattern in name_lower for pattern in KERNEL_PACKAGE_PATTERNS)
2573 -
2574 -def _rebuild_initramfs(deploy_dir: str = "") -> bool:
2575 - """
2576 - Przebudowuje initramfs dla aktywnego (lub podanego) deploymentu.
2577 - Używa skryptu pag-initramfs lub ręcznego cpio.
2578 - """
2579 - if deploy_dir:
2580 - root = deploy_dir
2581 - else:
2582 - root = _get_deployment_root()
2583 -
2584 - if root == PAG_ROOT:
2585 - # Zwykły system – użyj dracut jeśli dostępny
2586 - if shutil.which("dracut"):
2587 - print(" 🔧 Przebudowa initramfs (dracut)...")
2588 - result = subprocess.run(
2589 - ["dracut", "--force", "/boot/initramfs.img"],
2590 - capture_output=True, text=True, timeout=120
2591 - )
2592 - return result.returncode == 0
2593 - elif shutil.which("mkinitcpio"):
2594 - print(" 🔧 Przebudowa initramfs (mkinitcpio)...")
2595 - result = subprocess.run(
2596 - ["mkinitcpio", "-g", "/boot/initramfs.img"],
2597 - capture_output=True, text=True, timeout=120
2598 - )
2599 - return result.returncode == 0
2600 - else:
2601 - print(" ⚠ Brak dracut/mkinitcpio – initramfs nie został przebudowany")
2602 - return False
2603 -
2604 - # Tryb immutable – budujemy initramfs dla deploymentu
2605 - print(" 🔧 Budowanie initramfs dla deploymentu...")
2606 -
2607 - # Sprawdź czy mamy nasz skrypt init
2608 - pag_init_script = "/usr/share/pag/initramfs-init"
2609 - if not os.path.exists(pag_init_script):
2610 - # Szukaj w źródłach (developerski fallback)
2611 - alt_paths = [
2612 - os.path.join(os.path.dirname(os.path.abspath(__file__)), "scripts", "initramfs-init"),
2613 - "/usr/share/pag/init",
2614 - ]
2615 - for p in alt_paths:
2616 - if os.path.exists(p):
2617 - pag_init_script = p
2618 - break
2619 -
2620 - if not os.path.exists(pag_init_script):
2621 - print(" ⚠ Nie znaleziono pag-initramfs-init – pomijam budowę initramfs")
2622 - return False
2623 -
2624 - boot_dir = os.path.join(root, "boot")
2625 - os.makedirs(boot_dir, exist_ok=True)
2626 -
2627 - # Znajdź jądro (vmlinuz-*)
2628 - kernels = sorted(
2629 - [f for f in os.listdir(boot_dir) if f.startswith("vmlinuz-")],
2630 - reverse=True
2631 - ) if os.path.exists(boot_dir) else []
2632 - if not kernels:
2633 - print(" ⚠ Nie znaleziono vmlinuz-* w /boot deploymentu")
2634 - return False
2635 -
2636 - kernel_ver = kernels[0].replace("vmlinuz-", "")
2637 - print(f" 🐧 Jądro: {kernel_ver}")
2638 -
2639 - # Buduj initramfs ręcznie (cpio)
2640 - tmpdir = tempfile.mkdtemp(prefix="pag-initramfs-")
2641 - try:
2642 - # Podstawowa struktura
2643 - for d in ["bin", "sbin", "dev", "proc", "sys", "run", "new_root",
2644 - "usr/bin", "usr/sbin", "lib", "lib64", "etc"]:
2645 - os.makedirs(os.path.join(tmpdir, d), exist_ok=True)
2646 -
2647 - # Skopiuj init
2648 - shutil.copy2(pag_init_script, os.path.join(tmpdir, "init"))
2649 - os.chmod(os.path.join(tmpdir, "init"), 0o755)
2650 -
2651 - # Skopiuj niezbędne binaria (busybox lub podstawowe narzędzia)
2652 - busybox_paths = [
2653 - os.path.join(root, "usr/bin/busybox"),
2654 - os.path.join(root, "bin/busybox"),
2655 - "/usr/bin/busybox",
2656 - "/bin/busybox",
2657 - ]
2658 - busybox = None
2659 - for bp in busybox_paths:
2660 - if os.path.exists(bp):
2661 - busybox = bp
2662 - break
2663 -
2664 - if busybox:
2665 - shutil.copy2(busybox, os.path.join(tmpdir, "bin/busybox"))
2666 - # Utwórz symlinki dla podstawowych komend
2667 - for cmd in ["sh", "mount", "umount", "ls", "cat", "echo", "sleep",
2668 - "readlink", "mkdir", "switch_root", "cp", "rm"]:
2669 - link = os.path.join(tmpdir, "bin", cmd)
2670 - if not os.path.exists(link):
2671 - os.symlink("busybox", link)
2672 - # /bin/sh → busybox
2673 - if not os.path.exists(os.path.join(tmpdir, "bin/sh")):
2674 - os.symlink("busybox", os.path.join(tmpdir, "bin/sh"))
2675 - else:
2676 - # Bez busybox – kopiuj podstawowe narzędzia z deploymentu
2677 - for tool in ["bash", "mount", "umount", "readlink", "mkdir", "cat", "sleep", "cp", "rm"]:
2678 - src = os.path.join(root, "usr/bin", tool)
2679 - if not os.path.exists(src):
2680 - src = os.path.join(root, "bin", tool)
2681 - if os.path.exists(src):
2682 - dest = os.path.join(tmpdir, "bin", os.path.basename(tool))
2683 - shutil.copy2(src, dest)
2684 - # Kopiuj zależności .so
2685 - _copy_libs_for_binary(src, tmpdir, root)
2686 -
2687 - # Dodaj moduły jądra (opcjonalnie – dla sterowników dyskowych)
2688 - modules_src = os.path.join(root, "lib/modules", kernel_ver)
2689 - if os.path.isdir(modules_src):
2690 - modules_dst = os.path.join(tmpdir, "lib/modules", kernel_ver)
2691 - # Kopiuj tylko niezbędne (fs, block, drivers/ata, drivers/nvme)
2692 - for sub in ["kernel/fs", "kernel/drivers/ata", "kernel/drivers/nvme",
2693 - "kernel/drivers/scsi", "kernel/drivers/virtio",
2694 - "modules.order", "modules.builtin"]:
2695 - src_sub = os.path.join(modules_src, sub)
2696 - if os.path.exists(src_sub):
2697 - dst_sub = os.path.join(modules_dst, sub)
2698 - os.makedirs(os.path.dirname(dst_sub), exist_ok=True)
2699 - if os.path.isdir(src_sub):
2700 - shutil.copytree(src_sub, dst_sub, dirs_exist_ok=True, symlinks=True)
2701 - else:
2702 - shutil.copy2(src_sub, dst_sub)
2703 -
2704 - # Pakuj do initramfs.img
2705 - initramfs_path = os.path.join(boot_dir, "initramfs.img")
2706 - old_cwd = os.getcwd()
2707 - os.chdir(tmpdir)
2708 - try:
2709 - with open(initramfs_path + ".tmp", "wb") as out:
2710 - subprocess.run(
2711 - "find . | cpio -oH newc | gzip",
2712 - shell=True, stdout=out, check=True, timeout=120,
2713 - cwd=tmpdir
2714 - )
2715 - os.rename(initramfs_path + ".tmp", initramfs_path)
2716 - finally:
2717 - os.chdir(old_cwd)
2718 -
2719 - size_mb = os.path.getsize(initramfs_path) / 1048576
2720 - print(f" ✅ initramfs.img ({size_mb:.1f} MB) → {initramfs_path}")
2721 - return True
2722 -
2723 - except Exception as e:
2724 - print(f" ❌ Błąd budowy initramfs: {e}")
2725 - return False
2726 - finally:
2727 - shutil.rmtree(tmpdir, ignore_errors=True)
2728 -
2729 -
2730 -def _copy_libs_for_binary(binary: str, dest_dir: str, root: str):
2731 - """Kopiuje zależności .so dla binarki do initramfs (uproszczone ldd)."""
2732 - try:
2733 - result = subprocess.run(
2734 - ["ldd", binary], capture_output=True, text=True, timeout=10
2735 - )
2736 - for line in result.stdout.split("\n"):
2737 - m = re.search(r'=>\s+(/\S+)', line)
2738 - if m:
2739 - lib_path = m.group(1)
2740 - lib_rel = lib_path.lstrip("/")
2741 - lib_dest = os.path.join(dest_dir, lib_rel)
2742 - if not os.path.exists(lib_dest):
2743 - os.makedirs(os.path.dirname(lib_dest), exist_ok=True)
2744 - # Szukaj w deployment root lub systemie
2745 - if os.path.exists(lib_path):
2746 - shutil.copy2(lib_path, lib_dest)
2747 - else:
2748 - alt = os.path.join(root, lib_rel)
2749 - if os.path.exists(alt):
2750 - shutil.copy2(alt, lib_dest)
2751 - except Exception:
2752 - pass
2753 -
2754 -
2755 -def cmd_initramfs_update():
2756 - """Ręcznie przebudowuje initramfs dla bieżącego deploymentu."""
2757 - ensure_dirs()
2758 - deploy_dir = _get_deployment_root()
2759 - if deploy_dir != PAG_ROOT:
2760 - print(f"🏗️ Deployment: {os.path.basename(deploy_dir)}")
2761 - ok = _rebuild_initramfs(deploy_dir)
2762 - if ok:
2763 - print("✅ Initramfs zaktualizowany.")
2764 - # Po initramfs – zaktualizuj też GRUB
2765 - _update_grub_config()
2766 - else:
2767 - print("❌ Błąd aktualizacji initramfs.")
2768 - return 0 if ok else 1
2769 -
2770 -
2771 -def _update_grub_config():
2772 - """
2773 - Generuje wpisy GRUB dla wszystkich deploymentów.
2774 - Każdy deployment dostaje własny wpis – rollback możliwy z bootloadera.
2775 - """
2776 - grub_cfg = "/boot/grub/grub.cfg"
2777 - if not os.path.exists(os.path.dirname(grub_cfg)):
2778 - return # brak GRUB
2779 -
2780 - deployments = _load_deployments()
2781 - root_dev = _detect_root_device()
2782 -
2783 - lines = [
2784 - "# =====================================================================",
2785 - "# Pagan Linux – GRUB config (wygenerowane przez pag grub-update)",
2786 - f"# Data: {datetime.now().isoformat()}",
2787 - "# =====================================================================",
2788 - "",
2789 - ]
2790 -
2791 - # Domyślny – ostatni (najnowszy) deployment
2792 - if deployments:
2793 - latest = deployments[-1]["id"]
2794 - lines.append(f"set default=0")
2795 - lines.append(f"set timeout=5")
2796 - else:
2797 - lines.append("set default=0")
2798 - lines.append("set timeout=5")
2799 - lines.append("")
2800 -
2801 - # Wpisy dla każdego deploymentu (od najnowszego)
2802 - entry_num = 0
2803 - for d in reversed(deployments):
2804 - deploy_dir = os.path.join(DEPLOYMENTS_DIR, d["id"])
2805 - boot_dir = os.path.join(deploy_dir, "boot")
2806 - kernels = sorted(
2807 - [f for f in os.listdir(boot_dir) if f.startswith("vmlinuz-")],
2808 - reverse=True
2809 - ) if os.path.isdir(boot_dir) else []
2810 -
2811 - kernel_path = f"/.deployments/{d['id']}/boot/{kernels[0]}" if kernels else ""
2812 - initrd_path = f"/.deployments/{d['id']}/boot/initramfs.img"
2813 - initrd_line = f"initrd {initrd_path}" if os.path.exists(os.path.join(boot_dir, "initramfs.img")) else ""
2814 -
2815 - active_mark = " [AKTYWNY]" if d.get("active") else ""
2816 - pkg_list = ", ".join(d.get("packages", [])[:3])
2817 - label = f"Pagan Linux – {d['id']}{active_mark}"
2818 -
2819 - lines.append(f"menuentry '{label}' {{")
2820 - if kernel_path:
2821 - lines.append(f" linux {kernel_path} root={root_dev} rw quiet")
2822 - else:
2823 - lines.append(f" # Brak jądra w tym deploymencie")
2824 - if initrd_line:
2825 - lines.append(f" {initrd_line}")
2826 - lines.append("}")
2827 - lines.append("")
2828 - entry_num += 1
2829 -
2830 - # Wpis fallback: zwykły root (gdyby wszystko padło)
2831 - lines.append("menuentry 'Pagan Linux – fallback (zwykły root)' {")
2832 - lines.append(f" linux /boot/vmlinuz-* root={root_dev} rw quiet")
2833 - lines.append(f" initrd /boot/initramfs.img")
2834 - lines.append("}")
2835 - lines.append("")
2836 -
2837 - # Zapisz
2838 - os.makedirs(os.path.dirname(grub_cfg), exist_ok=True)
2839 - with open(grub_cfg, "w") as f:
2840 - f.write("\n".join(lines))
2841 -
2842 - print(" 📋 GRUB config zaktualizowany – wpisy dla każdego deploymentu")
2843 -
2844 -
2845 -def _detect_root_device() -> str:
2846 - """Wykrywa device partycji root (np. /dev/sda1)."""
2847 - try:
2848 - result = subprocess.run(
2849 - ["findmnt", "-n", "-o", "SOURCE", "/"],
2850 - capture_output=True, text=True, timeout=5
2851 - )
2852 - if result.returncode == 0 and result.stdout.strip():
2853 - return result.stdout.strip()
2854 - except Exception:
2855 - pass
2856 - return "/dev/sda1" # fallback
2857 -
2858 -
2859 -def cmd_grub_update():
2860 - """Ręcznie regeneruje konfigurację GRUB (wpisy dla deploymentów)."""
2861 - ensure_dirs()
2862 - print("📋 Aktualizacja konfiguracji GRUB...")
2863 - _update_grub_config()
2864 - print("✅ GRUB zaktualizowany.")
2865 - return 0
2866 -
2867 -def cmd_deploy_list():
2868 - """Wyświetla listę wszystkich deploymentów."""
2869 - deployments = _load_deployments()
2870 - if not deployments:
2871 - print(_("no_deployments")); return
2872 -
2873 - print(_("deployments_list", len(deployments)))
2874 - active = os.readlink(ACTIVE_LINK) if os.path.islink(ACTIVE_LINK) else ""
2875 -
2876 - for d in reversed(deployments):
2877 - marker = f" ◀ {_('active_deployment')}" if d.get("active") or d["id"] == os.path.basename(active) else ""
2878 - print(f" {d['id']}{marker}")
2879 - print(f" {d['action']}: {', '.join(d['packages'][:5])}")
2880 - if len(d.get('packages', [])) > 5:
2881 - print(f" +{len(d['packages']) - 5} więcej...")
2882 - print(f" {d['timestamp']}")
2883 -
2884 -
2885 -def cmd_deploy_rollback():
2886 - """Przełącza na poprzedni deployment."""
2887 - deployments = _load_deployments()
2888 - active_indices = [i for i, d in enumerate(deployments) if d.get("active")]
2889 -
2890 - if len(deployments) < 2:
2891 - print(f"❌ {_('deploy_rollback_fail')}"); return 1
2892 -
2893 - current_idx = active_indices[0] if active_indices else len(deployments) - 1
2894 - prev_idx = current_idx - 1 if current_idx > 0 else -1
2895 -
2896 - if prev_idx < 0:
2897 - print(f"❌ {_('deploy_rollback_fail')}"); return 1
2898 -
2899 - prev = deployments[prev_idx]
2900 - prev_dir = os.path.join(DEPLOYMENTS_DIR, prev["id"])
2901 -
2902 - if not os.path.isdir(prev_dir):
2903 - print(f"❌ Deployment {prev['id']} nie istnieje na dysku"); return 1
2904 -
2905 - print(f"⏪ Przywracanie deploymentu: {prev['id']}")
2906 - print(f" {prev['action']}: {', '.join(prev['packages'][:5])}")
2907 -
2908 - ans = input(_("continue_q")).strip().lower()
2909 - if ans and ans not in ("t", "y"):
2910 - return 0
2911 -
2912 - _switch_deployment(prev_dir)
2913 -
2914 - for d in deployments:
2915 - d["active"] = (d["id"] == prev["id"])
2916 - _save_deployments(deployments)
2917 -
2918 - _update_grub_config()
2919 - print(f"✅ {_('deploy_rollback_ok', prev['id'])}")
2920 - print(" 💡 Restart wymagany do przeładowania systemu.")
2921 - return 0
2922 -
2923 -
2924 -def cmd_deploy_cleanup(keep: int = 3):
2925 - """Usuwa stare deploymenty, zachowując ostatnie `keep`."""
2926 - deployments = _load_deployments()
2927 -
2928 - if len(deployments) <= keep:
2929 - print(f"✅ {_('deploy_cleanup_none', keep)}"); return 0
2930 -
2931 - to_remove = deployments[:-keep]
2932 - removed = 0
2933 -
2934 - for d in to_remove:
2935 - deploy_dir = os.path.join(DEPLOYMENTS_DIR, d["id"])
2936 - if os.path.isdir(deploy_dir):
2937 - shutil.rmtree(deploy_dir, ignore_errors=True)
2938 - removed += 1
2939 -
2940 - remaining = deployments[-keep:]
2941 - _save_deployments(remaining)
2942 -
2943 - print(f"✅ {_('deploy_cleanup_ok', removed)}")
2944 - return 0
2945 -
2946 -
2947 -# =============================================================================
2948 -# POMOCNICZE
2949 -# =============================================================================
2950 -
2951 -def _resolve_deps(names, repo, installed):
2952 - resolved, visited = [], set()
2953 - missing = [] # zależności których nie ma ani w repo ani zainstalowane
2954 -
2955 - def visit(name):
2956 - if name in visited: return
2957 -
2958 - # Rozwijanie wirtualnych zależności przez provides
2959 - target = _resolve_provides(name, repo)
2960 -
2961 - if target in visited: return
2962 - visited.add(target)
2963 - if target in repo:
2964 - for dep in repo[target].dependencies:
2965 - real_dep = _resolve_provides(dep, repo)
2966 - real_target = real_dep if real_dep in repo else dep
2967 -
2968 - # Sprawdź czy zależność jest dostępna
2969 - if real_target not in installed and real_target not in repo:
2970 - if dep not in missing:
2971 - missing.append(dep)
2972 -
2973 - if dep not in installed:
2974 - visit(real_target)
2975 - elif target not in installed:
2976 - # Pakiet nie istnieje ani w repo ani zainstalowany
2977 - if target not in missing:
2978 - missing.append(target)
2979 -
2980 - if target not in installed and target not in resolved:
2981 - resolved.append(target)
2982 -
2983 - for name in names:
2984 - visit(name)
2985 -
2986 - # Zwróć brakujące (do sprawdzenia przez wywołującego)
2987 - return resolved, missing
2988 -
2989 -def _verify_dependencies(to_install: list, repo: dict, installed: dict) -> int:
2990 - """
2991 - Sprawdza czy wszystkie zależności pakietów do instalacji są spełnione.
2992 - Zwraca liczbę brakujących zależności.
2993 - """
2994 - # Pakiety dostarczane przez bazowy system (zawsze "zainstalowane")
2995 - SYSTEM_BASE = {
2996 - "glibc", "libc", "gcc", "g++", "make", "binutils", "coreutils", "bash",
2997 - "linux-api-headers", "kernel-headers", "zlib", "pkg-config", "pkgconf",
2998 - "tar", "gzip", "xz", "bzip2", "findutils", "grep", "sed", "gawk", "awk",
2999 - "diffutils", "patch", "file", "m4", "perl", "python3", "sh",
3000 - }
3001 - all_missing = []
3002 - all_warnings = []
3003 -
3004 - for pkg_name in to_install:
3005 - pkg = repo.get(pkg_name)
3006 - if not pkg:
3007 - continue
3008 -
3009 - for dep in pkg.dependencies:
3010 - if dep in SYSTEM_BASE:
3011 - continue # bazowy system dostarcza tę zależność
3012 - real_dep = _resolve_provides(dep, repo)
3013 - # Sprawdź czy zależność jest dostępna (w repo lub już zainstalowana)
3014 - in_repo = real_dep in repo
3015 - in_installed = real_dep in installed
3016 - will_be_installed = real_dep in to_install
3017 -
3018 - if not in_repo and not in_installed and not will_be_installed:
3019 - if dep not in all_missing:
3020 - all_missing.append((pkg_name, dep))
3021 - elif in_repo and not in_installed and not will_be_installed:
3022 - if dep not in [w[1] for w in all_warnings]:
3023 - all_warnings.append((pkg_name, dep, real_dep))
3024 -
3025 - if all_missing:
3026 - print(f"\n❌ {_c('red', 'BRAKUJĄCE ZALEŻNOŚCI')} – nie można zainstalować:")
3027 - for pkg, dep in all_missing:
3028 - print(f" {pkg} → potrzebuje {_c('red', dep)} (brak w repozytoriach)")
3029 - print()
3030 -
3031 - if all_warnings:
3032 - print(f"\n⚠ {_c('yellow', 'NIESPEŁNIONE ZALEŻNOŚCI')} – zostaną doinstalowane:")
3033 - for pkg, dep, real in all_warnings:
3034 - print(f" {pkg} → {dep} ({_c('green', real)} – będzie pobrane)")
3035 - print()
3036 -
3037 - return len(all_missing)
3038 -
3039 -def _download_pkg(pkg):
3040 - url = f"{pkg.repo_url}/{pkg.filename}"
3041 - dest = os.path.join(PAG_CACHE, pkg.filename)
3042 - if os.path.exists(dest) and (not pkg.sha256 or _sha256_file(dest) == pkg.sha256):
3043 - _download_pkg_sig(pkg, dest) # upewnij się, że sygnatura jest w cache
3044 - return dest
3045 - try:
3046 - req = Request(url, headers={"User-Agent":"pag/3.0"})
3047 - with urlopen(req, timeout=600) as resp:
3048 - total = int(resp.headers.get("Content-Length", 0))
3049 - bar = DownloadBar(pkg.filename, total)
3050 - with open(dest, "wb") as f:
3051 - while True:
3052 - chunk = resp.read(65536)
3053 - if not chunk:
3054 - break
3055 - f.write(chunk)
3056 - bar.update(len(chunk))
3057 - bar.close()
3058 - if pkg.sha256 and _sha256_file(dest) != pkg.sha256:
3059 - os.remove(dest); return None
3060 - _download_pkg_sig(pkg, dest)
3061 - return dest
3062 - except Exception as e:
3063 - print(f" ⚠ Błąd pobierania {pkg.filename}: {e}", file=sys.stderr)
3064 - return None
3065 -
3066 -def _download_pkg_sig(pkg, dest):
3067 - """Pobiera podpis pakietu (.asc, fallback .sig) obok paczki w cache."""
3068 - for ext in (".asc", ".sig"):
3069 - sig_dest = dest + ext
3070 - if os.path.exists(sig_dest):
3071 - return
3072 - try:
3073 - req = Request(f"{pkg.repo_url}/{pkg.filename}{ext}", headers={"User-Agent":"pag/3.0"})
3074 - with urlopen(req, timeout=30) as resp:
3075 - with open(sig_dest, "wb") as f:
3076 - f.write(resp.read())
3077 - return
3078 - except Exception:
3079 - continue
3080 -
3081 -def _download_packages_parallel(pkgs: List[PackageInfo], max_workers: int = 4) -> Dict[str, Optional[str]]:
3082 - """
3083 - Równoległe pobieranie wielu pakietów przez ThreadPoolExecutor.
3084 - Znacząco przyspiesza przy dużych aktualizacjach (50+ pakietów).
3085 - Zwraca słownik {nazwa_pakietu: ścieżka_lub_None}.
3086 - """
3087 - results = {}
3088 - total = len(pkgs)
3089 - completed = 0
3090 - with ThreadPoolExecutor(max_workers=max_workers) as executor:
3091 - future_to_pkg = {executor.submit(_download_pkg, pkg): pkg for pkg in pkgs}
3092 - for future in as_completed(future_to_pkg):
3093 - pkg = future_to_pkg[future]
3094 - try:
3095 - results[pkg.name] = future.result()
3096 - except Exception:
3097 - results[pkg.name] = None
3098 - completed += 1
3099 - # Pasek postępu
3100 - pct = completed / total * 100
3101 - filled = int(20 * pct / 100)
3102 - bar = "█" * filled + "░" * (20 - filled)
3103 - print(f"\r ⏬ [{bar}] {completed}/{total} ({pct:.0f}%)", end="", file=sys.stderr, flush=True)
3104 - print(file=sys.stderr) # nowa linia po zakończeniu
3105 - return results
3106 -
3107 -def load_world():
3108 - if not os.path.exists(WORLD_FILE): return set()
3109 - return {l.strip() for l in open(WORLD_FILE) if l.strip()}
3110 -
3111 -def save_world(w):
3112 - with open(WORLD_FILE,"w") as f:
3113 - for n in sorted(w): f.write(f"{n}\n")
3114 -
3115 -def _find_orphans(installed, world):
3116 - needed = set(world)
3117 - changed = True
3118 - while changed:
3119 - changed = False
3120 - for n in list(needed):
3121 - for dep in installed.get(n,{}).get("dependencies",[]):
3122 - if dep not in needed and dep in installed:
3123 - needed.add(dep); changed = True
3124 - return {n for n in installed if n not in needed}
3125 -
3126 -# =============================================================================
3127 -# MAIN
3128 -# =============================================================================
3129 -
3130 -USAGE_EN = """pag v3 – Pagan Linux Package Manager
3131 -
3132 -BASIC:
3133 - pag install <pkg>... Install packages
3134 - pag remove <pkg>... Remove packages
3135 - pag update [--force] Refresh repo indexes
3136 - pag upgrade Upgrade all packages
3137 - pag list [--installed] List available / installed
3138 - pag search <query> Search packages
3139 - pag info <pkg> Package details
3140 - pag files <pkg> List package files
3141 - pag verify [--deep] Verify integrity (--deep = SHA256 per file)
3142 - pag clean Clear download cache
3143 - pag stats System statistics
3144 - pag download <pkg>... Download packages to cache (offline prep)
3145 -
3146 -SECURITY:
3147 - pag key-add <url|file> Import GPG key
3148 - pag key-list List trusted keys
3149 - pag key-remove <id> Remove key
3150 -
3151 -ADVANCED:
3152 - pag why <pkg> Show why a package is installed
3153 - pag autoremove Auto-remove orphaned dependencies
3154 - pag pin <pkg> [ver] Pin package version
3155 - pag unpin <pkg> Unpin
3156 - pag pinned List pinned
3157 - pag history Transaction history
3158 - pag rollback Rollback last transaction
3159 - pag remove-orphans Remove orphaned deps
3160 - pag repo-add <url> Add repository
3161 - pag repo-list List repositories
3162 -
3163 -FLATPAK:
3164 - pag flatpak [<query>] Search & install (smart)
3165 - pag flatpak search <q> Search Flathub
3166 - pag flatpak install <id> Install flatpak
3167 - pag flatpak remove <id> Remove flatpak
3168 - pag flatpak list List installed flatpaks
3169 - pag flatpak update Update all flatpaks
3170 - pag flatpak info <id> Show flatpak details
3171 -
3172 -IMMUTABLE OS (PAG_IMMUTABLE=1):
3173 - pag deploy-list List all deployments
3174 - pag deploy-rollback Switch to previous deployment
3175 - pag deploy-cleanup [N] Remove old deployments (keep last N, default 3)
3176 - pag initramfs-update Rebuild initramfs for current kernel/deployment
3177 - pag grub-update Regenerate GRUB entries for all deployments
3178 -"""
3179 -
3180 -USAGE_PL = """pag v3 – Pagan Linux Package Manager
3181 -
3182 -PODSTAWOWE:
3183 - pag install <pkg>... Instalacja pakietów
3184 - pag remove <pkg>... Usuwanie pakietów
3185 - pag update [--force] Odśwież indeksy repozytoriów
3186 - pag upgrade Aktualizacja wszystkich pakietów
3187 - pag list [--installed] Lista dostępnych / zainstalowanych
3188 - pag search <query> Szukaj pakietów
3189 - pag info <pkg> Szczegóły pakietu
3190 - pag files <pkg> Lista plików pakietu
3191 - pag verify [--deep] Weryfikacja integralności
3192 - pag clean Wyczyść cache pobierania
3193 - pag stats Statystyki systemu
3194 - pag download <pkg>... Pobierz do cache (offline)
3195 -
3196 -BEZPIECZEŃSTWO:
3197 - pag key-add <url|file> Importuj klucz GPG
3198 - pag key-list Lista zaufanych kluczy
3199 - pag key-remove <id> Usuń klucz
3200 -
3201 -ZAAWANSOWANE:
3202 - pag why <pkg> Dlaczego pakiet jest zainstalowany
3203 - pag autoremove Usuń osierocone zależności
3204 - pag pin <pkg> [ver] Przypnij wersję pakietu
3205 - pag unpin <pkg> Odepnij
3206 - pag pinned Lista przypiętych
3207 - pag history Historia transakcji
3208 - pag rollback Cofnij ostatnią transakcję
3209 - pag remove-orphans Usuń osierocone zależności
3210 - pag repo-add <url> Dodaj repozytorium
3211 - pag repo-list Lista repozytoriów
3212 -
3213 -FLATPAK:
3214 - pag flatpak [<query>] Szukaj i instaluj
3215 - pag flatpak search <q> Szukaj na Flathub
3216 - pag flatpak install <id> Zainstaluj flatpak
3217 - pag flatpak remove <id> Usuń flatpak
3218 - pag flatpak list Lista zainstalowanych
3219 - pag flatpak update Aktualizuj wszystkie
3220 - pag flatpak info <id> Szczegóły flatpaka
3221 -
3222 -IMMUTABLE OS (PAG_IMMUTABLE=1):
3223 - pag deploy-list Lista wdrożeń
3224 - pag deploy-rollback Przełącz na poprzednie wdrożenie
3225 - pag deploy-cleanup [N] Usuń stare wdrożenia (zachowaj N, domyślnie 3)
3226 - pag initramfs-update Przebuduj initramfs
3227 - pag grub-update Regeneruj wpisy GRUB"""
3228 -
3229 -def _get_usage():
3230 - if LANG == "pl":
3231 - return USAGE_PL
3232 - return USAGE_EN
3233 -
3234 -
3235 -def main():
3236 - if len(sys.argv) >= 2 and sys.argv[1] in ("--version", "-V", "version"):
3237 - print(f"pag {PAG_VERSION}")
3238 - sys.exit(0)
3239 - if len(sys.argv) < 2:
3240 - print(_get_usage()); sys.exit(0)
3241 -
3242 - cmd = sys.argv[1]
3243 - args = sys.argv[2:]
3244 -
3245 - # --- Komendy TYLKO DO ODCZYTU (nie wymagają roota) ---
3246 - READ_ONLY = {
3247 - "list": lambda: cmd_list("--installed" in args),
3248 - "search": lambda: cmd_search(args[0]) if args else print("Usage: pag search <query>"),
3249 - "info": lambda: cmd_info(args[0]) if args else print("Usage: pag info <pkg>"),
3250 - "files": lambda: cmd_files(args[0]) if args else print("Usage: pag files <pkg>"),
3251 - "verify": lambda: cmd_verify("--deep" in args),
3252 - "why": lambda: cmd_why(args[0]) if args else print("Usage: pag why <pkg>"),
3253 - "stats": cmd_stats,
3254 - "pinned": cmd_pinned,
3255 - "history": cmd_history,
3256 - "repo-list": cmd_repo_list,
3257 - "key-list": cmd_key_list,
3258 - "flatpak": lambda: cmd_flatpak(args),
3259 - "flatpak-search": lambda: cmd_flatpak_search(args[0]) if args else print("Usage: pag flatpak-search <query>"),
3260 - "flatpak-list": cmd_flatpak_list,
3261 - "flatpak-info": lambda: cmd_flatpak_info(args[0]) if args else print("Usage: pag flatpak-info <id>"),
3262 - "deploy-list": cmd_deploy_list,
3263 - "deploy": cmd_deploy_list,
3264 - }
3265 -
3266 - if cmd in READ_ONLY:
3267 - sys.exit(READ_ONLY[cmd]() or 0)
3268 -
3269 - # --- Smart search: `pag <nazwa-pakietu>` → repo + Flathub + sugestie ---
3270 - WRITE_CMDS = {
3271 - "install", "remove", "update", "upgrade", "clean", "download",
3272 - "autoremove", "remove-orphans", "pin", "unpin", "rollback",
3273 - "repo-add", "key-add", "key-remove", "self-update",
3274 - "flatpak", "flatpak-install", "flatpak-remove", "flatpak-update",
3275 - "deploy-rollback", "deploy-cleanup", "initramfs-update", "grub-update",
3276 - }
3277 - if cmd not in WRITE_CMDS:
3278 - sys.exit(_smart_search(" ".join([cmd] + args)) or 0)
3279 -
3280 - # --- Komendy ZAPISU (wymagają roota) ---
3281 - if os.geteuid() != 0:
3282 - print(f"❌ {_('root_required')}", file=sys.stderr); sys.exit(1)
3283 -
3284 - ensure_dirs()
3285 -
3286 - with DatabaseLock():
3287 - WRITE_COMMANDS = {
3288 - "install": lambda: cmd_install(args),
3289 - "remove": lambda: cmd_remove(args),
3290 - "update": cmd_update,
3291 - "upgrade": cmd_upgrade,
3292 - "clean": cmd_clean,
3293 - "download": lambda: cmd_download(args),
3294 - "autoremove": cmd_autoremove,
3295 - "remove-orphans": cmd_remove_orphans,
3296 - "pin": lambda: cmd_pin(args[0], args[1] if len(args)>1 else ""),
3297 - "unpin": lambda: cmd_unpin(args[0]) if args else print("Usage: pag unpin <pkg>"),
3298 - "rollback": cmd_rollback,
3299 - "repo-add": lambda: cmd_repo_add(args[0]) if args else print("Usage: pag repo-add <url>"),
3300 - "key-add": lambda: cmd_key_add(args[0]) if args else print("Usage: pag key-add <url|file>"),
3301 - "key-remove": lambda: cmd_key_remove(args[0]) if args else print("Usage: pag key-remove <id>"),
3302 - "self-update": cmd_self_update,
3303 - "flatpak": lambda: cmd_flatpak(args),
3304 - "flatpak-install": lambda: _flatpak_smart_install(args) if args else print("Usage: pag flatpak-install <app>"),
3305 - "flatpak-remove": lambda: _flatpak_smart_remove(args) if args else print("Usage: pag flatpak-remove <app>"),
3306 - "flatpak-update": cmd_flatpak_update,
3307 - "deploy-rollback": cmd_deploy_rollback,
3308 - "deploy-cleanup": lambda: cmd_deploy_cleanup(int(args[0]) if args else 3),
3309 - "initramfs-update": cmd_initramfs_update,
3310 - "grub-update": cmd_grub_update,
3311 - }
3312 -
3313 - fn = WRITE_COMMANDS.get(cmd)
3314 - if fn:
3315 - sys.exit(fn() or 0)
3316 - # Should never reach here – _smart_search handles unknowns
3317 - sys.exit(_smart_search(" ".join([cmd] + args)) or 0)
3318 -
3319 -if __name__ == "__main__":
1 +#!/usr/bin/env python3
2 +"""
3 +╔══════════════════════════════════════════════════════════════════════════════╗
4 +║ PAG - Pagan Linux Package Manager v3.3.7 ║
5 +║ Produkcyjny menedżer pakietów – atomowy, bezpieczny, i18n ║
6 +╚══════════════════════════════════════════════════════════════════════════════╝
7 +
8 +KLUCZOWE CECHY:
9 + - Atomowa instalacja przez staging (tmpdir → rename) – brak pół-instalacji
10 + - Bezpieczne usuwanie – sprawdza czy plik nie jest współdzielony
11 + - SQLite dla bazy plików – miliony plików bez problemu
12 + - GPG: weryfikacja repo.json + podpisy pakietów + pinning fingerprintu
13 + - Hooki: pre/post-install, pre/post-remove (piaskownica env, timeout, audit)
14 + - Głęboka weryfikacja SHA256 per-plik
15 + - Pełny rollback – cofa fizyczne pliki
16 + - Blokada flock – tylko jedna instancja
17 + - Transakcje z migawkami + rejestr wykonanych hooków
18 + - Cache HTTP (ETag/If-Modified-Since)
19 + - Wielojęzyczność (i18n) – PL, EN
20 +
21 +FORMAT PAKIETU (.pag):
22 + ├── data.tar.xz – pliki + sums.json (SHA256 per plik)
23 + ├── metadata.json – nazwa, wersja, zależności
24 + └── hooks/ – pre-install, post-install, pre-remove, post-remove
25 +
26 +MODEL ZAUFANIA / BEZPIECZEŃSTWO:
27 + - Repozytorium MUSI być zaufane: podpisy GPG zweryfikowane; fingerprint
28 + klucza przypiętego do repo (TOFU przy pierwszym użyciu, potem pinning).
29 + - Hooki uruchamiają dowolny plik z pakietu jako ROOT (jak apt/pacman).
30 + Ograniczamy je (czyste env, timeout, PAG_NO_HOOKS=1, log do
31 + /var/log/pag/audit.log) i rejestrujemy w transakcji, ale ostatecznie
32 + instalujesz kod, któremu ufasz.
33 + - self-update: weryfikacja podpisu + SHA256 + składnia, atomowa podmiana.
34 +"""
35 +
36 +import os, sys, json, shutil, hashlib, tarfile, tempfile, subprocess, time, fcntl, sqlite3, locale, re
37 +
38 +# Fix TLS trust inside the Pagan chroot: point Python at the CA bundle that
39 +# pag ships, otherwise urlopen() fails with "unable to get local issuer
40 +# certificate" (no default capath/cafile is resolved in the chroot).
41 +for _cafile in (
42 + "/etc/ssl/certs/ca-certificates.crt",
43 + "/etc/ssl/cert.pem",
44 +):
45 + if os.path.isfile(_cafile):
46 + os.environ["SSL_CERT_FILE"] = _cafile
47 + break
48 +
49 +from pathlib import Path
50 +from datetime import datetime, timezone
51 +from typing import Dict, List, Optional, Tuple, Set
52 +from concurrent.futures import ThreadPoolExecutor, as_completed
53 +from urllib.request import urlopen, Request
54 +import threading, itertools
55 +
56 +# Wersja klienta – do porównania z repo.json["pag_version"] (self-update)
57 +PAG_VERSION = "3.3.7"
58 +from urllib.error import URLError, HTTPError
59 +
60 +# =============================================================================
61 +# ProgressBar — minimalistyczny pasek postępu (bez zewnętrznych zależności)
62 +# =============================================================================
63 +
64 +class ProgressBar:
65 + """Czysty Python progress bar — działa z TTY i bez."""
66 + def __init__(self, total: int, desc: str = "", unit: str = "", width: int = 30):
67 + self.total = max(total, 1)
68 + self.desc = desc
69 + self.unit = unit
70 + self.width = width
71 + self.n = 0
72 + self.start = time.time()
73 + self.tty = sys.stderr.isatty()
74 + self._last_line_len = 0
75 +
76 + def update(self, n: Optional[int] = None, suffix: str = ""):
77 + if n is not None:
78 + self.n = n
79 + else:
80 + self.n += 1
81 + pct = self.n / self.total * 100
82 + elapsed = time.time() - self.start
83 + speed = self.n / elapsed if elapsed > 0 else 0
84 + if self.n >= self.total:
85 + eta_str = "done"
86 + elif speed > 0:
87 + eta = (self.total - self.n) / speed
88 + eta_str = f"{eta:.0f}s" if eta < 60 else f"{eta/60:.1f}m"
89 + else:
90 + eta_str = "?..."
91 + bar_len = int(self.width * pct / 100)
92 + bar = "█" * bar_len + "░" * (self.width - bar_len)
93 + line = f" {self.desc} [{bar}] {self.n}/{self.total} ({pct:.0f}%) ETA {eta_str}{suffix}"
94 + if self.tty:
95 + # Overwrite current line
96 + clear = " " * max(0, self._last_line_len - len(line))
97 + print(f"\r{line}{clear}", end="", file=sys.stderr, flush=True)
98 + self._last_line_len = len(line)
99 + else:
100 + # Print milestone lines only (every 10% or when done)
101 + if self.n == 1 or self.n >= self.total or self.n % max(1, self.total // 10) == 0:
102 + print(line, file=sys.stderr)
103 +
104 + def close(self):
105 + if self.tty:
106 + print(file=sys.stderr)
107 + self._last_line_len = 0
108 +
109 + def __enter__(self):
110 + return self
111 +
112 + def __exit__(self, *args):
113 + self.close()
114 +
115 +
116 +class DownloadBar:
117 + """Pasek postępu pobierania — na podstawie Content-Length."""
118 + def __init__(self, filename: str, total_bytes: int):
119 + self.filename = filename
120 + self.total = total_bytes
121 + self.downloaded = 0
122 + self.start = time.time()
123 + self.tty = sys.stderr.isatty()
124 + self._last_len = 0
125 +
126 + def update(self, chunk_size: int):
127 + self.downloaded += chunk_size
128 + if self.total <= 0:
129 + return
130 + pct = self.downloaded / self.total * 100
131 + elapsed = time.time() - self.start
132 + speed = self.downloaded / elapsed if elapsed > 0 else 0
133 + if speed > 0:
134 + eta = (self.total - self.downloaded) / speed
135 + eta_str = f"{eta:.0f}s" if eta < 60 else f"{eta/60:.1f}m"
136 + else:
137 + eta_str = "?..."
138 + bar_len = 25
139 + filled = int(bar_len * pct / 100)
140 + bar = "█" * filled + "░" * (bar_len - filled)
141 + sz = self._fmt_size(self.total)
142 + spd = self._fmt_size(int(speed))
143 + line = f" ↓ {self.filename} [{bar}] {pct:.0f}% {sz} {spd}/s ETA {eta_str}"
144 + if self.tty:
145 + clear = " " * max(0, self._last_len - len(line))
146 + print(f"\r{line}{clear}", end="", file=sys.stderr, flush=True)
147 + self._last_len = len(line)
148 +
149 + def close(self):
150 + if self.tty and self.total > 0:
151 + print(file=sys.stderr)
152 +
153 + @staticmethod
154 + def _fmt_size(n: int) -> str:
155 + for unit in ("B", "KB", "MB", "GB"):
156 + if n < 1024:
157 + return f"{n:.1f} {unit}"
158 + n /= 1024
159 + return f"{n:.1f} TB"
160 +
161 +# =============================================================================
162 +# GPG – BEZPIECZNE WYWOŁYWANIE (odporne na brak binarki gpg)
163 +# =============================================================================
164 +
165 +GPG_BINARY = shutil.which("gpg2") or shutil.which("gpg") or "gpg"
166 +GPG_HOME = "/etc/pag/gpg" # izolowany keyring (działa z keyboxd GPG 2.4+)
167 +
168 +def _gpg_run(*args, timeout: int = 30, **kwargs) -> subprocess.CompletedProcess:
169 + """
170 + Bezpieczne wywołanie GPG – przechwytuje FileNotFoundError,
171 + gdyby gpg/gpg2 nie było zainstalowane w minimalnym środowisku.
172 + Wymusza LC_ALL=C aby komunikaty GPG były zawsze po angielsku
173 + (niezależnie od locale systemu) – kluczowe dla parsowania stderr.
174 + """
175 + env = kwargs.pop("env", None) or os.environ.copy()
176 + env["LC_ALL"] = "C"
177 + env["GNUPGHOME"] = GPG_HOME
178 + try:
179 + return subprocess.run([GPG_BINARY, *args], timeout=timeout, env=env, **kwargs)
180 + except FileNotFoundError:
181 + # GPG nie jest dostępne – zwróć błąd z komunikatem
182 + return subprocess.CompletedProcess(
183 + [GPG_BINARY, *args], 127,
184 + stdout=b"", stderr=f"GPG binary not found ({GPG_BINARY})".encode()
185 + )
186 + except subprocess.TimeoutExpired:
187 + return subprocess.CompletedProcess(
188 + [GPG_BINARY, *args], 124,
189 + stdout=b"", stderr=b"GPG operation timed out"
190 + )
191 +
192 +def _load_trust_db() -> dict:
193 + """Mapa repo_url → fingerprint klucza podpisującego (baza zaufania)."""
194 + try:
195 + with open(TRUST_DB) as f:
196 + return json.load(f)
197 + except (FileNotFoundError, json.JSONDecodeError):
198 + return {}
199 +
200 +
201 +def _save_trust_db(db: dict):
202 + os.makedirs(os.path.dirname(TRUST_DB), exist_ok=True)
203 + with open(TRUST_DB, "w") as f:
204 + json.dump(db, f, indent=2)
205 +
206 +
207 +def _gpg_verify_fp(sig_path: str, data_path: str, timeout: int = 30):
208 + """Weryfikuje podpis i odczytuje fingerprint podpisującego.
209 +
210 + Używa --status-fd=1 i linii VALIDSIG <fingerprint>. Zwraca (ok, fingerprint).
211 + """
212 + env = os.environ.copy()
213 + res = _gpg_run("--verify", "--status-fd", "1", sig_path, data_path,
214 + capture_output=True, text=True, timeout=timeout, env=env)
215 + if res.returncode != 0:
216 + return False, None
217 + m = re.search(r"\[GNUPG:\]\s+VALIDSIG\s+([0-9A-Fa-f]+)", res.stdout or "")
218 + if not m:
219 + m = re.search(r"VALIDSIG\s+([0-9A-Fa-f]{16,})", res.stdout or "")
220 + return True, (m.group(1).upper() if m else None)
221 +
222 +
223 +# =============================================================================
224 +# i18n – WIELOJĘZYCZNOŚĆ
225 +# =============================================================================
226 +
227 +LANG = os.environ.get("LANG", "en_US.UTF-8")[:2] # pl, en, de...
228 +COLOR = os.environ.get("NO_COLOR", "") == "" and sys.stdout.isatty()
229 +
230 +def _c(code: str, text: str) -> str:
231 + """Dodaje kody ANSI jeśli kolor jest włączony."""
232 + if not COLOR:
233 + return text
234 + colors = {
235 + "green": "\033[32m", "red": "\033[31m", "yellow": "\033[33m",
236 + "cyan": "\033[36m", "bold": "\033[1m", "dim": "\033[2m",
237 + "reset": "\033[0m",
238 + }
239 + return f"{colors.get(code,'')}{text}{colors['reset']}"
240 +
241 +T = {
242 + "en": {
243 + "root_required": "pag requires root privileges (sudo).",
244 + "db_locked": "Another pag instance is running.",
245 + "db_lock_hint": "If this is an error, remove: rm {}",
246 + "no_index": "Cannot fetch repository indexes. Run 'pag update'.",
247 + "all_installed": "All packages are already installed.",
248 + "to_install": "To install: {} packages ({:.2f} MB)",
249 + "new": "NEW",
250 + "continue_q": "Continue? [Y/n] ",
251 + "cancelled": "Cancelled.",
252 + "not_found": "not found in repos",
253 + "downloading": "Downloading",
254 + "download_fail": "download failed",
255 + "gpg_fail": "GPG verification failed",
256 + "sha256_mismatch": "SHA256 mismatch",
257 + "installed": "Installed {} packages.",
258 + "rollback_restored": "Restored previous state from snapshot.",
259 + "rollback_files": "Rolled back {} files.",
260 + "no_history": "No transaction history.",
261 + "pinned_list": "Pinned packages ({}):",
262 + "no_pinned": "No pinned packages.",
263 + "pinned_to": "pinned to",
264 + "unpinned": "unpinned.",
265 + "not_pinned": "was not pinned.",
266 + "repo_added": "Added repository: {}",
267 + "repo_exists": "Repository already exists: {}",
268 + "updated_done": "Index refresh complete. {} packages cached.",
269 + "upgrading": "Upgrading: {} packages",
270 + "all_up_to_date": "All packages are up to date.",
271 + "removing": "Removing",
272 + "orphans_found": "Orphaned dependencies ({}): {}",
273 + "flatpak_missing": "Flatpak is not installed.",
274 + "flatpak_adding": "Adding Flathub remote...",
275 + "flatpak_searching": "Searching Flathub for '{}'...",
276 + "flatpak_found": "Found {} results:",
277 + "flatpak_not_found": "not found on Flathub",
278 + "flatpak_install_prompt": "Install {}? [Y/n] ",
279 + "flatpak_installing": "Installing {}...",
280 + "flatpak_installed": "Flatpak {} installed.",
281 + "flatpak_removed": "Flatpak {} removed.",
282 + "flatpak_not_installed": "Flatpak {} is not installed.",
283 + "flatpak_info_id": "ID",
284 + "flatpak_info_version": "Version",
285 + "flatpak_info_branch": "Branch",
286 + "flatpak_info_origin": "Origin",
287 + "flatpak_info_size": "Installed size",
288 + "flatpak_info_desc": "Description",
289 + "flatpak_updated": "Flatpaks updated.",
290 + "flatpak_usage": "Usage: pag flatpak <search|install|remove|list|update|info> [args]",
291 + "key_imported": "Key imported successfully.",
292 + "key_removed": "Key removed: {}",
293 + "no_keys": "No trusted GPG keys.",
294 + "verify_ok": "All {} files intact.",
295 + "verify_errors": "{} problems found:",
296 + "cache_cleared": "{} files ({:.2f} MB) cleared from cache.",
297 + "deployments_list": "Deployments ({}):",
298 + "no_deployments": "No deployments.",
299 + "active_deployment": "ACTIVE",
300 + "deploy_rollback_ok": "Switched to deployment: {}",
301 + "deploy_rollback_fail": "No previous deployment.",
302 + "deploy_cleanup_ok": "Removed {} old deployments.",
303 + "deploy_cleanup_none": "No deployments to clean (minimum {}).",
304 + "why_explicit": "explicitly installed",
305 + "why_dependency": "dependency of",
306 + "why_not_installed": "not installed",
307 + "autoremove_ok": "Removed {} orphaned packages.",
308 + "autoremove_none": "No orphaned packages.",
309 + "downloaded": "Downloaded {} to cache ({:.2f} MB).",
310 + "provides_mapped": "{} → {} (provides)",
311 + "stats_title": "PAG Statistics",
312 + "stats_packages": "Installed packages",
313 + "stats_files": "Tracked files",
314 + "stats_size": "Total size",
315 + "stats_cache": "Cache size",
316 + "stats_history": "Transactions",
317 + "stats_last_update": "Last update",
318 + },
319 + "pl": {
320 + "root_required": "pag wymaga uprawnień root (sudo).",
321 + "db_locked": "Inna instancja pag jest uruchomiona.",
322 + "db_lock_hint": "Jeśli to błąd, usuń: rm {}",
323 + "no_index": "Nie można pobrać indeksów repozytoriów. Uruchom 'pag update'.",
324 + "all_installed": "Wszystkie pakiety są już zainstalowane.",
325 + "to_install": "Do zainstalowania: {} pakietów ({:.2f} MB)",
326 + "new": "NOWY",
327 + "continue_q": "Kontynuować? [T/n] ",
328 + "cancelled": "Anulowano.",
329 + "not_found": "brak w repozytoriach",
330 + "downloading": "Pobieranie",
331 + "download_fail": "błąd pobierania",
332 + "gpg_fail": "błąd weryfikacji GPG",
333 + "sha256_mismatch": "niezgodność SHA256",
334 + "installed": "Zainstalowano {} pakietów.",
335 + "rollback_restored": "Przywrócono poprzedni stan z migawki.",
336 + "rollback_files": "Wycofano {} plików.",
337 + "no_history": "Brak historii transakcji.",
338 + "pinned_list": "Przypięte pakiety ({}):",
339 + "no_pinned": "Brak przypiętych pakietów.",
340 + "pinned_to": "przypięty do",
341 + "unpinned": "odpięty.",
342 + "not_pinned": "nie był przypięty.",
343 + "repo_added": "Dodano repozytorium: {}",
344 + "repo_exists": "Repozytorium już istnieje: {}",
345 + "updated_done": "Odświeżanie zakończone. {} pakietów w cache.",
346 + "upgrading": "Aktualizacje: {} pakietów",
347 + "all_up_to_date": "Wszystkie pakiety są aktualne.",
348 + "removing": "Usuwanie",
349 + "orphans_found": "Osierocone zależności ({}): {}",
350 + "flatpak_missing": "Flatpak nie jest zainstalowany.",
351 + "flatpak_adding": "Dodaję zdalne repozytorium Flathub...",
352 + "flatpak_searching": "Szukam '{}' we Flathub...",
353 + "flatpak_found": "Znaleziono {} wyników:",
354 + "flatpak_not_found": "nie znaleziono we Flathub",
355 + "flatpak_install_prompt": "Zainstalować {}? [T/n] ",
356 + "flatpak_installing": "Instalowanie {}...",
357 + "flatpak_installed": "Flatpak {} zainstalowany.",
358 + "flatpak_removed": "Flatpak {} usunięty.",
359 + "flatpak_not_installed": "Flatpak {} nie jest zainstalowany.",
360 + "flatpak_info_id": "ID",
361 + "flatpak_info_version": "Wersja",
362 + "flatpak_info_branch": "Gałąź",
363 + "flatpak_info_origin": "Źródło",
364 + "flatpak_info_size": "Rozmiar",
365 + "flatpak_info_desc": "Opis",
366 + "flatpak_updated": "Flapaki zaktualizowane.",
367 + "flatpak_usage": "Użycie: pag flatpak <search|install|remove|list|update|info> [args]",
368 + "key_imported": "Klucz zaimportowany pomyślnie.",
369 + "key_removed": "Klucz usunięty: {}",
370 + "no_keys": "Brak zaufanych kluczy GPG.",
371 + "verify_ok": "Wszystkie {} plików sprawne.",
372 + "verify_errors": "Znaleziono {} problemów:",
373 + "cache_cleared": "{} plików ({:.2f} MB) usuniętych z cache.",
374 + "deployments_list": "Deploymenty ({}):",
375 + "no_deployments": "Brak deploymentów.",
376 + "active_deployment": "AKTYWNY",
377 + "deploy_rollback_ok": "Przełączono na deployment: {}",
378 + "deploy_rollback_fail": "Brak poprzedniego deploymentu.",
379 + "deploy_cleanup_ok": "Usunięto {} starych deploymentów.",
380 + "deploy_cleanup_none": "Nie ma deploymentów do wyczyszczenia (minimum {}).",
381 + "why_explicit": "zainstalowany jawnie",
382 + "why_dependency": "zależność od",
383 + "why_not_installed": "niezainstalowany",
384 + "autoremove_ok": "Usunięto {} osieroconych pakietów.",
385 + "autoremove_none": "Brak osieroconych pakietów.",
386 + "downloaded": "Pobrano {} do cache ({:.2f} MB).",
387 + "sec_downgrade": "Downgrade blocked: {pkg} {new} < {old}",
388 + "sec_suid": "SUID stripped from {path}",
389 + "sec_https": "HTTPS required for repos",
390 + "sec_badname": "Invalid package name: {name}",
391 + "sec_toobig": "Package too large: {size_mb}MB > {max_mb}MB",
392 + "sec_conflict": "File conflict: {path} owned by {owner}",
393 + "sec_audit": "{pkg} installed by {user}",
394 + "sec_locked": "Another pag process is running",
395 + "sec_downgrade_pl": "Blokada downgrade: {pkg} {new} < {old}",
396 + "sec_suid_pl": "SUID usuniety z {path}",
397 + "sec_https_pl": "Repozytorium wymaga HTTPS",
398 + "sec_badname_pl": "Nieprawidlowa nazwa pakietu: {name}",
399 + "sec_toobig_pl": "Paczka za duza: {size_mb}MB > {max_mb}MB",
400 + "sec_conflict_pl": "Konflikt plikow: {path} nalezy do {owner}",
401 + "sec_audit_pl": "{pkg} zainstalowany przez {user}",
402 + "sec_locked_pl": "Inny proces pag juz dziala",
403 +
404 + "provides_mapped": "{} → {} (provides)",
405 + "stats_title": "Statystyki PAG",
406 + "stats_packages": "Zainstalowane pakiety",
407 + "stats_files": "Śledzone pliki",
408 + "stats_size": "Całkowity rozmiar",
409 + "stats_cache": "Rozmiar cache",
410 + "stats_history": "Transakcje",
411 + "stats_last_update": "Ostatnia aktualizacja",
412 + },
413 +}
414 +
415 +def _(key: str, *args, **kwargs) -> str:
416 + """Tłumaczy klucz i formatuje argumenty."""
417 + msg = T.get(LANG, T["en"]).get(key, T["en"].get(key, key))
418 + if args or kwargs:
419 + return msg.format(*args, **kwargs)
420 + return msg
421 +
422 +# =============================================================================
423 +# ŚCIEŻKI
424 +# =============================================================================
425 +PAG_ROOT = os.environ.get("PAG_ROOT", "/")
426 +PAG_DB = "/var/lib/pag"
427 +PAG_CACHE = "/var/cache/pag"
428 +PAG_CONF = "/etc/pag"
429 +REPO_CACHE = "/var/cache/pag/repos"
430 +REPOS_CONF = "/etc/pag/repos.conf"
431 +REPOS_DIR = PAG_CONF + "/repos" # drop-in: /etc/pag/repos/<nazwa>.conf
432 +INSTALLED_DB = "/var/lib/pag/installed.json"
433 +FILES_DB_SQL = "/var/lib/pag/files.db" # SQLite!
434 +WORLD_FILE = "/var/lib/pag/world"
435 +PINNED_FILE = "/var/lib/pag/pinned.json"
436 +HISTORY_FILE = "/var/lib/pag/history.json"
437 +LOCK_FILE = "/var/lib/pag/pag.lock"
438 +STAGING_DIR = "/.pag_staging" # na tej samej partycji co / (unikamy EXDEV)
439 +PKG_EXT = ".pag"
440 +REPO_CACHE_TTL = 3600
441 +MAX_PKG_SIZE = 2 * 1024 * 1024 * 1024 # 2 GB – maksymalny rozmiar paczki
442 +ALLOWED_PKG_RE = re.compile(r'^[a-zA-Z0-9][a-zA-Z0-9._+@-]*$')
443 +
444 +# Bezpieczeństwo / audyt
445 +AUDIT_LOG = "/var/log/pag/audit.log" # dziennik operacji krytycznych (hooki, self-update)
446 +TRUST_DB = "/etc/pag/trusted.json" # mapa repo_url → fingerprint klucza podpisującego
447 +HOOK_API_VERSION = "1" # wersjonowane API hooków (env PKG_HOOK_API)
448 +MAX_PKG_SIZE = 2 * 1024 * 1024 * 1024 # 2 GB – maksymalny rozmiar paczki
449 +
450 +# =============================================================================
451 +# IMMUTABLE OS – DEPLOYMENTY
452 +# =============================================================================
453 +# Model: zamiast mutować /, każda operacja tworzy NOWY deployment.
454 +# /var, /etc, /home są współdzielone między deploymentami.
455 +#
456 +# STRUKTURA:
457 +# /.deployments/
458 +# active → 20260723T120000 (symlink do aktywnego)
459 +# 20260723T120000/
460 +# usr/ bin/ lib/ lib64/ ... (pełny system)
461 +# var → /var (symlink do współdzielonego)
462 +# etc → /etc
463 +# home → /home
464 +# ...
465 +#
466 +# Jak to działa:
467 +# 1. pag install → kopiuje active → nowy deployment + nakłada zmiany → switch symlinka
468 +# 2. pag remove → kopiuje active → nowy deployment - usuwa pliki → switch symlinka
469 +# 3. pag deploy-rollback → przełącza active symlink na poprzedni deployment
470 +# 4. Przy starcie systemu: initrd montuje /.deployments/active jako /
471 +# =============================================================================
472 +
473 +DEPLOYMENTS_DIR = "/.deployments"
474 +ACTIVE_LINK = "/.deployments/active"
475 +DEPLOYMENTS_DB = "/var/lib/pag/deployments.json"
476 +
477 +# Ścieżki współdzielone – NIE wchodzą do deploymentu (są symlinkami do /...)
478 +SHARED_PATHS = {
479 + "/var", "/etc", "/home", "/root", "/tmp", "/run",
480 + "/dev", "/proc", "/sys", "/mnt", "/media", "/srv",
481 + "/.deployments", "/.pag_staging",
482 +}
483 +
484 +def _is_shared_path(rel: str) -> bool:
485 + """Sprawdza czy ścieżka należy do katalogów współdzielonych (poza deploymentem)."""
486 + for sp in SHARED_PATHS:
487 + if rel == sp or rel.startswith(sp + "/"):
488 + return True
489 + return False
490 +
491 +def _get_deployment_root() -> str:
492 + """Zwraca ścieżkę do aktywnego deploymentu, lub PAG_ROOT jeśli tryb niemutowalny wyłączony."""
493 + if os.environ.get("PAG_IMMUTABLE", "") in ("0", "no", "false", ""):
494 + return PAG_ROOT
495 + if os.path.islink(ACTIVE_LINK):
496 + return os.readlink(ACTIVE_LINK)
497 + if os.path.isdir(ACTIVE_LINK):
498 + return ACTIVE_LINK
499 + # Brak deploymentów – użyj /
500 + return PAG_ROOT
501 +
502 +def _load_deployments() -> List[dict]:
503 + """Wczytuje historię deploymentów."""
504 + if not os.path.exists(DEPLOYMENTS_DB):
505 + return []
506 + try:
507 + return json.load(open(DEPLOYMENTS_DB))
508 + except Exception:
509 + return []
510 +
511 +def _save_deployments(deployments: List[dict]):
512 + os.makedirs(os.path.dirname(DEPLOYMENTS_DB), exist_ok=True)
513 + json.dump(deployments, open(DEPLOYMENTS_DB, "w"), indent=2)
514 +
515 +def _create_deployment(pkg_names: List[str], action: str) -> Tuple[str, str]:
516 + """
517 + Tworzy nowy deployment przez skopiowanie aktywnego (CoW) i zwraca jego ścieżkę.
518 + Zwraca (deployment_dir, deployment_id).
519 + """
520 + deploy_id = datetime.now().strftime("%Y%m%dT%H%M%S")
521 + deploy_dir = os.path.join(DEPLOYMENTS_DIR, deploy_id)
522 + os.makedirs(DEPLOYMENTS_DIR, exist_ok=True)
523 +
524 + active = _get_deployment_root()
525 +
526 + if os.path.isdir(active) and active != PAG_ROOT:
527 + # Trójstopniowa strategia kopiowania deploymentu:
528 + # 1. reflink (CoW – btrfs, xfs) → 0 MB kopiowane
529 + # 2. hardlink (linki twarde) → 0 MB kopiowane, tylko inody
530 + # 3. zwykłe cp (ostateczność) → pełna kopia
531 + print(f" ⚡ Kopiowanie aktywnego deploymentu...")
532 + copied = False
533 + for method, cmd, label in [
534 + ("reflink", ["cp", "--reflink=auto", "-a", active + "/.", deploy_dir + "/"], "CoW (reflink)"),
535 + ("hardlink", ["cp", "-al", active + "/.", deploy_dir + "/"], "hardlinki"),
536 + ("copy", ["cp", "-a", active + "/.", deploy_dir + "/"], "pełna kopia"),
537 + ]:
538 + try:
539 + subprocess.run(cmd, check=True, timeout=600, capture_output=True)
540 + print(f" ✅ Deployment: {deploy_id} ({label})")
541 + copied = True
542 + break
543 + except subprocess.CalledProcessError:
544 + if method == "copy":
545 + raise # ostatnia deska – niech leci wyjątek
546 + continue
547 + if not copied:
548 + raise RuntimeError("Nie udało się skopiować deploymentu żadną metodą")
549 + else:
550 + # Pierwszy deployment – tylko katalogi szkieletowe
551 + for d in ["/usr", "/lib", "/lib64", "/bin", "/sbin", "/boot", "/opt"]:
552 + if os.path.isdir(d):
553 + dest = os.path.join(deploy_dir, d.lstrip("/"))
554 + os.makedirs(dest, exist_ok=True)
555 + print(f" ✅ Pierwszy deployment: {deploy_id}")
556 +
557 + # Utwórz symlinki do współdzielonych katalogów
558 + for sp in SHARED_PATHS:
559 + link_dst = os.path.join(deploy_dir, sp.lstrip("/"))
560 + if not os.path.lexists(link_dst) and os.path.isdir(sp):
561 + os.symlink(sp, link_dst)
562 +
563 + # Zapisz w bazie deploymentów
564 + deployments = _load_deployments()
565 + deployments.append({
566 + "id": deploy_id,
567 + "action": action,
568 + "packages": pkg_names,
569 + "timestamp": datetime.now().isoformat(),
570 + "active": True,
571 + })
572 + # Oznacz poprzednie jako nieaktywne
573 + for d in deployments[:-1]:
574 + d["active"] = False
575 + _save_deployments(deployments)
576 +
577 + return deploy_dir, deploy_id
578 +
579 +def _switch_deployment(deploy_dir: str) -> bool:
580 + """Atomowo przełącza aktywny deployment przez podmianę symlinka."""
581 + tmp_link = ACTIVE_LINK + ".new"
582 + if os.path.lexists(tmp_link):
583 + os.remove(tmp_link)
584 + os.symlink(deploy_dir, tmp_link)
585 + os.rename(tmp_link, ACTIVE_LINK) # atomowe na tym samym FS
586 + return True
587 +
588 +DEFAULT_REPOS = [
589 + "https://repo.paganlinux.eu/stable/",
590 +]
591 +
592 +# =============================================================================
593 +# INICJALIZACJA
594 +# =============================================================================
595 +
596 +def ensure_dirs():
597 + for d in [PAG_DB, PAG_CACHE, PAG_CONF, REPO_CACHE, REPOS_DIR, STAGING_DIR, DEPLOYMENTS_DIR]:
598 + os.makedirs(d, exist_ok=True)
599 + for f, default in [
600 + (REPOS_CONF, "\n".join(DEFAULT_REPOS) + "\n"),
601 + (INSTALLED_DB, "{}"),
602 + (PINNED_FILE, "{}"),
603 + (HISTORY_FILE, "[]"),
604 + ]:
605 + if not os.path.exists(f):
606 + with open(f, "w") as fh: fh.write(default)
607 + if not os.path.exists(WORLD_FILE):
608 + Path(WORLD_FILE).touch()
609 + if not os.path.exists(GPG_HOME):
610 + os.makedirs(GPG_HOME, exist_ok=True)
611 + os.chmod(GPG_HOME, 0o700)
612 + _gpg_run("--list-keys", capture_output=True)
613 + # Inicjalizuj SQLite
614 + _db_init()
615 + # Wyczyść staging po poprzednim przerwanym buildzie/instalacji
616 + if os.path.isdir(STAGING_DIR):
617 + for entry in os.listdir(STAGING_DIR):
618 + if entry == "backups":
619 + continue # backupy starych wersji – potrzebne do `pag rollback`
620 + path = os.path.join(STAGING_DIR, entry)
621 + try:
622 + if os.path.isfile(path) or os.path.islink(path):
623 + os.unlink(path)
624 + elif os.path.isdir(path):
625 + shutil.rmtree(path, ignore_errors=True)
626 + except OSError:
627 + pass
628 +
629 +# =============================================================================
630 +# SQLITE – BAZA PLIKÓW (poprawne zarządzanie połączeniami)
631 +# =============================================================================
632 +
633 +from contextlib import contextmanager
634 +
635 +@contextmanager
636 +def _db_session():
637 + """Context manager – gwarantuje zamknięcie połączenia."""
638 + conn = sqlite3.connect(FILES_DB_SQL)
639 + conn.execute("PRAGMA journal_mode=WAL")
640 + conn.execute("PRAGMA synchronous=NORMAL")
641 + conn.execute("PRAGMA foreign_keys=ON")
642 + conn.row_factory = sqlite3.Row
643 + try:
644 + yield conn
645 + conn.commit()
646 + except Exception:
647 + conn.rollback()
648 + raise
649 + finally:
650 + conn.close()
651 +
652 +
653 +def _db_init():
654 + """Tworzy tabele SQLite jeśli nie istnieją."""
655 + with _db_session() as db:
656 + db.execute("""
657 + CREATE TABLE IF NOT EXISTS files (
658 + id INTEGER PRIMARY KEY AUTOINCREMENT,
659 + path TEXT NOT NULL,
660 + package TEXT NOT NULL,
661 + sha256 TEXT,
662 + size INTEGER,
663 + is_symlink INTEGER DEFAULT 0,
664 + symlink_target TEXT,
665 + UNIQUE(path, package)
666 + )
667 + """)
668 + db.execute("CREATE INDEX IF NOT EXISTS idx_files_path ON files(path)")
669 + db.execute("CREATE INDEX IF NOT EXISTS idx_files_pkg ON files(package)")
670 + db.execute("""
671 + CREATE TABLE IF NOT EXISTS file_checksums (
672 + path TEXT PRIMARY KEY,
673 + sha256 TEXT NOT NULL,
674 + installed_at TEXT
675 + )
676 + """)
677 + db.commit()
678 +
679 +def _db_record_files(pkg_name: str, files: List[dict]):
680 + """Zapisuje pliki do SQLite (obsługuje symlinki)."""
681 + with _db_session() as db:
682 + # Context manager sam zarządza transakcją atomowo
683 + db.executemany(
684 + "INSERT OR REPLACE INTO files (path, package, sha256, size, is_symlink, symlink_target) "
685 + "VALUES (?,?,?,?,?,?)",
686 + [(f["path"], pkg_name, f.get("sha256",""), f.get("size",0),
687 + f.get("is_symlink", 0), f.get("symlink_target", ""))
688 + for f in files]
689 + )
690 + db.executemany(
691 + "INSERT OR REPLACE INTO file_checksums (path, sha256, installed_at) VALUES (?,?,?)",
692 + [(f["path"], f.get("sha256",""), datetime.now().isoformat())
693 + for f in files if f.get("sha256")]
694 + )
695 +
696 +def _db_get_package_files(pkg_name: str) -> List[str]:
697 + with _db_session() as db:
698 + return [r["path"] for r in db.execute(
699 + "SELECT DISTINCT path FROM files WHERE package=?", (pkg_name,)
700 + )]
701 +
702 +def _db_get_file_owners(filepath: str) -> List[str]:
703 + """Zwraca listę pakietów będących właścicielami pliku."""
704 + with _db_session() as db:
705 + return [r["package"] for r in db.execute(
706 + "SELECT package FROM files WHERE path=?", (filepath,)
707 + )]
708 +
709 +def _db_remove_package_files(pkg_name: str):
710 + with _db_session() as db:
711 + db.execute("DELETE FROM files WHERE package=?", (pkg_name,))
712 + db.commit()
713 +
714 +def _db_get_all_file_checksums() -> Dict[str, str]:
715 + with _db_session() as db:
716 + return {r["path"]: r["sha256"] for r in db.execute("SELECT path, sha256 FROM file_checksums")}
717 +
718 +def _db_count_files() -> int:
719 + with _db_session() as db:
720 + return db.execute("SELECT COUNT(*) FROM files").fetchone()[0]
721 +
722 +# =============================================================================
723 +# BLOKADA
724 +# =============================================================================
725 +
726 +class DatabaseLock:
727 + """Blokada oparta na PID-file – niezawodna, bez flock."""
728 + def __init__(self):
729 + self._acquired = False
730 + def __enter__(self):
731 + os.makedirs(os.path.dirname(LOCK_FILE), exist_ok=True)
732 + if os.path.exists(LOCK_FILE):
733 + try:
734 + old_pid = int(open(LOCK_FILE).read().strip())
735 + os.kill(old_pid, 0) # sygnał 0 = sprawdź czy proces żyje
736 + except (ValueError, OSError, ProcessLookupError):
737 + # Stary PID nie żyje – usuwamy nieświeżą blokadę
738 + try:
739 + os.remove(LOCK_FILE)
740 + except OSError:
741 + pass
742 + else:
743 + print(f"❌ {_('db_locked')}", file=sys.stderr)
744 + print(f" {_('db_lock_hint', LOCK_FILE)}", file=sys.stderr)
745 + sys.exit(1)
746 + with open(LOCK_FILE, "w") as f:
747 + f.write(str(os.getpid()))
748 + self._acquired = True
749 + return self
750 + def __exit__(self, *args):
751 + if self._acquired:
752 + try:
753 + os.remove(LOCK_FILE)
754 + except OSError:
755 + pass
756 +
757 +# =============================================================================
758 +# POMOCNICZE
759 +# =============================================================================
760 +
761 +
762 +_ALLOWED_PREFIXES = ("/usr/", "/etc/", "/var/", "/opt/",
763 + "/boot/", "/lib/", # kernel: vmlinuz/System.map + moduły (usrmerge: lib→usr/lib)
764 + # Pliki wewnętrzne paczki .pkg.tar.xz
765 + "metadata.json", "data.tar.xz", "hooks/",
766 + "sums.json")
767 +
768 +def _check_path_safety(name: str) -> bool:
769 + # Normalizuj – usuń leading ./
770 + if name.startswith("./"):
771 + name = name[2:]
772 + if name in (".", ""):
773 + return True
774 + # Porównuj z prefiksami BEZ wiodącego '/', by zarówno "/usr/bin/ls", jak i
775 + # wewnętrzne pliki pakietu ("hooks/pre-install", "data.tar.xz") przechodziły.
776 + norm = name.lstrip("/")
777 + for prefix in _ALLOWED_PREFIXES:
778 + p = prefix.lstrip("/").rstrip("/")
779 + if norm == p or norm.startswith(p + "/"):
780 + return True
781 + return False
782 +
783 +
784 +def _validate_pkg_name(name):
785 + return bool(ALLOWED_PKG_RE.match(name))
786 +
787 +
788 +
789 +def _audit(msg):
790 + from datetime import datetime, timezone
791 + os.makedirs(os.path.dirname(AUDIT_LOG), exist_ok=True)
792 + with open(AUDIT_LOG, "a") as f:
793 + f.write(datetime.now(timezone.utc).isoformat() + " " + msg + "\n")
794 +
795 +def _strip_suid(path):
796 + try:
797 + st = os.stat(path)
798 + if st.st_mode & 0o4000:
799 + os.chmod(path, st.st_mode & ~0o4000)
800 + print(f" {_("sec_suid", path=path)}")
801 + except OSError:
802 + pass
803 +
804 +def _check_downgrade(pkg_name, new_ver, installed_db):
805 + if pkg_name in installed_db:
806 + old = installed_db[pkg_name].get("version", "0")
807 + if new_ver < old:
808 + print(f" {_("sec_downgrade", pkg=pkg_name, new=new_ver, old=old)}")
809 + return False
810 + return True
811 +
812 +def _safe_extractall(tar: tarfile.TarFile, dest: str, *, preserve_perms: bool = True):
813 + """
814 + Bezpieczne rozpakowanie archiwum tar z ochroną przed Directory Traversal.
815 +
816 + Działa na Python < 3.12 (gdzie parametr 'filter' w extractall nie istnieje)
817 + oraz na Python 3.12+. W przeciwieństwie do filtra 'data' z Pythona 3.12,
818 + zachowuje bity uprawnień POSIX (SUID, SGID, sticky) – preserve_perms=True.
819 +
820 + Ochrona oparta jest na FINALNEJ ścieżce (os.path.realpath), nie tylko na
821 + prostym sprawdzaniu stringa:
822 + - Blokuje ścieżki absolutne i z '..' (path traversal)
823 + - Blokuje symlinki/hardlinki, których cel wychodzi poza dest
824 + - Blokuje zapis "przez" złośliwy symlink, który został wcześniej
825 + rozpakowany (np. katalog → /etc, potem zapis katalog/plik)
826 + - Zachowuje oryginalne uprawnienia plików
827 + """
828 + dest_real = os.path.realpath(dest)
829 + os.makedirs(dest_real, exist_ok=True)
830 +
831 + def _target_within(path: str) -> bool:
832 + try:
833 + return os.path.commonpath([dest_real, os.path.realpath(path)]) == dest_real
834 + except ValueError:
835 + # różne napędy / ścieżki nie da się wspólnie porównać → odrzuć
836 + return False
837 +
838 + for member in tar.getmembers():
839 + name = member.name
840 +
841 + # --- Ochrona przed Directory Traversal (szybkie string-checki) ---
842 + if name.startswith('/'):
843 + continue
844 + if '..' in name.split('/'):
845 + continue
846 + # Zablokuj bajt NUL i backslash (bugi/obejścia tarfile na niektórych platformach)
847 + if '\x00' in name or '\\' in name:
848 + continue
849 + if not _check_path_safety(name):
850 + print(f" BLOCKED: {name}")
851 + continue
852 +
853 + target = os.path.join(dest, name)
854 +
855 + # --- Ochrona na podstawie finalnej ścieżki ---
856 + # Jeśli którykolwiek komponent nadrzędny jest (złośliwym) symlinkiem
857 + # wskazującym poza dest, realpath to wykryje – zablokuj zapis.
858 + if not _target_within(target):
859 + print(f" BLOCKED (escape): {name}")
860 + continue
861 +
862 + # --- Ochrona dla symlinków i hardlinków ---
863 + if member.issym() or member.islnk():
864 + link = member.linkname
865 + # Szybkie odrzucenie linków absolutnych / z '..'
866 + if link.startswith('/') or '..' in link.split('/'):
867 + continue
868 + # Sprawdź, gdzie realnie prowadzi cel linku (względem katalogu linku)
869 + link_target = os.path.join(os.path.dirname(target), link)
870 + if not _target_within(link_target):
871 + print(f" BLOCKED (link escape): {name} -> {link}")
872 + continue
873 +
874 + # Rozpakuj z zachowaniem metadanych
875 + try:
876 + tar.extract(member, dest, set_attrs=preserve_perms, numeric_owner=False)
877 + except Exception as e:
878 + print(f" ⚠ Nie rozpakowano {name}: {e}")
879 + continue
880 + _strip_suid(target)
881 +
882 +
883 +def _sha256_file(path: str) -> str:
884 + h = hashlib.sha256()
885 + with open(path, "rb") as f:
886 + for chunk in iter(lambda: f.read(65536), b""):
887 + h.update(chunk)
888 + return h.hexdigest()
889 +
890 +def _split_version(v: str):
891 + """Rozdziela wersję na (release_parts, prerelease_parts).
892 +
893 + Przykład: '1.2.0-rc1' → ([1,2,0], ['rc','1']).
894 + """
895 + v = v.strip().lower().lstrip("v")
896 + # build metadata po '+' jest ignorowane przy porównywaniu (semver)
897 + v = v.split("+", 1)[0]
898 + # prerelease po '-' lub '_' (np. 1.2.0-rc1, 1.2.0_rc1)
899 + if "-" in v:
900 + rel, pre = v.split("-", 1)
901 + elif "_" in v:
902 + rel, pre = v.split("_", 1)
903 + else:
904 + rel, pre = v, ""
905 + nums = []
906 + for part in rel.split("."):
907 + m = re.match(r"(\d+)", part)
908 + nums.append(int(m.group(1)) if m else 0)
909 + pre_parts = [p for p in pre.split(".") if p]
910 + return nums, pre_parts
911 +
912 +
913 +def _cmp_pre(a, b):
914 + """Porównuje ciągi identyfikatorów prerelease (reguły semver)."""
915 + for i in range(max(len(a), len(b))):
916 + if i >= len(a):
917 + return -1 # krótszy prerelease jest niższy
918 + if i >= len(b):
919 + return 1
920 + ia, ib = a[i], b[i]
921 + if ia == ib:
922 + continue
923 + na, nb = ia.isdigit(), ib.isdigit()
924 + if na and nb:
925 + return 1 if int(ia) > int(ib) else -1
926 + if na != nb:
927 + return -1 if na else 1 # identyfikator liczbowy < alfanumeryczny
928 + return 1 if ia > ib else -1
929 + return 0
930 +
931 +
932 +def _cmp_version(a: str, b: str) -> int:
933 + """Porównuje dwie wersje; zwraca -1/0/1. Obsługuje prerelease (rc1, beta...)."""
934 + a_rel, a_pre = _split_version(a)
935 + b_rel, b_pre = _split_version(b)
936 + # Porównaj część release (brakujące komponenty traktuj jako 0)
937 + for i in range(max(len(a_rel), len(b_rel))):
938 + xa = a_rel[i] if i < len(a_rel) else 0
939 + xb = b_rel[i] if i < len(b_rel) else 0
940 + if xa != xb:
941 + return 1 if xa > xb else -1
942 + # Część release równa → decyduje prerelease.
943 + # Wersja finalna (bez prerelease) jest ZAWSZE nowsza od prerelease.
944 + if not a_pre and not b_pre:
945 + return 0
946 + if not a_pre:
947 + return 1
948 + if not b_pre:
949 + return -1
950 + return _cmp_pre(a_pre, b_pre)
951 +
952 +
953 +def _version_newer(a: str, b: str) -> bool:
954 + """True gdy wersja a jest nowsza od b (z poprawną obsługą prerelease)."""
955 + try:
956 + return _cmp_version(a, b) > 0
957 + except Exception:
958 + return a != b
959 +
960 +def load_json(path):
961 + try:
962 + with open(path) as f:
963 + return json.load(f)
964 + except (FileNotFoundError, json.JSONDecodeError):
965 + return {}
966 +
967 +def save_json(path, data):
968 + with open(path, "w") as f:
969 + json.dump(data, f, indent=2)
970 +
971 +class PackageInfo:
972 + __slots__ = ("name","version","description","dependencies",
973 + "size_bytes","sha256","gpg_fp","repo_url","filename","provides")
974 + def __init__(self, d, repo=""):
975 + self.name = d.get("name","?")
976 + self.version = d.get("version","0")
977 + self.description = d.get("description","")
978 + self.dependencies = d.get("dependencies",[])
979 + self.size_bytes = d.get("size",0)
980 + self.sha256 = d.get("sha256","")
981 + self.gpg_fp = d.get("gpg_fingerprint","")
982 + self.repo_url = repo
983 + self.filename = d.get("filename", f"{self.name}-{self.version}{PKG_EXT}")
984 + self.provides = d.get("provides", []) or []
985 +
986 +# =============================================================================
987 +# REPOZYTORIA (cache, ETag, GPG)
988 +# =============================================================================
989 +
990 +def _parse_repos_config():
991 + """Parsuje repozytoria z /etc/pag/repos.conf oraz /etc/pag/repos/*.conf.
992 +
993 + Format linii: <url> [fingerprint]
994 + Opcjonalny `fingerprint` (40 znaków hex) pozwala przypiąć klucz
995 + podpisujący repo do konkretnego adresu – wtedy TOFU (auto-zaufanie przy
996 + pierwszym użyciu) nie jest potrzebne, a zmiana klucza = błąd bezpieczeństwa.
997 +
998 + Drop-iny (np. stable.conf) są czytane alfabetycznie – pozwalają na
999 + wygodne dodawanie repo bez dotykania głównego repos.conf
1000 + (np. `echo 'https://repo.paganlinux.eu/stable' > /etc/pag/repos/stable.conf`).
1001 + """
1002 + entries = []
1003 +
1004 + def _read_lines(path):
1005 + if not os.path.exists(path):
1006 + return
1007 + for line in open(path):
1008 + line = line.strip()
1009 + if not line or line.startswith("#"):
1010 + continue
1011 + parts = line.split()
1012 + url = parts[0].rstrip("/")
1013 + fp = parts[1].lower() if len(parts) > 1 else ""
1014 + entries.append({"url": url, "fingerprint": fp or None})
1015 +
1016 + # 1) Legacy: pojedynczy plik /etc/pag/repos.conf
1017 + _read_lines(REPOS_CONF)
1018 + # 2) Drop-in: /etc/pag/repos/<nazwa>.conf (sortowane, stabilna kolejność)
1019 + if os.path.isdir(REPOS_DIR):
1020 + for drop in sorted(os.listdir(REPOS_DIR)):
1021 + if drop.endswith(".conf"):
1022 + _read_lines(os.path.join(REPOS_DIR, drop))
1023 +
1024 + # Dedupe po URL (zachowaj pierwszy wpis – może mieć fingerprint)
1025 + seen, unique = set(), []
1026 + for e in entries:
1027 + if e["url"] not in seen:
1028 + seen.add(e["url"])
1029 + unique.append(e)
1030 +
1031 + if not unique:
1032 + for url in DEFAULT_REPOS:
1033 + unique.append({"url": url, "fingerprint": None})
1034 + return unique
1035 +
1036 +
1037 +def get_repos():
1038 + return [e["url"] for e in _parse_repos_config()]
1039 +
1040 +
1041 +def _repo_pinned_fp(repo_url):
1042 + """Zwraca przypięty fingerprint klucza dla repo (z konfiguracji lub trust DB)."""
1043 + by_url = {e["url"]: e["fingerprint"] for e in _parse_repos_config()}
1044 + if by_url.get(repo_url):
1045 + return by_url[repo_url]
1046 + db = _load_trust_db()
1047 + fp = db.get(repo_url)
1048 + return fp.lower() if fp else None
1049 +
1050 +def _repo_cache_path(url):
1051 + return os.path.join(REPO_CACHE, url.replace("://","_").replace("/","_").replace(".","_") + ".json")
1052 +
1053 +def _repo_etag_path(url): return _repo_cache_path(url) + ".etag"
1054 +def _repo_ts_path(url): return _repo_cache_path(url) + ".ts"
1055 +
1056 +def fetch_repo_index(repo_url, force=False):
1057 + cp = _repo_cache_path(repo_url)
1058 + ep = _repo_etag_path(repo_url)
1059 + tp = _repo_ts_path(repo_url)
1060 +
1061 + if not force and os.path.exists(cp) and os.path.exists(tp):
1062 + try:
1063 + if time.time() - float(open(tp).read().strip()) < REPO_CACHE_TTL:
1064 + return json.load(open(cp)).get("packages",[])
1065 + except: pass
1066 +
1067 + headers = {"User-Agent": "pag/3.0"}
1068 + if os.path.exists(tp) and not force:
1069 + try:
1070 + lm = datetime.fromtimestamp(float(open(tp).read().strip()), tz=timezone.utc)
1071 + # Wymuś lokalizację C/POSIX dla nagłówków HTTP, aby unikać problemów z nazwami dni/miesięcy
1072 + try:
1073 + old_locale = locale.setlocale(locale.LC_TIME)
1074 + locale.setlocale(locale.LC_TIME, 'C')
1075 + headers["If-Modified-Since"] = lm.strftime("%a, %d %b %Y %H:%M:%S GMT")
1076 + locale.setlocale(locale.LC_TIME, old_locale)
1077 + except (locale.Error, ValueError):
1078 + # Jeśli ustawienie lokalizacji się nie powiedzie, użyj domyślnej
1079 + headers["If-Modified-Since"] = lm.strftime("%a, %d %b %Y %H:%M:%S GMT")
1080 + except: pass
1081 + if os.path.exists(ep) and not force:
1082 + try: headers["If-None-Match"] = open(ep).read().strip()
1083 + except: pass
1084 +
1085 + try:
1086 + req = Request(f"{repo_url}/repo.json", headers=headers)
1087 + with urlopen(req, timeout=30) as resp:
1088 + etag = resp.headers.get("ETag","")
1089 + if etag: open(ep,"w").write(etag)
1090 + raw = resp.read()
1091 + data = json.loads(raw.decode())
1092 + # Zapisuj SUROWE bajty (nie re-serializuj!) – podpis GPG jest nad
1093 + # oryginalnymi bajtami repo.json z serwera
1094 + with open(cp,"wb") as f: f.write(raw)
1095 + open(tp,"w").write(str(time.time()))
1096 + # SPRAWDŹ WYNIK WERYFIKACJI – nie ignoruj!
1097 + if not _verify_repo_sig(repo_url, cp):
1098 + return None # weryfikacja nie powiodła się, cache usunięty
1099 + return data.get("packages",[])
1100 + except HTTPError as e:
1101 + if e.code == 304:
1102 + open(tp,"w").write(str(time.time()))
1103 + if os.path.exists(cp):
1104 + return json.load(open(cp)).get("packages",[])
1105 + print(f" ⚠ HTTP {e.code} dla {repo_url}", file=sys.stderr)
1106 + return None
1107 + except Exception as e:
1108 + print(f" ⚠ Błąd pobierania indeksu {repo_url}: {e}", file=sys.stderr)
1109 + if os.path.exists(cp):
1110 + try: return json.load(open(cp)).get("packages",[])
1111 + except Exception: pass
1112 + return None
1113 +
1114 +def _verify_repo_sig(repo_url, cache_path) -> bool:
1115 + """Weryfikuje podpis GPG indeksu repozytorium i przypina fingerprint.
1116 +
1117 + FAIL-CLOSED: brak/nieprawidłowy podpis = False (chyba że PAG_INSECURE=1).
1118 + Zwraca True jeśli indeks jest zaufany, False jeśli należy go odrzucić.
1119 +
1120 + Model zaufania (TOFU + pinning):
1121 + - Pierwszy raz (brak przypiętego fingerprintu) → klucz jest importowany,
1122 + a fingerprint zapisywany w /etc/pag/trusted.json z JAWNYM ostrzeżeniem.
1123 + To świadomy kompromis wygody i bezpieczeństwa.
1124 + - Kolejne uruchomienia: fingerprint jest porównywany z przypiętym.
1125 + Zmiana klucza = ❌ SECURITY ERROR (fail-closed), wymagane ręczne:
1126 + pag key-trust <repo_url> (po weryfikacji nowego klucza)
1127 + """
1128 + insecure = os.environ.get("PAG_INSECURE", "") == "1"
1129 +
1130 + if not os.path.exists(GPG_HOME):
1131 + if insecure:
1132 + return True # brak GPG home – tryb insecure, akceptuj
1133 + print(f" ❌ {repo_url}: brak kluczy GPG – weryfikacja niemożliwa!")
1134 + print(f" Ustaw PAG_INSECURE=1 aby pominąć (niezalecane)")
1135 + os.remove(cache_path)
1136 + return False
1137 +
1138 + sig_path = cache_path + ".sig"
1139 + # Podpisy generowane jako .asc (armored) – próbuj .asc, potem .sig
1140 + sig_data = None
1141 + sig_ext = ""
1142 + for ext in (".asc", ".sig"):
1143 + try:
1144 + req = Request(f"{repo_url}/repo.json{ext}", headers={"User-Agent":"pag/3.0"})
1145 + with urlopen(req, timeout=15) as resp:
1146 + sig_data = resp.read()
1147 + sig_ext = ext
1148 + break
1149 + except Exception:
1150 + continue
1151 + if not sig_data:
1152 + if insecure:
1153 + return True # tryb insecure – akceptuj bez podpisu
1154 + print(f" ❌ {repo_url}: NIE MOŻNA POBRAĆ PODPISU repo.json.asc/.sig!")
1155 + print(f" Ustaw PAG_INSECURE=1 aby pominąć (niezalecane)")
1156 + os.remove(cache_path)
1157 + return False
1158 + sig_path = cache_path + sig_ext
1159 + with open(sig_path, "wb") as f:
1160 + f.write(sig_data)
1161 +
1162 + ok, fingerprint = _gpg_verify_fp(sig_path, cache_path)
1163 + if not ok:
1164 + # Automatyczny import klucza repo przy pierwszym uruchomieniu (TOFU,
1165 + # jak apt) – gdy w keyringu brakuje klucza (No public key).
1166 + res = _gpg_run("--verify", sig_path, cache_path,
1167 + capture_output=True, text=True, timeout=30)
1168 + _stderr = (res.stderr or "")
1169 + if "public key" in _stderr.lower() and "no public key" in _stderr.lower():
1170 + try:
1171 + with urlopen(Request(f"{repo_url}/paganos.asc", headers={"User-Agent": "pag/3.0"}), timeout=20) as r:
1172 + keydata = r.read()
1173 + with tempfile.NamedTemporaryFile(delete=False, suffix=".asc") as tmp:
1174 + tmp.write(keydata)
1175 + tmp.flush()
1176 + _gpg_run("--import", tmp.name, capture_output=True, timeout=30)
1177 + os.unlink(tmp.name)
1178 + print(f" 🔑 Importowano klucz repo z {repo_url}/paganos.asc")
1179 + ok, fingerprint = _gpg_verify_fp(sig_path, cache_path)
1180 + except Exception:
1181 + pass
1182 + if not ok:
1183 + if insecure:
1184 + print(f" ⚠ {repo_url}: nieprawidłowy podpis GPG (PAG_INSECURE – ignoruję)")
1185 + return True
1186 + os.remove(cache_path)
1187 + print(f" ❌ {repo_url}: NIEPRAWIDŁOWY PODPIS GPG indeksu repozytorium!")
1188 + return False
1189 +
1190 + # --- Wymuś przypięty fingerprint (TOFU + pinning) ---
1191 + pinned = _repo_pinned_fp(repo_url)
1192 + if pinned:
1193 + if not fingerprint:
1194 + if insecure:
1195 + print(f" ⚠ {repo_url}: nie można odczytać fingerprintu (PAG_INSECURE – ignoruję)")
1196 + return True
1197 + os.remove(cache_path)
1198 + print(f" ❌ [SECURITY ERROR] {repo_url}: nie można odczytać fingerprintu podpisu!")
1199 + print(f" Przypięty klucz: {pinned} – odrzucam indeks.")
1200 + return False
1201 + if fingerprint != pinned.upper():
1202 + if insecure:
1203 + print(f" ⚠ {repo_url}: ZMIENIONY KLUCZ PODPISU (PAG_INSECURE – ignoruję)")
1204 + return True
1205 + os.remove(cache_path)
1206 + print(f" ❌ [SECURITY ERROR] {repo_url}: Klucz podpisujący repo uległ zmianie!")
1207 + print(f" Oczekiwany: {pinned}")
1208 + print(f" Otrzymany: {fingerprint}")
1209 + print(f" Jeśli to celowa rotacja klucza: pag key-trust {repo_url}")
1210 + return False
1211 + return True
1212 +
1213 + if fingerprint:
1214 + # Brak przypiętego fingerprintu → TOFU: zapisz go w bazie zaufania.
1215 + db = _load_trust_db()
1216 + if db.get(repo_url) != fingerprint:
1217 + _save_trust_db({**db, repo_url: fingerprint})
1218 + print(f" 🔐 Przypięto fingerprint repo {repo_url}: {fingerprint}")
1219 + print(f" (TOFU – pierwsze zaufanie. Gdy klucz się zmieni, pag odmówi aktualizacji.)")
1220 + print(f" Aby uniknąć TOFU, dopisz fingerprint w /etc/pag/repos.conf.")
1221 + return True
1222 +
1223 +def fetch_all_packages(force=False):
1224 + all_pkgs = {}
1225 + for repo_url in get_repos():
1226 + pkgs = fetch_repo_index(repo_url, force)
1227 + if pkgs:
1228 + for pdata in pkgs:
1229 + name = pdata.get("name", pdata.get("filename","?").split("-")[0])
1230 + pkg = PackageInfo(pdata, repo_url)
1231 + if name not in all_pkgs or _version_newer(pkg.version, all_pkgs[name].version):
1232 + all_pkgs[name] = pkg
1233 + return all_pkgs
1234 +
1235 +# =============================================================================
1236 +# GPG
1237 +# =============================================================================
1238 +
1239 +def _verify_pkg_gpg(pkg_path, repo_url=None):
1240 + """Weryfikuje podpis GPG pakietu i (jeśli znamy repo) przypięty fingerprint.
1241 +
1242 + FAIL-CLOSED: brak podpisu = odrzucenie (chyba że PAG_INSECURE=1).
1243 + Zwraca (passed: bool, message: str).
1244 + """
1245 + insecure = os.environ.get("PAG_INSECURE", "") == "1"
1246 + sig_path = pkg_path + ".sig"
1247 + if not os.path.exists(sig_path) and os.path.exists(pkg_path + ".asc"):
1248 + sig_path = pkg_path + ".asc"
1249 +
1250 + if not os.path.exists(sig_path):
1251 + if insecure:
1252 + return True, "(no signature – PAG_INSECURE)"
1253 + return False, "BRAK PODPISU – pakiet odrzucony (ustaw PAG_INSECURE=1 aby pominąć)"
1254 +
1255 + ok, fp = _gpg_verify_fp(sig_path, pkg_path)
1256 + if not ok:
1257 + if insecure:
1258 + return True, "(invalid signature – PAG_INSECURE)"
1259 + return False, "NIEPRAWIDŁOWY PODPIS GPG"
1260 +
1261 + # Opcjonalnie: sprawdź, czy podpis pochodzi od klucza przypiętego dla repo.
1262 + if repo_url:
1263 + pinned = _repo_pinned_fp(repo_url)
1264 + if pinned and fp and fp != pinned.upper():
1265 + if insecure:
1266 + return True, "(pkg signer mismatch – PAG_INSECURE)"
1267 + return False, f"PAKIET PODPISANY INNYM KLUCZEM niż repo (oczekiwano {pinned})"
1268 +
1269 + return True, "GPG verified"
1270 +
1271 +def cmd_key_add(source):
1272 + ensure_dirs()
1273 + if source.startswith("http"):
1274 + try:
1275 + with urlopen(Request(source, headers={"User-Agent":"pag/3.0"}), timeout=30) as resp:
1276 + keydata = resp.read()
1277 + with tempfile.NamedTemporaryFile(delete=False, suffix=".gpg") as tmp:
1278 + tmp.write(keydata); tmp.flush()
1279 + _gpg_run("--import", tmp.name, capture_output=True, timeout=30)
1280 + os.unlink(tmp.name)
1281 + except Exception as e:
1282 + print(f"❌ Download error: {e}"); return 1
1283 + else:
1284 + _gpg_run("--import", source, capture_output=True, timeout=30)
1285 + print(f"✅ {_('key_imported')}")
1286 +
1287 +def cmd_key_list():
1288 + if not os.path.exists(GPG_HOME):
1289 + print(_("no_keys")); return
1290 + result = _gpg_run("--list-keys", "--keyid-format", "LONG",
1291 + capture_output=True, text=True, timeout=30)
1292 + print(result.stdout or _("no_keys"))
1293 +
1294 +def cmd_key_remove(key_id):
1295 + _gpg_run("--batch", "--yes", "--delete-key", key_id,
1296 + capture_output=True, timeout=30)
1297 + print(f"✅ {_('key_removed', key_id)}")
1298 +
1299 +def _repo_signer_fp(repo_url):
1300 + """Pobiera repo.json + podpis i zwraca fingerprint podpisującego (bez pinningu)."""
1301 + repo_url = repo_url.rstrip("/")
1302 + try:
1303 + with urlopen(Request(f"{repo_url}/repo.json", headers={"User-Agent":"pag/3.0"}), timeout=30) as r:
1304 + data = r.read()
1305 + except Exception:
1306 + return None
1307 + sig = None
1308 + sig_ext = ".asc"
1309 + for ext in (".asc", ".sig"):
1310 + try:
1311 + with urlopen(Request(f"{repo_url}/repo.json{ext}", headers={"User-Agent":"pag/3.0"}), timeout=20) as r:
1312 + sig = r.read()
1313 + sig_ext = ext
1314 + break
1315 + except Exception:
1316 + continue
1317 + if not sig:
1318 + return None
1319 + with tempfile.NamedTemporaryFile(delete=False, suffix=".json") as tf:
1320 + tf.write(data); tf.flush()
1321 + data_path = tf.name
1322 + sig_path = data_path + sig_ext
1323 + try:
1324 + with open(sig_path, "wb") as f:
1325 + f.write(sig)
1326 + ok, fp = _gpg_verify_fp(sig_path, data_path)
1327 + finally:
1328 + for p in (data_path, sig_path):
1329 + try: os.unlink(p)
1330 + except OSError: pass
1331 + return fp if ok else None
1332 +
1333 +
1334 +def cmd_key_trust(repo_url):
1335 + """Przypina fingerprint klucza podpisującego repo (koniec z TOFU dla tego repo)."""
1336 + repo_url = repo_url.rstrip("/")
1337 + print(f"🔐 Przypinam klucz repo {repo_url}...")
1338 + fp = _repo_signer_fp(repo_url)
1339 + if not fp:
1340 + print(" ❌ Nie można odczytać fingerprintu podpisu (brak/nieudany).")
1341 + print(" Upewnij się, że klucz repo jest w keyringu (pag key-add <url|file>).")
1342 + return 1
1343 + db = _load_trust_db()
1344 + _save_trust_db({**db, repo_url: fp})
1345 + print(f" ✅ Przypięto {fp} dla {repo_url}")
1346 + print(" Od teraz zmiana klucza zostanie zgłoszona jako SECURITY ERROR.")
1347 + return 0
1348 +
1349 +
1350 +def cmd_key_untrust(repo_url):
1351 + """Usuwa przypięcie fingerprintu dla repo (wraca do TOFU)."""
1352 + repo_url = repo_url.rstrip("/")
1353 + db = _load_trust_db()
1354 + if repo_url not in db:
1355 + print(f" ℹ {repo_url} nie ma przypiętego fingerprintu.")
1356 + return 0
1357 + del db[repo_url]
1358 + _save_trust_db(db)
1359 + print(f" ✅ Usunięto przypięcie dla {repo_url}.")
1360 + return 0
1361 +
1362 +
1363 +def cmd_key_trusted():
1364 + """Listuje przypięte fingerprinty repozytoriów."""
1365 + db = _load_trust_db()
1366 + if not db:
1367 + print(_("no_keys"))
1368 + return
1369 + for url, fp in sorted(db.items()):
1370 + print(f" {url}\n {fp}")
1371 +
1372 +# =============================================================================
1373 +# ATOMOWA INSTALACJA (STAGING)
1374 +# =============================================================================
1375 +
1376 +def _safe_rename(src: str, dst: str) -> bool:
1377 + """
1378 + Atomowe przeniesienie pliku. Jeśli src i dst są na różnych
1379 + systemach plików (EXDEV), kopiuje + usuwa źródło.
1380 + """
1381 + try:
1382 + os.rename(src, dst)
1383 + return True
1384 + except OSError as e:
1385 + if e.errno == 18: # EXDEV – cross-device link
1386 + shutil.copy2(src, dst)
1387 + os.remove(src)
1388 + return True
1389 + raise
1390 +
1391 +
1392 +def _install_file(src: str, rel: str, data_staging: str, sums: dict,
1393 + staging: str, journal: list, installed_files: list,
1394 + deploy_dir: str = "", backup_dir: str = "",
1395 + backup_journal: Optional[list] = None) -> bool:
1396 + """
1397 + Instaluje pojedynczy plik (zwykły lub symlink).
1398 + Obsługuje: cross-device rename, symlinki, weryfikację SHA256.
1399 +
1400 + Jeśli deploy_dir jest podany (tryb immutable), pliki systemowe trafiają
1401 + do deploymentu, a współdzielone (/var, /etc, ...) bezpośrednio do /.
1402 +
1403 + Jeśli backup_dir jest podany, a pod dst istnieje już plik (upgrade/reinstall),
1404 + stara wersja jest przenoszona do backup_dir, by rollback mógł ją przywrócić.
1405 + """
1406 + # W trybie immutable: pliki współdzielone idą do /, reszta do deploymentu
1407 + if deploy_dir and _is_shared_path("/" + rel):
1408 + dst_root = PAG_ROOT
1409 + elif deploy_dir:
1410 + dst_root = deploy_dir
1411 + else:
1412 + dst_root = PAG_ROOT
1413 +
1414 + dst = os.path.join(dst_root, rel)
1415 +
1416 + # --- SYMLINK ---
1417 + if os.path.islink(src):
1418 + link_target = os.readlink(src)
1419 + # Weryfikuj sums.json dla symlinka (hash ścieżki docelowej)
1420 + expected = sums.get("/" + rel, "")
1421 + if expected:
1422 + link_hash = hashlib.sha256(link_target.encode()).hexdigest()
1423 + if expected and link_hash != expected:
1424 + return False
1425 +
1426 + os.makedirs(os.path.dirname(dst), exist_ok=True)
1427 + # Backup istniejącego symlinka (upgrade) – dla poprawnego rollbacku
1428 + if backup_dir and backup_journal is not None and os.path.lexists(dst):
1429 + try:
1430 + backup_path = os.path.join(backup_dir, rel)
1431 + os.makedirs(os.path.dirname(backup_path), exist_ok=True)
1432 + os.replace(dst, backup_path)
1433 + backup_journal.append((backup_path, "/" + rel))
1434 + journal.append(("backup", backup_path, dst))
1435 + except OSError:
1436 + pass
1437 + # Jeśli docelowy symlink już istnieje, usuń go
1438 + if os.path.islink(dst) or os.path.exists(dst):
1439 + os.remove(dst)
1440 + os.symlink(link_target, dst)
1441 + journal.append(("symlink", "", dst))
1442 + installed_files.append({
1443 + "path": "/" + rel,
1444 + "sha256": hashlib.sha256(link_target.encode()).hexdigest(),
1445 + "size": len(link_target),
1446 + "is_symlink": True,
1447 + "symlink_target": link_target,
1448 + })
1449 + return True
1450 +
1451 + # --- ZWYKŁY PLIK ---
1452 + # Oblicz SHA256
1453 + try:
1454 + file_sha = _sha256_file(src)
1455 + except Exception:
1456 + file_sha = ""
1457 +
1458 + # Weryfikuj sums.json
1459 + expected = sums.get("/" + rel, "")
1460 + if expected and file_sha and file_sha != expected:
1461 + return False
1462 +
1463 + # Utwórz katalog docelowy
1464 + os.makedirs(os.path.dirname(dst), exist_ok=True)
1465 +
1466 + # Backup istniejącego pliku (upgrade) – dla poprawnego rollbacku
1467 + if backup_dir and backup_journal is not None and os.path.lexists(dst):
1468 + try:
1469 + backup_path = os.path.join(backup_dir, rel)
1470 + os.makedirs(os.path.dirname(backup_path), exist_ok=True)
1471 + os.replace(dst, backup_path)
1472 + backup_journal.append((backup_path, "/" + rel))
1473 + journal.append(("backup", backup_path, dst))
1474 + except OSError:
1475 + pass
1476 +
1477 + # Atomowe przeniesienie (z fallbackiem dla cross-device).
1478 + # Zachowuje bity uprawnień (SUID/SGID/sticky) – NIE używamy filter='data'.
1479 + _safe_rename(src, dst)
1480 +
1481 + # Wymuś właściciela root:root. UWAGA: os.chown() NIE czyści bitów SUID/SGID.
1482 + try:
1483 + os.chown(dst, 0, 0)
1484 + except (OSError, PermissionError):
1485 + # Na niektórych systemach plików (tmpfs, fat) chown może się nie powieść
1486 + pass
1487 +
1488 + journal.append(("file", src, dst))
1489 + installed_files.append({
1490 + "path": "/" + rel,
1491 + "sha256": file_sha,
1492 + "size": os.path.getsize(dst),
1493 + "is_symlink": False,
1494 + })
1495 + return True
1496 +
1497 +
1498 +def _atomic_install(pkg_path: str, pkg: PackageInfo, deploy_dir: str = "",
1499 + backup_dir: str = "") -> Tuple[bool, List[dict], List[Tuple[str, str]]]:
1500 + """
1501 + Rozpakowuje do staging area, potem atomowo przenosi pliki.
1502 + Jeśli deploy_dir podany – instaluje do deploymentu (tryb immutable).
1503 + Zwraca (success, [lista plików z SHA256], [(backup_path, dst), ...]).
1504 + """
1505 + staging = tempfile.mkdtemp(dir=STAGING_DIR, prefix=f".staging-{pkg.name}-")
1506 + journal = []
1507 + installed_files = []
1508 + backup_journal: List[Tuple[str, str]] = []
1509 +
1510 + try:
1511 + # Rozpakuj .pkg.tar.xz → staging (bezpieczne – ochrona Directory Traversal)
1512 + with tarfile.open(pkg_path, "r:xz") as tf:
1513 + _safe_extractall(tf, staging)
1514 +
1515 + data_tar = os.path.join(staging, "data.tar.xz")
1516 + if not os.path.exists(data_tar):
1517 + shutil.rmtree(staging, ignore_errors=True)
1518 + return False, [], backup_journal
1519 +
1520 + # Rozpakuj data.tar.xz → staging/data (bezpieczne – ochrona Directory Traversal)
1521 + data_staging = os.path.join(staging, "data")
1522 + os.makedirs(data_staging, exist_ok=True)
1523 + with tarfile.open(data_tar, "r:xz") as tf:
1524 + _safe_extractall(tf, data_staging)
1525 +
1526 + # Wczytaj sums.json
1527 + sums_path = os.path.join(data_staging, "sums.json")
1528 + sums = json.load(open(sums_path)) if os.path.exists(sums_path) else {}
1529 +
1530 + # Hook pre-install (przed przeniesieniem plików do systemu)
1531 + _run_hook(os.path.join(staging, "hooks"), "pre-install", pkg)
1532 +
1533 + # Przenieś pliki: staging/data/* → /
1534 + for root, dirs, files in os.walk(data_staging):
1535 + for fname in files:
1536 + if fname == "sums.json":
1537 + continue
1538 + src = os.path.join(root, fname)
1539 + rel = os.path.relpath(src, data_staging)
1540 +
1541 + ok = _install_file(src, rel, data_staging, sums,
1542 + staging, journal, installed_files, deploy_dir,
1543 + backup_dir, backup_journal)
1544 + if not ok:
1545 + # Cofnij wszystkie operacje
1546 + _rollback_journal(journal, staging)
1547 + return False, [], backup_journal
1548 +
1549 + # Uruchom hooki post-install
1550 + hooks_dir = os.path.join(staging, "hooks")
1551 + _run_hook(hooks_dir, "post-install", pkg)
1552 +
1553 + # Zachowaj hooki na wypadek usunięcia pakietu (pre/post-remove)
1554 + try:
1555 + if os.path.isdir(hooks_dir):
1556 + persisted = os.path.join(PAG_DB, "hooks", pkg.name)
1557 + shutil.rmtree(persisted, ignore_errors=True)
1558 + shutil.copytree(hooks_dir, persisted)
1559 + except Exception:
1560 + pass
1561 +
1562 + # Zapisz do SQLite
1563 + _db_record_files(pkg.name, installed_files)
1564 +
1565 + shutil.rmtree(staging, ignore_errors=True)
1566 + return True, installed_files, backup_journal
1567 +
1568 + except Exception as e:
1569 + _rollback_journal(journal, staging)
1570 + return False, [], backup_journal
1571 +
1572 +
1573 +def _refresh_dynamic_linker_cache(deploy_dir: str = "") -> bool:
1574 + """Odświeża cache ld.so po udanej instalacji pakietów."""
1575 + ldconfig = shutil.which("ldconfig")
1576 + if not ldconfig:
1577 + print(" ⚠ Nie znaleziono ldconfig — cache linkera nie został odświeżony.",
1578 + file=sys.stderr)
1579 + return False
1580 +
1581 + target_root = deploy_dir or PAG_ROOT
1582 + command = [ldconfig]
1583 + if target_root != "/":
1584 + command.extend(["-r", target_root])
1585 +
1586 + try:
1587 + subprocess.run(command, check=True, timeout=60,
1588 + stdout=subprocess.DEVNULL, stderr=subprocess.PIPE,
1589 + text=True)
1590 + return True
1591 + except (OSError, subprocess.CalledProcessError, subprocess.TimeoutExpired) as exc:
1592 + detail = getattr(exc, "stderr", None) or str(exc)
1593 + print(f" ⚠ Nie udało się odświeżyć cache'a ld.so: {detail.strip()}",
1594 + file=sys.stderr)
1595 + return False
1596 +
1597 +
1598 +def _rollback_journal(journal: list, staging_path: str):
1599 + """Cofa wszystkie operacje z journala (odwrotna kolejność)."""
1600 + for entry in reversed(journal):
1601 + op = entry[0]
1602 + if op == "file":
1603 + _, src, dst = entry
1604 + try:
1605 + if os.path.exists(dst) or os.path.islink(dst):
1606 + _safe_rename(dst, src)
1607 + except Exception:
1608 + pass
1609 + elif op == "symlink":
1610 + _, _, dst = entry
1611 + try:
1612 + if os.path.islink(dst) or os.path.exists(dst):
1613 + os.remove(dst)
1614 + except Exception:
1615 + pass
1616 + elif op == "backup":
1617 + # Przywróć starą wersję pliku z backupu (upgrade)
1618 + _, bpath, dst = entry
1619 + try:
1620 + if os.path.lexists(bpath):
1621 + os.replace(bpath, dst)
1622 + except Exception:
1623 + pass
1624 + shutil.rmtree(staging_path, ignore_errors=True)
1625 +
1626 +# =============================================================================
1627 +# BEZPIECZNE USUWANIE
1628 +# =============================================================================
1629 +
1630 +def _safe_remove_files(pkg_name: str, installed_db: dict) -> Tuple[int, List[str]]:
1631 + """
1632 + Usuwa pliki pakietu, ale tylko jeśli NIE są współdzielone z innym pakietem.
1633 + Zwraca (liczba usuniętych, [lista usuniętych ścieżek]).
1634 + """
1635 + pkg_files = _db_get_package_files(pkg_name)
1636 + removed = []
1637 + skipped_shared = []
1638 +
1639 + for fpath in pkg_files:
1640 + owners = _db_get_file_owners(fpath)
1641 + # Sprawdź czy inny ZAINSTALOWANY pakiet też jest właścicielem
1642 + other_owners = [o for o in owners if o != pkg_name and o in installed_db]
1643 +
1644 + if other_owners:
1645 + # Plik współdzielony – tylko usuń wpis w DB, nie kasuj pliku
1646 + skipped_shared.append(fpath)
1647 + continue
1648 +
1649 + full = os.path.join(PAG_ROOT, fpath.lstrip("/"))
1650 + if os.path.isfile(full) or os.path.islink(full):
1651 + os.remove(full)
1652 + removed.append(fpath)
1653 +
1654 + # Usuń puste katalogi (od najgłębszych)
1655 + dirs = set()
1656 + for fpath in removed + skipped_shared:
1657 + parent = os.path.dirname(fpath)
1658 + while parent and parent != "/":
1659 + dirs.add(parent)
1660 + parent = os.path.dirname(parent)
1661 +
1662 + for d in sorted(dirs, key=len, reverse=True):
1663 + full_d = os.path.join(PAG_ROOT, d.lstrip("/"))
1664 + if os.path.isdir(full_d):
1665 + try:
1666 + os.rmdir(full_d)
1667 + except OSError:
1668 + pass # nie jest pusty – OK
1669 +
1670 + # Usuń z SQLite
1671 + _db_remove_package_files(pkg_name)
1672 +
1673 + if skipped_shared:
1674 + print(f" ⚠ {len(skipped_shared)} plików współdzielonych zachowanych")
1675 +
1676 + return len(removed) + len(skipped_shared), removed
1677 +
1678 +
1679 +def _remove_stale_files(pkg_name: str, old_files: List[str], new_paths: List[str],
1680 + installed_db: dict, deploy_dir: str = "",
1681 + backup_dir: str = "", backup_journal: Optional[list] = None) -> Tuple[int, List[str]]:
1682 + """
1683 + Po upgrade usuwa pliki starej wersji, których nie ma w nowej.
1684 +
1685 + - Pliki współdzielone z innym zainstalowanym pakietem są ZACHOWYWANE
1686 + (usuwany jest tylko wpis z bazy `files` dla tego pakietu).
1687 + - Sprząta puste katalogi i wpisy SQLite starej wersji.
1688 + Zwraca (liczba usuniętych, [usunięte ścieżki]).
1689 + """
1690 + new_set = set(new_paths)
1691 + stale = [f for f in old_files if f not in new_set]
1692 + if not stale:
1693 + return 0, []
1694 +
1695 + root = deploy_dir or PAG_ROOT
1696 + removed = []
1697 + skipped = 0
1698 + for fpath in stale:
1699 + owners = _db_get_file_owners(fpath)
1700 + other_owners = [o for o in owners if o != pkg_name and o in installed_db]
1701 + if other_owners:
1702 + # Współdzielony z innym pakietem – tylko usuń wpis z DB dla tego pakietu
1703 + skipped += 1
1704 + else:
1705 + full = os.path.join(root, fpath.lstrip("/"))
1706 + if os.path.isfile(full) or os.path.islink(full):
1707 + try:
1708 + if backup_dir and backup_journal is not None:
1709 + backup_path = os.path.join(backup_dir, fpath.lstrip("/"))
1710 + os.makedirs(os.path.dirname(backup_path), exist_ok=True)
1711 + os.replace(full, backup_path) # przenieś do backupu (rollback)
1712 + backup_journal.append((backup_path, fpath))
1713 + else:
1714 + os.remove(full)
1715 + removed.append(fpath)
1716 + except OSError:
1717 + pass
1718 + # Usuń wpis `files` dla tego pakietu (stara wersja już go nie zawiera)
1719 + with _db_session() as db:
1720 + db.execute("DELETE FROM files WHERE package=? AND path=?", (pkg_name, fpath))
1721 +
1722 + # Usuń puste katalogi (od najgłębszych)
1723 + dirs = set()
1724 + for fpath in removed:
1725 + parent = os.path.dirname(fpath)
1726 + while parent and parent != "/":
1727 + dirs.add(parent)
1728 + parent = os.path.dirname(parent)
1729 + for d in sorted(dirs, key=len, reverse=True):
1730 + full_d = os.path.join(root, d.lstrip("/"))
1731 + if os.path.isdir(full_d):
1732 + try:
1733 + os.rmdir(full_d)
1734 + except OSError:
1735 + pass # nie jest pusty – OK
1736 +
1737 + if removed:
1738 + print(f" 🧹 Usunięto {len(removed)} nieaktualnych plików ({pkg_name})")
1739 + if skipped:
1740 + print(f" ⚠ {skipped} plików współdzielonych zachowanych")
1741 +
1742 + return len(removed), removed
1743 +
1744 +
1745 +def _new_upgrade_backup_root() -> str:
1746 + """Tworzy katalog na backupy starych wersji dla bieżącej transakcji upgrade."""
1747 + txn = datetime.now().strftime("%Y%m%dT%H%M%S") + "-" + str(os.getpid())
1748 + root = os.path.join(STAGING_DIR, "backups", txn)
1749 + os.makedirs(root, exist_ok=True)
1750 + return root
1751 +
1752 +
1753 +def _purge_old_backups(keep_root: str = ""):
1754 + """Usuwa backupy starszych transakcji (zostawia bieżący – dla `pag rollback`)."""
1755 + base = os.path.join(STAGING_DIR, "backups")
1756 + if not os.path.isdir(base):
1757 + return
1758 + for entry in os.listdir(base):
1759 + p = os.path.join(base, entry)
1760 + if p != keep_root and os.path.isdir(p):
1761 + shutil.rmtree(p, ignore_errors=True)
1762 +
1763 +# =============================================================================
1764 +# HOOKI
1765 +# =============================================================================
1766 +# Hooki uruchamiają dowolny plik z pakietu jako root — to naturalna cecha
1767 +# menedżera pakietów (apt/pacman też tak mają), dlatego MUSISZ ufać repozytorium.
1768 +# Aby ograniczyć ryzyko:
1769 +# - hook dostaje minimalne, "czyste" środowisko (bez LD_PRELOAD, BASH_ENV itp.)
1770 +# - hooki można wyłączyć (PAG_NO_HOOKS=1) i ustawić timeout (PAG_HOOK_TIMEOUT)
1771 +# - każde uruchomienie jest logowane do /var/log/pag/audit.log
1772 +# - hook ma wersjonowane API (PKG_HOOK_API)
1773 +# =============================================================================
1774 +
1775 +# Lista wykonanych hooków — trafia do wpisu transakcji (informacja w rejestrze).
1776 +_HOOKS_RUN: List[str] = []
1777 +
1778 +
1779 +def _hook_env(pkg: PackageInfo, hook_name: str) -> dict:
1780 + """Buduje minimalne środowisko dla hooka (bez niebezpiecznych zmiennych)."""
1781 + return {
1782 + "PATH": "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin",
1783 + "HOME": "/root",
1784 + "LANG": "C.UTF-8",
1785 + "LC_ALL": "C.UTF-8",
1786 + "PKG_NAME": pkg.name,
1787 + "PKG_VERSION": pkg.version,
1788 + "PKG_ACTION": hook_name,
1789 + "PKG_HOOK_API": HOOK_API_VERSION,
1790 + }
1791 +
1792 +
1793 +def _hook_timeout() -> int:
1794 + try:
1795 + return max(1, int(os.environ.get("PAG_HOOK_TIMEOUT", "60")))
1796 + except Exception:
1797 + return 60
1798 +
1799 +
1800 +def _run_hook(hooks_dir: str, hook_name: str, pkg: PackageInfo) -> bool:
1801 + """Uruchamia skrypt hooka jeśli istnieje.
1802 +
1803 + Zwraca True jeśli hook został WYKONANY (istniał i uruchomiono go), False w
1804 + pozostałych przypadkach (brak pliku, wyłączone hooki, błąd). Obsługuje
1805 + ograniczone środowisko, timeout, logowanie do audytu i rejestr w transakcji.
1806 + """
1807 + hook_path = os.path.join(hooks_dir, hook_name)
1808 + if not os.path.exists(hook_path):
1809 + return False
1810 +
1811 + if os.environ.get("PAG_NO_HOOKS", "") == "1":
1812 + print(f" ⚠ Hook pominięty (PAG_NO_HOOKS=1): {hook_name} dla {pkg.name}")
1813 + _audit(f"hook SKIP {hook_name} {pkg.name}-{pkg.version} (PAG_NO_HOOKS=1)")
1814 + return False
1815 +
1816 + os.chmod(hook_path, 0o755)
1817 + env = _hook_env(pkg, hook_name)
1818 + tag = f"{hook_name} {pkg.name}-{pkg.version}"
1819 + try:
1820 + result = subprocess.run([hook_path], env=env, timeout=_hook_timeout(),
1821 + check=False, capture_output=True, text=True,
1822 + cwd="/")
1823 + _HOOKS_RUN.append(tag)
1824 + if result.returncode != 0:
1825 + print(f" ⚠ Hook {hook_name} dla {pkg.name} zakończony z kodem {result.returncode}")
1826 + if result.stderr:
1827 + print(f" {result.stderr.strip()[-200:]}")
1828 + _audit(f"hook FAIL {tag} rc={result.returncode}")
1829 + else:
1830 + _audit(f"hook OK {tag}")
1831 + return True
1832 + except subprocess.TimeoutExpired:
1833 + print(f" ⚠ Hook {hook_name} dla {pkg.name} przekroczył timeout ({_hook_timeout()}s)")
1834 + _audit(f"hook TIMEOUT {tag}")
1835 + return False
1836 + except Exception as e:
1837 + print(f" ⚠ Hook {hook_name} dla {pkg.name}: {e}")
1838 + _audit(f"hook ERROR {tag}: {e}")
1839 + return False
1840 +
1841 +# =============================================================================
1842 +# TRANSAKCJE I ROLLBACK
1843 +# =============================================================================
1844 +
1845 +def _record_transaction(action, packages, success, snapshot, file_journal=None, hooks=None,
1846 + upgrade_backups=None, upgrade_backup_root=""):
1847 + history = load_json(HISTORY_FILE) if os.path.exists(HISTORY_FILE) else []
1848 + # Rejestr wykonanych hooków – informacja o tym, że uruchomiono kod pakietu
1849 + # jako root. Trafia do historii, by dało się później sprawdzić, co się działo.
1850 + executed_hooks = list(_HOOKS_RUN) if hooks is None else hooks
1851 + _HOOKS_RUN.clear()
1852 + entry = {
1853 + "action": action, "packages": packages, "success": success,
1854 + "timestamp": datetime.now().isoformat(),
1855 + "snapshot": snapshot,
1856 + "file_journal": file_journal, # lista plików do wycofania
1857 + "hooks": executed_hooks, # wykonane hooki (pre/post-install/remove)
1858 + }
1859 + if upgrade_backups:
1860 + entry["upgrade_backups"] = upgrade_backups # {dst: backup_path}
1861 + entry["upgrade_backup_root"] = upgrade_backup_root
1862 + history.append(entry)
1863 + if len(history) > 50:
1864 + history = history[-50:]
1865 + save_json(HISTORY_FILE, history)
1866 +
1867 +def cmd_history():
1868 + if not os.path.exists(HISTORY_FILE):
1869 + print(_("no_history")); return
1870 + history = load_json(HISTORY_FILE)
1871 + if not history:
1872 + print(_("no_history")); return
1873 + print(f"Ostatnie transakcje ({len(history)}):")
1874 + for i, e in enumerate(reversed(history), 1):
1875 + icon = "✅" if e["success"] else "❌"
1876 + pkgs = ", ".join(e["packages"][:5])
1877 + if len(e["packages"]) > 5: pkgs += f" (+{len(e['packages'])-5})"
1878 + print(f" {i}. {icon} {e['action']}: {pkgs}")
1879 + print(f" {e['timestamp']}")
1880 +
1881 +def cmd_rollback():
1882 + if not os.path.exists(HISTORY_FILE):
1883 + print(_("no_history")); return 1
1884 + history = load_json(HISTORY_FILE)
1885 + if not history:
1886 + print(_("no_history")); return 1
1887 +
1888 + last = None
1889 + for e in reversed(history):
1890 + if e["success"] and e.get("snapshot"):
1891 + last = e; break
1892 +
1893 + if not last:
1894 + print("❌ No snapshot to restore."); return 1
1895 +
1896 + print(f"⏪ Rolling back: {last['action']} ({last['timestamp']})")
1897 + print(f" Packages: {', '.join(last['packages'][:10])}")
1898 +
1899 + if os.environ.get("PAG_YES", "") == "1":
1900 + print(_("continue_q") + " t (--yes)")
1901 + else:
1902 + ans = input(_("continue_q")).strip().lower()
1903 + if ans and ans not in ("t","y"):
1904 + return 0
1905 +
1906 + # Przywróć installed.json
1907 + save_json(INSTALLED_DB, last["snapshot"])
1908 +
1909 + # Wycofaj fizyczne pliki (jeśli zapisano journal)
1910 + file_journal = last.get("file_journal", [])
1911 + upgrade_backups = last.get("upgrade_backups", {}) or {}
1912 + backup_root = last.get("upgrade_backup_root", "")
1913 +
1914 + # Przywróć stare wersje z backupów (upgrade) – nadpisane i usunięte stale pliki
1915 + for dst, bpath in upgrade_backups.items():
1916 + full = os.path.join(PAG_ROOT, dst.lstrip("/"))
1917 + if bpath and os.path.lexists(bpath):
1918 + try:
1919 + os.makedirs(os.path.dirname(full), exist_ok=True)
1920 + os.replace(bpath, full)
1921 + except OSError:
1922 + pass
1923 +
1924 + # Usuń nowe pliki (które nie miały poprzedniej wersji)
1925 + backed = set(upgrade_backups)
1926 + if file_journal:
1927 + for fpath in reversed(file_journal):
1928 + if fpath in backed:
1929 + continue
1930 + full = os.path.join(PAG_ROOT, fpath.lstrip("/"))
1931 + if os.path.exists(full) or os.path.islink(full):
1932 + os.remove(full)
1933 + print(f" {_('rollback_files', len(file_journal))}")
1934 +
1935 + # Sprzątanie pustych katalogów + katalogu backupów
1936 + dirs = set()
1937 + for fpath in file_journal:
1938 + parent = os.path.dirname(fpath)
1939 + while parent and parent != "/":
1940 + dirs.add(parent)
1941 + parent = os.path.dirname(parent)
1942 + for d in sorted(dirs, key=len, reverse=True):
1943 + full_d = os.path.join(PAG_ROOT, d.lstrip("/"))
1944 + if os.path.isdir(full_d):
1945 + try:
1946 + os.rmdir(full_d)
1947 + except OSError:
1948 + pass
1949 + if backup_root:
1950 + shutil.rmtree(backup_root, ignore_errors=True)
1951 +
1952 + print(f"✅ {_('rollback_restored')}")
1953 + _record_transaction("rollback", last["packages"], True, None)
1954 + return 0
1955 +
1956 +# =============================================================================
1957 +# INSTALACJA
1958 +# =============================================================================
1959 +
1960 +def _install_local_pkg_files(paths, install_succeeded):
1961 + """Instaluje lokalne pliki .pkg.tar.xz (bez repozytorium).
1962 + Zgodnie z _atomic_install każdy plik jest instalowany atomowo.
1963 + Zwraca (failed_count, installed_files)."""
1964 + failed = 0
1965 + all_files = []
1966 + for p in paths:
1967 + p = os.path.abspath(p)
1968 + if not os.path.isfile(p):
1969 + print(f" ❌ Nie znaleziono pakietu: {p}")
1970 + failed += 1
1971 + continue
1972 + try:
1973 + with tarfile.open(p, "r:xz") as tf:
1974 + meta = tf.extractfile("metadata.json")
1975 + if meta is None:
1976 + print(f" ❌ {p}: brak metadata.json")
1977 + failed += 1
1978 + continue
1979 + data = json.loads(meta.read())
1980 + except Exception as e:
1981 + print(f" ❌ {p}: nie udało się odczytać pakietu ({e})")
1982 + failed += 1
1983 + continue
1984 + pkg = PackageInfo(data, repo="local")
1985 + print(f" ↓ {pkg.name}-{pkg.version} (lokalny) ... ", end="", flush=True)
1986 + ok, files, _ = _atomic_install(p, pkg)
1987 + if ok:
1988 + install_succeeded(pkg, files)
1989 + all_files.extend(f["path"] for f in files)
1990 + print("✅")
1991 + else:
1992 + print("❌")
1993 + failed += 1
1994 + return failed, all_files
1995 +
1996 +
1997 +def cmd_install(package_names, as_dep=False, upgrade=False):
1998 + ensure_dirs()
1999 + installed_db = load_json(INSTALLED_DB)
2000 + world = load_world()
2001 + pinned = load_json(PINNED_FILE)
2002 +
2003 + # Obsługa lokalnych plików .pkg.tar.xz (zbudowanych przez pagbuild) –
2004 + # nie wymaga repozytorium ani GPG.
2005 + local_files = [p for p in package_names if p.endswith(PKG_EXT) or
2006 + (os.sep in p and os.path.isfile(os.path.abspath(p)))]
2007 + if local_files:
2008 + def _ok(pkg, files):
2009 + installed_db[pkg.name] = {
2010 + "version": pkg.version, "description": pkg.description,
2011 + "dependencies": pkg.dependencies, "size_bytes": pkg.size_bytes,
2012 + "sha256": pkg.sha256, "installed_at": datetime.now().isoformat(),
2013 + "repo": "local",
2014 + }
2015 + world.add(pkg.name)
2016 + failed_local, _fl = _install_local_pkg_files(local_files, _ok)
2017 + save_json(INSTALLED_DB, installed_db)
2018 + save_world(world)
2019 + if failed_local:
2020 + return 1
2021 + _refresh_dynamic_linker_cache()
2022 + package_names = [n for n in package_names if n not in
2023 + [os.path.abspath(x) for x in local_files] and
2024 + n not in local_files]
2025 + to_install = []
2026 + if not package_names:
2027 + return 0
2028 + # pozostałe argumenty to nazwy pakietów z repo – kontynuuj
2029 +
2030 + repo_pkgs = fetch_all_packages()
2031 +
2032 + if not repo_pkgs:
2033 + print(f"❌ {_('no_index')}"); return 1
2034 +
2035 + for name in list(package_names):
2036 + if name in pinned:
2037 + print(f"⚠ {name} {_('pinned_to')} {pinned[name]} – skipping")
2038 + package_names.remove(name)
2039 +
2040 + to_install, missing_deps = _resolve_deps(package_names, repo_pkgs, installed_db)
2041 +
2042 + # --- Tryb upgrade: pakiety już zainstalowane MUSZĄ zostać ponownie
2043 + # zainstalowane z nowszej wersji (zastąpienie w tej samej transakcji).
2044 + if upgrade:
2045 + upgrade_targets = [
2046 + name for name in package_names
2047 + if name in repo_pkgs
2048 + and name in installed_db
2049 + and _version_newer(
2050 + repo_pkgs[name].version,
2051 + installed_db[name].get("version", "0")
2052 + )
2053 + and name not in pinned
2054 + ]
2055 + for name in upgrade_targets:
2056 + if name not in to_install:
2057 + to_install.append(name)
2058 +
2059 + if not to_install and not missing_deps:
2060 + print(f"✅ {_('all_installed')}"); return 0
2061 +
2062 + # ── WERYFIKACJA ZALEŻNOŚCI ──────────────────────────────────────────
2063 + fatal_missing = _verify_dependencies(to_install, repo_pkgs, installed_db)
2064 +
2065 + if fatal_missing > 0:
2066 + print(f"❌ Nie można kontynuować – {fatal_missing} brakujących zależności.")
2067 + print(f" Zainstaluj brakujące pakiety lub dodaj repozytoria.")
2068 + return 1
2069 +
2070 + if not to_install:
2071 + print(f"✅ {_('all_installed')}"); return 0
2072 +
2073 + MAX_MB = MAX_PKG_SIZE // 1048576
2074 + for n in to_install:
2075 + if not _validate_pkg_name(n):
2076 + print(f" {_("sec_badname", name=n)}")
2077 + return 1
2078 + sz = repo_pkgs[n].size_bytes if n in repo_pkgs else 0
2079 + if sz > MAX_PKG_SIZE:
2080 + mb = sz // 1048576
2081 + print(f" {_("sec_toobig", size_mb=mb, max_mb=MAX_MB)}")
2082 + return 1
2083 + total_size = sum(repo_pkgs[n].size_bytes for n in to_install if n in repo_pkgs)
2084 + print(f"\n📦 {_('to_install', len(to_install), total_size/1048576)}")
2085 + for name in to_install:
2086 + p = repo_pkgs.get(name)
2087 + if p:
2088 + if name in installed_db:
2089 + marker = " [upgrade]" if upgrade else ""
2090 + else:
2091 + marker = f" [{_('new')}]"
2092 + print(f" {name}-{p.version}{marker}")
2093 +
2094 + if not as_dep and not upgrade:
2095 + if os.environ.get("PAG_YES", "") == "1":
2096 + print(_("continue_q") + " t (--yes)")
2097 + else:
2098 + ans = input(_("continue_q")).strip().lower()
2099 + if ans and ans not in ("t","y"):
2100 + print(_("cancelled")); return 0
2101 +
2102 + snapshot = json.loads(json.dumps(installed_db))
2103 + all_installed_files = []
2104 + failed = []
2105 + # Pary (pkg, stare_pliki, nowe_pliki) do usunięcia martwych plików po upgrade
2106 + stale_candidates = []
2107 + # Katalog backupów starych wersji (upgrade) – dla poprawnego rollbacku
2108 + backup_root = ""
2109 + all_backups: List[Tuple[str, str]] = [] # (backup_path, dst)
2110 + if upgrade and to_install:
2111 + backup_root = _new_upgrade_backup_root()
2112 +
2113 + # --- Dziennik transakcji (dla pełnej atomowości) ---
2114 + # Jeśli którykolwiek pakiet zawiedzie, cofamy WSZYSTKIE zainstalowane
2115 + # w tej transakcji przez _rollback_transaction().
2116 + transaction_journal: List[Tuple[str, str, str]] = [] # (op, src, dst)
2117 +
2118 + # --- Tryb immutable: utwórz nowy deployment ---
2119 + immutable = os.environ.get("PAG_IMMUTABLE", "") == "1"
2120 + deploy_dir = ""
2121 + deploy_id = ""
2122 + if immutable:
2123 + print(f"\n 🏗️ Tworzenie nowego deploymentu...")
2124 + deploy_dir, deploy_id = _create_deployment(to_install, "upgrade" if upgrade else "install")
2125 + target_root = deploy_dir
2126 + else:
2127 + target_root = ""
2128 +
2129 + # --- Faza 1: Równoległe pobieranie wszystkich pakietów ---
2130 + to_download = [repo_pkgs[name] for name in to_install if name in repo_pkgs]
2131 + if len(to_download) > 1:
2132 + print(f"\n ⏬ Pobieranie {len(to_download)} pakietów równolegle...")
2133 + downloaded = _download_packages_parallel(to_download)
2134 + else:
2135 + downloaded = {}
2136 +
2137 + # --- Faza 2: Instalacja z paskiem postępu ---
2138 + t0 = time.time()
2139 +
2140 + for name in to_install:
2141 + pkg = repo_pkgs.get(name)
2142 + if not pkg:
2143 + print(f" ❌ {name}: {_('not_found')}")
2144 + failed.append(name)
2145 + break
2146 +
2147 + # Pasek postępu na stderr (nie koliduje z download barem)
2148 + idx = len(all_installed_files) + 1
2149 + pct = (idx - 1) / len(to_install) * 100
2150 + fl = int(25 * pct / 100)
2151 + pbar = "█" * fl + "░" * (25 - fl)
2152 + elapsed = time.time() - t0
2153 + if idx > 1 and elapsed > 0:
2154 + avg = elapsed / (idx - 1)
2155 + remaining = avg * (len(to_install) - idx + 1)
2156 + if remaining < 60:
2157 + eta_s = f" ~{remaining:.0f}s"
2158 + else:
2159 + eta_s = f" ~{remaining/60:.1f}m"
2160 + else:
2161 + eta_s = ""
2162 + status = f" [{pbar}] {idx}/{len(to_install)} ({pct:.0f}%){eta_s}"
2163 + print(status, file=sys.stderr, flush=True)
2164 +
2165 + print(f" ↓ {name}-{pkg.version} ...", end=" ", flush=True)
2166 +
2167 + # Pobierz (z cache fazy 1 lub bezpośrednio)
2168 + pkg_path = downloaded.get(name) if name in downloaded else _download_pkg(pkg)
2169 + if not pkg_path:
2170 + print(f"❌ {_('download_fail')}")
2171 + failed.append(name)
2172 + break # przerwij transakcję
2173 +
2174 + # GPG
2175 + gpg_ok, gpg_msg = _verify_pkg_gpg(pkg_path, repo_url=pkg.repo_url)
2176 + if not gpg_ok:
2177 + print(f"❌ {_('gpg_fail')}: {gpg_msg[:60]}")
2178 + failed.append(name)
2179 + break # PRZERWIJ – niezaufany pakiet
2180 +
2181 + # SHA256 całego pakietu
2182 + if pkg.sha256 and _sha256_file(pkg_path) != pkg.sha256:
2183 + print(f"❌ {_('sha256_mismatch')}")
2184 + failed.append(name)
2185 + break # PRZERWIJ – uszkodzony pakiet
2186 +
2187 + # Przed instalacją zapamiętaj pliki starej wersji (potrzebne w upgrade)
2188 + old_files = _db_get_package_files(name) if name in installed_db else []
2189 +
2190 + # Atomowa instalacja (w upgrade backupuje nadpisywane pliki)
2191 + ok, files, backup_j = _atomic_install(pkg_path, pkg, deploy_dir,
2192 + backup_dir=backup_root)
2193 + if ok:
2194 + installed_db[name] = {
2195 + "version": pkg.version, "description": pkg.description,
2196 + "dependencies": pkg.dependencies, "size_bytes": pkg.size_bytes,
2197 + "sha256": pkg.sha256, "installed_at": datetime.now().isoformat(),
2198 + "repo": pkg.repo_url,
2199 + }
2200 + if not as_dep and name in package_names:
2201 + world.add(name)
2202 + print("✅")
2203 + all_installed_files.extend(f["path"] for f in files)
2204 + all_backups.extend(backup_j)
2205 +
2206 + # Upgrade: zapamiętaj stare pliki, by po sukcesie usunąć te,
2207 + # których nie ma już w nowej wersji.
2208 + if upgrade and old_files:
2209 + stale_candidates.append((name, old_files, [f["path"] for f in files]))
2210 +
2211 + # Po instalacji kernela – przebuduj initramfs
2212 + if _is_kernel_package(name):
2213 + _rebuild_initramfs(deploy_dir)
2214 + else:
2215 + print("❌")
2216 + failed.append(name)
2217 + break # PRZERWIJ – błąd instalacji
2218 +
2219 + # --- Rollback całej transakcji jeśli cokolwiek zawiodło ---
2220 + if failed:
2221 + print(f"\n ↩ Cofanie transakcji ({len(failed)} błędów)...")
2222 + _rollback_transaction(installed_db, snapshot, all_installed_files,
2223 + deploy_dir, immutable, backups=all_backups)
2224 + if backup_root:
2225 + shutil.rmtree(backup_root, ignore_errors=True)
2226 + _record_transaction("upgrade" if upgrade else "install", to_install, False, snapshot)
2227 + return 1
2228 +
2229 + # --- Po sukcesie transakcji: usuń nieaktualne pliki starych wersji (upgrade).
2230 + # Usunięte pliki trafiają do backupu, aby `pag rollback` mógł je przywrócić.
2231 + for pkg_name, old_files, new_paths in stale_candidates:
2232 + _remove_stale_files(pkg_name, old_files, new_paths, installed_db, deploy_dir,
2233 + backup_root, all_backups)
2234 +
2235 + save_json(INSTALLED_DB, installed_db)
2236 + save_world(world)
2237 + _record_transaction("upgrade" if upgrade else "install", to_install, True, snapshot,
2238 + file_journal=all_installed_files,
2239 + upgrade_backups={dst: bp for bp, dst in all_backups} if all_backups else None,
2240 + upgrade_backup_root=backup_root)
2241 +
2242 + # Zachowaj backupy bieżącej transakcji (dla `pag rollback`), usuń starsze.
2243 + if backup_root:
2244 + _purge_old_backups(keep_root=backup_root)
2245 +
2246 + # --- Tryb immutable: przełącz na nowy deployment ---
2247 + if immutable and not failed:
2248 + _refresh_dynamic_linker_cache(deploy_dir)
2249 + print(f"\n 🔄 Przełączanie na deployment {deploy_id}...")
2250 + _switch_deployment(deploy_dir)
2251 + print(f" ✅ Aktywny deployment: {deploy_id}")
2252 + _update_grub_config()
2253 + cmd_deploy_cleanup(keep=5) # Zostawia 5 najnowszych deploymentów
2254 + print(f" 💡 Restart wymagany do przeładowania systemu.")
2255 + else:
2256 + _refresh_dynamic_linker_cache()
2257 +
2258 + print(f"\n✅ {_('installed', len(to_install))}")
2259 + return 0
2260 +
2261 +
2262 +def _rollback_transaction(installed_db: dict, snapshot: dict,
2263 + installed_files: List[str],
2264 + deploy_dir: str, is_immutable: bool,
2265 + backups: Optional[List[Tuple[str, str]]] = None):
2266 + """
2267 + Cofa WSZYSTKIE pakiety zainstalowane w bieżącej transakcji.
2268 + Przywraca installed_db do stanu sprzed transakcji.
2269 + Usuwa fizyczne pliki z systemu (lub deploymentu w trybie immutable).
2270 + Jeśli podano `backups` (upgrade) – przywraca stare wersje nadpisanych plików.
2271 + """
2272 + # Przywróć installed_db
2273 + installed_db.clear()
2274 + installed_db.update(snapshot)
2275 +
2276 + root = deploy_dir if is_immutable else PAG_ROOT
2277 + backup_map = {dst: src for src, dst in (backups or [])}
2278 +
2279 + # Przywróć stare wersje z backupów (upgrade)
2280 + for dst, bpath in backup_map.items():
2281 + full = os.path.join(root, dst.lstrip("/"))
2282 + if os.path.lexists(bpath):
2283 + try:
2284 + os.makedirs(os.path.dirname(full), exist_ok=True)
2285 + os.replace(bpath, full)
2286 + except OSError:
2287 + pass
2288 +
2289 + # Usuń nowe pliki (które nie miały poprzedniej wersji)
2290 + for fpath in reversed(installed_files):
2291 + if fpath in backup_map:
2292 + continue
2293 + full = os.path.join(root, fpath.lstrip("/"))
2294 + if os.path.isfile(full) or os.path.islink(full):
2295 + try:
2296 + os.remove(full)
2297 + except OSError:
2298 + pass
2299 +
2300 + # Wyczyść puste katalogi
2301 + dirs_to_check = set()
2302 + for fpath in installed_files:
2303 + parent = os.path.dirname(fpath)
2304 + while parent and parent != "/":
2305 + dirs_to_check.add(parent)
2306 + parent = os.path.dirname(parent)
2307 + for d in sorted(dirs_to_check, key=len, reverse=True):
2308 + full_d = os.path.join(root, d.lstrip("/"))
2309 + if os.path.isdir(full_d):
2310 + try:
2311 + os.rmdir(full_d)
2312 + except OSError:
2313 + pass
2314 +
2315 + # W trybie immutable: usuń nieudany deployment
2316 + if is_immutable and deploy_dir:
2317 + shutil.rmtree(deploy_dir, ignore_errors=True)
2318 +
2319 + save_json(INSTALLED_DB, snapshot)
2320 +
2321 +
2322 +# =============================================================================
2323 +# USUWANIE
2324 +# =============================================================================
2325 +
2326 +def cmd_remove(package_names):
2327 + installed_db = load_json(INSTALLED_DB)
2328 + world = load_world()
2329 + snapshot = json.loads(json.dumps(installed_db))
2330 + removed = []
2331 +
2332 + total = len(package_names)
2333 + for i, name in enumerate(package_names, 1):
2334 + if name not in installed_db:
2335 + print(f" ⚠ {name}: not installed"); continue
2336 +
2337 + # Pasek postępu
2338 + pct = (i - 1) / total * 100
2339 + filled = int(25 * pct / 100)
2340 + print(f" 🗑 [{'█' * filled + '░' * (25 - filled)}] {i}/{total} ({pct:.0f}%) ", end="\r", file=sys.stderr, flush=True)
2341 +
2342 + print(f"🗑 {name}-{installed_db[name]['version']} ...", end=" ", flush=True)
2343 +
2344 + # Pre-remove hook (jeśli dostępny w staging)
2345 + _run_hook_for_installed(name, "pre-remove")
2346 +
2347 + count, _ = _safe_remove_files(name, installed_db)
2348 + del installed_db[name]
2349 + world.discard(name)
2350 + removed.append(name)
2351 + print(f"✅ ({count} files)")
2352 +
2353 + # Post-remove hook + sprzątanie zapisanych hooków
2354 + _run_hook_for_installed(name, "post-remove")
2355 + shutil.rmtree(os.path.join(PAG_DB, "hooks", name), ignore_errors=True)
2356 +
2357 + save_json(INSTALLED_DB, installed_db)
2358 + save_world(world)
2359 + _record_transaction("remove", removed, True, snapshot)
2360 +
2361 + print(file=sys.stderr) # wyczyść linię paska postępu
2362 +
2363 + if not removed: return 0
2364 + print(f"\n✅ Removed {len(removed)}.")
2365 +
2366 + orphans = _find_orphans(installed_db, world)
2367 + if orphans:
2368 + print(f"\n💡 {_('orphans_found', len(orphans), ', '.join(sorted(orphans)[:10]))}")
2369 + print(" 'pag remove-orphans' to clean up.")
2370 + return 0
2371 +
2372 +def _run_hook_for_installed(pkg_name, hook_name):
2373 + """Próbuje uruchomić hook z katalogu pakietu (jeśli został zapisany)."""
2374 + hook_dir = os.path.join(PAG_DB, "hooks", pkg_name)
2375 + if os.path.isdir(hook_dir):
2376 + ver = load_json(INSTALLED_DB).get(pkg_name, {}).get("version", "")
2377 + _run_hook(hook_dir, hook_name, PackageInfo({"name": pkg_name, "version": ver}))
2378 +
2379 +# =============================================================================
2380 +# UPDATE / UPGRADE / LIST / SEARCH / INFO / VERIFY
2381 +# =============================================================================
2382 +
2383 +def _cleanup_tmp_files(*paths):
2384 + """Usuwa tymczasowe pliki (np. .pag.new) po nieudanej operacji."""
2385 + for p in paths:
2386 + try:
2387 + if os.path.isfile(p):
2388 + os.remove(p)
2389 + except OSError:
2390 + pass
2391 +
2392 +
2393 +def cmd_self_update():
2394 + """Aktualizuje samego klienta pag z repo (podpisany /stable/pag).
2395 +
2396 + Kolejność: pobierz → weryfikacja GPG (+ fingerprint repo) → SHA256 →
2397 + kontrola składni (compile) → backup → atomowe os.replace. Nowa wersja
2398 + idzie do tego samego katalogu (/usr/local/bin/.pag.new), dzięki czemu
2399 + podmiana jest atomowa; jeśli system padnie w trakcie, stary pag zostaje.
2400 + """
2401 + repos = get_repos()
2402 + if not repos:
2403 + print("❌ Brak repozytoriów w konfiguracji.")
2404 + return 1
2405 + base = repos[0]
2406 + dst = "/usr/local/bin/pag"
2407 + dst_new = dst + ".new"
2408 + dst_bak = dst + ".bak"
2409 + print(f"🔄 Sprawdzam aktualizację pag z {base}...")
2410 + try:
2411 + with urlopen(Request(f"{base}/pag", headers={"User-Agent": "pag/3.0"}), timeout=20) as r:
2412 + data = r.read()
2413 + with urlopen(Request(f"{base}/pag.asc", headers={"User-Agent": "pag/3.0"}), timeout=20) as r:
2414 + sig = r.read()
2415 + except Exception as e:
2416 + print(f" ❌ Nie można pobrać pag: {e}")
2417 + return 1
2418 +
2419 + # Zapisz nową wersję w katalogu docelowym (ta sama partycja → atomowy rename)
2420 + with open(dst_new, "wb") as f:
2421 + f.write(data)
2422 + with open(dst_new + ".asc", "wb") as f:
2423 + f.write(sig)
2424 +
2425 + # --- 1. Weryfikacja podpisu GPG – bez tego nie instalujemy ---
2426 + insecure = os.environ.get("PAG_INSECURE", "") == "1"
2427 + ok, fp = _gpg_verify_fp(dst_new + ".asc", dst_new)
2428 + if not ok:
2429 + # Automatyczny import klucza (TOFU) – jak w _verify_repo_sig
2430 + res = _gpg_run("--verify", dst_new + ".asc", dst_new,
2431 + capture_output=True, text=True)
2432 + _stderr = (res.stderr or "")
2433 + if "public key" in _stderr.lower() and "no public key" in _stderr.lower():
2434 + try:
2435 + with urlopen(Request(f"{base}/paganos.asc", headers={"User-Agent": "pag/3.0"}), timeout=20) as r:
2436 + keydata = r.read()
2437 + with tempfile.NamedTemporaryFile(delete=False, suffix=".asc") as tmp:
2438 + tmp.write(keydata); tmp.flush()
2439 + _gpg_run("--import", tmp.name, capture_output=True, timeout=30)
2440 + os.unlink(tmp.name)
2441 + print(f" 🔑 Importowano klucz repo z {base}/paganos.asc")
2442 + ok, fp = _gpg_verify_fp(dst_new + ".asc", dst_new)
2443 + except Exception:
2444 + pass
2445 + if not ok:
2446 + if insecure:
2447 + print(" ⚠ Nieprawidłowy podpis aktualizacji (PAG_INSECURE – ignoruję)")
2448 + else:
2449 + print(" ❌ Nieprawidłowy podpis aktualizacji – nie aktualizuję.")
2450 + _cleanup_tmp_files(dst_new, dst_new + ".asc")
2451 + return 1
2452 + # Sprawdź fingerprint względem przypiętego klucza repo
2453 + pinned = _repo_pinned_fp(base)
2454 + if pinned:
2455 + if not fp:
2456 + print(" ❌ Nie można potwierdzić fingerprintu podpisu aktualizacji.")
2457 + _cleanup_tmp_files(dst_new, dst_new + ".asc")
2458 + return 1
2459 + if fp != pinned.upper():
2460 + if insecure:
2461 + print(" ⚠ Podpis aktualizacji innym kluczem (PAG_INSECURE – ignoruję)")
2462 + else:
2463 + print(" ❌ [SECURITY ERROR] Podpis aktualizacji innym kluczem niż repo!")
2464 + print(f" Oczekiwany: {pinned}, Otrzymany: {fp}")
2465 + _cleanup_tmp_files(dst_new, dst_new + ".asc")
2466 + return 1
2467 +
2468 + # --- 2. Weryfikacja SHA256 (jeśli repo publikuje pag.sha256) ---
2469 + try:
2470 + with urlopen(Request(f"{base}/pag.sha256", headers={"User-Agent": "pag/3.0"}), timeout=15) as r:
2471 + sha = r.read().decode().strip().split()[0]
2472 + if sha:
2473 + actual = hashlib.sha256(data).hexdigest()
2474 + if actual.lower() != sha.lower():
2475 + print(f" ❌ SHA256 niezgodny! Oczekiwano {sha}, jest {actual}")
2476 + _cleanup_tmp_files(dst_new, dst_new + ".asc")
2477 + return 1
2478 + print(" ✅ SHA256 zgodny")
2479 + except Exception:
2480 + # Brak pag.sha256 w repo – opcjonalne; nie blokuj aktualizacji.
2481 + pass
2482 +
2483 + # --- 3. Kontrola składni (nie uruchamiaj uszkodzonego/poddanego edycji pliku) ---
2484 + try:
2485 + compile(data, "pag", "exec")
2486 + except SyntaxError as e:
2487 + print(f" ❌ Błąd składni w nowym pag: {e}")
2488 + _cleanup_tmp_files(dst_new, dst_new + ".asc")
2489 + return 1
2490 +
2491 + m = (re.search(rb'PAG_VERSION\s*=\s*"(\d+\.\d+\.\d+)"', data[:3000])
2492 + or re.search(rb"v(\d+\.\d+\.\d+)", data[:3000]))
2493 + new_ver = m.group(1).decode() if m else "?"
2494 + print(f" ✅ Pobrano pag {new_ver} (obecny {PAG_VERSION}), podpis zweryfikowany")
2495 +
2496 + # --- 4. Backup + atomowa podmiana ---
2497 + if os.path.exists(dst):
2498 + shutil.copy2(dst, dst_bak)
2499 + os.chmod(dst_new, 0o755)
2500 + os.replace(dst_new, dst) # atomowe na tym samym FS
2501 + try:
2502 + if os.path.exists(dst_new + ".asc"):
2503 + os.remove(dst_new + ".asc")
2504 + except OSError:
2505 + pass
2506 + print(f" ✅ Zainstalowano nowy pag. Stary zachowany jako {dst_bak}")
2507 + print(" Uruchom ponownie pag, aby użyć nowej wersji.")
2508 + return 0
2509 +
2510 +
2511 +def cmd_update():
2512 + force = "--force" in sys.argv
2513 + print(f"🔄 {'Forced refresh' if force else 'Updating'} indexes...")
2514 + for repo_url in get_repos():
2515 + pkgs = fetch_repo_index(repo_url, force=force)
2516 + cp = _repo_cache_path(repo_url)
2517 + has_sig = os.path.exists(cp + ".sig")
2518 + print(f" {'✅' if pkgs is not None else '❌'} {repo_url}: {len(pkgs or [])} pkgs {'🔐' if has_sig else '⚠'}")
2519 + total = 0
2520 + for r in get_repos():
2521 + cp = _repo_cache_path(r)
2522 + if os.path.exists(cp):
2523 + try:
2524 + total += len(json.load(open(cp)).get("packages", []))
2525 + except Exception:
2526 + pass
2527 + print(f"✅ {_('updated_done', total)}")
2528 +
2529 + # Powiadomienie o nowszej wersji pag (repo.json["pag_version"])
2530 + try:
2531 + for r in get_repos():
2532 + cp = _repo_cache_path(r)
2533 + if os.path.exists(cp):
2534 + d = json.load(open(cp))
2535 + rv = d.get("pag_version", "")
2536 + if rv and rv != PAG_VERSION:
2537 + print(f" ⚠ Nowa wersja pag {rv} dostępna – uruchom: pag self-update")
2538 + except Exception:
2539 + pass
2540 +
2541 +def cmd_upgrade():
2542 + ensure_dirs()
2543 + installed = load_json(INSTALLED_DB)
2544 + pinned = load_json(PINNED_FILE)
2545 + repo = fetch_all_packages()
2546 + if not repo:
2547 + print(f"❌ {_('no_index')}")
2548 + return 1
2549 + upgrades = [n for n, i in installed.items()
2550 + if n not in pinned and (rp := repo.get(n)) and _version_newer(rp.version, i["version"])]
2551 + if not upgrades:
2552 + print(f"✅ {_('all_up_to_date')}"); return 0
2553 + print(f"📦 {_('upgrading', len(upgrades))}")
2554 + for n in upgrades:
2555 + print(f" {n}: {installed[n]['version']} → {repo[n].version}")
2556 + if os.environ.get("PAG_YES", "") == "1":
2557 + print(_("continue_q") + " t (--yes)")
2558 + else:
2559 + ans = input(_("continue_q")).strip().lower()
2560 + if ans and ans not in ("t","y"): return 0
2561 + return cmd_install(upgrades, upgrade=True)
2562 +
2563 +def cmd_list(installed_only=False):
2564 + if installed_only:
2565 + db = load_json(INSTALLED_DB)
2566 + pinned = load_json(PINNED_FILE)
2567 + if not db: print("No packages installed."); return
2568 + print(f"Installed ({len(db)}):")
2569 + for n, i in sorted(db.items()):
2570 + pin = " 📌" if n in pinned else ""
2571 + print(f" {n}-{i['version']}{pin} – {i.get('description','')}")
2572 + else:
2573 + pkgs = fetch_all_packages()
2574 + installed = load_json(INSTALLED_DB)
2575 + pinned = load_json(PINNED_FILE)
2576 + print(f"Available ({len(pkgs)}):")
2577 + for n, p in sorted(pkgs.items()):
2578 + m = "✓" if n in installed else " "
2579 + extra = f" [installed: {installed[n]['version']}]" if n in installed else ""
2580 + if n in pinned: extra += " 📌"
2581 + print(f" [{m}] {n}-{p.version} – {p.description}{extra}")
2582 +
2583 +def cmd_search(query):
2584 + pkgs = fetch_all_packages()
2585 + results = [(n,p) for n,p in pkgs.items() if query.lower() in n.lower() or query.lower() in p.description.lower()]
2586 + if not results: print(f"❌ No results for: {query}"); return
2587 + installed = load_json(INSTALLED_DB)
2588 + print(f"Results for '{query}' ({len(results)}):")
2589 + for n,p in sorted(results):
2590 + print(f" [{'✓' if n in installed else ' '}] {n}-{p.version}")
2591 + print(f" {p.description}")
2592 +
2593 +
2594 +def _smart_search(query: str) -> int:
2595 + """
2596 + Inteligentne wyszukiwanie: repo PaganOS + Flathub.
2597 + Uruchamiane gdy użytkownik wpisze `pag <nazwa>` zamiast `pag install <nazwa>`.
2598 + Pokazuje dostępne źródła i sugeruje komendy instalacji.
2599 + """
2600 + # 1. Repo PaganOS
2601 + try:
2602 + pkgs = fetch_all_packages()
2603 + except Exception:
2604 + pkgs = {}
2605 + repo_lower = [(n, p) for n, p in pkgs.items()
2606 + if query.lower() in n.lower() or query.lower() in p.description.lower()]
2607 +
2608 + # 2. Flathub (jeśli dostępny)
2609 + flat = _flatpak_search_raw(query) if _check_flatpak() else []
2610 +
2611 + if not repo_lower and not flat:
2612 + print(f"\n ❌ '{query}' — nie znaleziono.")
2613 + print(f" Repo PaganOS: pag search {query}")
2614 + if _check_flatpak():
2615 + print(f" Flathub: pag flatpak search {query}")
2616 + print(f" Dodaj repo: pag repo-add <url>")
2617 + return 1
2618 +
2619 + installed = load_json(INSTALLED_DB)
2620 +
2621 + # ── Repo PaganOS ──
2622 + if repo_lower:
2623 + exact = [(n, p) for n, p in repo_lower if n.lower() == query.lower()]
2624 + show = (exact or repo_lower)[:6]
2625 + print(f"\n 📦 PaganOS — '{query}':")
2626 + for n, p in sorted(show):
2627 + mark = "✓" if n in installed else " "
2628 + desc = p.description[:70] if len(p.description) > 75 else p.description
2629 + print(f" [{mark}] {n}-{p.version}")
2630 + if desc:
2631 + print(f" {desc}")
2632 + if len(repo_lower) > 6:
2633 + print(f" ... i {len(repo_lower) - 6} więcej (pag search {query})")
2634 +
2635 + # ── Flathub ──
2636 + if flat:
2637 + print(f"\n 📦 Flathub — '{query}':")
2638 + for r in flat[:5]:
2639 + mark = "✓" if r.get("installed") else " "
2640 + name = r.get("name") or r.get("application", "?")
2641 + desc = (r.get("description") or "")[:65]
2642 + print(f" [{mark}] {name}")
2643 + if desc:
2644 + print(f" {desc}")
2645 + if len(flat) > 5:
2646 + print(f" ... i {len(flat) - 5} więcej (pag flatpak search {query})")
2647 +
2648 + # ── Sugestie instalacji ──
2649 + print()
2650 + if repo_lower:
2651 + 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]
2652 + if best in installed:
2653 + print(f" ✓ {best} jest już zainstalowany ({installed[best]['version']})")
2654 + else:
2655 + print(f" 💡 sudo pag install {best}")
2656 + if flat:
2657 + best_fp = flat[0].get("application") or flat[0].get("name", query)
2658 + print(f" 💡 pag flatpak install {best_fp}")
2659 +
2660 + return 0
2661 +
2662 +def cmd_info(name):
2663 + pkgs = fetch_all_packages()
2664 + p = pkgs.get(name)
2665 + info = load_json(INSTALLED_DB).get(name)
2666 + if not p and not info: print(f"❌ '{name}' not found."); return 1
2667 + print(f"📦 {name}")
2668 + if p:
2669 + print(f" Version (repo): {p.version}")
2670 + print(f" Description: {p.description}")
2671 + print(f" Size: {p.size_bytes/1048576:.1f} MB")
2672 + print(f" SHA256: {p.sha256[:32]}...")
2673 + print(f" GPG: {p.gpg_fp or 'none'}")
2674 + print(f" Dependencies: {', '.join(p.dependencies) if p.dependencies else '(none)'}")
2675 + if info:
2676 + print(f" Installed: {info['version']} ({info.get('installed_at','?')})")
2677 +
2678 +def cmd_files(name):
2679 + if name not in load_json(INSTALLED_DB):
2680 + print(f"❌ '{name}' not installed."); return 1
2681 + files = _db_get_package_files(name)
2682 + print(f"Files in {name} ({len(files)}):")
2683 + for f in sorted(files): print(f" {f}")
2684 +
2685 +def cmd_verify(deep=False):
2686 + installed = load_json(INSTALLED_DB)
2687 + if not installed: print("Nothing to verify."); return
2688 + errors = []
2689 +
2690 + for name in installed:
2691 + for fpath in _db_get_package_files(name):
2692 + full = os.path.join(PAG_ROOT, fpath.lstrip("/"))
2693 + if not (os.path.exists(full) or os.path.islink(full)):
2694 + errors.append(f" ❌ {name}: missing {fpath}")
2695 + elif deep:
2696 + checksums = _db_get_all_file_checksums()
2697 + expected = checksums.get(fpath, "")
2698 + if expected:
2699 + actual = _sha256_file(full)
2700 + if actual != expected:
2701 + errors.append(f" ❌ {name}: SHA256 mismatch {fpath}")
2702 +
2703 + if errors:
2704 + print(f"❌ {_('verify_errors', len(errors))}")
2705 + for e in errors[:50]: print(e)
2706 + return 1
2707 + total = _db_count_files()
2708 + print(f"✅ {_('verify_ok', total)}")
2709 +
2710 +# =============================================================================
2711 +# PINNING / CLEAN / ORPHANS / REPO / FLATPAK
2712 +# =============================================================================
2713 +
2714 +def cmd_pin(name, version=""):
2715 + pinned = load_json(PINNED_FILE)
2716 + if version:
2717 + pinned[name] = version
2718 + else:
2719 + info = load_json(INSTALLED_DB).get(name, {})
2720 + pinned[name] = info.get("version", "?")
2721 + save_json(PINNED_FILE, pinned)
2722 + print(f"📌 {name} {_('pinned_to')} {pinned[name]}")
2723 +
2724 +def cmd_unpin(name):
2725 + pinned = load_json(PINNED_FILE)
2726 + if name in pinned:
2727 + del pinned[name]; save_json(PINNED_FILE, pinned)
2728 + print(f"🔓 {name} {_('unpinned')}")
2729 + else:
2730 + print(f"⚠ {name} {_('not_pinned')}")
2731 +
2732 +def cmd_pinned():
2733 + pinned = load_json(PINNED_FILE)
2734 + if not pinned: print(_("no_pinned")); return
2735 + print(_("pinned_list", len(pinned)))
2736 + for n,v in sorted(pinned.items()): print(f" 📌 {n} = {v}")
2737 +
2738 +def cmd_clean():
2739 + if os.path.isdir(PAG_CACHE):
2740 + count = size = 0
2741 + for f in os.listdir(PAG_CACHE):
2742 + fp = os.path.join(PAG_CACHE, f)
2743 + if os.path.isfile(fp):
2744 + size += os.path.getsize(fp); os.remove(fp); count += 1
2745 + print(f"✅ {_('cache_cleared', count, size/1048576)}")
2746 +
2747 +def cmd_remove_orphans():
2748 + installed = load_json(INSTALLED_DB)
2749 + world = load_world()
2750 + orphans = _find_orphans(installed, world)
2751 + if not orphans: print("✅ No orphans."); return
2752 + print(f"Orphans ({len(orphans)}):")
2753 + for n in sorted(orphans): print(f" {n}-{installed[n]['version']}")
2754 + if os.environ.get("PAG_YES", "") == "1":
2755 + print(_("continue_q") + " t (--yes)")
2756 + else:
2757 + ans = input(_("continue_q")).strip().lower()
2758 + if ans and ans not in ("t","y"): return
2759 + cmd_remove(list(orphans))
2760 +
2761 +
2762 +# =============================================================================
2763 +# PROVIDES – PAKIETY WIRTUALNE
2764 +# =============================================================================
2765 +
2766 +PROVIDES_MAP = {
2767 + "pkgconfig(glib-2.0)": "glib",
2768 + "pkgconfig(gobject-introspection-1.0)": "gobject-introspection",
2769 + "pkgconfig(gtk+-3.0)": "gtk",
2770 + "pkgconfig(gtk4)": "gtk",
2771 + "pkgconfig(zlib)": "zlib",
2772 + "pkgconfig(libffi)": "libffi",
2773 + "pkgconfig(expat)": "expat",
2774 + "pkgconfig(libsystemd)": "systemd",
2775 + "pkgconfig(dbus-1)": "dbus",
2776 + "pkgconfig(mount)": "util-linux",
2777 + "pkgconfig(blkid)": "util-linux",
2778 + "pkgconfig(libcap)": "libcap",
2779 + "pkgconfig(liblzma)": "xz",
2780 + "pkgconfig(libzstd)": "zstd",
2781 + "pkgconfig(bzip2)": "bzip2",
2782 + "pkgconfig(libcurl)": "curl",
2783 + "pkgconfig(openssl)": "openssl",
2784 + "pkgconfig(libpcre2-8)": "pcre2",
2785 + "pkgconfig(libxml-2.0)": "libxml2",
2786 + "pkgconfig(libxslt)": "libxslt",
2787 + "pkgconfig(freetype2)": "freetype",
2788 + "pkgconfig(fontconfig)": "fontconfig",
2789 + "pkgconfig(harfbuzz)": "harfbuzz",
2790 + "pkgconfig(cairo)": "cairo",
2791 + "pkgconfig(pango)": "pango",
2792 +}
2793 +
2794 +def _resolve_provides(name: str, repo: dict) -> str:
2795 + """Rozwija wirtualną nazwę pakietu do rzeczywistej nazwy z repo."""
2796 + if name in repo:
2797 + return name
2798 + if name in PROVIDES_MAP:
2799 + real = PROVIDES_MAP[name]
2800 + if real in repo:
2801 + return real
2802 + # Dynamiczne provides z repo.json (sekcja provides: w PAGBUILD.yaml)
2803 + for _pkg_name, _pkg in repo.items():
2804 + _provs = getattr(_pkg, "provides", None) or []
2805 + if name in _provs:
2806 + return _pkg_name
2807 + clean = name
2808 + if name.startswith("pkgconfig(") and ")" in name:
2809 + clean = name.split("(", 1)[1].rstrip(")")
2810 + elif name.startswith("pkgconfig32(") and ")" in name:
2811 + clean = name.split("(", 1)[1].rstrip(")")
2812 + if clean != name and clean in repo:
2813 + return clean
2814 + return name
2815 +
2816 +
2817 +def cmd_why(pkg_name: str):
2818 + """Pokazuje dlaczego pakiet jest zainstalowany."""
2819 + installed = load_json(INSTALLED_DB)
2820 + world = load_world()
2821 + if pkg_name not in installed:
2822 + print(f" {pkg_name}: {_('why_not_installed')}"); return 1
2823 + if pkg_name in world:
2824 + print(f" {pkg_name}-{installed[pkg_name]['version']}: {_('why_explicit')}")
2825 + return 0
2826 + parents = set()
2827 + for w in world:
2828 + _find_dep_path(w, pkg_name, installed, set(), [], parents)
2829 + if parents:
2830 + for pp in sorted(parents):
2831 + print(f" {pkg_name}: {_('why_dependency')} {' → '.join(pp)}")
2832 + else:
2833 + print(f" {pkg_name}: {_('why_dependency')} (unknown/orphan)")
2834 + return 0
2835 +
2836 +
2837 +def _find_dep_path(cur, target, installed, visited, path, results):
2838 + if cur in visited: return
2839 + visited.add(cur); path.append(cur)
2840 + if cur == target:
2841 + results.add(tuple(path))
2842 + else:
2843 + for dep in installed.get(cur, {}).get("dependencies", []):
2844 + _find_dep_path(dep, target, installed, visited, path, results)
2845 + path.pop(); visited.discard(cur)
2846 +
2847 +
2848 +def cmd_autoremove():
2849 + """Automatycznie usuwa osierocone zależności bez pytania."""
2850 + installed = load_json(INSTALLED_DB)
2851 + world = load_world()
2852 + orphans = _find_orphans(installed, world)
2853 + if not orphans: print(f"✅ {_('autoremove_none')}"); return 0
2854 + print(f"🗑 {_('orphans_found', len(orphans), ', '.join(sorted(orphans)[:10]))}")
2855 + return cmd_remove(list(orphans))
2856 +
2857 +
2858 +def cmd_download(package_names):
2859 + """Pobiera pakiety do cache bez instalowania."""
2860 + ensure_dirs()
2861 + repo = fetch_all_packages()
2862 + if not repo: print(f"❌ {_('no_index')}"); return 1
2863 + total_size = 0; downloaded = []
2864 + for name in package_names:
2865 + pkg = repo.get(name)
2866 + if not pkg:
2867 + print(f" ❌ {name}: {_('not_found')}"); continue
2868 + print(f" ↓ {name}-{pkg.version} ...", end=" ", flush=True)
2869 + path = _download_pkg(pkg)
2870 + if path:
2871 + total_size += os.path.getsize(path)
2872 + downloaded.append(name)
2873 + print(_c("green", "✓"))
2874 + else:
2875 + print(_c("red", "✗"))
2876 + if downloaded:
2877 + print(f"\n✅ {_('downloaded', len(downloaded), total_size/1048576)}")
2878 + return 0 if len(downloaded) == len(package_names) else 1
2879 +
2880 +
2881 +def cmd_stats():
2882 + """Wyświetla statystyki PAG."""
2883 + installed = load_json(INSTALLED_DB)
2884 + history = load_json(HISTORY_FILE) if os.path.exists(HISTORY_FILE) else []
2885 + total_size = sum(i.get("size_bytes", 0) for i in installed.values())
2886 + total_files = _db_count_files()
2887 + cache_size = sum(
2888 + os.path.getsize(os.path.join(PAG_CACHE, f))
2889 + for f in os.listdir(PAG_CACHE)
2890 + if os.path.isfile(os.path.join(PAG_CACHE, f))
2891 + ) if os.path.isdir(PAG_CACHE) else 0
2892 + last_update = "never"
2893 + for e in reversed(history):
2894 + if e.get("action") in ("install", "upgrade") and e.get("success"):
2895 + last_update = e.get("timestamp", "?")[:19]; break
2896 + print(f"\n {_c('bold', _('stats_title'))}")
2897 + print(f" {'─' * 40}")
2898 + print(f" {_('stats_packages'):<30} {len(installed)}")
2899 + print(f" {_('stats_files'):<30} {total_files}")
2900 + print(f" {_('stats_size'):<30} {total_size/1048576:.1f} MB")
2901 + print(f" {_('stats_cache'):<30} {cache_size/1048576:.1f} MB")
2902 + print(f" {_('stats_history'):<30} {len(history)}")
2903 + print(f" {_('stats_last_update'):<30} {last_update}")
2904 + by_size = sorted(installed.items(), key=lambda x: x[1].get("size_bytes", 0), reverse=True)[:5]
2905 + if by_size:
2906 + print(f"\n {_c('dim', 'Top 5:')}")
2907 + for n, i in by_size:
2908 + print(f" {n}-{i['version']} {i.get('size_bytes',0)/1048576:.1f} MB")
2909 + return 0
2910 +
2911 +
2912 +def cmd_repo_add(url, name=None):
2913 + if not url.startswith("https://") and not os.environ.get("PAG_INSECURE"):
2914 + print(f" {_('sec_https')}"); return 1
2915 + ensure_dirs()
2916 + url = url.rstrip("/")
2917 + repos = get_repos()
2918 + if url in repos: print(f"⚠ {_('repo_exists', url)}"); return
2919 + if name:
2920 + # Drop-in: /etc/pag/repos/<nazwa>.conf (jak `echo url > .../stable.conf`)
2921 + os.makedirs(REPOS_DIR, exist_ok=True)
2922 + target = os.path.join(REPOS_DIR, name.rstrip("/").replace("/", "_") + ".conf")
2923 + with open(target, "w") as f: f.write(f"{url}\n")
2924 + print(f"✅ {_('repo_added', url)} → {target}")
2925 + return
2926 + with open(REPOS_CONF, "a") as f: f.write(f"{url}\n")
2927 + print(f"✅ {_('repo_added', url)}")
2928 +
2929 +def cmd_repo_list():
2930 + for i, url in enumerate(get_repos(), 1): print(f" {i}. {url}")
2931 +
2932 +def _check_flatpak():
2933 + if not shutil.which("flatpak"):
2934 + print(f"❌ {_('flatpak_missing')}"); return False
2935 + r = subprocess.run(["flatpak","remotes"], capture_output=True, text=True)
2936 + if "flathub" not in r.stdout:
2937 + print(f"⚠ {_('flatpak_adding')}")
2938 + subprocess.run(["flatpak","remote-add","--if-not-exists","flathub",
2939 + "https://flathub.org/repo/flathub.flatpakrepo"], check=False)
2940 + return True
2941 +
2942 +def _spinner(msg: str):
2943 + """Prosty spinner „myślenia” w osobnym wątku. Zwraca funkcję stop()."""
2944 + stop = threading.Event()
2945 + def _spin():
2946 + for c in itertools.cycle("|/-\\"):
2947 + if stop.is_set():
2948 + break
2949 + sys.stdout.write(f"\r {msg} {c}")
2950 + sys.stdout.flush()
2951 + time.sleep(0.1)
2952 + t = threading.Thread(target=_spin, daemon=True)
2953 + t.start()
2954 + def _stop():
2955 + stop.set()
2956 + t.join(timeout=0.3)
2957 + sys.stdout.write("\r" + " " * (len(msg) + 4) + "\r")
2958 + sys.stdout.flush()
2959 + return _stop
2960 +
2961 +
2962 +def _flatpak_search_raw(query: str) -> List[dict]:
2963 + """Szuka we Flathub i zwraca listę wyników jako słowniki."""
2964 + if not _check_flatpak():
2965 + return []
2966 + stop = _spinner("Szukam we Flathub...")
2967 + try:
2968 + try:
2969 + r = subprocess.run(
2970 + ["flatpak", "search", "--columns=name,description,application,version,branch,remotes", query],
2971 + capture_output=True, text=True, timeout=120
2972 + )
2973 + finally:
2974 + stop()
2975 + if r.returncode != 0 and "No matches found" not in r.stdout and not r.stdout.strip():
2976 + print(f" ⚠ flatpak search: {r.stderr.strip()[:150]}")
2977 + results = []
2978 + for line in r.stdout.strip().split("\n"):
2979 + parts = line.split("\t")
2980 + if len(parts) >= 3:
2981 + results.append({
2982 + "name": parts[0].strip(),
2983 + "description": parts[1].strip() if len(parts) > 1 else "",
2984 + "app_id": parts[2].strip() if len(parts) > 2 else "",
2985 + "version": parts[3].strip() if len(parts) > 3 else "",
2986 + "branch": parts[4].strip() if len(parts) > 4 else "stable",
2987 + "origin": parts[5].strip() if len(parts) > 5 else "flathub",
2988 + })
2989 + return results
2990 + except Exception as e:
2991 + print(f" ⚠ Błąd wyszukiwania: {e}", file=sys.stderr)
2992 + return []
2993 +
2994 +def _flatpak_find_best(query: str) -> Optional[dict]:
2995 + """
2996 + Szuka we Flathub i próbuje znaleźć najlepsze dopasowanie.
2997 + - Jeśli query dokładnie pasuje do app_id → zwraca od razu
2998 + - Jeśli query pasuje do nazwy → zwraca pierwsze
2999 + - Jeśli wiele wyników → wyświetla listę i pyta użytkownika
3000 + - Jeśli brak → zwraca None
3001 + """
3002 + results = _flatpak_search_raw(query)
3003 + if not results:
3004 + return None
3005 +
3006 + # Dokładne dopasowanie app_id
3007 + exact = [r for r in results if r["app_id"].lower() == query.lower()]
3008 + if exact:
3009 + return exact[0]
3010 +
3011 + # Dokładne dopasowanie nazwy
3012 + exact_name = [r for r in results if r["name"].lower() == query.lower()]
3013 + if exact_name:
3014 + return exact_name[0]
3015 +
3016 + # Jednoznaczne dopasowanie (tylko 1 wynik)
3017 + if len(results) == 1:
3018 + return results[0]
3019 +
3020 + # Wiele wyników – pokaż użytkownikowi
3021 + print(f"\n {_('flatpak_found', len(results))}")
3022 + for i, r in enumerate(results):
3023 + print(f" {i+1}. {_c('bold', r['name'])} ({r['app_id']})")
3024 + if r["version"]:
3025 + print(f" {_('flatpak_info_version')}: {r['version']}")
3026 + if r["description"]:
3027 + desc = r["description"][:80] + ("..." if len(r["description"]) > 80 else "")
3028 + print(f" {desc}")
3029 +
3030 + try:
3031 + choice = input(f"\n Wybierz numer (1-{len(results)}) lub Enter aby anulować: ").strip()
3032 + if not choice:
3033 + return None
3034 + idx = int(choice) - 1
3035 + if 0 <= idx < len(results):
3036 + return results[idx]
3037 + except (ValueError, IndexError):
3038 + pass
3039 + return None
3040 +
3041 +def _flatpak_get_installed_info(app_id: str) -> Optional[dict]:
3042 + """Zwraca info o zainstalowanym flatpaku lub None."""
3043 + try:
3044 + r = subprocess.run(
3045 + ["flatpak", "info", "--columns=name,version,branch,origin,installed-size,description", app_id],
3046 + capture_output=True, text=True, timeout=10
3047 + )
3048 + if r.returncode != 0:
3049 + return None
3050 + parts = r.stdout.strip().split("\t")
3051 + if len(parts) < 3:
3052 + return None
3053 + return {
3054 + "name": parts[0].strip(),
3055 + "version": parts[1].strip() if len(parts) > 1 else "",
3056 + "branch": parts[2].strip() if len(parts) > 2 else "",
3057 + "origin": parts[3].strip() if len(parts) > 3 else "",
3058 + "size": parts[4].strip() if len(parts) > 4 else "",
3059 + "description": parts[5].strip() if len(parts) > 5 else "",
3060 + }
3061 + except Exception:
3062 + return None
3063 +
3064 +def _flatpak_is_installed(app_id: str) -> bool:
3065 + """Sprawdza czy flatpak o danym ID jest zainstalowany."""
3066 + try:
3067 + r = subprocess.run(
3068 + ["flatpak", "info", app_id],
3069 + capture_output=True, text=True, timeout=10
3070 + )
3071 + return r.returncode == 0
3072 + except Exception:
3073 + return False
3074 +
3075 +# =============================================================================
3076 +# FLATPAK – KOMENDY GŁÓWNE (zunifikowany interfejs)
3077 +# =============================================================================
3078 +# pag flatpak <query> → szuka i proponuje instalację (jeśli nie zainstalowany)
3079 +# pag flatpak search <query> → tylko szuka
3080 +# pag flatpak install <query> → instaluje
3081 +# pag flatpak remove <id> → usuwa
3082 +# pag flatpak list → lista zainstalowanych
3083 +# pag flatpak update → aktualizuje wszystkie
3084 +# pag flatpak info <id> → szczegóły flatpaka
3085 +
3086 +def cmd_flatpak(args: list):
3087 + """
3088 + Główna komenda flatpak – inteligentnie rozpoznaje intencję:
3089 + pag flatpak firefox → szuka i instaluje (jeśli nieznaleziony → szuka)
3090 + pag flatpak search firefox → tylko wyszukiwanie
3091 + pag flatpak install ... → bezpośrednia instalacja
3092 + pag flatpak remove ... → odinstalowanie
3093 + pag flatpak list → lista
3094 + pag flatpak update → aktualizacja
3095 + pag flatpak info ... → szczegóły
3096 + """
3097 + if not _check_flatpak():
3098 + return 1
3099 +
3100 + if not args:
3101 + # Bez argumentów – domyślnie lista
3102 + return cmd_flatpak_list()
3103 +
3104 + subcmd = args[0].lower()
3105 + rest = args[1:]
3106 +
3107 + # ── Podkomendy jawne ────────────────────────────────────────────────
3108 + if subcmd == "search":
3109 + if not rest:
3110 + print(_("flatpak_usage")); return 1
3111 + return cmd_flatpak_search(" ".join(rest))
3112 +
3113 + elif subcmd == "install":
3114 + if not rest:
3115 + print(_("flatpak_usage")); return 1
3116 + return _flatpak_smart_install(rest)
3117 +
3118 + elif subcmd == "remove" or subcmd == "uninstall":
3119 + if not rest:
3120 + print(_("flatpak_usage")); return 1
3121 + return _flatpak_smart_remove(rest)
3122 +
3123 + elif subcmd == "list":
3124 + return cmd_flatpak_list()
3125 +
3126 + elif subcmd == "update":
3127 + return cmd_flatpak_update()
3128 +
3129 + elif subcmd == "info":
3130 + if not rest:
3131 + print(_("flatpak_usage")); return 1
3132 + return cmd_flatpak_info(rest[0])
3133 +
3134 + else:
3135 + # ── Inteligentne wykrywanie: pag flatpak <nazwa> ────────────────
3136 + # Sprawdź czy to zainstalowany flatpak → pokaż info
3137 + # Jeśli nie → szukaj i zaproponuj instalację
3138 + query = " ".join(args)
3139 +
3140 + # Najpierw sprawdź czy już zainstalowany
3141 + if _flatpak_is_installed(query):
3142 + print(f" 📦 {_c('green', query)} – already installed (use 'pag flatpak info {query}' for details)")
3143 + return cmd_flatpak_info(query)
3144 +
3145 + # Szukaj we Flathub
3146 + print(f" {_('flatpak_searching', query)}")
3147 + best = _flatpak_find_best(query)
3148 + if not best:
3149 + print(f" ❌ '{query}' – {_('flatpak_not_found')}")
3150 + return 1
3151 +
3152 + print(f"\n {_c('cyan', best['name'])} ({best['app_id']})")
3153 + if best["version"]:
3154 + print(f" {_('flatpak_info_version')}: {best['version']}")
3155 + if best["description"]:
3156 + print(f" {best['description']}")
3157 +
3158 + ans = input(f"\n {_('flatpak_install_prompt', best['name'])}").strip().lower()
3159 + if ans and ans not in ("t", "y"):
3160 + print(_("cancelled"))
3161 + return 0
3162 +
3163 + return _flatpak_do_install(best["app_id"])
3164 +
3165 +def _flatpak_smart_install(names: list) -> int:
3166 + """Instaluje flatpaki – obsługuje nazwy częściowe (wyszukuje przed instalacją)."""
3167 + failed = 0
3168 + for name in names:
3169 + if "." in name and "/" not in name:
3170 + # Wygląda na pełne app_id (np. org.mozilla.firefox)
3171 + app_id = name
3172 + else:
3173 + # Szukaj najlepszego dopasowania
3174 + best = _flatpak_find_best(name)
3175 + if not best:
3176 + print(f" ❌ '{name}' – {_('flatpak_not_found')}")
3177 + failed += 1
3178 + continue
3179 + app_id = best["app_id"]
3180 + print(f" → {best['name']} ({app_id})")
3181 +
3182 + if _flatpak_do_install(app_id) != 0:
3183 + failed += 1
3184 + return 1 if failed else 0
3185 +
3186 +def _flatpak_do_install(app_id: str) -> int:
3187 + """Wykonuje właściwą instalację flatpaka."""
3188 + print(f" {_('flatpak_installing', app_id)}")
3189 + result = subprocess.run(
3190 + ["flatpak", "install", "-y", "flathub", app_id],
3191 + check=False, timeout=600
3192 + )
3193 + if result.returncode == 0:
3194 + print(f" ✅ {_('flatpak_installed', app_id)}")
3195 + return 0
3196 + else:
3197 + print(f" ❌ {_('download_fail')}: {app_id}")
3198 + return 1
3199 +
3200 +def _flatpak_smart_remove(names: list) -> int:
3201 + """Usuwa flatpaki – obsługuje nazwy częściowe."""
3202 + # Pobierz listę zainstalowanych
3203 + try:
3204 + r = subprocess.run(
3205 + ["flatpak", "list", "--columns=application,name"],
3206 + capture_output=True, text=True, timeout=10
3207 + )
3208 + installed = {}
3209 + for line in r.stdout.strip().split("\n"):
3210 + parts = line.split("\t")
3211 + if len(parts) >= 2:
3212 + installed[parts[0].strip()] = parts[1].strip()
3213 + except Exception:
3214 + installed = {}
3215 +
3216 + failed = 0
3217 + for name in names:
3218 + app_id = name
3219 +
3220 + # Jeśli nie podano pełnego ID – spróbuj dopasować
3221 + if name not in installed:
3222 + matches = {aid: aname for aid, aname in installed.items()
3223 + if name.lower() in aid.lower() or name.lower() in aname.lower()}
3224 + if len(matches) == 0:
3225 + print(f" ❌ '{name}' – {_('flatpak_not_installed', name)}")
3226 + failed += 1
3227 + continue
3228 + elif len(matches) == 1:
3229 + app_id = list(matches.keys())[0]
3230 + print(f" → {matches[app_id]} ({app_id})")
3231 + else:
3232 + print(f"\n Wiele dopasowań dla '{name}':")
3233 + for i, (aid, aname) in enumerate(sorted(matches.items()), 1):
3234 + print(f" {i}. {aname} ({aid})")
3235 + try:
3236 + choice = input(f"\n Wybierz numer (1-{len(matches)}) lub Enter: ").strip()
3237 + if not choice:
3238 + failed += 1
3239 + continue
3240 + aid_list = sorted(matches.keys())
3241 + app_id = aid_list[int(choice) - 1]
3242 + except (ValueError, IndexError):
3243 + failed += 1
3244 + continue
3245 +
3246 + print(f" 🗑 {app_id} ...", end=" ", flush=True)
3247 + result = subprocess.run(
3248 + ["flatpak", "uninstall", "-y", app_id],
3249 + capture_output=True, text=True, timeout=120
3250 + )
3251 + if result.returncode == 0:
3252 + print("✅")
3253 + print(f" {_('flatpak_removed', app_id)}")
3254 + else:
3255 + print("❌")
3256 + failed += 1
3257 + return 1 if failed else 0
3258 +
3259 +def cmd_flatpak_search(q: str):
3260 + """Wyszukuje we Flathub i wyświetla wyniki (z możliwością wyboru do instalacji)."""
3261 + if not _check_flatpak():
3262 + return 1
3263 + results = _flatpak_search_raw(q)
3264 + if not results:
3265 + print(f" ❌ '{q}' – {_('flatpak_not_found')}")
3266 + return 1
3267 + print(f"\n {_('flatpak_found', len(results))}")
3268 + shown = results[:30] # max 30 wyników
3269 + for i, r in enumerate(shown, 1):
3270 + installed = "📦 " if _flatpak_is_installed(r["app_id"]) else " "
3271 + print(f" {i:>2}. {installed}{_c('bold', r['name'])} ({r['app_id']})")
3272 + if r["version"]:
3273 + print(f" {_('flatpak_info_version')}: {r['version']} | {_('flatpak_info_branch')}: {r['branch']}")
3274 + if r["description"]:
3275 + desc = r["description"][:100] + ("..." if len(r["description"]) > 100 else "")
3276 + print(f" {_c('dim', desc)}")
3277 + if len(results) > 30:
3278 + print(f" ... i {len(results) - 30} więcej. Doprecyzuj zapytanie.")
3279 +
3280 + # Interaktywny wybór – wpisz numer, aby zainstalować (Enter = anuluj)
3281 + try:
3282 + ans = input(f"\n Wybierz numer do zainstalowania (1-{len(shown)}) lub Enter aby anulować: ").strip()
3283 + except (EOFError, KeyboardInterrupt):
3284 + return 0
3285 + if ans:
3286 + try:
3287 + idx = int(ans) - 1
3288 + if 0 <= idx < len(shown):
3289 + return _flatpak_do_install(shown[idx]["app_id"])
3290 + print(_("cancelled"))
3291 + except (ValueError, IndexError):
3292 + print(_("cancelled"))
3293 + return 0
3294 +
3295 +def cmd_flatpak_list():
3296 + """Wyświetla zainstalowane flatpaki."""
3297 + if not _check_flatpak():
3298 + return 1
3299 + r = subprocess.run(
3300 + ["flatpak", "list", "--columns=application,name,version,origin,installed-size"],
3301 + capture_output=True, text=True, timeout=10
3302 + )
3303 + lines = [l for l in r.stdout.strip().split("\n") if l.strip()]
3304 + if not lines:
3305 + print(" (brak zainstalowanych flatpaków)")
3306 + return 0
3307 + print(f" Zainstalowane flatpaki ({len(lines)}):")
3308 + for line in lines:
3309 + parts = line.split("\t")
3310 + if len(parts) >= 3:
3311 + app_id, name, version = parts[0], parts[1], parts[2]
3312 + size = parts[4] if len(parts) > 4 else ""
3313 + size_str = f" ({size})" if size else ""
3314 + print(f" 📦 {_c('bold', name)} {version}{size_str}")
3315 + print(f" {_c('dim', app_id)}")
3316 + return 0
3317 +
3318 +def cmd_flatpak_update():
3319 + """Aktualizuje wszystkie flatpaki."""
3320 + if not _check_flatpak():
3321 + return 1
3322 + print(" 🔄 Aktualizacja flatpaków...")
3323 + result = subprocess.run(["flatpak", "update", "-y"], check=False, timeout=600)
3324 + if result.returncode == 0:
3325 + print(f" ✅ {_('flatpak_updated')}")
3326 + return result.returncode
3327 +
3328 +def cmd_flatpak_info(app_id: str):
3329 + """Wyświetla szczegóły flatpaka (zainstalowanego lub z Flathub)."""
3330 + if not _check_flatpak():
3331 + return 1
3332 +
3333 + # Najpierw sprawdź zainstalowany
3334 + info = _flatpak_get_installed_info(app_id)
3335 + if info:
3336 + print(f"\n 📦 {_c('bold', info['name'])} {_c('green', '[zainstalowany]')}")
3337 + print(f" {'─' * 45}")
3338 + print(f" {_('flatpak_info_id'):<16} {app_id}")
3339 + print(f" {_('flatpak_info_version'):<16} {info['version']}")
3340 + print(f" {_('flatpak_info_branch'):<16} {info['branch']}")
3341 + print(f" {_('flatpak_info_origin'):<16} {info['origin']}")
3342 + if info["size"]:
3343 + print(f" {_('flatpak_info_size'):<16} {info['size']}")
3344 + if info["description"]:
3345 + print(f" {_('flatpak_info_desc'):<16} {info['description']}")
3346 + return 0
3347 +
3348 + # Szukaj we Flathub
3349 + results = _flatpak_search_raw(app_id)
3350 + exact = [r for r in results if r["app_id"].lower() == app_id.lower()]
3351 + if not exact:
3352 + # Spróbuj częściowego dopasowania
3353 + if results:
3354 + exact = [results[0]]
3355 + else:
3356 + print(f" ❌ '{app_id}' – {_('flatpak_not_found')}")
3357 + return 1
3358 +
3359 + r = exact[0]
3360 + print(f"\n 📦 {_c('bold', r['name'])} (Flathub)")
3361 + print(f" {'─' * 45}")
3362 + print(f" {_('flatpak_info_id'):<16} {r['app_id']}")
3363 + print(f" {_('flatpak_info_version'):<16} {r['version']}")
3364 + if r["description"]:
3365 + print(f" {_('flatpak_info_desc'):<16} {r['description']}")
3366 + print(f"\n 💡 Aby zainstalować: pag flatpak install {r['app_id']}")
3367 + return 0
3368 +
3369 +# =============================================================================
3370 +# IMMUTABLE OS – KOMENDY DEPLOYMENTOWE
3371 +# =============================================================================
3372 +
3373 +# Pakiety jądra – po ich instalacji trzeba przebudować initramfs
3374 +KERNEL_PACKAGE_PATTERNS = ["linux", "kernel", "linux-kernel", "linux-lts"]
3375 +
3376 +def _is_kernel_package(name: str) -> bool:
3377 + """Sprawdza czy pakiet to jądro (wymaga przebudowy initramfs)."""
3378 + name_lower = name.lower()
3379 + return any(pattern in name_lower for pattern in KERNEL_PACKAGE_PATTERNS)
3380 +
3381 +def _rebuild_initramfs(deploy_dir: str = "") -> bool:
3382 + """
3383 + Przebudowuje initramfs dla aktywnego (lub podanego) deploymentu.
3384 + Używa skryptu pag-initramfs lub ręcznego cpio.
3385 + """
3386 + if deploy_dir:
3387 + root = deploy_dir
3388 + else:
3389 + root = _get_deployment_root()
3390 +
3391 + if root == PAG_ROOT:
3392 + # Zwykły system – użyj dracut jeśli dostępny
3393 + if shutil.which("dracut"):
3394 + print(" 🔧 Przebudowa initramfs (dracut)...")
3395 + result = subprocess.run(
3396 + ["dracut", "--force", "/boot/initramfs.img"],
3397 + capture_output=True, text=True, timeout=120
3398 + )
3399 + return result.returncode == 0
3400 + elif shutil.which("mkinitcpio"):
3401 + print(" 🔧 Przebudowa initramfs (mkinitcpio)...")
3402 + result = subprocess.run(
3403 + ["mkinitcpio", "-g", "/boot/initramfs.img"],
3404 + capture_output=True, text=True, timeout=120
3405 + )
3406 + return result.returncode == 0
3407 + else:
3408 + print(" ⚠ Brak dracut/mkinitcpio – initramfs nie został przebudowany")
3409 + return False
3410 +
3411 + # Tryb immutable – budujemy initramfs dla deploymentu
3412 + print(" 🔧 Budowanie initramfs dla deploymentu...")
3413 +
3414 + # Sprawdź czy mamy nasz skrypt init
3415 + pag_init_script = "/usr/share/pag/initramfs-init"
3416 + if not os.path.exists(pag_init_script):
3417 + # Szukaj w źródłach (developerski fallback)
3418 + alt_paths = [
3419 + os.path.join(os.path.dirname(os.path.abspath(__file__)), "scripts", "initramfs-init"),
3420 + "/usr/share/pag/init",
3421 + ]
3422 + for p in alt_paths:
3423 + if os.path.exists(p):
3424 + pag_init_script = p
3425 + break
3426 +
3427 + if not os.path.exists(pag_init_script):
3428 + print(" ⚠ Nie znaleziono pag-initramfs-init – pomijam budowę initramfs")
3429 + return False
3430 +
3431 + boot_dir = os.path.join(root, "boot")
3432 + os.makedirs(boot_dir, exist_ok=True)
3433 +
3434 + # Znajdź jądro (vmlinuz-*)
3435 + kernels = sorted(
3436 + [f for f in os.listdir(boot_dir) if f.startswith("vmlinuz-")],
3437 + reverse=True
3438 + ) if os.path.exists(boot_dir) else []
3439 + if not kernels:
3440 + print(" ⚠ Nie znaleziono vmlinuz-* w /boot deploymentu")
3441 + return False
3442 +
3443 + kernel_ver = kernels[0].replace("vmlinuz-", "")
3444 + print(f" 🐧 Jądro: {kernel_ver}")
3445 +
3446 + # Buduj initramfs ręcznie (cpio)
3447 + tmpdir = tempfile.mkdtemp(prefix="pag-initramfs-")
3448 + try:
3449 + # Podstawowa struktura
3450 + for d in ["bin", "sbin", "dev", "proc", "sys", "run", "new_root",
3451 + "usr/bin", "usr/sbin", "lib", "lib64", "etc"]:
3452 + os.makedirs(os.path.join(tmpdir, d), exist_ok=True)
3453 +
3454 + # Skopiuj init
3455 + shutil.copy2(pag_init_script, os.path.join(tmpdir, "init"))
3456 + os.chmod(os.path.join(tmpdir, "init"), 0o755)
3457 +
3458 + # Skopiuj niezbędne binaria (busybox lub podstawowe narzędzia)
3459 + busybox_paths = [
3460 + os.path.join(root, "usr/bin/busybox"),
3461 + os.path.join(root, "bin/busybox"),
3462 + "/usr/bin/busybox",
3463 + "/bin/busybox",
3464 + ]
3465 + busybox = None
3466 + for bp in busybox_paths:
3467 + if os.path.exists(bp):
3468 + busybox = bp
3469 + break
3470 +
3471 + if busybox:
3472 + shutil.copy2(busybox, os.path.join(tmpdir, "bin/busybox"))
3473 + # Utwórz symlinki dla podstawowych komend
3474 + for cmd in ["sh", "mount", "umount", "ls", "cat", "echo", "sleep",
3475 + "readlink", "mkdir", "switch_root", "cp", "rm"]:
3476 + link = os.path.join(tmpdir, "bin", cmd)
3477 + if not os.path.exists(link):
3478 + os.symlink("busybox", link)
3479 + # /bin/sh → busybox
3480 + if not os.path.exists(os.path.join(tmpdir, "bin/sh")):
3481 + os.symlink("busybox", os.path.join(tmpdir, "bin/sh"))
3482 + else:
3483 + # Bez busybox – kopiuj podstawowe narzędzia z deploymentu
3484 + for tool in ["bash", "mount", "umount", "readlink", "mkdir", "cat", "sleep", "cp", "rm"]:
3485 + src = os.path.join(root, "usr/bin", tool)
3486 + if not os.path.exists(src):
3487 + src = os.path.join(root, "bin", tool)
3488 + if os.path.exists(src):
3489 + dest = os.path.join(tmpdir, "bin", os.path.basename(tool))
3490 + shutil.copy2(src, dest)
3491 + # Kopiuj zależności .so
3492 + _copy_libs_for_binary(src, tmpdir, root)
3493 +
3494 + # Dodaj moduły jądra (opcjonalnie – dla sterowników dyskowych)
3495 + modules_src = os.path.join(root, "lib/modules", kernel_ver)
3496 + if os.path.isdir(modules_src):
3497 + modules_dst = os.path.join(tmpdir, "lib/modules", kernel_ver)
3498 + # Kopiuj tylko niezbędne (fs, block, drivers/ata, drivers/nvme)
3499 + for sub in ["kernel/fs", "kernel/drivers/ata", "kernel/drivers/nvme",
3500 + "kernel/drivers/scsi", "kernel/drivers/virtio",
3501 + "modules.order", "modules.builtin"]:
3502 + src_sub = os.path.join(modules_src, sub)
3503 + if os.path.exists(src_sub):
3504 + dst_sub = os.path.join(modules_dst, sub)
3505 + os.makedirs(os.path.dirname(dst_sub), exist_ok=True)
3506 + if os.path.isdir(src_sub):
3507 + shutil.copytree(src_sub, dst_sub, dirs_exist_ok=True, symlinks=True)
3508 + else:
3509 + shutil.copy2(src_sub, dst_sub)
3510 +
3511 + # Pakuj do initramfs.img
3512 + initramfs_path = os.path.join(boot_dir, "initramfs.img")
3513 + old_cwd = os.getcwd()
3514 + os.chdir(tmpdir)
3515 + try:
3516 + with open(initramfs_path + ".tmp", "wb") as out:
3517 + _run_cpio_pipeline(tmpdir, out)
3518 + os.rename(initramfs_path + ".tmp", initramfs_path)
3519 + finally:
3520 + os.chdir(old_cwd)
3521 +
3522 + size_mb = os.path.getsize(initramfs_path) / 1048576
3523 + print(f" ✅ initramfs.img ({size_mb:.1f} MB) → {initramfs_path}")
3524 + return True
3525 +
3526 + except Exception as e:
3527 + print(f" ❌ Błąd budowy initramfs: {e}")
3528 + return False
3529 + finally:
3530 + shutil.rmtree(tmpdir, ignore_errors=True)
3531 +
3532 +
3533 +def _run_cpio_pipeline(tmpdir: str, out):
3534 + """find . | cpio -oH newc | gzip — bez shell=True.
3535 +
3536 + Buduje pipeline przez subprocess.Popen, unikając pośrednika powłoki
3537 + (brak ryzyka injection i niepotrzebnego procesu sh). Wykonuje się w cwd=tmpdir.
3538 + """
3539 + find = subprocess.Popen(["find", "."], cwd=tmpdir, stdout=subprocess.PIPE)
3540 + cpio = subprocess.Popen(["cpio", "-oH", "newc"], cwd=tmpdir,
3541 + stdin=find.stdout, stdout=subprocess.PIPE)
3542 + find.stdout.close() # zwolnij uchwyt – cpio dostanie SIGPIPE po zakończeniu find
3543 + gzip = subprocess.Popen(["gzip"], stdin=cpio.stdout, stdout=out)
3544 + cpio.stdout.close()
3545 + try:
3546 + gzip.wait(timeout=120)
3547 + if gzip.returncode != 0:
3548 + raise subprocess.CalledProcessError(gzip.returncode, ["gzip"])
3549 + cpio.wait(timeout=30)
3550 + find.wait(timeout=30)
3551 + except subprocess.TimeoutExpired:
3552 + for p in (gzip, cpio, find):
3553 + p.kill()
3554 + raise
3555 + finally:
3556 + for p in (find, cpio, gzip):
3557 + if p.poll() is None:
3558 + p.kill()
3559 + # Skontroluj też kody procesów pośrednich (cpio/find mogą zawieść, a gzip zwrócić 0)
3560 + if cpio.returncode != 0:
3561 + raise subprocess.CalledProcessError(cpio.returncode, ["cpio"])
3562 + if find.returncode != 0:
3563 + raise subprocess.CalledProcessError(find.returncode, ["find"])
3564 +
3565 +
3566 +def _copy_libs_for_binary(binary: str, dest_dir: str, root: str):
3567 + """Kopiuje zależności .so dla binarki do initramfs (uproszczone ldd)."""
3568 + try:
3569 + result = subprocess.run(
3570 + ["ldd", binary], capture_output=True, text=True, timeout=10
3571 + )
3572 + for line in result.stdout.split("\n"):
3573 + m = re.search(r'=>\s+(/\S+)', line)
3574 + if m:
3575 + lib_path = m.group(1)
3576 + lib_rel = lib_path.lstrip("/")
3577 + lib_dest = os.path.join(dest_dir, lib_rel)
3578 + if not os.path.exists(lib_dest):
3579 + os.makedirs(os.path.dirname(lib_dest), exist_ok=True)
3580 + # Szukaj w deployment root lub systemie
3581 + if os.path.exists(lib_path):
3582 + shutil.copy2(lib_path, lib_dest)
3583 + else:
3584 + alt = os.path.join(root, lib_rel)
3585 + if os.path.exists(alt):
3586 + shutil.copy2(alt, lib_dest)
3587 + except Exception:
3588 + pass
3589 +
3590 +
3591 +def cmd_initramfs_update():
3592 + """Ręcznie przebudowuje initramfs dla bieżącego deploymentu."""
3593 + ensure_dirs()
3594 + deploy_dir = _get_deployment_root()
3595 + if deploy_dir != PAG_ROOT:
3596 + print(f"🏗️ Deployment: {os.path.basename(deploy_dir)}")
3597 + ok = _rebuild_initramfs(deploy_dir)
3598 + if ok:
3599 + print("✅ Initramfs zaktualizowany.")
3600 + # Po initramfs – zaktualizuj też GRUB
3601 + _update_grub_config()
3602 + else:
3603 + print("❌ Błąd aktualizacji initramfs.")
3604 + return 0 if ok else 1
3605 +
3606 +
3607 +def _update_grub_config():
3608 + """
3609 + Generuje wpisy GRUB dla wszystkich deploymentów.
3610 + Każdy deployment dostaje własny wpis – rollback możliwy z bootloadera.
3611 + """
3612 + grub_cfg = "/boot/grub/grub.cfg"
3613 + if not os.path.exists(os.path.dirname(grub_cfg)):
3614 + return # brak GRUB
3615 +
3616 + deployments = _load_deployments()
3617 + root_dev = _detect_root_device()
3618 +
3619 + lines = [
3620 + "# =====================================================================",
3621 + "# Pagan Linux – GRUB config (wygenerowane przez pag grub-update)",
3622 + f"# Data: {datetime.now().isoformat()}",
3623 + "# =====================================================================",
3624 + "",
3625 + ]
3626 +
3627 + # Domyślny – ostatni (najnowszy) deployment
3628 + if deployments:
3629 + latest = deployments[-1]["id"]
3630 + lines.append(f"set default=0")
3631 + lines.append(f"set timeout=5")
3632 + else:
3633 + lines.append("set default=0")
3634 + lines.append("set timeout=5")
3635 + lines.append("")
3636 +
3637 + # Wpisy dla każdego deploymentu (od najnowszego)
3638 + entry_num = 0
3639 + for d in reversed(deployments):
3640 + deploy_dir = os.path.join(DEPLOYMENTS_DIR, d["id"])
3641 + boot_dir = os.path.join(deploy_dir, "boot")
3642 + kernels = sorted(
3643 + [f for f in os.listdir(boot_dir) if f.startswith("vmlinuz-")],
3644 + reverse=True
3645 + ) if os.path.isdir(boot_dir) else []
3646 +
3647 + kernel_path = f"/.deployments/{d['id']}/boot/{kernels[0]}" if kernels else ""
3648 + initrd_path = f"/.deployments/{d['id']}/boot/initramfs.img"
3649 + initrd_line = f"initrd {initrd_path}" if os.path.exists(os.path.join(boot_dir, "initramfs.img")) else ""
3650 +
3651 + active_mark = " [AKTYWNY]" if d.get("active") else ""
3652 + pkg_list = ", ".join(d.get("packages", [])[:3])
3653 + label = f"Pagan Linux – {d['id']}{active_mark}"
3654 +
3655 + lines.append(f"menuentry '{label}' {{")
3656 + if kernel_path:
3657 + lines.append(f" linux {kernel_path} root={root_dev} rw quiet")
3658 + else:
3659 + lines.append(f" # Brak jądra w tym deploymencie")
3660 + if initrd_line:
3661 + lines.append(f" {initrd_line}")
3662 + lines.append("}")
3663 + lines.append("")
3664 + entry_num += 1
3665 +
3666 + # Wpis fallback: zwykły root (gdyby wszystko padło)
3667 + lines.append("menuentry 'Pagan Linux – fallback (zwykły root)' {")
3668 + lines.append(f" linux /boot/vmlinuz-* root={root_dev} rw quiet")
3669 + lines.append(f" initrd /boot/initramfs.img")
3670 + lines.append("}")
3671 + lines.append("")
3672 +
3673 + # Zapisz
3674 + os.makedirs(os.path.dirname(grub_cfg), exist_ok=True)
3675 + with open(grub_cfg, "w") as f:
3676 + f.write("\n".join(lines))
3677 +
3678 + print(" 📋 GRUB config zaktualizowany – wpisy dla każdego deploymentu")
3679 +
3680 +
3681 +def _detect_root_device() -> str:
3682 + """Wykrywa device partycji root (np. /dev/sda1)."""
3683 + try:
3684 + result = subprocess.run(
3685 + ["findmnt", "-n", "-o", "SOURCE", "/"],
3686 + capture_output=True, text=True, timeout=5
3687 + )
3688 + if result.returncode == 0 and result.stdout.strip():
3689 + return result.stdout.strip()
3690 + except Exception:
3691 + pass
3692 + return "/dev/sda1" # fallback
3693 +
3694 +
3695 +def cmd_grub_update():
3696 + """Ręcznie regeneruje konfigurację GRUB (wpisy dla deploymentów)."""
3697 + ensure_dirs()
3698 + print("📋 Aktualizacja konfiguracji GRUB...")
3699 + _update_grub_config()
3700 + print("✅ GRUB zaktualizowany.")
3701 + return 0
3702 +
3703 +def cmd_deploy_list():
3704 + """Wyświetla listę wszystkich deploymentów."""
3705 + deployments = _load_deployments()
3706 + if not deployments:
3707 + print(_("no_deployments")); return
3708 +
3709 + print(_("deployments_list", len(deployments)))
3710 + active = os.readlink(ACTIVE_LINK) if os.path.islink(ACTIVE_LINK) else ""
3711 +
3712 + for d in reversed(deployments):
3713 + marker = f" ◀ {_('active_deployment')}" if d.get("active") or d["id"] == os.path.basename(active) else ""
3714 + print(f" {d['id']}{marker}")
3715 + print(f" {d['action']}: {', '.join(d['packages'][:5])}")
3716 + if len(d.get('packages', [])) > 5:
3717 + print(f" +{len(d['packages']) - 5} więcej...")
3718 + print(f" {d['timestamp']}")
3719 +
3720 +
3721 +def cmd_deploy_rollback():
3722 + """Przełącza na poprzedni deployment."""
3723 + deployments = _load_deployments()
3724 + active_indices = [i for i, d in enumerate(deployments) if d.get("active")]
3725 +
3726 + if len(deployments) < 2:
3727 + print(f"❌ {_('deploy_rollback_fail')}"); return 1
3728 +
3729 + current_idx = active_indices[0] if active_indices else len(deployments) - 1
3730 + prev_idx = current_idx - 1 if current_idx > 0 else -1
3731 +
3732 + if prev_idx < 0:
3733 + print(f"❌ {_('deploy_rollback_fail')}"); return 1
3734 +
3735 + prev = deployments[prev_idx]
3736 + prev_dir = os.path.join(DEPLOYMENTS_DIR, prev["id"])
3737 +
3738 + if not os.path.isdir(prev_dir):
3739 + print(f"❌ Deployment {prev['id']} nie istnieje na dysku"); return 1
3740 +
3741 + print(f"⏪ Przywracanie deploymentu: {prev['id']}")
3742 + print(f" {prev['action']}: {', '.join(prev['packages'][:5])}")
3743 +
3744 + if os.environ.get("PAG_YES", "") == "1":
3745 + print(_("continue_q") + " t (--yes)")
3746 + else:
3747 + ans = input(_("continue_q")).strip().lower()
3748 + if ans and ans not in ("t", "y"):
3749 + return 0
3750 +
3751 + _switch_deployment(prev_dir)
3752 +
3753 + for d in deployments:
3754 + d["active"] = (d["id"] == prev["id"])
3755 + _save_deployments(deployments)
3756 +
3757 + _update_grub_config()
3758 + print(f"✅ {_('deploy_rollback_ok', prev['id'])}")
3759 + print(" 💡 Restart wymagany do przeładowania systemu.")
3760 + return 0
3761 +
3762 +
3763 +def cmd_deploy_cleanup(keep: int = 3):
3764 + """Usuwa stare deploymenty, zachowując ostatnie `keep`."""
3765 + deployments = _load_deployments()
3766 +
3767 + if len(deployments) <= keep:
3768 + print(f"✅ {_('deploy_cleanup_none', keep)}"); return 0
3769 +
3770 + to_remove = deployments[:-keep]
3771 + removed = 0
3772 +
3773 + for d in to_remove:
3774 + deploy_dir = os.path.join(DEPLOYMENTS_DIR, d["id"])
3775 + if os.path.isdir(deploy_dir):
3776 + shutil.rmtree(deploy_dir, ignore_errors=True)
3777 + removed += 1
3778 +
3779 + remaining = deployments[-keep:]
3780 + _save_deployments(remaining)
3781 +
3782 + print(f"✅ {_('deploy_cleanup_ok', removed)}")
3783 + return 0
3784 +
3785 +
3786 +# =============================================================================
3787 +# POMOCNICZE
3788 +# =============================================================================
3789 +
3790 +def _resolve_deps(names, repo, installed):
3791 + resolved, visited = [], set()
3792 + missing = [] # zależności których nie ma ani w repo ani zainstalowane
3793 +
3794 + def visit(name):
3795 + if name in visited: return
3796 +
3797 + # Rozwijanie wirtualnych zależności przez provides
3798 + target = _resolve_provides(name, repo)
3799 +
3800 + if target in visited: return
3801 + visited.add(target)
3802 + if target in repo:
3803 + for dep in repo[target].dependencies:
3804 + real_dep = _resolve_provides(dep, repo)
3805 + real_target = real_dep if real_dep in repo else dep
3806 +
3807 + # Sprawdź czy zależność jest dostępna
3808 + if real_target not in installed and real_target not in repo:
3809 + if dep not in missing:
3810 + missing.append(dep)
3811 +
3812 + if dep not in installed:
3813 + visit(real_target)
3814 + elif target not in installed:
3815 + # Pakiet nie istnieje ani w repo ani zainstalowany
3816 + if target not in missing:
3817 + missing.append(target)
3818 +
3819 + if target not in installed and target not in resolved:
3820 + resolved.append(target)
3821 +
3822 + for name in names:
3823 + visit(name)
3824 +
3825 + # Zwróć brakujące (do sprawdzenia przez wywołującego)
3826 + return resolved, missing
3827 +
3828 +def _verify_dependencies(to_install: list, repo: dict, installed: dict) -> int:
3829 + """
3830 + Sprawdza czy wszystkie zależności pakietów do instalacji są spełnione.
3831 + Zwraca liczbę brakujących zależności.
3832 + """
3833 + # Pakiety dostarczane przez bazowy system (zawsze "zainstalowane")
3834 + SYSTEM_BASE = {
3835 + "glibc", "libc", "gcc", "g++", "make", "binutils", "coreutils", "bash",
3836 + "linux-api-headers", "kernel-headers", "zlib", "pkg-config", "pkgconf",
3837 + "tar", "gzip", "xz", "bzip2", "findutils", "grep", "sed", "gawk", "awk",
3838 + "diffutils", "patch", "file", "m4", "perl", "python3", "sh",
3839 + }
3840 + all_missing = []
3841 + all_warnings = []
3842 +
3843 + for pkg_name in to_install:
3844 + pkg = repo.get(pkg_name)
3845 + if not pkg:
3846 + continue
3847 +
3848 + for dep in pkg.dependencies:
3849 + if dep in SYSTEM_BASE:
3850 + continue # bazowy system dostarcza tę zależność
3851 + real_dep = _resolve_provides(dep, repo)
3852 + # Sprawdź czy zależność jest dostępna (w repo lub już zainstalowana)
3853 + in_repo = real_dep in repo
3854 + in_installed = real_dep in installed
3855 + will_be_installed = real_dep in to_install
3856 +
3857 + if not in_repo and not in_installed and not will_be_installed:
3858 + if dep not in all_missing:
3859 + all_missing.append((pkg_name, dep))
3860 + elif in_repo and not in_installed and not will_be_installed:
3861 + if dep not in [w[1] for w in all_warnings]:
3862 + all_warnings.append((pkg_name, dep, real_dep))
3863 +
3864 + if all_missing:
3865 + print(f"\n❌ {_c('red', 'BRAKUJĄCE ZALEŻNOŚCI')} – nie można zainstalować:")
3866 + for pkg, dep in all_missing:
3867 + print(f" {pkg} → potrzebuje {_c('red', dep)} (brak w repozytoriach)")
3868 + print()
3869 +
3870 + if all_warnings:
3871 + print(f"\n⚠ {_c('yellow', 'NIESPEŁNIONE ZALEŻNOŚCI')} – zostaną doinstalowane:")
3872 + for pkg, dep, real in all_warnings:
3873 + print(f" {pkg} → {dep} ({_c('green', real)} – będzie pobrane)")
3874 + print()
3875 +
3876 + return len(all_missing)
3877 +
3878 +def _download_pkg(pkg):
3879 + url = f"{pkg.repo_url}/{pkg.filename}"
3880 + dest = os.path.join(PAG_CACHE, pkg.filename)
3881 + if os.path.exists(dest) and (not pkg.sha256 or _sha256_file(dest) == pkg.sha256):
3882 + _download_pkg_sig(pkg, dest) # upewnij się, że sygnatura jest w cache
3883 + return dest
3884 + try:
3885 + req = Request(url, headers={"User-Agent":"pag/3.0"})
3886 + with urlopen(req, timeout=600) as resp:
3887 + total = int(resp.headers.get("Content-Length", 0))
3888 + bar = DownloadBar(pkg.filename, total)
3889 + with open(dest, "wb") as f:
3890 + while True:
3891 + chunk = resp.read(65536)
3892 + if not chunk:
3893 + break
3894 + f.write(chunk)
3895 + bar.update(len(chunk))
3896 + bar.close()
3897 + if pkg.sha256 and _sha256_file(dest) != pkg.sha256:
3898 + os.remove(dest); return None
3899 + _download_pkg_sig(pkg, dest)
3900 + return dest
3901 + except Exception as e:
3902 + print(f" ⚠ Błąd pobierania {pkg.filename}: {e}", file=sys.stderr)
3903 + return None
3904 +
3905 +def _download_pkg_sig(pkg, dest):
3906 + """Pobiera podpis pakietu (.asc, fallback .sig) obok paczki w cache."""
3907 + for ext in (".asc", ".sig"):
3908 + sig_dest = dest + ext
3909 + if os.path.exists(sig_dest):
3910 + return
3911 + try:
3912 + req = Request(f"{pkg.repo_url}/{pkg.filename}{ext}", headers={"User-Agent":"pag/3.0"})
3913 + with urlopen(req, timeout=30) as resp:
3914 + with open(sig_dest, "wb") as f:
3915 + f.write(resp.read())
3916 + return
3917 + except Exception:
3918 + continue
3919 +
3920 +def _download_packages_parallel(pkgs: List[PackageInfo], max_workers: int = 4) -> Dict[str, Optional[str]]:
3921 + """
3922 + Równoległe pobieranie wielu pakietów przez ThreadPoolExecutor.
3923 + Znacząco przyspiesza przy dużych aktualizacjach (50+ pakietów).
3924 + Zwraca słownik {nazwa_pakietu: ścieżka_lub_None}.
3925 + """
3926 + results = {}
3927 + total = len(pkgs)
3928 + completed = 0
3929 + with ThreadPoolExecutor(max_workers=max_workers) as executor:
3930 + future_to_pkg = {executor.submit(_download_pkg, pkg): pkg for pkg in pkgs}
3931 + for future in as_completed(future_to_pkg):
3932 + pkg = future_to_pkg[future]
3933 + try:
3934 + results[pkg.name] = future.result()
3935 + except Exception:
3936 + results[pkg.name] = None
3937 + completed += 1
3938 + # Pasek postępu
3939 + pct = completed / total * 100
3940 + filled = int(20 * pct / 100)
3941 + bar = "█" * filled + "░" * (20 - filled)
3942 + print(f"\r ⏬ [{bar}] {completed}/{total} ({pct:.0f}%)", end="", file=sys.stderr, flush=True)
3943 + print(file=sys.stderr) # nowa linia po zakończeniu
3944 + return results
3945 +
3946 +def load_world():
3947 + if not os.path.exists(WORLD_FILE): return set()
3948 + return {l.strip() for l in open(WORLD_FILE) if l.strip()}
3949 +
3950 +def save_world(w):
3951 + with open(WORLD_FILE,"w") as f:
3952 + for n in sorted(w): f.write(f"{n}\n")
3953 +
3954 +def _find_orphans(installed, world):
3955 + needed = set(world)
3956 + changed = True
3957 + while changed:
3958 + changed = False
3959 + for n in list(needed):
3960 + for dep in installed.get(n,{}).get("dependencies",[]):
3961 + if dep not in needed and dep in installed:
3962 + needed.add(dep); changed = True
3963 + return {n for n in installed if n not in needed}
3964 +
3965 +# =============================================================================
3966 +# MAIN
3967 +# =============================================================================
3968 +
3969 +USAGE_EN = """pag v3 – Pagan Linux Package Manager
3970 +
3971 +BASIC:
3972 + pag install <pkg>... Install packages
3973 + pag remove <pkg>... Remove packages
3974 + pag update [--force] Refresh repo indexes
3975 + pag sync Refresh repo indexes (alias for update)
3976 + pag upgrade Upgrade all packages
3977 + pag list [--installed] List available / installed
3978 + pag search <query> Search packages
3979 + pag info <pkg> Package details
3980 + pag files <pkg> List package files
3981 + pag verify [--deep] Verify integrity (--deep = SHA256 per file)
3982 + pag clean Clear download cache
3983 + pag stats System statistics
3984 + pag download <pkg>... Download packages to cache (offline prep)
3985 +
3986 +SECURITY:
3987 + pag key-add <url|file> Import GPG key
3988 + pag key-list List trusted keys
3989 + pag key-remove <id> Remove key
3990 + pag key-trust <repo> Pin repo signing key fingerprint (no TOFU)
3991 + pag key-untrust <repo> Forget repo fingerprint (back to TOFU)
3992 + pag key-trusted List pinned repo fingerprints
3993 +
3994 +ADVANCED:
3995 + pag why <pkg> Show why a package is installed
3996 + pag autoremove Auto-remove orphaned dependencies
3997 + pag pin <pkg> [ver] Pin package version
3998 + pag unpin <pkg> Unpin
3999 + pag pinned List pinned
4000 + pag history Transaction history
4001 + pag rollback Rollback last transaction
4002 + pag remove-orphans Remove orphaned deps
4003 + pag repo-add <url> [name] Add repository (drop-in /etc/pag/repos/)
4004 + pag repo-list List repositories
4005 +
4006 +FLATPAK:
4007 + pag flatpak [<query>] Search & install (smart)
4008 + pag flatpak search <q> Search Flathub
4009 + pag flatpak install <id> Install flatpak
4010 + pag flatpak remove <id> Remove flatpak
4011 + pag flatpak list List installed flatpaks
4012 + pag flatpak update Update all flatpaks
4013 + pag flatpak info <id> Show flatpak details
4014 +
4015 +IMMUTABLE OS (PAG_IMMUTABLE=1):
4016 + pag deploy-list List all deployments
4017 + pag deploy-rollback Switch to previous deployment
4018 + pag deploy-cleanup [N] Remove old deployments (keep last N, default 3)
4019 + pag initramfs-update Rebuild initramfs for current kernel/deployment
4020 + pag grub-update Regenerate GRUB entries for all deployments
4021 +"""
4022 +
4023 +USAGE_PL = """pag v3 – Pagan Linux Package Manager
4024 +
4025 +PODSTAWOWE:
4026 + pag install <pkg>... Instalacja pakietów
4027 + pag remove <pkg>... Usuwanie pakietów
4028 + pag update [--force] Odśwież indeksy repozytoriów
4029 + pag sync Odśwież indeksy repozytoriów (alias dla update)
4030 + pag upgrade Aktualizacja wszystkich pakietów
4031 + pag list [--installed] Lista dostępnych / zainstalowanych
4032 + pag search <query> Szukaj pakietów
4033 + pag info <pkg> Szczegóły pakietu
4034 + pag files <pkg> Lista plików pakietu
4035 + pag verify [--deep] Weryfikacja integralności
4036 + pag clean Wyczyść cache pobierania
4037 + pag stats Statystyki systemu
4038 + pag download <pkg>... Pobierz do cache (offline)
4039 +
4040 +BEZPIECZEŃSTWO:
4041 + pag key-add <url|file> Importuj klucz GPG
4042 + pag key-list Lista zaufanych kluczy
4043 + pag key-remove <id> Usuń klucz
4044 + pag key-trust <repo> Przypnij fingerprint klucza repo (bez TOFU)
4045 + pag key-untrust <repo> Zapomnij fingerprint repo (powrót do TOFU)
4046 + pag key-trusted Lista przypiętych fingerprintów repo
4047 +
4048 +ZAAWANSOWANE:
4049 + pag why <pkg> Dlaczego pakiet jest zainstalowany
4050 + pag autoremove Usuń osierocone zależności
4051 + pag pin <pkg> [ver] Przypnij wersję pakietu
4052 + pag unpin <pkg> Odepnij
4053 + pag pinned Lista przypiętych
4054 + pag history Historia transakcji
4055 + pag rollback Cofnij ostatnią transakcję
4056 + pag remove-orphans Usuń osierocone zależności
4057 + pag repo-add <url> [nazwa] Dodaj repozytorium (drop-in w /etc/pag/repos/)
4058 + pag repo-list Lista repozytoriów
4059 +
4060 +FLATPAK:
4061 + pag flatpak [<query>] Szukaj i instaluj
4062 + pag flatpak search <q> Szukaj na Flathub
4063 + pag flatpak install <id> Zainstaluj flatpak
4064 + pag flatpak remove <id> Usuń flatpak
4065 + pag flatpak list Lista zainstalowanych
4066 + pag flatpak update Aktualizuj wszystkie
4067 + pag flatpak info <id> Szczegóły flatpaka
4068 +
4069 +IMMUTABLE OS (PAG_IMMUTABLE=1):
4070 + pag deploy-list Lista wdrożeń
4071 + pag deploy-rollback Przełącz na poprzednie wdrożenie
4072 + pag deploy-cleanup [N] Usuń stare wdrożenia (zachowaj N, domyślnie 3)
4073 + pag initramfs-update Przebuduj initramfs
4074 + pag grub-update Regeneruj wpisy GRUB"""
4075 +
4076 +def _get_usage():
4077 + if LANG == "pl":
4078 + return USAGE_PL
4079 + return USAGE_EN
4080 +
4081 +
4082 +def main():
4083 + if len(sys.argv) >= 2 and sys.argv[1] in ("--version", "-V", "version"):
4084 + print(f"pag {PAG_VERSION}")
4085 + sys.exit(0)
4086 + if len(sys.argv) < 2:
4087 + print(_get_usage()); sys.exit(0)
4088 +
4089 + cmd = sys.argv[1]
4090 + args = sys.argv[2:]
4091 +
4092 + # --- Komendy TYLKO DO ODCZYTU (nie wymagają roota) ---
4093 + READ_ONLY = {
4094 + "list": lambda: cmd_list("--installed" in args),
4095 + "search": lambda: cmd_search(args[0]) if args else print("Usage: pag search <query>"),
4096 + "info": lambda: cmd_info(args[0]) if args else print("Usage: pag info <pkg>"),
4097 + "files": lambda: cmd_files(args[0]) if args else print("Usage: pag files <pkg>"),
4098 + "verify": lambda: cmd_verify("--deep" in args),
4099 + "why": lambda: cmd_why(args[0]) if args else print("Usage: pag why <pkg>"),
4100 + "stats": cmd_stats,
4101 + "pinned": cmd_pinned,
4102 + "history": cmd_history,
4103 + "repo-list": cmd_repo_list,
4104 + "key-list": cmd_key_list,
4105 + "key-trusted": cmd_key_trusted,
4106 + "flatpak": lambda: cmd_flatpak(args),
4107 + "flatpak-search": lambda: cmd_flatpak_search(args[0]) if args else print("Usage: pag flatpak-search <query>"),
4108 + "flatpak-list": cmd_flatpak_list,
4109 + "flatpak-info": lambda: cmd_flatpak_info(args[0]) if args else print("Usage: pag flatpak-info <id>"),
4110 + "deploy-list": cmd_deploy_list,
4111 + "deploy": cmd_deploy_list,
4112 + }
4113 +
4114 + if cmd in READ_ONLY:
4115 + sys.exit(READ_ONLY[cmd]() or 0)
4116 +
4117 + # --- Smart search: `pag <nazwa-pakietu>` → repo + Flathub + sugestie ---
4118 + WRITE_CMDS = {
4119 + "install", "remove", "update", "sync", "upgrade", "clean", "download",
4120 + "autoremove", "remove-orphans", "pin", "unpin", "rollback",
4121 + "repo-add", "key-add", "key-remove", "key-trust", "key-untrust",
4122 + "self-update",
4123 + "flatpak", "flatpak-install", "flatpak-remove", "flatpak-update",
4124 + "deploy-rollback", "deploy-cleanup", "initramfs-update", "grub-update",
4125 + }
4126 + if cmd not in WRITE_CMDS:
4127 + sys.exit(_smart_search(" ".join([cmd] + args)) or 0)
4128 +
4129 + # Obsługa flag globalnych (-y/--yes)
4130 + global_args = []
4131 + for a in args:
4132 + if a in ("-y", "--yes"):
4133 + os.environ["PAG_YES"] = "1"
4134 + else:
4135 + global_args.append(a)
4136 + args = global_args
4137 +
4138 + # --- Komendy ZAPISU (wymagają roota) ---
4139 + if os.geteuid() != 0:
4140 + print(f"❌ {_('root_required')}", file=sys.stderr); sys.exit(1)
4141 +
4142 + ensure_dirs()
4143 +
4144 + with DatabaseLock():
4145 + WRITE_COMMANDS = {
4146 + "install": lambda: cmd_install(args),
4147 + "remove": lambda: cmd_remove(args),
4148 + "update": cmd_update,
4149 + "sync": cmd_update,
4150 + "upgrade": cmd_upgrade,
4151 + "clean": cmd_clean,
4152 + "download": lambda: cmd_download(args),
4153 + "autoremove": cmd_autoremove,
4154 + "remove-orphans": cmd_remove_orphans,
4155 + "pin": lambda: cmd_pin(args[0], args[1] if len(args)>1 else ""),
4156 + "unpin": lambda: cmd_unpin(args[0]) if args else print("Usage: pag unpin <pkg>"),
4157 + "rollback": cmd_rollback,
4158 + "repo-add": lambda: cmd_repo_add(args[0], args[1] if len(args) > 1 else "") if args else print("Usage: pag repo-add <url> [name]"),
4159 + "key-add": lambda: cmd_key_add(args[0]) if args else print("Usage: pag key-add <url|file>"),
4160 + "key-remove": lambda: cmd_key_remove(args[0]) if args else print("Usage: pag key-remove <id>"),
4161 + "key-trust": lambda: cmd_key_trust(args[0]) if args else print("Usage: pag key-trust <repo_url>"),
4162 + "key-untrust": lambda: cmd_key_untrust(args[0]) if args else print("Usage: pag key-untrust <repo_url>"),
4163 + "self-update": cmd_self_update,
4164 + "flatpak": lambda: cmd_flatpak(args),
4165 + "flatpak-install": lambda: _flatpak_smart_install(args) if args else print("Usage: pag flatpak-install <app>"),
4166 + "flatpak-remove": lambda: _flatpak_smart_remove(args) if args else print("Usage: pag flatpak-remove <app>"),
4167 + "flatpak-update": cmd_flatpak_update,
4168 + "deploy-rollback": cmd_deploy_rollback,
4169 + "deploy-cleanup": lambda: cmd_deploy_cleanup(int(args[0]) if args else 3),
4170 + "initramfs-update": cmd_initramfs_update,
4171 + "grub-update": cmd_grub_update,
4172 + }
4173 +
4174 + fn = WRITE_COMMANDS.get(cmd)
4175 + if fn:
4176 + sys.exit(fn() or 0)
4177 + # Should never reach here – _smart_search handles unknowns
4178 + sys.exit(_smart_search(" ".join([cmd] + args)) or 0)
4179 +
4180 +if __name__ == "__main__":
3320 4181 main()