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