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