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