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