🔒 Repository is read-only – file editing is disabled.

PaganLinux/pagan-web-v2/pagsync main

3704 linii Raw ← Powrót
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355135613571358135913601361136213631364136513661367136813691370137113721373137413751376137713781379138013811382138313841385138613871388138913901391139213931394139513961397139813991400140114021403140414051406140714081409141014111412141314141415141614171418141914201421142214231424142514261427142814291430143114321433143414351436143714381439144014411442144314441445144614471448144914501451145214531454145514561457145814591460146114621463146414651466146714681469147014711472147314741475147614771478147914801481148214831484148514861487148814891490149114921493149414951496149714981499150015011502150315041505150615071508150915101511151215131514151515161517151815191520152115221523152415251526152715281529153015311532153315341535153615371538153915401541154215431544154515461547154815491550155115521553155415551556155715581559156015611562156315641565156615671568156915701571157215731574157515761577157815791580158115821583158415851586158715881589159015911592159315941595159615971598159916001601160216031604160516061607160816091610161116121613161416151616161716181619162016211622162316241625162616271628162916301631163216331634163516361637163816391640164116421643164416451646164716481649165016511652165316541655165616571658165916601661166216631664166516661667166816691670167116721673167416751676167716781679168016811682168316841685168616871688168916901691169216931694169516961697169816991700170117021703170417051706170717081709171017111712171317141715171617171718171917201721172217231724172517261727172817291730173117321733173417351736173717381739174017411742174317441745174617471748174917501751175217531754175517561757175817591760176117621763176417651766176717681769177017711772177317741775177617771778177917801781178217831784178517861787178817891790179117921793179417951796179717981799180018011802180318041805180618071808180918101811181218131814181518161817181818191820182118221823182418251826182718281829183018311832183318341835183618371838183918401841184218431844184518461847184818491850185118521853185418551856185718581859186018611862186318641865186618671868186918701871187218731874187518761877187818791880188118821883188418851886188718881889189018911892189318941895189618971898189919001901190219031904190519061907190819091910191119121913191419151916191719181919192019211922192319241925192619271928192919301931193219331934193519361937193819391940194119421943194419451946194719481949195019511952195319541955195619571958195919601961196219631964196519661967196819691970197119721973197419751976197719781979198019811982198319841985198619871988198919901991199219931994199519961997199819992000200120022003200420052006200720082009201020112012201320142015201620172018201920202021202220232024202520262027202820292030203120322033203420352036203720382039204020412042204320442045204620472048204920502051205220532054205520562057205820592060206120622063206420652066206720682069207020712072207320742075207620772078207920802081208220832084208520862087208820892090209120922093209420952096209720982099210021012102210321042105210621072108210921102111211221132114211521162117211821192120212121222123212421252126212721282129213021312132213321342135213621372138213921402141214221432144214521462147214821492150215121522153215421552156215721582159216021612162216321642165216621672168216921702171217221732174217521762177217821792180218121822183218421852186218721882189219021912192219321942195219621972198219922002201220222032204220522062207220822092210221122122213221422152216221722182219222022212222222322242225222622272228222922302231223222332234223522362237223822392240224122422243224422452246224722482249225022512252225322542255225622572258225922602261226222632264226522662267226822692270227122722273227422752276227722782279228022812282228322842285228622872288228922902291229222932294229522962297229822992300230123022303230423052306230723082309231023112312231323142315231623172318231923202321232223232324232523262327232823292330233123322333233423352336233723382339234023412342234323442345234623472348234923502351235223532354235523562357235823592360236123622363236423652366236723682369237023712372237323742375237623772378237923802381238223832384238523862387238823892390239123922393239423952396239723982399240024012402240324042405240624072408240924102411241224132414241524162417241824192420242124222423242424252426242724282429243024312432243324342435243624372438243924402441244224432444244524462447244824492450245124522453245424552456245724582459246024612462246324642465246624672468246924702471247224732474247524762477247824792480248124822483248424852486248724882489249024912492249324942495249624972498249925002501250225032504250525062507250825092510251125122513251425152516251725182519252025212522252325242525252625272528252925302531253225332534253525362537253825392540254125422543254425452546254725482549255025512552255325542555255625572558255925602561256225632564256525662567256825692570257125722573257425752576257725782579258025812582258325842585258625872588258925902591259225932594259525962597259825992600260126022603260426052606260726082609261026112612261326142615261626172618261926202621262226232624262526262627262826292630263126322633263426352636263726382639264026412642264326442645264626472648264926502651265226532654265526562657265826592660266126622663266426652666266726682669267026712672267326742675267626772678267926802681268226832684268526862687268826892690269126922693269426952696269726982699270027012702270327042705270627072708270927102711271227132714271527162717271827192720272127222723272427252726272727282729273027312732273327342735273627372738273927402741274227432744274527462747274827492750275127522753275427552756275727582759276027612762276327642765276627672768276927702771277227732774277527762777277827792780278127822783278427852786278727882789279027912792279327942795279627972798279928002801280228032804280528062807280828092810281128122813281428152816281728182819282028212822282328242825282628272828282928302831283228332834283528362837283828392840284128422843284428452846284728482849285028512852285328542855285628572858285928602861286228632864286528662867286828692870287128722873287428752876287728782879288028812882288328842885288628872888288928902891289228932894289528962897289828992900290129022903290429052906290729082909291029112912291329142915291629172918291929202921292229232924292529262927292829292930293129322933293429352936293729382939294029412942294329442945294629472948294929502951295229532954295529562957295829592960296129622963296429652966296729682969297029712972297329742975297629772978297929802981298229832984298529862987298829892990299129922993299429952996299729982999300030013002300330043005300630073008300930103011301230133014301530163017301830193020302130223023302430253026302730283029303030313032303330343035303630373038303930403041304230433044304530463047304830493050305130523053305430553056305730583059306030613062306330643065306630673068306930703071307230733074307530763077307830793080308130823083308430853086308730883089309030913092309330943095309630973098309931003101310231033104310531063107310831093110311131123113311431153116311731183119312031213122312331243125312631273128312931303131313231333134313531363137313831393140314131423143314431453146314731483149315031513152315331543155315631573158315931603161316231633164316531663167316831693170317131723173317431753176317731783179318031813182318331843185318631873188318931903191319231933194319531963197319831993200320132023203320432053206320732083209321032113212321332143215321632173218321932203221322232233224322532263227322832293230323132323233323432353236323732383239324032413242324332443245324632473248324932503251325232533254325532563257325832593260326132623263326432653266326732683269327032713272327332743275327632773278327932803281328232833284328532863287328832893290329132923293329432953296329732983299330033013302330333043305330633073308330933103311331233133314331533163317331833193320332133223323332433253326332733283329333033313332333333343335333633373338333933403341334233433344334533463347334833493350335133523353335433553356335733583359336033613362336333643365336633673368336933703371337233733374337533763377337833793380338133823383338433853386338733883389339033913392339333943395339633973398339934003401340234033404340534063407340834093410341134123413341434153416341734183419342034213422342334243425342634273428342934303431343234333434343534363437343834393440344134423443344434453446344734483449345034513452345334543455345634573458345934603461346234633464346534663467346834693470347134723473347434753476347734783479348034813482348334843485348634873488348934903491349234933494349534963497349834993500350135023503350435053506350735083509351035113512351335143515351635173518351935203521352235233524352535263527352835293530353135323533353435353536353735383539354035413542354335443545354635473548354935503551355235533554355535563557355835593560356135623563356435653566356735683569357035713572357335743575357635773578357935803581358235833584358535863587358835893590359135923593359435953596359735983599360036013602360336043605360636073608360936103611361236133614361536163617361836193620362136223623362436253626362736283629363036313632363336343635363636373638363936403641364236433644364536463647364836493650365136523653365436553656365736583659366036613662366336643665366636673668366936703671367236733674367536763677367836793680368136823683368436853686368736883689369036913692369336943695369636973698369937003701370237033704
#!/usr/bin/env python3
# =============================================================================
# PAGSYNC v2 – PaganOS Recipe Sync & Build Queue Manager
# Skanuje drzewo recipes/, buduje pakiety przez pagbuild, synchronizuje do repo.
# =============================================================================
import os, sys, json, time, argparse, subprocess, shutil, re, tempfile, signal, glob
from pathlib import Path
from datetime import datetime, timezone

# ── Pełny PATH – procesy wywoływane z cron.d / panelu mają ucięte PATH
# (/usr/bin:/bin) i NIE znajdują /usr/sbin/chroot, /usr/sbin/sshd itp.
# To była przyczyna masowych „⚠ Instalacja do rootfs nieudana: [Errno 2]
# No such file or directory: 'chroot'” – pakiet był rozpakowywany do rootfs,
# ale NIE rejestrowany w installed.json, przez co pre-flight kolejnych
# buildów w kółko zgłaszał BRAK i dobudowywał te same zależności.
os.environ["PATH"] = os.environ.get(
    "PATH", "") + ":/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"

def _find_chroot():
    """Ścieżka do binarki chroot – bywa w /usr/sbin, poza uciętym PATH."""
    for _cand in ("/usr/sbin/chroot", "/sbin/chroot", "/bin/chroot",
                  "/usr/bin/chroot", "/usr/local/sbin/chroot"):
        if os.path.exists(_cand):
            return _cand
    return shutil.which("chroot") or "chroot"

CHROOT_BIN = _find_chroot()


def _load_build_conf(path=None):
    """Wczytuje /etc/pagan/build.conf do os.environ (bez nadpisywania już
    ustawionych zmiennych).

    Dzięki temu KAŻDE wywołanie pagsync – panel, cron, timer czy ręczne z CLI –
    ma PAGAN_DO_SIGN/PAGAN_GPG_KEY, więc repo.json jest aktualizowany po każdym
    udanym buildzie, a paczki są podpisywane. Wcześniej zależało to od tego, czy
    wywołujący sam wyeksportował te zmienne (panel/cron/timer robiły to osobno).
    """
    if path is None:
        path = os.environ.get("PAGAN_BUILD_CONF", "/etc/pagan/build.conf")
    try:
        with open(path) as _f:
            for _line in _f:
                _line = _line.strip()
                if not _line or _line.startswith("#") or "=" not in _line:
                    continue
                if _line.startswith("export "):
                    _line = _line[len("export "):].strip()
                _k, _, _v = _line.partition("=")
                _k = _k.strip()
                _v = _v.strip().strip('"').strip("'")
                if _k and _k not in os.environ:
                    os.environ[_k] = _v
    except FileNotFoundError:
        pass
    except Exception:
        pass


_load_build_conf()

# ── Konfiguracja (nadpisywalna przez env – ułatwia testy i lokalne repo) ──
STATE_FILE      = os.environ.get("PAGAN_STATE", "/var/lib/pagan-sync/state.json")
RECIPES_DIR     = os.environ.get("PAGAN_RECIPES", "/var/lib/pagan-sync/recipes")
REPO_BASE       = os.environ.get("PAGAN_REPO", "/var/www/repo.paganlinux.eu")
BUILD_OUT       = os.environ.get("PAGAN_BUILD_OUT", "/var/cache/pagbuild/output")
PAGBUILD_BIN    = os.environ.get("PAGAN_PAGBUILD", "/opt/pagan-web-v2/pagbuild")
ROOTFS_PATH     = os.environ.get("PAGAN_ROOTFS", "/var/lib/pagan-build/rootfs")
PKG_EXT         = ".pag"
GPG_KEY         = os.environ.get("PAGAN_GPG_KEY", "")     # ID klucza GPG do podpisywania
DO_SIGN         = os.environ.get("PAGAN_DO_SIGN", "") == "1" or bool(GPG_KEY)

# Diagnostyka nieudanych buildów (diagnose-<name>-<ver>.json pisany przez
# pagbuild). pagsync --auto-deps czyta ją i dobudowuje brakujące zależności.
DIAG_DIR        = os.environ.get("PAGAN_DIAG", "/var/cache/pagbuild/diagnostics")
AUTO_DEPS       = True          # --no-auto-deps wyłącza
MAX_AUTO_DEPS_RETRIES = 3       # ile razy ponowić build po dobudowaniu deps

# Zawsze wywołuj pagbuild z --strict-deps: brak makedepends w rootfs = twardy
# błąd PRZED buildem (zamiast ostrzeżenia i budowania „na ślepo”). Auto-deps
# i tak dobuduje/doinstaluje braki i ponowi – tryb twardy tylko nie marnuje
# czasu na build, który i tak by padł. Wyłącz przez PAGAN_STRICT_DEPS=0.
STRICT_DEPS = os.environ.get("PAGAN_STRICT_DEPS", "1") not in ("0", "no", "false")

# Exit 75 z pagbuild = „rootfs zajęty przez inny build” (flock). To zwykle stan
# PRZEJŚCIOWY (inny build właśnie się kończy / osierocony proces dobudowuje) –
# zamiast od razu oznaczać pakiet jako FAIL, czekamy i ponawiamy.
MAX_ROOTFS_RETRIES = int(os.environ.get("PAGAN_ROOTFS_RETRIES", "60"))
ROOTFS_RETRY_SLEEP = 30          # sekundy między próbami (30 min max czekania)

# Aktywne procesy pagbuild – przy SIGTERM/SIGINT zabijamy CAŁĄ grupę procesów,
# żeby osierocony build nie trzymał dalej locka rootfs (przyczyna masowych
# porażek exit 75 w przebiegach historycznych).
_ACTIVE_PROCS = []

def _kill_active_procs(signum, frame):
    for _p in list(_ACTIVE_PROCS):
        try:
            os.killpg(_p.pid, signal.SIGTERM)   # cała grupa (build script, gcc, ...)
        except Exception:
            try:
                _p.terminate()
            except Exception:
                pass
    sys.exit(128 + signum)

signal.signal(signal.SIGTERM, _kill_active_procs)
signal.signal(signal.SIGINT, _kill_active_procs)

# Kategorie – mapowanie ścieżek recipes na kategorie repo
RECIPE_CAT_MAP = {
    "core": "core", "libs": "core", "boot": "core",
    "drivers": "drivers", "net": "network",
    "gui": "desktop", "de": "desktop",
    "utils": "tools",
}
# Domyślne kategorie w repo
REPO_CATEGORIES = ["stable"]

# Git repo z recepturami
RECIPES_GIT_REMOTE = "/var/git/recipes.git"       # lokalne bare repo
RECIPES_GIT_DIR    = "/var/git/recipes.git"       # bare repo na serwerze

# Pakiety z martwym źródłem (host zlikwidowany / brak wydania) – pomijane
# w trybie --missing, żeby nie blokowały kolejki. Można nadpisać env
# PAGAN_SKIP (spacja-oddzielona) lub odkomentowując/wpisując nazwy.
SKIP_PACKAGES = set(os.environ.get("PAGAN_SKIP", "").split()) | {
    "vamp-plugin-sdk", "libmpeg2", "libsynctex", "upp", "spotify",
    "teams", "nutyx", "xquisite", "volumeicon", "foomatic-db",
    "foomatic-db-engine", "foomatic-db-nonfree", "qt2", "openjdk",
    "ttf-mplus", "libfakekey", "netcf", "volume_key",
    "sayonara-player", "xfce4-datetime-plugin", "virt-viewer",
    "qt-creator", "fox", "libunique1",
    "syncrosvn",   # martwe źródło (syncrosvnclient.com przekierowuje na marketing)
}

# Zależności buildowe dostarczane przez bazowy builder (zawsze "obecne")
BUILD_SYSTEM_DEPS = {
    "glibc", "libc", "gcc", "g++", "cc", "make", "cmake", "meson", "ninja",
    "binutils", "ld", "ar", "coreutils", "bash", "sh", "tar", "gzip", "xz",
    "bzip2", "findutils", "grep", "sed", "gawk", "awk", "diffutils", "patch",
    "file", "m4", "perl", "python3", "python", "pkg-config", "pkgconf",
    "linux-api-headers", "kernel-headers", "autoconf", "automake", "libtool",
    "flex", "bison", "gettext", "makeinfo", "which", "util-linux",
}

# Znane mapowania nazw pkg-config / modułów → nazwy receptur (heurystyka dla
# auto-deps i sugerowania makedepends)
PC_ALIASES = {
    "libsoup-3.0": "libsoup3", "libsoup-2.4": "libsoup2",
    "gtk+-3.0": "gtk3", "gtk+-2.0": "gtk2", "gtk4": "gtk4",
    "glib-2.0": "glib", "gobject-2.0": "glib", "gio-2.0": "glib",
    "gdk-pixbuf-2.0": "gdk-pixbuf2", "libpng": "libpng",
    "libjpeg": "libjpeg-turbo", "libtiff-4": "libtiff", "ffi": "libffi",
    "libcurl": "curl", "openssl": "openssl", "libxml-2.0": "libxml2",
    "libxslt": "libxslt", "freetype2": "freetype", "fontconfig": "fontconfig",
    "xlib": "xorg-libx11", "xutil": "xorg-libx11",
    # ── system / baza ──
    "libcrypto": "openssl", "libssl": "openssl",
    "libudev": "systemd", "libmount": "util-linux", "libblkid": "util-linux",
    "uuid": "util-linux", "libexpat": "expat", "libpcre": "pcre",
    "ncursesw": "ncurses", "tinfo": "ncurses", "panel": "ncurses",
    "readline": "readline", "libseccomp": "libseccomp", "pam": "linux-pam",
    "libxcrypt": "libxcrypt", "libcrypt": "libxcrypt", "libnsl": "libnsl",
    "liblz4": "lz4", "bzip2": "bzip2", "zlib": "zlib",
    "libevent": "libevent", "libarchive": "libarchive",
    "sqlite3": "sqlite", "libpq": "postgresql", "mysqlclient": "mariadb",
    "json-c": "json-c", "json-glib-1.0": "json-glib",
    "libunistring": "libunistring", "libidn2": "libidn2", "libpsl": "libpsl",
    "icu-uc": "icu", "icu-i18n": "icu", "icu-io": "icu",
    "gnutls": "gnutls", "nettle": "nettle", "hogweed": "nettle",
    "libgcrypt": "libgcrypt", "libgpg-error": "libgpg-error",
    "libassuan": "libassuan", "libusb-1.0": "libusb", "libusb": "libusb",
    "libgudev-1.0": "libgudev", "gudev-1.0": "libgudev",
    "polkit-gobject-1": "polkit", "polkit-agent-1": "polkit",
    "libpciaccess": "libpciaccess", "pixman-1": "pixman", "libdrm": "libdrm",
    "libva": "libva", "libva-drm": "libva", "libva-x11": "libva",
    "libva-wayland": "libva", "vdpau": "libvdpau", "libvdpau": "libvdpau",
    "libinput": "libinput", "libevdev": "libevdev", "mtdev": "mtdev",
    # ── grafika / GL / multimedia ──
    "gbm": "mesa", "gl": "libglvnd", "egl": "libglvnd",
    "glesv2": "libglvnd", "glx": "libglvnd", "vulkan": "vulkan-loader",
    "libxkbcommon": "libxkbcommon", "xkbcommon": "libxkbcommon",
    "xkbcommon-x11": "libxkbcommon", "xcb": "xorg-libxcb",
    "xcb-util": "xcb-util", "xcb-keysyms": "xcb-util-keysyms",
    "xcb-icccm": "xcb-util-wm", "xcb-cursor": "xcb-util-cursor",
    "xcb-renderutil": "xcb-util-renderutil", "xcb-image": "xcb-util-image",
    "xcb-errors": "xcb-util-errors",
    "wayland-client": "wayland", "wayland-server": "wayland",
    "wayland-cursor": "wayland", "wayland-egl": "wayland",
    "wayland-protocols": "wayland-protocols",
    "gstreamer-1.0": "gstreamer", "gstreamer-base-1.0": "gstreamer",
    "gstreamer-check-1.0": "gstreamer", "gstreamer-controller-1.0": "gstreamer",
    "gstreamer-app-1.0": "gst-plugins-base", "gstreamer-video-1.0": "gst-plugins-base",
    "gstreamer-audio-1.0": "gst-plugins-base", "gstreamer-pbutils-1.0": "gst-plugins-base",
    "gstreamer-fft-1.0": "gst-plugins-base", "gstreamer-riff-1.0": "gst-plugins-base",
    "gstreamer-rtp-1.0": "gst-plugins-base", "gstreamer-rtsp-1.0": "gst-plugins-base",
    "gstreamer-sdp-1.0": "gst-plugins-base", "gstreamer-net-1.0": "gst-plugins-base",
    "gstreamer-gl-1.0": "gst-plugins-base",
    "libpulse": "libpulse", "libpulse-simple": "libpulse",
    "libpulse-mainloop-glib": "libpulse", "alsa": "alsa-lib",
    "jack": "jack2", "libsamplerate": "libsamplerate", "sndfile": "libsndfile",
    "libavcodec": "ffmpeg", "libavformat": "ffmpeg", "libavutil": "ffmpeg",
    "libavfilter": "ffmpeg", "libswscale": "ffmpeg", "libswresample": "ffmpeg",
    "libpostproc": "ffmpeg",
    "SDL2": "sdl2", "SDL": "sdl", "SDL2_image": "sdl2-image",
    "SDL2_ttf": "sdl2-ttf", "SDL2_mixer": "sdl2-mixer", "SDL2_net": "sdl2-net",
    "libpng16": "libpng", "libwebp": "libwebp", "libwebpmux": "libwebp",
    "libwebpdemux": "libwebp", "libopenjp2": "openjpeg2", "lcms2": "lcms2",
    "libheif": "libheif", "libde265": "libde265", "x264": "x264",
    "x265": "x265",
    # ── glib / gio ──
    "gio-unix-2.0": "glib", "gmodule-2.0": "glib", "gthread-2.0": "glib",
    "girepository-2.0": "gobject-introspection",
    "girepository-1.0": "gobject-introspection",
    "libglib-2.0": "glib", "libgobject-2.0": "glib",
}

# Narzędzia buildowe (komenda → pakiet dostarczający) – dla auto-deps i sugestii
TOOL_ALIASES = {
    "jam": "ftjam", "pkg-config": "pkgconf", "pkgconfig": "pkgconf",
    "glib-mkenums": "glib", "g-ir-scanner": "gobject-introspection",
    "intltool-update": "intltool", "msgfmt": "gettext", "autopoint": "gettext",
    "xsltproc": "libxslt", "xmllint": "libxml2", "help2man": "help2man",
    "gperf": "gperf", "yacc": "bison", "lex": "flex",
}

X11_SHORT = {"xt", "xmu", "ice", "sm", "x11", "xext", "xrandr", "xfixes",
              "xcursor", "xinerama", "xrender", "xau", "xcb", "xdamage",
              "xcomposite", "xft", "xss", "xvmc", "xres", "xtst",
              "xpresent", "xshmfence", "xscrnsaver", "xxf86vm", "xv",
              "xkbfile", "xpm", "xaw"}

def load_state():
    if os.path.exists(STATE_FILE):
        try:
            with open(STATE_FILE, "r") as f:
                return json.load(f)
        except Exception:
            pass
    return {
        "builds": [],
        "last_sync": "",
        "current_build": None,
        "packages": {},       # pkg_name -> {version, category, status, sha256}
        "recipe_index": {},   # pkg_name -> {path, mtime, sha256}
    }

def save_state(state):
    # Zapis ATOMOWY: tmp + os.replace, żeby panel build nigdy nie przeczytał
    # napoczętego (niekompletnego) state.json – wcześniej json.dump pisał
    # bezpośrednio do pliku i przy dużej zawartości (logi buildów) dało się
    # złapać JSONDecodeError w trakcie zapisu.
    os.makedirs(os.path.dirname(STATE_FILE), exist_ok=True)
    _tmp = STATE_FILE + ".tmp"
    with open(_tmp, "w") as f:
        json.dump(state, f, indent=2, default=str)
        f.flush()
        os.fsync(f.fileno())
    os.replace(_tmp, STATE_FILE)

def update_current_build(state, name, version, category, progress, step_text=""):
    state["current_build"] = {
        "name": name, "version": version, "category": category,
        "progress": progress, "step_text": step_text,
        "start_time": datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S"),
    }
    save_state(state)

def clear_current_build(state):
    state["current_build"] = None
    save_state(state)

# ── Parsowanie PAGBUILD.yaml ──
def parse_recipe(path):
    """Parsuje PAGBUILD.yaml i zwraca słownik z metadanymi."""
    import yaml
    try:
        with open(path) as f:
            data = yaml.safe_load(f)
    except Exception as e:
        print(f"⚠ Błąd parsowania {path}: {e}")
        return None

    if not data:
        return None

    pkgname = data.get("pkgname", "")
    if not pkgname:
        # zgadnij z nazwy katalogu
        pkgname = os.path.basename(os.path.dirname(path))

    depends = data.get("depends") or []
    if isinstance(depends, str):
        depends = [depends]
    makedepends = data.get("makedepends") or []
    if isinstance(makedepends, str):
        makedepends = [makedepends]
    provides = data.get("provides") or []
    if isinstance(provides, str):
        provides = [provides]
    pkgconfig = data.get("pkgconfig") or []
    if isinstance(pkgconfig, str):
        pkgconfig = [pkgconfig]
    provides = list(provides) + ["pkgconfig(%s)" % p for p in pkgconfig]

    return {
        "name": pkgname,
        "version": str(data.get("pkgver", "0")),
        "release": data.get("pkgrel", 1),
        "description": data.get("pkgdesc", ""),
        "url": data.get("url", ""),
        "arch": data.get("arch", "x86_64"),
        "license": data.get("license", []) if isinstance(data.get("license"), list) else [data.get("license", "")],
        "depends": depends,
        "makedepends": makedepends,
        "provides": provides,
        "sources": data.get("source", []) if isinstance(data.get("source"), list) else [data.get("source", "")],
        "sha256sums": data.get("sha256sums", []) if isinstance(data.get("sha256sums"), list) else [data.get("sha256sums", "")],
    }

def find_recipe_path(pkg_name):
    """Szuka PAGBUILD.yaml dla pakietu w drzewie recipes."""
    for root, dirs, files in os.walk(RECIPES_DIR):
        if os.path.basename(root) == pkg_name:
            for fn in ["PAGBUILD.yaml", "package.yml", "recipe.yaml"]:
                fp = os.path.join(root, fn)
                if os.path.isfile(fp):
                    return fp
    return None

def recipe_to_repo_category(recipe_path):
    """Mapuje ścieżkę recipes na kategorię repo."""
    parts = recipe_path.split("/")
    for part in parts:
        if part in RECIPE_CAT_MAP:
            return RECIPE_CAT_MAP[part]
    return "other"


def _pkg_meta_name(fp):
    """Odczytuje pole 'name' z metadata.json wewnątrz pakietu (None, gdy brak)."""
    import tarfile as _tf
    import json as _json
    try:
        with _tf.open(fp, "r:xz") as tf:
            for mname in ("metadata.json", "./metadata.json"):
                if mname in tf.getnames():
                    f = tf.extractfile(mname)
                    if f:
                        return _json.loads(f.read().decode()).get("name", "")
    except Exception:
        pass
    return None


def _record_installed(name, provides):
    """Dopisuje pakiet do rejestru zainstalowanych w rootfs
    ($ROOTFS/var/lib/pagbuild/installed.json).

    Dzięki temu probe zależności (pre-flight pagbuild i
    _dep_satisfied_in_rootfs) rozpoznaje pakiety, których nie da się wykryć
    po artefaktach – np. docbook-xsl (dane w /usr/share/xml),
    gobject-introspection (binarka g-ir-scanner, nie gobject-introspection),
    kerberos (krb5.pc), texinfo (makeinfo), ..."""
    if not name:
        return
    db_path = os.path.join(ROOTFS_PATH.rstrip("/"), "var/lib/pagbuild/installed.json")
    try:
        os.makedirs(os.path.dirname(db_path), exist_ok=True)
        db = {}
        if os.path.isfile(db_path):
            with open(db_path, encoding="utf-8") as fh:
                db = json.load(fh)
        inst = set(db.get("installed", []))
        prov = set(db.get("provides", []))
        inst.add(name)
        prov.update(p for p in (provides or []) if p)
        db["installed"] = sorted(inst)
        db["provides"] = sorted(prov)
        with open(db_path, "w", encoding="utf-8") as fh:
            json.dump(db, fh, indent=1)
    except Exception:
        pass


def _install_to_rootfs(pkg_file):
    """Rozpakowuje zbudowany pakiet do działającego rootfs buildera.

    Kolejne receptury potrzebują nagłówków/bibliotek wcześniej zbudowanych
    pakietów (pagbuild buduje tylko .pag – nie instaluje). Dodatkowo zapisuje
    nazwę/provides pakietu do installed.json (patrz _record_installed)."""
    import tarfile as _tar
    # PUSTE .pag (historyczne 4.0K sprzed naprawy make install) NIE instalujemy:
    # nie dają artefaktów, a rejestr installed.json i tak by je „uznał” za
    # obecne i probe zależności przestałby je zgłaszać jako BRAK.
    if os.path.getsize(pkg_file) < 30000 and _pag_is_empty(pkg_file):
        print(f"⏭ PUSTY pakiet {os.path.basename(pkg_file)} – nie instaluję do rootfs (dobuduj z receptury)")
        return
    tmp_name = ""
    meta = {}
    try:
        with _tar.open(pkg_file, "r:xz") as tf:
            names = tf.getnames()
            mname = None
            if "metadata.json" in names:
                mname = "metadata.json"
            elif "./metadata.json" in names:
                mname = "./metadata.json"
            if mname:
                mf = tf.extractfile(mname)
                if mf:
                    meta = json.loads(mf.read().decode())
            if "data.tar.xz" not in names and "./data.tar.xz" not in names:
                return
            member = "data.tar.xz" if "data.tar.xz" in names else "./data.tar.xz"
            data = tf.extractfile(member)
            if not data:
                return
            tmp = tempfile.NamedTemporaryFile(delete=False, suffix=".data.tar.xz")
            tmp_name = tmp.name
            shutil.copyfileobj(data, tmp)
            tmp.close()
        with _tar.open(tmp_name, "r:xz") as tf2:
            # Python 3.12+: filter="data" (ochrona przed path traversal +
            # koniec DeprecationWarning 3.14). Gdyby jakiś pakiet miał np.
            # absolutny symlink, wracamy do zachowania sprzed filtra.
            try:
                tf2.extractall(ROOTFS_PATH, filter="data")
            except Exception:
                tf2.extractall(ROOTFS_PATH)
        os.unlink(tmp_name)
        # ldconfig przez chroot: binarka bywa w /usr/sbin (poza uciętym PATH
        # cron.d). Błąd ldconfig NIE może blokować rejestracji pakietu – pliki
        # są już rozpakowane; rejestr (installed.json) decyduje o pre-flight.
        try:
            subprocess.run([CHROOT_BIN, ROOTFS_PATH, "/sbin/ldconfig"],
                           capture_output=True, timeout=120)
        except Exception as e:
            print(f"⚠ ldconfig w rootfs nieudany (niekrytyczne): {e}")
        print(f"🧰 Zainstalowano do rootfs: {os.path.basename(pkg_file)}")
        _record_installed(meta.get("name") or "", meta.get("provides") or [])
    except Exception as e:
        if tmp_name and os.path.exists(tmp_name):
            os.unlink(tmp_name)
        # Rozpakowanie mogło się już udać – mimo błędu rejestrujemy pakiet,
        # żeby pre-flight nie zgłaszał go w kółko jako BRAK (to zapobiega
        # pętli „dobuduj tę samą zależność” w kolejnych przejściach kolejki).
        if meta and meta.get("name"):
            _record_installed(meta.get("name"), meta.get("provides") or [])
        print(f"⚠ Instalacja do rootfs nieudana: {e}")


def install_pkg_to_rootfs(name):
    """Instaluje zbudowany pakiet <name> (stable/<name>-*.pag) do rootfs buildera.
    Jeśli pakietu nie ma w repo – informuje, że trzeba go najpierw zbudować.
    """
    import glob as _g
    stable = os.path.join(REPO_BASE, "stable")
    files = sorted(_g.glob(os.path.join(stable, name + "-*.pag")))
    if not files:
        print(f"❌ Brak pakietu {name} w repo (stable). Najpierw zbuduj go – "
              f"pojedynczy build automatycznie instaluje do rootfs.")
        return
    pkg_file = files[-1]
    print(f"🧰 Instalacja do rootfs: {os.path.basename(pkg_file)}")
    _install_to_rootfs(pkg_file)


def _purge_empty_from_installed_db():
    """Usuwa z installed.json pakiety, których .pag w repo jest PUSTY.

    Historyczne puste .pag (receptury bez make install, sprzed naprawy)
    zostały zainstalowane do rootfs i zarejestrowane – rejestr maskuje probe
    zależności (pre-flight uznaje dep za obecny), więc nigdy nie są
    przebudowywane, a konsumenci padają na brakujących .pc/nagłówkach
    (xorg-libice -> ice.pc, xorg-libxv -> xv.pc, libevdev -> libevdev.pc).
    Po usunięciu z rejestru probe zgłosi BRAK i auto-deps dobuduje je."""
    db_path = os.path.join(ROOTFS_PATH.rstrip("/"), "var/lib/pagbuild/installed.json")
    if not os.path.isfile(db_path):
        return
    import glob as _g
    try:
        with open(db_path, encoding="utf-8") as fh:
            db = json.load(fh)
    except Exception:
        return
    installed = set(db.get("installed", []))
    if not installed:
        return
    stable = os.path.join(REPO_BASE, "stable")
    removed = []
    for name in sorted(installed):
        files = sorted(_g.glob(os.path.join(stable, name + "-*.pag")))
        if not files:
            continue
        pkg = files[-1]
        if os.path.getsize(pkg) >= 30000:
            continue
        if _pag_is_empty(pkg):
            installed.discard(name)
            removed.append(name)
    if removed:
        db["installed"] = sorted(installed)
        with open(db_path, "w", encoding="utf-8") as fh:
            json.dump(db, fh, indent=1)
        print(f"🧹 Purge installed.json: usunięto puste pakiety (dobuduję je): {', '.join(removed)}")


# ── Sprawdzanie makedepends/depends względem rootfs (pre-flight) ──
def _dep_satisfied_in_rootfs(dep):
    """Heurystyczna proba: czy zależność buildowa jest obecna w rootfs.

    Sprawdza narzędzie (usr/bin), moduł pkg-config (*.pc), nagłówek (include/)
    i bibliotekę (lib*.so) – to artefakty, których szukają buildy.
    """
    if not dep:
        return True
    rootfs = ROOTFS_PATH.rstrip("/")
    # 0) rejestr pakietów zainstalowanych do rootfs (installed.json pisany przez
    #    _install_to_rootfs / pagsync --install) – dokładne pokrycie pakietów,
    #    których nie da się wykryć po artefaktach (docbook-xsl, kerberos, ...)
    inst_db = os.path.join(rootfs, "var/lib/pagbuild/installed.json")
    if os.path.isfile(inst_db):
        try:
            with open(inst_db, encoding="utf-8") as fh:
                db = json.load(fh)
            names = set(db.get("installed", [])) | set(db.get("provides", []))
            check = dep
            if dep.startswith("pkgconfig(") and dep.endswith(")"):
                check = dep[len("pkgconfig("):-1]
            if dep in names or check in names:
                return True
        except Exception:
            pass
    # 1) narzędzie
    for sub in ("usr/local/bin", "usr/bin", "usr/sbin", "bin", "sbin"):
        if os.path.exists(os.path.join(rootfs, sub, dep)):
            return True
    # 2) moduł pkg-config
    for sub in ("usr/lib/pkgconfig", "usr/share/pkgconfig", "usr/lib64/pkgconfig",
                "usr/lib/x86_64-linux-gnu/pkgconfig"):
        if os.path.isfile(os.path.join(rootfs, sub, dep + ".pc")):
            return True
    # 3) nagłówek / katalog nagłówków
    if os.path.isfile(os.path.join(rootfs, "usr/include", dep + ".h")):
        return True
    if os.path.isdir(os.path.join(rootfs, "usr/include", dep)):
        return True
    # 4) biblioteka lib<dep>.so* / lib<dep>.a (i <dep>.so*, gdy dep już ma
    #    prefiks lib – np. liblmdb -> liblmdb.so, a nie libliblmdb.so)
    for sub in ("usr/lib", "usr/lib64", "usr/lib/x86_64-linux-gnu", "lib", "lib64"):
        d = os.path.join(rootfs, sub)
        if not os.path.isdir(d):
            continue
        for entry in os.listdir(d):
            if entry.startswith("lib" + dep + ".") or entry.startswith(dep + "."):
                return True
    # 5) heurystyka xorg-libX11 (nazwa pakietu != nazwa biblioteki)
    if dep.startswith("xorg-lib"):
        short = dep[len("xorg-lib"):]
        libname = "lib" + (short[:1].upper() + short[1:])
        for sub in ("usr/lib", "usr/lib64", "usr/lib/x86_64-linux-gnu"):
            d = os.path.join(rootfs, sub)
            if not os.path.isdir(d):
                continue
            for entry in os.listdir(d):
                if entry.startswith(libname + "."):
                    return True
    # 6) znane mapowania pakiet -> artefakty (nazwa pakietu != nazwa artefaktu:
    #    dbus -> dbus-1.pc/libdbus-1, kerberos -> krb5.pc, alsa-lib -> alsa.pc,
    #    ...). Bez tego zbudowane pakiety były fałszywie zgłaszane jako BRAK
    #    i niepotrzebnie przebudowywane (--strict-deps potrafił nawet zatrzymać
    #    build na zainstalowanym pakiecie).
    arts = PKG_ARTIFACTS.get(dep)
    if arts:
        pcs, bins, hdrs, libs = arts
        for sub in ("usr/lib/pkgconfig", "usr/share/pkgconfig", "usr/lib64/pkgconfig",
                    "usr/lib/x86_64-linux-gnu/pkgconfig"):
            for pc in pcs:
                if os.path.isfile(os.path.join(rootfs, sub, pc)):
                    return True
        for sub in ("usr/local/bin", "usr/bin", "usr/sbin", "bin", "sbin"):
            for b in bins:
                if os.path.exists(os.path.join(rootfs, sub, b)):
                    return True
        for h in hdrs:
            if os.path.isfile(os.path.join(rootfs, "usr/include", h)):
                return True
        for sub in ("usr/lib", "usr/lib64", "usr/lib/x86_64-linux-gnu"):
            d = os.path.join(rootfs, sub)
            if not os.path.isdir(d):
                continue
            for entry in os.listdir(d):
                for lb in libs:
                    if entry.startswith(lb + "."):
                        return True
    # 7) pakiety-dane (katalogi w /usr/share)
    if dep == "docbook-xsl" and os.path.isdir(os.path.join(rootfs, "usr/share/xml/docbook")):
        return True
    if dep == "sgml-common" and os.path.isdir(os.path.join(rootfs, "usr/share/sgml")):
        return True
    # 8) moduły Pythona – czysty python (python-setuptools, python-build, ...)
    #    nie ma binarki/.pc/nagłówka/lib, więc probe po artefaktach zawsze
    #    zgłaszał BRAK (nawet po instalacji do rootfs) i pętla auto-deps
    #    przebudowywała te same pakiety. Szukamy katalogu modułu i
    #    dist-info/egg-info w site-packages.
    if _py_dep_satisfied_in_rootfs(dep, rootfs):
        return True
    # 9) moduły Perla (perl-*): brak binarki/.pc/lib o tej nazwie – szukamy
    #    pliku/katalogu modułu w site_perl/vendor_perl (perl-xml-simple ->
    #    XML/Simple.pm).
    if _perl_dep_satisfied_in_rootfs(dep, rootfs):
        return True
    return False


PY_MODULE_ALIASES = {
    # nazwa receptury (python-*) -> nazwa modułu / katalogu w site-packages,
    # gdy nie wynika z nazwy (python-xdg instaluje się jako pyxdg, ...)
    "python-xdg": "pyxdg",
    "python-yaml": "yaml",
    "python-gobject": "gi",
    "python-jinja": "jinja2",
    "python-importlib-metadata": "importlib_metadata",
    "python-setuptools-scm": "setuptools_scm",
    "python-vcs-versioning": "vcs_versioning",
    "python-pyproject-hooks": "pyproject_hooks",
    "python-typing-extensions": "typing_extensions",
    "python-docutils": "docutils",
    "python-markupsafe": "markupsafe",
}


def _py_module_candidates(dep):
    """Kandydaci na nazwy modułów/katalogów w site-packages dla zależności."""
    out = []
    n = dep
    if n.startswith("pkgconfig(") and n.endswith(")"):
        n = n[len("pkgconfig("):-1]
    if n.startswith("python-") or n.startswith("py-"):
        mod = n.split("-", 1)[1]
        out += [mod, mod.replace("-", "_")]
        out.append(n.replace("-", "_"))
    else:
        out.append(n)
        if "-" in n:
            out.append(n.replace("-", "_"))
    out.append(PY_MODULE_ALIASES.get(dep, ""))
    return [c for c in dict.fromkeys(out) if c]


PERL_MODULE_PATHS = {
    # nazwa receptury (perl-*) -> relatywna ścieżka modułu w site_perl
    "perl-libwww": "LWP.pm",
    "perl-lwp-mediatypes": "LWP/MediaTypes.pm",
    "perl-www-robotrules": "WWW/RobotRules.pm",
    "perl-uri": "URI.pm",
    "perl-gd": "GD.pm",
    "perl-dbi": "DBI.pm",
    "perl-xml-parser": "XML/Parser.pm",
    "perl-xml-simple": "XML/Simple.pm",
    "perl-xml-sax": "XML/SAX.pm",
    "perl-xml-sax-base": "XML/SAX/Base.pm",
    "perl-xml-libxml": "XML/LibXML.pm",
    "perl-xml-namespacesupport": "XML/NamespaceSupport.pm",
    "perl-xml-xpath": "XML/XPath.pm",
    "perl-html-parser": "HTML/Parser.pm",
    "perl-html-tagset": "HTML/Tagset.pm",
    "perl-html-tree": "HTML/Tree.pm",
    "perl-http-date": "HTTP/Date.pm",
    "perl-http-message": "HTTP/Message.pm",
    "perl-http-cookies": "HTTP/Cookies.pm",
    "perl-http-daemon": "HTTP/Daemon.pm",
    "perl-http-negotiate": "HTTP/Negotiate.pm",
    "perl-net-http": "Net/HTTP.pm",
    "perl-file-listing": "File/Listing.pm",
    "perl-encode-locale": "Encode/Locale.pm",
    "perl-datemanip": "Date/Manip.pm",
}


def _perl_dep_satisfied_in_rootfs(dep, rootfs):
    """Czy zależność to moduł Perla obecny w site_perl/vendor_perl rootfs."""
    if not dep.startswith("perl-"):
        return False
    rel = PERL_MODULE_PATHS.get(dep)
    if not rel:
        mod = dep[len("perl-"):]
        rel = "/".join(s.capitalize() for s in mod.split("-")) + ".pm"
    pl_roots = []
    for cand in glob.glob(os.path.join(rootfs, "usr/lib/perl*/*/site_perl")):
        pl_roots.append(cand)
    for cand in glob.glob(os.path.join(rootfs, "usr/lib/perl*/*/vendor_perl")):
        pl_roots.append(cand)
    for pl in pl_roots:
        if os.path.isfile(os.path.join(pl, rel)):
            return True
        if os.path.isdir(os.path.join(pl, rel[: -len(".pm")])):
            return True
    return False


def _py_dep_satisfied_in_rootfs(dep, rootfs):
    """Czy zależność wygląda na moduł Pythona obecny w site-packages rootfs."""
    sp_roots = []
    for cand in glob.glob(os.path.join(rootfs, "usr/lib/python*")):
        sp = os.path.join(cand, "site-packages")
        if os.path.isdir(sp):
            sp_roots.append(sp)
    # dystro mogą kłaść też w /usr/lib64/python*/site-packages
    for cand in glob.glob(os.path.join(rootfs, "usr/lib64/python*")):
        sp = os.path.join(cand, "site-packages")
        if os.path.isdir(sp):
            sp_roots.append(sp)
    if not sp_roots:
        return False
    entries = set()
    for sp in sp_roots:
        try:
            entries.update(os.listdir(sp))
        except OSError:
            continue
    norm = {e.replace("-", "_") for e in entries}
    for cand in _py_module_candidates(dep):
        c_ = cand.replace("-", "_")
        if c_ in norm:
            return True
        # dist-info / egg-info: „setuptools-84.0.0.dist-info” → prefix „setuptools”,
        # „typing_extensions-4.16.0.dist-info” → „typing_extensions”
        for e in norm:
            stem = e
            for sep in (".dist_info", ".egg_info"):
                if sep in stem:
                    stem = stem.split(sep)[0]
                    break
            m = re.match(r"^(.+?)_\d+", stem)   # odetnij wersję
            if m:
                stem = m.group(1)
            if stem == c_:
                return True
    return False


# Pakiet -> (moduły .pc, binarki, nagłówki, biblioteki z prefiksem lib) –
# używane przez _dep_satisfied_in_rootfs. Lustrzane do tabeli aliasów
# probe_rootfs_dep w pagbuild (dwóch źródeł nie unikniemy – różne języki).
PKG_ARTIFACTS = {
    "dbus":               (["dbus-1.pc"], ["dbus-daemon"], ["dbus-1.0/dbus/dbus.h"], ["libdbus-1"]),
    "kerberos":           (["krb5.pc"], ["krb5-config"], ["krb5/krb5.h"], ["libkrb5"]),
    "alsa-lib":           (["alsa.pc"], [], ["alsa/asoundlib.h"], ["libasound"]),
    "e2fsprogs":          (["e2p.pc"], ["mkfs.ext4"], ["ext2fs/ext2fs.h"], ["libext2fs"]),
    "gobject-introspection": (["gobject-introspection-1.0.pc"], ["g-ir-scanner"],
                              ["girepository-1.0/girepository.h"], ["libgirepository-1.0"]),
    "sgml-common":        ([], ["install-catalog"], [], []),
    "glib":               (["glib-2.0.pc"], [], [], ["libglib-2.0"]),
    "gtk3":               (["gtk+-3.0.pc"], [], [], ["libgtk-3"]),
    "gtk2":               (["gtk+-2.0.pc"], [], [], ["libgtk-x11-2.0"]),
    "gtk4":               (["gtk4.pc"], [], [], ["libgtk-4"]),
    "gdk-pixbuf":         (["gdk-pixbuf-2.0.pc"], [], [], ["libgdk_pixbuf-2.0"]),
    "pango":              (["pango.pc"], [], [], ["libpango-1.0"]),
    "cairo":              (["cairo.pc"], [], [], ["libcairo"]),
    "harfbuzz":           (["harfbuzz.pc"], [], [], ["libharfbuzz"]),
    "freetype2":          (["freetype2.pc"], [], [], ["libfreetype"]),
    "libxml2":            (["libxml-2.0.pc"], [], [], ["libxml2"]),
    "libpng":             (["libpng.pc"], [], [], ["libpng"]),
    "libjpeg-turbo":      (["libjpeg.pc"], [], [], ["libjpeg"]),
    "libtiff":            (["libtiff-4.pc"], [], [], ["libtiff"]),
    "lcms2":              (["lcms2.pc"], [], [], ["liblcms2"]),
    "libpcap":            (["libpcap.pc"], [], [], ["libpcap"]),
    "ncurses":            (["ncursesw.pc"], [], [], ["libncursesw"]),
    "zlib":               (["zlib.pc"], [], [], ["libz"]),
    "bzip2":              (["bzip2.pc"], [], [], ["libbz2"]),
    "xz":                 (["liblzma.pc"], [], [], ["liblzma"]),
    "readline":           (["readline.pc"], [], [], ["libreadline"]),
    "expat":              (["expat.pc"], [], [], ["libexpat"]),
    "sqlite":             (["sqlite3.pc"], [], [], ["libsqlite3"]),
    "curl":               (["libcurl.pc"], [], [], ["libcurl"]),
    "openssl":            (["openssl.pc"], [], [], ["libssl"]),
    "libffi":             (["libffi.pc"], [], [], ["libffi"]),
    "libxcrypt":          (["libxcrypt.pc"], [], [], ["libxcrypt"]),
    "libgpg-error":       (["gpg-error.pc"], [], [], ["libgpg-error"]),
    "gnutls":             (["gnutls.pc"], [], [], ["libgnutls"]),
    "libxkbcommon":       (["xkbcommon.pc"], [], [], ["libxkbcommon"]),
    "wayland":            (["wayland-client.pc"], [], [], ["libwayland-client"]),
    "libx11":             (["x11.pc"], [], [], ["libX11"]),
    "xorg-libx11":        (["x11.pc"], [], [], ["libX11"]),
    "mesa":               (["gl.pc"], [], [], ["libGL"]),
    "libgl":              (["gl.pc"], [], [], ["libGL"]),
    # cargo-c instaluje binarki cargo-cbuild/cargo-capi/... – nie ma binarki
    # "cargo-c", więc probe po nazwie nie trafiała i pakiet był niepotrzebnie
    # przebudowywany mimo dobrego .pag w repo.
    "cargo-c":            ([], ["cargo-cbuild", "cargo-capi", "cargo-cinstall", "cargo-ctest"], [], []),
    # openssh / apr / apr-util: instalowane artefakty mają inne nazwy niż
    # pakiet (ssh/sshd, apr-1.pc/libapr-1.so, apu-1.pc/libaprutil-1.so) –
    # probe po nazwie zawsze zgłaszał BRAK mimo obecności w rootfs.
    "openssh":            (["openssh.pc"], ["ssh", "sshd", "ssh-keygen", "ssh-keyscan", "ssh-agent"], [], []),
    "apr":                (["apr-1.pc"], ["apr-1-config"], ["apr-1/apr.h"], ["libapr-1"]),
    "apr-util":           (["apr-util-1.pc"], ["apu-1-config"], ["apr-1/apu.h"], ["libaprutil-1"]),
    # sway-desktop: wlroots (wlroots-0.20.pc), pcre2 (pcre2-8.pc), pam i
    # header-only tllist (tllist.h) – artefakty o innych nazwach niż pakiet.
    "wlroots":            (["wlroots-0.20.pc"], [], ["wlr/types.h"], ["libwlroots-0.20"]),
    "pcre2":              (["pcre2-8.pc"], [], ["pcre2.h"], ["libpcre2-8"]),
    "pam":                (["pam.pc"], [], ["security/pam_appl.h"], ["libpam"]),
    "tllist":             ([], [], ["tllist.h"], []),
}


def warn_unsatisfied_deps(ordered, all_recipes):
    """Pre-flight: sprawdza makedepends/depends względem rootfs, receptur i repo.

    Depy obecne w repo instaluje do rootfs OD RAZU (zanim build ruszy –
    „instaluj z repo; jak nie ma w repo, dobuduj z receptury"); ostrzega
    tylko o tych, których nie ma NIGDZIE.
    """
    name_to_meta = {m["name"]: m for _, m in all_recipes}
    installed = set()   # już doinstalowane w tej sesji pre-flightu
    for fp, meta in ordered:
        name = meta["name"]
        for dep in list(meta.get("makedepends", [])) + list(meta.get("depends", [])):
            if dep in name_to_meta or dep in BUILD_SYSTEM_DEPS:
                continue
            if _dep_satisfied_in_rootfs(dep):
                continue
            hit = _search_repo_package(dep)
            if hit:
                if dep not in installed:
                    print(f"  🔧 {name}: '{dep}' brak w rootfs – instaluję z repo ({hit[0]})")
                    _install_to_rootfs(hit[1])
                    installed.add(dep)
                continue
            print(f"  ⚠ {name}: '{dep}' nie jest spełniony w rootfs i nie ma receptury "
                  f"ani w repo – build może paść (auto-deps nie pomoże, brak przepisu)")


# ── Auto-deps: analiza diagnostyki pagbuild i dobudowa zależności ──
def read_build_diagnostics(pkg_name):
    """Czyta najnowszy plik diagnose-<name>-*.json z DIAG_DIR.

    Zwraca listę brakujących zależności; plik jest usuwany po odczytaniu.
    """
    import glob as _g
    if not os.path.isdir(DIAG_DIR):
        return []
    files = sorted(_g.glob(os.path.join(DIAG_DIR, "diagnose-" + pkg_name + "-*.json")),
                   key=os.path.getmtime, reverse=True)
    for f in files:
        try:
            with open(f, encoding="utf-8") as fh:
                data = json.load(fh)
        except Exception:
            continue
        try:
            os.unlink(f)
        except OSError:
            pass
        return data.get("missing", [])
    return []


def _provider_candidates(name, kind=""):
    """Możliwe nazwy receptur dostarczających brakujący element (heurystyka)."""
    if not isinstance(name, str) or not name:
        return set()
    n = name.strip()
    if n.startswith("pkgconfig(") and n.endswith(")"):
        n = n[len("pkgconfig("):-1]
    cands = {name, n}
    if not n.startswith("pkgconfig("):
        cands.add("pkgconfig(" + n + ")")
    if n in PC_ALIASES:
        cands.add(PC_ALIASES[n])
    if n in TOOL_ALIASES:
        cands.add(TOOL_ALIASES[n])
    # lib<foo> -> <foo> (liblmdb -> lmdb, libpcre2 -> pcre2, ...) – dopasowanie
    # nazw bibliotek do nazw pakietów bez prefiksu "lib" (receptury bywają
    # nazwane od biblioteki, a zależność wpisana z prefiksem albo odwrotnie).
    if n.startswith("lib") and len(n) > 4:
        cands.add(n[3:])
    m = re.match(r"^(lib[\w+]+)-(\d+)(?:[.-]\d+)*$", n)
    if m:
        cands.add(m.group(1) + m.group(2))
    if n in X11_SHORT or n.lower() in X11_SHORT:
        cands.add("xorg-lib" + n.lower())
    # Wzorce: moduły Qt (Qt5Core/Qt6Widgets) i GStreamer (gstreamer-video-1.0)
    if n.startswith("Qt5"):
        cands.add("qt5")
    if n.startswith("Qt6"):
        cands.add("qt6")
    if n.startswith("gstreamer-") and n.endswith("-1.0"):
        cands.add("gstreamer")
    if n.startswith("gst-"):
        cands.add("gst-plugins-base")
    if kind == "header":
        base = os.path.basename(n).replace(".h", "")
        if base:
            cands.add(base)
            cands.add("lib" + base)
            if base.lower() in PC_ALIASES:
                cands.add(PC_ALIASES[base.lower()])
            if base in X11_SHORT or base.lower() in X11_SHORT:
                cands.add("xorg-lib" + base.lower())
            if re.match(r"^X[A-Za-z]+$", base):
                cands.add("xorg-lib" + base.lower())
    if kind == "library":
        cands.add("lib" + n)
    return {c for c in cands if c}


def _recipes_maps(state):
    """(name -> recipe_path, provides -> [recipe_names]) dla całego drzewa."""
    recipes = scan_recipes(state)
    name_to_path = {m["name"]: fp for fp, m in recipes}
    provides_index = {}
    for fp, m in recipes:
        for p in m.get("provides", []):
            provides_index.setdefault(p, []).append(m["name"])
    return name_to_path, provides_index


def resolve_missing_to_recipes(missing, state):
    """Dla listy braków z diagnostyki zwraca nazwy receptur do dobudowania."""
    name_to_path, provides_index = _recipes_maps(state)
    out = []
    for item in missing:
        if not isinstance(item, dict):
            continue
        name = item.get("name", "")
        kind = item.get("kind", "")
        if not name:
            continue
        for c in _provider_candidates(name, kind):
            if c in name_to_path:
                if c not in out and c not in SKIP_PACKAGES:
                    out.append(c)
                break
            found = next((p for p in provides_index.get(c, []) if p not in SKIP_PACKAGES), None)
            if found:
                if found not in out:
                    out.append(found)
                break
    return out


def _pkg_built(name, state):
    """Czy pakiet ma już zbudowany .pag (w state packages lub w repo)."""
    sp = state.get("packages", {}).get(name)
    if sp and sp.get("status") == "ok":
        return True
    import glob as _g
    for cat in REPO_CATEGORIES:
        if _g.glob(os.path.join(REPO_BASE, cat, name + "-*.pag")):
            return True
    return False


def _search_repo_package(name, kind=""):
    """Szuka w lokalnym repo pakietu dostarczającego brakujący element –
    odpowiednik `pag search <nazwa>` po stronie buildera.

    Najpierw czyta repo.json (indeks z polami provides/pkgconfig – dokładne
    mapowanie, np. pkgconfig(foo) -> pakiet), potem robi fallback do globa
    po plikach .pag na dysku. Zwraca (nazwa_pakietu, ścieżka_do_pagu) lub None.
    """
    import glob as _g
    cands = _provider_candidates(name, kind) | {name.strip()}
    # 1) repo.json – provides (np. pkgconfig(foo)) i nazwy pakietów
    for cat in REPO_CATEGORIES:
        rj = os.path.join(REPO_BASE, cat, "repo.json")
        if not os.path.isfile(rj):
            continue
        try:
            with open(rj, encoding="utf-8") as fh:
                data = json.load(fh)
        except Exception:
            continue
        for p in data.get("packages", []) or []:
            pname = p.get("name", "")
            provs = set(p.get("provides") or [])
            if not (pname in cands or (provs & cands)):
                continue
            fname = p.get("filename", "")
            fp = os.path.join(REPO_BASE, cat, fname)
            if fname and os.path.isfile(fp):
                return pname, fp
    # 2) fallback: pliki .pag na dysku
    for cat in REPO_CATEGORIES:
        pkg_dir = os.path.join(REPO_BASE, cat)
        if not os.path.isdir(pkg_dir):
            continue
        for c in sorted(cands):
            hits = sorted(_g.glob(os.path.join(pkg_dir, c + "-*.pag")))
            if hits:
                return c, hits[-1]
    return None


def _install_missing_from_repo(missing):
    """Fallback dla braków BEZ receptury: szuka gotowego pakietu w repo
    (odpowiednik `pag search <nazwa>`) i instaluje go do rootfs buildera
    (odpowiednik `pag install <nazwa>`, ale do CHROOT – `pag install` celuje
    w system HOSTA i nie pomógłby buildowi w chroot; bazę pag trzyma też poza
    PAG_ROOT, więc użycie PAG_ROOT=rootfs zepsułoby DB hosta).

    Zwraca True, jeśli cokolwiek zainstalowano (lub już było spełnione).
    """
    installed_any = False
    for item in missing:
        if not isinstance(item, dict):
            continue
        name = item.get("name", "")
        kind = item.get("kind", "")
        if not name:
            continue
        hit = _search_repo_package(name, kind)
        if not hit:
            print(f"   ⚠ brak receptury i brak w repo: {name}")
            continue
        pkg_name, pkg_file = hit
        # pkgconfig(foo) sprawdzamy po wewnętrznej nazwie modułu (foo.pc)
        check = name[10:-1] if name.startswith("pkgconfig(") and name.endswith(")") else name
        if _dep_satisfied_in_rootfs(check):
            print(f"   ✅ {name} już obecny w rootfs (repo: {pkg_name})")
            installed_any = True
            continue
        print(f"📦 Z repo (odpowiednik `pag install {pkg_name}`): "
              f"{os.path.basename(pkg_file)} → instaluję do rootfs")
        _install_to_rootfs(pkg_file)
        installed_any = True
    return installed_any


def _unsatisfied_declared_deps(recipe_path, state):
    """Makedepends/depends z receptury, które NIE są spełnione w rootfs ani przez
    zbudowany .pag – uzupełnienie dla diagnostyki z logu builda.

    Dzięki temu auto-deps działa nawet wtedy, gdy log nie wskazuje wprost braku
    (np. ftjam dostarcza binarkę `jam`, a build woła `jam: command not found`
    – po logu nie widać związku z pakietem ftjam, a po makedepends widać).
    """
    meta = parse_recipe(recipe_path)
    if not meta:
        return []
    name_to_path, _ = _recipes_maps(state)
    out = []
    for dep in list(meta.get("makedepends", [])) + list(meta.get("depends", [])):
        if not dep or dep in BUILD_SYSTEM_DEPS:
            continue
        if dep in name_to_path:
            # ma recepturę w drzewie – dobuduj, jeśli nie ma jeszcze .pag
            if not _pkg_built(dep, state):
                out.append({"kind": "makedep", "name": dep, "suggested": dep})
            continue
        if not _dep_satisfied_in_rootfs(dep):
            out.append({"kind": "makedep", "name": dep, "suggested": dep})
    return out


def clean_old_versions(category_dir, name, keep_filename):
    """Usuwa starsze wersje pakietu `name` z category_dir (zostaje tylko aktualna).

    Bezpieczne dzięki weryfikacji metadata.json — nazwa pliku może zawierać
    myślniki (np. xorg-libx11), więc prefiks `<name>-` nie wystarcza; sprawdzamy
    rzeczywistą nazwę pakietu w metadata, aby nie skasować innego pakietu.
    """
    removed = []
    for fname in sorted(os.listdir(category_dir)):
        if not fname.endswith(PKG_EXT):
            continue
        if fname == keep_filename:
            continue
        if not fname.startswith(name + "-"):
            continue
        fp = os.path.join(category_dir, fname)
        if _pkg_meta_name(fp) != name:
            continue
        try:
            os.remove(fp)
            removed.append(fname)
        except OSError:
            continue
        # usuń też podpisy odłączone
        for sfx in (".asc", ".sig"):
            sp = fp + sfx
            if os.path.exists(sp):
                try:
                    os.remove(sp)
                except OSError:
                    pass
    return removed


def _ver_key(pkg):
    """Klucz sortowania wersji – porównywalna krotka (major.minor.patch, release).

    Umożliwia wybór najnowszej wersji przy dedupe repo.json. Wersje nie-numeryczne
    (np. '1.4rc5') traktowane są leksykalnie jako fallback.
    """
    ver = str(pkg.get("version", "0"))
    rel = int(pkg.get("release", 0) or 0)
    nums = []
    for part in re.split(r"[^\d]+", ver):
        nums.append(int(part) if part.isdigit() else -1)
    return (tuple(nums), rel, ver)

# ── Budowanie pojedynczego pakietu ──
def _warn_unresolved_runtime_deps(name, depends, state):
    """(test pakietu) Po udanym buildzie sprawdza, czy runtime `depends` są
    osiągalne: w repo (stable), w rootfs buildera albo jako receptura (dobuduje
    się w kolejce). Odpowiednik repotest – ostrzega o depends, których klient
    pag nie będzie umiał rozwiązać przy instalacji."""
    if not depends:
        return
    import glob as _g
    name_to_path, _ = _recipes_maps(state)
    bad = []
    for dep in depends:
        dep = (dep or "").strip()
        if not dep or dep in BUILD_SYSTEM_DEPS:
            continue
        if _g.glob(os.path.join(REPO_BASE, "stable", dep + "-*.pag")):
            continue
        if dep in name_to_path or _dep_satisfied_in_rootfs(dep):
            continue
        bad.append(dep)
    if bad:
        print(f"⚠ [test] {name}: depends spoza repo/rootfs/receptur: "
              f"{', '.join(bad)} – pag install może ich nie rozwiązać")


def _do_single_build(pkg_name, recipe_path, state):
    meta = parse_recipe(recipe_path)
    if not meta:
        print(f"❌ Nie można sparsować {recipe_path}")
        # caller robi: ok, preflight_missing = _do_single_build(...) –
        # sam bool wywalał kolejkę TypeError (cannot unpack).
        return (False, [])

    name = meta["name"]
    ver = meta["version"]
    rel = meta["release"]
    category = recipe_to_repo_category(recipe_path)

    print(f"🔨 [BUILD] {name}-{ver}-{rel} [{category}] – start")
    update_current_build(state, name, ver, category, "0%", "Uruchamianie pagbuild...")

    cmd = [PAGBUILD_BIN, recipe_path, "--rootfs", ROOTFS_PATH]
    if STRICT_DEPS:
        cmd.append("--strict-deps")
    if DO_SIGN:
        cmd.append("--sign")
        if GPG_KEY:
            cmd.extend(["--gpg-key", GPG_KEY])
    log_lines = []
    start_t = time.time()

    # Exit 75 z pagbuild = „rootfs zajęty przez inny build” – to zwykle stan
    # przejściowy (konkurujący/osierocony build właśnie się kończy). Zamiast
    # od razu oznaczać pakiet jako FAIL, czekamy i ponawiamy.
    rootfs_retries = 0
    while True:
        try:
            proc = subprocess.Popen(
                cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True,
                errors='replace',
                env={**os.environ, "PYTHONUNBUFFERED": "1"},
                start_new_session=True,   # własna grupa – czyszczenie przy SIGTERM
            )
        except Exception as e:
            # ETXTBSY („Text file busy”) = pagbuild jest właśnie podmieniany
            # (deploy w trakcie runu) – stan przejściowy, ponawiamy jak exit 75.
            if rootfs_retries < MAX_ROOTFS_RETRIES:
                rootfs_retries += 1
                msg = (f"⏳ Nie można uruchomić pagbuild ({e}) – czekam "
                       f"{ROOTFS_RETRY_SLEEP} s i ponawiam ({rootfs_retries}/{MAX_ROOTFS_RETRIES})...")
                print(f"  │ {msg}")
                time.sleep(ROOTFS_RETRY_SLEEP)
                continue
            print(f"❌ Nie można uruchomić pagbuild: {e}")
            clear_current_build(state)
            # UWAGA: caller oczekuje krotki (ok, preflight_missing) – zwrócenie
            # samego False wywalało całą kolejkę TypeError (cannot unpack).
            return False, []
        _ACTIVE_PROCS.append(proc)

        for line in proc.stdout:
            line = line.rstrip()
            log_lines.append(line)
            print(f"  │ {line}")

            if "FAZA BUILD" in line:
                update_current_build(state, name, ver, category, "30%", "Kompilacja...")
            elif "FAZA PACKAGE" in line:
                update_current_build(state, name, ver, category, "70%", "Instalacja do DESTDIR...")
            elif "Pakowanie" in line or "🗜" in line:
                update_current_build(state, name, ver, category, "90%", f"Generowanie {PKG_EXT}...")

        proc.wait()
        try:
            _ACTIVE_PROCS.remove(proc)
        except ValueError:
            pass

        if proc.returncode == 75 and rootfs_retries < MAX_ROOTFS_RETRIES:
            rootfs_retries += 1
            msg = (f"⏳ rootfs zajęty przez inny build – czekam {ROOTFS_RETRY_SLEEP} s "
                   f"i ponawiam ({rootfs_retries}/{MAX_ROOTFS_RETRIES})...")
            print(f"  │ {msg}")
            log_lines.append(msg)
            time.sleep(ROOTFS_RETRY_SLEEP)
            continue
        break

    duration = round(time.time() - start_t, 1)
    status = "ok" if proc.returncode == 0 else "failed"

    # ── Kopiowanie zbudowanego pakietu do repo ──
    if status == "ok":
        print(f"✅ [BUILD] {name}-{ver} OK ({duration}s)")

        # Szukaj wyjściowego pliku
        pkg_pattern = f"{name}-{ver}-{rel}{PKG_EXT}"
        src_pkg = os.path.join(BUILD_OUT, pkg_pattern)

        if not os.path.exists(src_pkg):
            # Spróbuj alternatywne nazwy
            import glob as gmod
            candidates = gmod.glob(os.path.join(BUILD_OUT, f"{name}-{ver}*{PKG_EXT}"))
            if candidates:
                src_pkg = candidates[0]

        if os.path.exists(src_pkg):
            dst_dir = os.path.join(REPO_BASE, "stable")
            os.makedirs(dst_dir, exist_ok=True)
            dst_file = os.path.basename(src_pkg)
            shutil.move(src_pkg, os.path.join(dst_dir, dst_file))
            print(f"📦 Przeniesiono do: {dst_dir}/{dst_file}")

            # Podpis GPG (.asc) musi trafić do repo razem z pakietem –
            # klient pag weryfikuje go fail-closed (brak podpisu = odrzucenie).
            sig_src = src_pkg + ".asc"
            if os.path.exists(sig_src):
                shutil.move(sig_src, os.path.join(dst_dir, dst_file + ".asc"))
                print(f"🔏 Przeniesiono podpis: {dst_file}.asc")
            else:
                print(f"⚠ Brak podpisu {dst_file}.asc – pakiet zostanie odrzucony przez pag!")

            # Gwarancja podpisu: gdy .asc nie dotarł (podpisywanie padło), a w repo
            # leżał STARY .asc od tej samej nazwy pliku, klient dostawał
            # „NIEPRAWIDŁOWY PODPIS GPG". Zweryfikuj i w razie potrzeby podpisz
            # ponownie; jeśli to się nie uda – usuń zły .asc (czytelny błąd > BAD SIG).
            _dst_fp = os.path.join(dst_dir, dst_file)
            _sig_ok, _sig_reason = _verify_sig(_dst_fp)
            if not _sig_ok:
                if _sign_file(_dst_fp):
                    _sig_ok, _sig_reason = _verify_sig(_dst_fp)
                if _sig_ok:
                    print(f"🔏 Podpis zweryfikowany/odtworzony: {dst_file}")
                else:
                    print(f"⚠ {dst_file}: podpis nieprawidłowy ({_sig_reason}) – usuwam .asc")
                    try:
                        os.remove(_dst_fp + ".asc")
                    except OSError:
                        pass

            # Zachowaj tylko aktualną wersję – usuń starsze <name>-*.pag w repo
            removed = clean_old_versions(dst_dir, name, dst_file)
            for r in removed:
                print(f"🗑  Usunięto starą wersję: {r}")

            # Gdy (prze)budowano klienta pag – odśwież luźny /stable/pag
            # (payload self-update) z tej właśnie paczki, ZANIM zaktualizujemy
            # repo.json, żeby pag_version wskazywał opublikowaną wersję.
            if name == "pag":
                _publish_pag_client(dst_dir)

            # Natychmiast odśwież wpis w repo.json. Stara wersja została właśnie
            # skasowana, więc bez tego panel i klienci linkowali do nieistniejącego
            # pliku (404) aż do końca całej serii buildów.
            update_repo_json_after_build(dst_dir, dst_file)

            # Instalacja do rootfs – build-deps dla kolejnych pakietów.
            _install_to_rootfs(os.path.join(dst_dir, dst_file))

            # Auto-zapis configu jądra (olddefconfig) z powrotem do receptury.
            _maybe_save_kernel_config(name)

            # Test pakietu: czy runtime depends będą rozwiązywalne dla klienta.
            _warn_unresolved_runtime_deps(name, meta.get("depends") or [], state)

            # Aktualizuj indeks pakietów w state
            state.setdefault("packages", {})[name] = {
                "version": ver, "release": rel, "category": category,
                "status": "ok", "built_at": datetime.now(timezone.utc).isoformat(),
                "filename": dst_file,
            }
        else:
            print(f"⚠ Brak pliku {PKG_EXT} w {BUILD_OUT}")
    else:
        print(f"❌ [BUILD] {name}-{ver} FAIL (kod {proc.returncode})")

    # Zapis historii
    build_record = {
        "name": name, "version": ver, "release": rel,
        "category": category, "status": status,
        "duration": f"{duration}s",
        "time": datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S"),
        "log": "\n".join(log_lines[-150:]),
    }
    state.setdefault("builds", []).append(build_record)

    # Historia: do 5000 wpisów (statystyki 24h/7d/30d i strona panelu).
    # Pełny log tylko dla ostatnich 300 wpisów – starsze zostają jako lekkie
    # metadane (bez logu), żeby state.json nie urósł do setek MB.
    MAX_BUILD_HISTORY = 5000
    KEEP_LOG_BUILDS = 300
    if len(state["builds"]) > MAX_BUILD_HISTORY:
        state["builds"] = state["builds"][-MAX_BUILD_HISTORY:]
    if len(state["builds"]) > KEEP_LOG_BUILDS:
        for _older in state["builds"][:-KEEP_LOG_BUILDS]:
            _older.pop("log", None)

    clear_current_build(state)
    save_state(state)
    # Pre-flight pagbuild: makedepends wykryte jako BRAK w rootfs. To nie są
    # fałszywe alarmy (mapa aliasów w pagbuild), więc pagsync WYMUSI ich
    # przebudowę nawet jeśli state/repo mówi "zbudowane" – stare .pag bywają
    # puste/zepsute (skutek historycznych błędów buildera).
    preflight_missing = []
    for ln in log_lines:
        if "makedepends:" in ln and "BRAK w rootfs" in ln:
            m = re.search(r"makedepends:\s*([\w+.-]+)\s*–\s*BRAK", ln)
            if m:
                preflight_missing.append(m.group(1))
    return (status == "ok", preflight_missing)


def _pag_is_empty(pkg_file):
    """Czy .pag ma PUSTE data.tar.xz (historyczne puste pakiety 4.0K).

    Puste .pag powstawały, gdy receptura nie instalowała nic do PKGDIR
    (build: bez make install). Instalacja takiego pakietu do rootfs nic nie
    daje, a rejestr installed.json i tak by go „uznał” – dlatego przy wyborze
    „doinstaluj istniejący .pag zamiast budować” pomijamy puste.
    """
    import tarfile as _tar
    try:
        with _tar.open(pkg_file, "r:xz") as tf:
            for m in tf:
                if not m.name.endswith("data.tar.xz"):
                    continue
                fobj = tf.extractfile(m)
                if fobj is None:
                    return True
                with _tar.open(fileobj=fobj, mode="r:xz") as dt:
                    for mm in dt.getmembers():
                        # pusty pakiet ma tylko wpis '.' (katalog); pliki,
                        # symlinki i realne katalogi oznaczają treść
                        if mm.isdir() and mm.name in (".", "./", "/"):
                            continue
                        # stary bug pagbuild: tar pakował metadata.json DO
                        # data.tar.xz (--exclude nie działał) – to nie treść
                        if mm.name.rstrip("/") in ("metadata.json", "./metadata.json"):
                            continue
                        return False
                    return True
        return False
    except Exception:
        # uszkodzony/ucięty .pag – nie uznajemy za pusty (bezpieczniej dobudować)
        return False


def run_single_build(pkg_name, recipe_path, state, auto_retry=0, chain=None):
    """Buduje pakiet; z AUTO_DEPS po nieudanym buildzie analizuje diagnostykę
    pagbuild, dobudowuje brakujące zależności (jeśli mają receptury) i ponawia.

    chain – zbiór nazw już próbowanych w tej sesji (zabezpieczenie przed cyklami:
    A potrzebuje B, B potrzebuje A).
    """
    if chain is None:
        chain = set()
    chain.add(pkg_name)

    ok, preflight_missing = _do_single_build(pkg_name, recipe_path, state)
    if ok:
        return True

    if not AUTO_DEPS:
        print("ℹ --auto-deps wyłączone – brak dobudowy zależności.")
        return False
    if auto_retry >= MAX_AUTO_DEPS_RETRIES:
        print(f"ℹ --auto-deps: osiągnięto limit ponowień ({MAX_AUTO_DEPS_RETRIES}) dla {pkg_name}.")
        return False

    missing = read_build_diagnostics(pkg_name)
    # Uzupełnij braki o NIESPEŁNIONE makedepends/depends z samej receptury
    # (np. ftjam → jam): log pokazuje tylko objaw („jam: command not found"),
    # a receptura mówi wprost, że potrzebuje pakietu ftjam.
    declared = _unsatisfied_declared_deps(recipe_path, state)
    missing_names = {m.get("name") for m in missing if isinstance(m, dict)}
    for d in declared:
        if d.get("name") not in missing_names:
            missing.append(d)
            missing_names.add(d.get("name"))
    if declared:
        print(f"ℹ --auto-deps: niespełnione makedepends/depends receptury: "
              f"{', '.join(sorted(d.get('name', '') for d in declared))}")
    # Makedepends, które pre-flight pagbuild wykrył jako BRAK w rootfs – wymuszamy
    # ich (prze)budowę, nawet jeśli state mówi "zbudowane" (stare .pag bywają
    # puste/zepsute po historycznych błędach buildera).
    # Narzędzia DOC generują tylko dokumentację – pomijamy je: budowa i tak
    # zwykle działa bez nich (np. librsvg z -Ddocs=disabled), a ich łańcuch
    # zależności potrafi blokować retry na długo.
    _OPTIONAL_DOC_DEPS = {
        "gi-docgen", "gtk-doc", "python-docutils", "python-smartypants",
        "doxygen", "help2man", "gnome-doc-utils", "yelp-tools", "itstool",
    }
    force_rebuild = set()
    for name in preflight_missing:
        if name in _OPTIONAL_DOC_DEPS:
            continue
        if name not in missing_names:
            missing.append({"kind": "preflight", "name": name})
            missing_names.add(name)
            force_rebuild.add(name)
    _forced = sorted(f for f in preflight_missing if f not in _OPTIONAL_DOC_DEPS)
    if _forced:
        print(f"ℹ --auto-deps: pre-flight wykrył BRAK w rootfs (wymuszam przebudowę): "
              f"{', '.join(_forced)}")
    elif preflight_missing:
        print("ℹ --auto-deps: pre-flight: brakuje tylko narzędzi docs (pomijam dobudowę): "
              f"{', '.join(sorted(preflight_missing))}")
    if not missing:
        print("ℹ --auto-deps: nie wykryto brakujących zależności – brak dobudowy.")
        return False

    providers = [p for p in resolve_missing_to_recipes(missing, state) if p not in chain]

    # Braki BEZ receptury: szukaj gotowego pakietu w repo (odpowiednik
    # `pag search` + `pag install`) i instaluj do rootfs buildera – zanim
    # uznamy build za niemożliwy.
    provider_set = set(providers)
    repo_missing = []
    for item in missing:
        if not isinstance(item, dict) or not item.get("name"):
            continue
        if item["name"] in provider_set:
            continue
        # Brak pokryty przez provider wybrany przez alias (pkgconfig(foo) -> foo)?
        if any(item["name"] in _provider_candidates(p, item.get("kind", ""))
               for p in provider_set):
            continue
        repo_missing.append(item)
    installed_repo = _install_missing_from_repo(repo_missing) if repo_missing else False

    if not providers:
        if installed_repo:
            print(f"🔁 --auto-deps: ponawiam build {pkg_name} (próba {auto_retry + 1}/{MAX_AUTO_DEPS_RETRIES})")
            return run_single_build(pkg_name, recipe_path, state, auto_retry + 1, chain)
        names = ", ".join(sorted({m.get("name", "") for m in missing if isinstance(m, dict)}))
        print(f"ℹ --auto-deps: brak receptury dostarczającej: {names}")
        print(f"   Sprawdź makedepends w {recipe_path} – dodaj brakujący pakiet albo dopisz recepturę.")
        return False

    print(f"🔧 --auto-deps: dobudowuję zależności buildowe: {', '.join(providers)}")
    for prov in providers:
        if _pkg_built(prov, state):
            # .pag już jest w repo – doinstaluj go do rootfs zamiast budować:
            # sam skip zostawiał rootfs BEZ pakietu i build padał dalej.
            # Dotyczy też force_rebuild (pre-flight BRAK): np. cargo-c instaluje
            # binarki cargo-cbuild/... (probe po nazwie nie trafia), a dobry .pag
            # leży w repo – instalacja rejestruje pakiet w installed.json i probe
            # przechodzi. PUSTEGO .pag (historyczne 4.0K) nie instalujemy – taki
            # trzeba dobudować z receptury.
            hit = _search_repo_package(prov, "makedep")
            if hit and not _dep_satisfied_in_rootfs(prov) and not _pag_is_empty(hit[1]):
                print(f"   📦 {prov} już zbudowany – instaluję .pag z repo do rootfs")
                _install_to_rootfs(hit[1])
            if prov not in force_rebuild or _dep_satisfied_in_rootfs(prov):
                print(f"   ⏭ {prov} już zbudowany – pomijam dobudowę")
                continue
        prov_path = find_recipe_path(prov)
        if not prov_path:
            print(f"   ⚠ brak receptury dla {prov} (mimo indeksu)")
            continue
        run_single_build(prov, prov_path, state, auto_retry=0, chain=chain)

    print(f"🔁 --auto-deps: ponawiam build {pkg_name} (próba {auto_retry + 1}/{MAX_AUTO_DEPS_RETRIES})")
    return run_single_build(pkg_name, recipe_path, state, auto_retry + 1, chain)


# ── REBUILD-DEPS: wymuszona przebudowa całego łańcucha zależności ──
def rebuild_deps_chain(pkg_name, state):
    """Przebudowuje pakiet wraz z CAŁYM łańcuchem zależności build/runtime.

    Zbiera wszystkich przodków (transitive depends+makedepends) z drzewa
    recipes, sortuje topologicznie i przebudowuje wszystkich (nawet już
    zbudowanych) w poprawnej kolejności – jak `--rebuild` w portage.
    """
    recipes = scan_recipes(state)
    name_to_meta = {m["name"]: m for _, m in recipes}
    name_to_path = {m["name"]: fp for fp, m in recipes}

    if pkg_name not in name_to_meta:
        print(f"❌ Brak receptury dla pakietu: {pkg_name}")
        return

    needed = set()

    def collect(name):
        if name in needed or name not in name_to_meta:
            return
        needed.add(name)
        meta = name_to_meta[name]
        for dep in list(meta.get("depends", [])) + list(meta.get("makedepends", [])):
            if dep in name_to_meta:
                collect(dep)

    collect(pkg_name)

    ordered = resolve_build_order([(name_to_path[n], name_to_meta[n]) for n in needed])

    # Zależności spoza recipes (baza systemu / rootfs) – tylko informacyjnie
    external = set()
    for n in needed:
        meta = name_to_meta[n]
        for dep in list(meta.get("depends", [])) + list(meta.get("makedepends", [])):
            if dep not in name_to_meta and dep not in BUILD_SYSTEM_DEPS:
                external.add(dep)

    print(f"🔧 REBUILD-DEPS {pkg_name}: przebudowa całego łańcucha ({len(ordered)} pakietów):")
    for fp, meta in ordered:
        print(f"   • {meta['name']}-{meta['version']}")
    if external:
        print(f"   (pomijam zależności spoza recipes: {', '.join(sorted(external))})")
    print()

    ok_count = fail_count = 0
    for fp, meta in ordered:
        success = run_single_build(meta["name"], fp, state)
        if success:
            ok_count += 1
        else:
            fail_count += 1

    generate_repo_json(verbose=True)
    save_state(state)
    print(f"\n✨ REBUILD-DEPS zakończony: ✅ {ok_count} | ❌ {fail_count}")

# ── Skanowanie drzewa recipes ──
def scan_recipes(state):
    """Skanuje wszystkie receptury i zwraca listę do zbudowania.

    Używa recipe_index jako cache: niezmienione pliki (ta sama ścieżka i mtime)
    nie są ponownie parsowane – pełny YAML-parse wszystkich receptur był
    zauważalnie wolny, a każdy --build robił skan.
    """
    print(f"🔍 Skanowanie {RECIPES_DIR}...")

    index = state.setdefault("recipe_index", {})
    found = []
    cached = 0
    parsed = 0
    for root, dirs, files in os.walk(RECIPES_DIR):
        for fn in ["PAGBUILD.yaml", "package.yml", "recipe.yaml"]:
            if fn in files:
                fp = os.path.join(root, fn)
                mtime = os.path.getmtime(fp)
                entry = index.get(fp)
                if entry and entry.get("mtime") == mtime and entry.get("meta"):
                    meta = entry["meta"]
                    cached += 1
                else:
                    meta = parse_recipe(fp)
                    if meta:
                        index[fp] = {"mtime": mtime, "meta": meta}
                        parsed += 1
                if meta:
                    found.append((fp, meta))
                break  # tylko jeden plik na katalog

    print(f"   Znaleziono {len(found)} receptur (cache: {cached}, parsowanych: {parsed}).")

    # ── Sprzątanie cache recipe_index ──
    # Po rename'ach/usunięciach zostają wpisy dla nieistniejących plików oraz
    # legacy wpisy kluczowane nazwą (duplikaty ścieżek) – przez to indeks puchł
    # (np. 4413 wpisów przy 2214 recepturach), a panel mógł pokazywać stare
    # pozycje mimo świeżego git pull. Zostawiamy WYŁĄCZNIE aktualne wpisy
    # ścieżkowe (meta z cache dla niezmienionych plików – bez ponownego parsu).
    live_paths = {fp for fp, _m in found}
    stale = [k for k in index
             if not str(k).startswith("/") or k not in live_paths]
    for k in stale:
        del index[k]
    if stale:
        print(f"   🧹 Usunięto z cache {len(stale)} nieaktualnych wpisów")

    save_state(state)
    return found

def resolve_build_order(recipes):
    """
    Topologiczne sortowanie receptur według zależności.
    Zwraca listę (recipe_path, meta) w poprawnej kolejności.
    """
    # Budowanie grafu zależności
    name_to_meta = {}
    for fp, meta in recipes:
        name_to_meta[meta["name"]] = (fp, meta)

    in_degree = {meta["name"]: 0 for _, meta in recipes}
    adj = {meta["name"]: [] for _, meta in recipes}

    for _, meta in recipes:
        # Zarówno zależności runtime, jak i build-time wpływają na kolejność:
        # makedepends dostarcza nagłówków/pkg-config dla kolejnych receptur.
        all_deps = list(meta.get("depends", [])) + list(meta.get("makedepends", []))
        for dep in all_deps:
            if dep in name_to_meta and meta["name"] not in adj[dep]:
                adj[dep].append(meta["name"])
                in_degree[meta["name"]] += 1

    # Część receptur historycznie nie deklaruje bibliotek PaganOS w depends,
    # mimo że ich build wymaga pkg-config. Dodaj znane relacje z rodziny XFCE,
    # aby czyste --rebuild-failed nie próbowało budować konsumenta przed biblioteką.
    xfce_edges = {
        "libxfce4util": ("exo", "garcon", "libxfce4ui"),
    }
    for provider, consumers in xfce_edges.items():
        if provider not in name_to_meta:
            continue
        for consumer in consumers:
            if consumer in name_to_meta and consumer not in adj[provider]:
                adj[provider].append(consumer)
                in_degree[consumer] += 1

    # Kahn's algorithm
    queue = [n for n, d in in_degree.items() if d == 0]
    order = []

    while queue:
        # Sortuj alfabetycznie dla stabilności
        queue.sort()
        node = queue.pop(0)
        order.append(name_to_meta[node])
        for neighbor in adj.get(node, []):
            in_degree[neighbor] -= 1
            if in_degree[neighbor] == 0:
                queue.append(neighbor)

    # Dodaj pozostałe (cykle lub niezależne)
    for name, (fp, meta) in name_to_meta.items():
        if (fp, meta) not in order:
            order.append((fp, meta))

    return order

# ── Git Sync – aktualizuje recipes z repozytorium git ──
def git_sync_recipes():
    """
    Synchronizuje lokalne recipes z git repo.
    Jeśli RECIPES_DIR nie istnieje lub nie jest repo – klonuje.
    Jeśli istnieje – robi git pull (auto-stash lokalnych zmian).
    """
    if not os.path.isdir(os.path.join(RECIPES_DIR, ".git")):
        # Pierwszy raz – klonujemy
        print(f"📥 Klonowanie recipes z {RECIPES_GIT_REMOTE}...")
        if os.path.isdir(RECIPES_DIR):
            backup = RECIPES_DIR + ".bak." + datetime.now(timezone.utc).strftime("%Y%m%d%H%M%S")
            os.rename(RECIPES_DIR, backup)
            print(f"   📁 Stary katalog przeniesiony do {backup}")
        os.makedirs(os.path.dirname(RECIPES_DIR), exist_ok=True)
        try:
            result = subprocess.run(
                ["git", "clone", RECIPES_GIT_REMOTE, RECIPES_DIR],
                capture_output=True, text=True, errors='replace', timeout=120
            )
            if result.returncode != 0:
                print(f"   ⚠ git clone nieudany: {result.stderr.strip()[:200]}")
                return False
            print(f"   ✅ Sklonowano ({len(os.listdir(RECIPES_DIR))} plików/katalogów)")
            return True
        except Exception as e:
            print(f"   ❌ Błąd klonowania: {e}")
            return False
    else:
        # Już jest repo – stash lokalnych zmian, pull, drop stash
        print(f"🔄 Git pull recipes ({RECIPES_GIT_REMOTE})...")
        try:
            # Sprzątanie po wcześniej przerwanym rebase/mergu (konflikt zostawiał
            # .git/rebase-merge lub MERGE_HEAD → każdy kolejny pull padał na
            # "unmerged files" i blokował kolejkę aż do ręcznej naprawy).
            if os.path.isdir(os.path.join(RECIPES_DIR, ".git", "rebase-merge")) or \
               os.path.isdir(os.path.join(RECIPES_DIR, ".git", "rebase-apply")):
                print("   🧹 Znaleziono przerwany rebase – przerywam (abort)")
                subprocess.run(["git", "-C", RECIPES_DIR, "rebase", "--abort"],
                               capture_output=True, timeout=15)
            if os.path.exists(os.path.join(RECIPES_DIR, ".git", "MERGE_HEAD")):
                print("   🧹 Znaleziono niedokończony merge – przerywam (abort)")
                subprocess.run(["git", "-C", RECIPES_DIR, "merge", "--abort"],
                               capture_output=True, timeout=15)
            # Sprawdź czy są lokalne zmiany
            status = subprocess.run(
                ["git", "-C", RECIPES_DIR, "status", "--porcelain"],
                capture_output=True, text=True, errors='replace', timeout=10
            )
            has_changes = bool(status.stdout.strip())
            
            if has_changes:
                # Stash lokalnych zmian przed pullem
                subprocess.run(
                    ["git", "-C", RECIPES_DIR, "stash", "--include-untracked"],
                    capture_output=True, text=True, errors='replace', timeout=10
                )
                print(f"   📦 Lokalne zmiany odłożone na stash")
            
            result = subprocess.run(
                ["git", "-C", RECIPES_DIR, "pull", "--ff-only", "origin", "main"],
                capture_output=True, text=True, errors='replace', timeout=60
            )
            output = result.stdout.strip()

            if result.returncode != 0:
                # Rozjazd gałęzi: lokalne commity automatyki (check-updates/auto-fix)
                # nie weszły do origin (push bywa cicho odrzucany jako non-fast-forward).
                # Zamiast przerywać całą kolejkę – rebase lokalnych commitów na origin.
                # To bezpieczne: to output automatyki, nie ręczna praca użytkownika.
                print(f"   ⚠ Pull --ff-only nieudany – próbuję rebase lokalnych commitów...")
                rb = subprocess.run(
                    ["git", "-C", RECIPES_DIR, "pull", "--rebase", "origin", "main"],
                    capture_output=True, text=True, errors='replace', timeout=120
                )
                if rb.returncode != 0:
                    # Nie zostawiaj pół-rebase'a – kolejne synce padałyby na
                    # "unmerged files" do czasu ręcznej naprawy.
                    subprocess.run(["git", "-C", RECIPES_DIR, "rebase", "--abort"],
                                   capture_output=True, timeout=15)
                    print(f"   ⚠ Pull nieudany: {result.stderr.strip()[:200]}")
                    print(f"   ⚠ Rebase też nieudany: {rb.stderr.strip()[:200]}")
                    return False
                result = rb
                output = result.stdout.strip()
            
            if has_changes:
                # Próbuj przywrócić stash – jeśli konflikt, discard
                pop = subprocess.run(
                    ["git", "-C", RECIPES_DIR, "stash", "pop"],
                    capture_output=True, text=True, errors='replace', timeout=10
                )
                if pop.returncode != 0:
                    print(f"   ⚠ Konflikt ze stash – zachowuję wersję z repo")
                    subprocess.run(
                        ["git", "-C", RECIPES_DIR, "checkout", "--", "."],
                        capture_output=True, timeout=10
                    )
                    subprocess.run(
                        ["git", "-C", RECIPES_DIR, "stash", "drop"],
                        capture_output=True, timeout=5
                    )
            
            if "Already up to date" in output:
                print(f"   ✅ Już aktualne")
            elif result.returncode == 0:
                print(f"   ✅ Zaktualizowano: {output[:150]}")
            else:
                print(f"   ⚠ Pull nieudany: {result.stderr.strip()[:200]}")
                return False
            return True
        except Exception as e:
            print(f"   ⚠ Błąd git pull: {e}")
            return False


# ── Główna funkcja synchronizacji ──
# ── Generowanie repo.json dla każdej kategorii ──
def _pkg_entry_from_file(fp, fname):
    """Buduje wpis repo.json dla jednego pliku .pag (metadata.json + sha256)."""
    # Odczytaj metadata.json z paczki (toleruj prefiks ./)
    meta = {}
    import tarfile
    try:
        with tarfile.open(fp, "r:xz") as tf:
            names = tf.getnames()
            mname = None
            if "metadata.json" in names:
                mname = "metadata.json"
            elif "./metadata.json" in names:
                mname = "./metadata.json"
            if mname:
                meta_f = tf.extractfile(mname)
                if meta_f:
                    meta = json.loads(meta_f.read().decode())
    except Exception:
        pass

    # Fallback: parsuj nazwę pliku
    base = fname.replace(PKG_EXT, "")
    m = re.match(r'^(.+)-([\d][\w.-]*)-(\d+)$', base)
    if m:
        pname, pver, prel = m.group(1), m.group(2), m.group(3)
    else:
        pname, pver, prel = base, "0", "1"

    # SHA256 całej paczki – klient pag weryfikuje pobieranie
    import hashlib as _hl
    _h = _hl.sha256()
    with open(fp, "rb") as _f:
        for _chunk in iter(lambda: _f.read(65536), b""):
            _h.update(_chunk)

    return {
        "name": meta.get("name", pname),
        "version": meta.get("version", pver),
        "release": meta.get("release", int(prel)),
        "filename": fname,
        "size": os.path.getsize(fp),
        "sha256": _h.hexdigest(),
        "arch": meta.get("arch", "x86_64"),
        "description": meta.get("description", ""),
        "depends": meta.get("depends", []),
        "provides": meta.get("provides", []),
        "license": meta.get("license", []),
    }


def _publish_pag_client(cat_dir, verbose=True):
    """Publikuje luźny klient `pag` (dla self-update) z najnowszej paczki `pag-*.pag`.

    `pag self-update` pobiera `/stable/pag` (+ `.asc`, `.sha256`), a
    `repo.json["pag_version"]` jest z niego czytany. Ten plik był wcześniej
    publikowany ręcznie i potrafił zostać w tyle za paczką (np. 3.3.17 vs
    3.3.19). Tutaj wyciągamy `usr/bin/pag` z NAJNOWSZEJ paczki pag w repo i
    odtwarzamy luźny plik wraz z SHA256 i podpisem, więc self-update i indeks
    zawsze wskazują tę samą wersję co pakiet z repo.
    """
    import glob as _g, hashlib as _hl, tarfile as _tf, tempfile as _tmp
    cands = _g.glob(os.path.join(cat_dir, "pag-*.pag"))
    if not cands:
        return False

    def _ver_of(f):
        m = re.search(r'/pag-([\d][\w.]*?)-\d+\.pag$', f)
        return m.group(1) if m else "0"

    src = max(cands, key=_ver_of)
    try:
        with _tf.open(src, "r:xz") as tf:
            data_member = next((m for m in tf.getmembers()
                                if m.name.endswith("data.tar.xz")), None)
            if data_member is None:
                return False
            inner_xz = tf.extractfile(data_member).read()
        with _tmp.NamedTemporaryFile(delete=False, suffix=".tar.xz") as t:
            t.write(inner_xz)
            inner_path = t.name
        pag_data = None
        try:
            with _tf.open(inner_path, "r:xz") as itf:
                for m in itf.getmembers():
                    if m.name.lstrip("./") == "usr/bin/pag":
                        pag_data = itf.extractfile(m).read()
                        break
        finally:
            try:
                os.unlink(inner_path)
            except OSError:
                pass
        if not pag_data:
            return False
        # Nie publikuj uszkodzonego klienta (self-update i tak sprawdza składnię,
        # ale lepiej nie wypuszczać śmiecia do repo).
        try:
            compile(pag_data, "pag", "exec")
        except SyntaxError:
            return False
    except Exception:
        return False

    dst = os.path.join(cat_dir, "pag")
    old = None
    if os.path.isfile(dst):
        try:
            old = open(dst, "rb").read()
        except Exception:
            old = None
    if old == pag_data:
        return True  # bez zmian – nie ruszamy podpisu

    tmp_dst = dst + ".new"
    with open(tmp_dst, "wb") as f:
        f.write(pag_data)
    os.chmod(tmp_dst, 0o755)
    os.replace(tmp_dst, dst)
    with open(dst + ".sha256", "w") as f:
        f.write(_hl.sha256(pag_data).hexdigest() + "  pag\n")

    if DO_SIGN:
        gpg_bin = shutil.which("gpg2") or shutil.which("gpg") or ""
        if gpg_bin:
            sig = dst + ".asc"
            if os.path.exists(sig):
                os.remove(sig)
            key_args = ["--local-user", GPG_KEY] if GPG_KEY else []
            subprocess.run(
                [gpg_bin, "--detach-sign", "--armor", "--batch", "--no-tty"] + key_args + [dst],
                check=False, capture_output=True,
            )
    if verbose:
        print(f"   🔁 Opublikowano klienta pag z {os.path.basename(src)} "
              f"→ {os.path.basename(dst)} (+.asc/.sha256)")
    return True


def _write_and_sign_repo_json(cat_dir, packages, cat, verbose=True):
    """Zapisuje repo.json (+ podpis GPG, gdy DO_SIGN). Wspólne dla pełnej
    generacji i przyrostowej aktualizacji po pojedynczym buildzie."""
    repo_json = os.path.join(cat_dir, "repo.json")

    # Wersja pag dla self-update (repo.json["pag_version"]) – czytana
    # z pliku `pag` w katalogu repo. Klient pag porównuje ją z lokalnym
    # PAG_VERSION przy `pag update` i sugeruje `pag self-update`.
    pag_version = ""
    _pag_file = os.path.join(cat_dir, "pag")
    if os.path.isfile(_pag_file):
        try:
            with open(_pag_file, "rb") as _pf:
                _head = _pf.read(4096)
            _m = re.search(rb'PAG_VERSION\s*=\s*"(\d+\.\d+\.\d+[a-z]?)"', _head)
            if _m:
                pag_version = _m.group(1).decode()
        except Exception:
            pass
    # Gdy plik `pag` nie leży w katalogu repo, zachowaj wersję z istniejącego indeksu.
    if not pag_version and os.path.isfile(repo_json):
        try:
            pag_version = json.load(open(repo_json)).get("pag_version", "") or pag_version
        except Exception:
            pass

    with open(repo_json, "w") as f:
        json.dump({
            "category": cat,
            "updated": datetime.now(timezone.utc).isoformat(),
            "count": len(packages),
            "pag_version": pag_version,
            "packages": packages,
        }, f, indent=2)

    # Podpisz repo.json GPG. UWAGA: gpg --detach-sign NIE nadpisuje
    # istniejącego pliku .asc ("File exists") – stary podpis trzeba usunąć,
    # inaczej klienci dostają NIEPRAWIDŁOWY PODPIS indeksu.
    repo_sig = repo_json + ".asc"
    signed = False
    if DO_SIGN:
        gpg_bin = shutil.which("gpg2") or shutil.which("gpg") or ""
        if gpg_bin:
            key_args = ["--local-user", GPG_KEY] if GPG_KEY else []
            if os.path.exists(repo_sig):
                os.remove(repo_sig)
            sign_res = subprocess.run(
                [gpg_bin, "--detach-sign", "--armor", "--batch", "--no-tty"] + key_args + [repo_json],
                check=False, capture_output=True,
            )
            sig_ok = (sign_res.returncode == 0 and os.path.exists(repo_sig))
            if sig_ok:
                signed = True
                if verbose:
                    print(f"   {cat}: {len(packages)} pakietów → repo.json + 🔏 repo.json.asc")
            else:
                print(f"   ⚠ {cat}: podpisanie repo.json NIE powiodło się "
                      f"(rc={sign_res.returncode}): {sign_res.stderr.decode(errors='replace').strip()[:120]}")
        else:
            print(f"   ⚠ {cat}: brak gpg w PATH – repo.json bez podpisu!")
    if not signed and os.path.exists(repo_sig):
        # repo.json został właśnie nadpisany, a .asc nie powstał w tej
        # iteracji → stary podpis jest NIEAKTUALNY. Usuń go, żeby klienci
        # dostawali czytelny błąd „brak podpisu” zamiast BAD SIGNATURE.
        try:
            os.remove(repo_sig)
        except OSError:
            pass
    if verbose:
        print(f"   {cat}: {len(packages)} pakietów → repo.json")
    return signed


def update_repo_json_after_build(cat_dir, dst_file, verbose=True):
    """Przyrostowa aktualizacja repo.json po pojedynczym buildzie.

    Bez tego indeks jest odświeżany dopiero na KOŃCU całej serii buildów.
    Ponieważ `clean_old_versions` usuwa starą wersję .pag natychmiast po
    przeniesieniu nowej, przez cały czas trwania serii panel linkował do
    skasowanych plików (404). Tutaj:
      1) wyrzucamy wpisy, których plik zniknął,
      2) podmieniamy/dodajemy wpis świeżo zbudowanej paczki.
    """
    if not DO_SIGN:
        # Nie nadpisujemy (być może podpisanego) repo.json wersją bez podpisu.
        return False
    fp = os.path.join(cat_dir, dst_file)
    if not os.path.isfile(fp):
        return False
    repo_json = os.path.join(cat_dir, "repo.json")
    try:
        data = json.load(open(repo_json)) if os.path.isfile(repo_json) else {}
    except Exception:
        data = {}
    packages = [
        p for p in data.get("packages", [])
        if isinstance(p, dict) and p.get("filename")
        and os.path.isfile(os.path.join(cat_dir, p["filename"]))
    ]
    entry = _pkg_entry_from_file(fp, dst_file)
    packages = [p for p in packages if p.get("name") != entry["name"]]
    packages.append(entry)

    # Dodaj pliki obecne na dysku, których nie ma w indeksie (np. zbudowane
    # inną ścieżką albo po przerwanej serii) – indeks sam się leczy, a hashujemy
    # tylko te brakujące pozycje, bez pełnego skanu przy każdym pakiecie.
    known = {p.get("filename") for p in packages}
    for fname in sorted(os.listdir(cat_dir)):
        if not fname.endswith(PKG_EXT) or fname in known:
            continue
        try:
            packages.append(_pkg_entry_from_file(os.path.join(cat_dir, fname), fname))
        except Exception:
            continue

    # Dedupe po nazwie – zostaw najnowszą wersję (jak generate_repo_json).
    best = {}
    for p in packages:
        cur = best.get(p.get("name"))
        if cur is None or _ver_key(p) > _ver_key(cur):
            best[p["name"]] = p
    packages = sorted(best.values(), key=lambda p: p.get("name", ""))

    cat = os.path.basename(cat_dir.rstrip("/")) or (data.get("category") or "stable")
    if verbose:
        print(f"🔄 repo.json: przyrostowa aktualizacja wpisu {entry['name']}-{entry['version']}...")
    _write_and_sign_repo_json(cat_dir, packages, cat, verbose=verbose)
    return True


def generate_repo_json(verbose=True):
    """Skanuje katalogi repo i generuje repo.json (indeks pakietów)."""
    if verbose:
        print("\n📋 Aktualizacja plików repo.json...")
    generated = 0
    for cat in REPO_CATEGORIES:
        cat_dir = os.path.join(REPO_BASE, cat)
        if not os.path.isdir(cat_dir):
            continue

        packages = []
        for fname in sorted(os.listdir(cat_dir)):
            if not fname.endswith(PKG_EXT):
                continue
            packages.append(_pkg_entry_from_file(os.path.join(cat_dir, fname), fname))

        # Dedupe: zostaw tylko najnowszą wersję per-pakiet (zgodnie z polityką
        # "w repo trzymamy tylko aktualną wersję" – zabezpieczenie, gdyby w
        # katalogu zostały stare pliki z innego źródła).
        best = {}
        for p in packages:
            key = p["name"]
            cur = best.get(key)
            if cur is None or _ver_key(p) > _ver_key(cur):
                best[key] = p
        packages = sorted(best.values(), key=lambda p: p["name"])

        # Utrzymaj luźnego klienta pag (self-update) w zgodzie z najnowszą
        # paczką pag w repo, zanim odczytamy z niego pag_version.
        _publish_pag_client(cat_dir, verbose=False)
        _write_and_sign_repo_json(cat_dir, packages, cat, verbose=verbose)
        generated += 1
    return generated


# ── AUTO-REBUILD MODUŁÓW PO AKTUALIZACJI JĄDRA ──
# Gdy pakiet jądra (kernel*, np. kernel-618) zostanie (prze)zbudowany w tej
# sesji, przebuduj pakiety, które deklarują `depends:` na nim (np.
# nvidia-kernel-618). Ich .ko niosą vermagic nowej wersji jądra.
#
# Bezpieczeństwo:
#  * tylko JEDEN kierunek (kernel -> konsumenci), brak pętli,
#  * pkgrel konsumenta podbijamy WYŁĄCZNIE gdy wcześniej zbudowano go pod
#    INNĄ wersją jądra (state['kernel_builds']) – force-rebuild jądra o tej
#    samej wersji nie powoduje churnu pkgrel,
#  * wyłącznik: PAGAN_KERNEL_REBUILD=0 (np. w /etc/pagan/build.conf),
#  * zmiany w recepturach commituje wzorcem auto-fix SHA (pull --rebase + push).
KERNEL_REBUILD = os.environ.get("PAGAN_KERNEL_REBUILD", "1") != "0"

# Auto-zapis configu jądra (olddefconfig) z powrotem do receptury po udanym
# buildzie. Wyłącznik: PAGAN_KERNEL_SAVE_CONFIG=0 (np. w /etc/pagan/build.conf).
KERNEL_SAVE_CONFIG = os.environ.get("PAGAN_KERNEL_SAVE_CONFIG", "1") != "0"
_PENDING_KERNEL_RECORD = {}  # konsument -> „zbudowany pod kernel-X-Y-Z” (zapis po buildzie w pętli)
_KERNEL_INDEX = None


def _kernel_key_from_rootfs(kernel_name):
    """Rzeczywista wersja jądra w rootfs buildera (kernel.release) – moduły
    powstają przeciw SYSSRC=/usr/src/<kernel>, więc to jest źródło prawdy.
    Zwraca 'kernel-618-6.18.44' albo None, gdy rootfs jeszcze nie ma jądra."""
    for _sub in ("include/config/kernel.release", ".kernelrelease"):
        try:
            _v = open(os.path.join(ROOTFS_PATH, "usr/src", kernel_name, _sub),
                      encoding="utf-8").read().strip()
        except OSError:
            continue
        if _v:
            return "%s-%s" % (kernel_name, _v)
    return None


def _kernel_index(state):
    """Indeks pakietów jądra (kernel*) – budowany raz na proces (cache: skan)."""
    global _KERNEL_INDEX
    if _KERNEL_INDEX is None:
        _KERNEL_INDEX = [m for _fp, m in scan_recipes(state) if _kernel_like(m.get("name"))]
    return _KERNEL_INDEX


def _kernel_like(name):
    """Czy pakiet to jądro (kernel, kernel-618, kernel-mainline, ...)."""
    return bool(name and re.match(r"^kernel(?:-|$)", name))


def _consumer_recipes_of(kernel_name, recipes):
    """Receptury, których depends/makedepends wskazuje DOKŁADNIE kernel_name."""
    out = []
    for fp, meta in recipes or []:
        if (meta or {}).get("name") == kernel_name:
            continue
        deps = list(meta.get("depends") or []) + list(meta.get("makedepends") or [])
        if any(str(d or "").strip() == kernel_name for d in deps):
            out.append((fp, meta))
    return out


def _bump_pkgrel(fp):
    """pkgrel +1 (linia `pkgrel:`), bez przebudowy całego YAML. Zwraca nowy rel albo None."""
    try:
        text = open(fp, encoding="utf-8").read()
    except OSError:
        return None
    def _rep(m):
        return "%s%d%s" % (m.group(1), int(m.group(2)) + 1, m.group(3))
    new, n = re.subn(r"(?m)^(pkgrel:\s*['\"]?)(\d+)(['\"]?)\s*$", _rep, text, count=1)
    if n != 1:
        return None
    try:
        with open(fp, "w", encoding="utf-8") as fh:
            fh.write(new)
    except OSError:
        return None
    m = re.search(r"(?m)^pkgrel:\s*['\"]?(\d+)['\"]?\s*$", new)
    return m.group(1) if m else None


def _git_commit_recipes(msg):
    """Commit + pull --rebase + push do recipes.git (wzorzec jak auto-fix SHA)."""
    try:
        subprocess.run(["git", "-C", RECIPES_DIR, "add", "-A"],
                       capture_output=True, text=True, errors='replace', timeout=30)
        subprocess.run(["git", "-C", RECIPES_DIR, "commit", "-m", msg],
                       capture_output=True, text=True, errors='replace', timeout=30)
        rebase_r = subprocess.run(["git", "-C", RECIPES_DIR, "pull", "--rebase", "origin", "main"],
                                  capture_output=True, text=True, errors='replace', timeout=120)
        if rebase_r.returncode != 0:
            subprocess.run(["git", "-C", RECIPES_DIR, "rebase", "--abort"],
                           capture_output=True, timeout=10)
            print("   ⚠ Rebase przed pushem nieudany – zmiany receptur zostają lokalnie")
        push_r = subprocess.run(["git", "-C", RECIPES_DIR, "push", "origin", "main"],
                                capture_output=True, text=True, errors='replace', timeout=30)
        if push_r.returncode != 0:
            print(f"   ⚠ Push receptur NIEUDANY (commit lokalnie): {push_r.stderr.strip()[:200]}")
        else:
            print("   📤 Receptury wypchnięte do recipes.git")
    except Exception as e:
        print(f"   ⚠ git commit receptur: {e}")


def _set_sha_entry(text, idx, value):
    """Podmienia idx-ty wpis (od 0) listy sha256sums w tekście YAML receptury."""
    lines = text.split("\n")
    start = None
    for i, ln in enumerate(lines):
        if ln.startswith("sha256sums:"):
            start = i
            break
    if start is None:
        return None
    n = -1
    for j in range(start + 1, len(lines)):
        ln = lines[j]
        if ln.startswith("- "):
            n += 1
            if n == idx:
                lines[j] = "- " + value
                return "\n".join(lines)
        elif ln and not ln.startswith((" ", "\t")):
            break
    return None


def _extract_kernel_config(pkg_path):
    """Wyjmuje (config, release) z .pag → data.tar.xz → boot/config-<release>.

    W pakiecie jądra config leży pod boot/config-<release> (po olddefconfig)."""
    import tarfile, io
    try:
        with tarfile.open(pkg_path, "r:xz") as tf:
            fh = None
            for entry in ("data.tar.xz", "./data.tar.xz"):
                try:
                    fh = tf.extractfile(entry)
                    break
                except KeyError:
                    continue
            if fh is None:
                return None, None
            data = fh.read()
    except Exception:
        return None, None
    try:
        with tarfile.open(fileobj=io.BytesIO(data), mode="r:xz") as df:
            for ti in df.getmembers():
                nm = ti.name[2:] if ti.name.startswith("./") else ti.name
                parts = nm.split("/")
                if len(parts) == 2 and parts[0] == "boot" and parts[1].startswith("config-"):
                    fh = df.extractfile(ti)
                    if fh is None:
                        continue
                    return fh.read().decode("utf-8", "replace"), parts[1][len("config-"):]
    except Exception:
        return None, None
    return None, None


def _find_built_pkg(name):
    """Najnowszy zbudowany <name>-*.pag w repo (stable/ oraz katalogu kategorii)."""
    cands = []
    for d in (os.path.join(REPO_BASE, "stable"), REPO_BASE):
        if os.path.isdir(d):
            cands += glob.glob(os.path.join(d, name + "-*" + PKG_EXT))
    if not cands:
        return None
    return max(cands, key=os.path.getmtime)


def save_kernel_config(verbose=True):
    """
    Aktualizuje config jąder w recepturach na podstawie zbudowanych .pag.

    Faza build receptury robi `make olddefconfig`, więc w pakiecie leży config
    dopasowany do bieżącego Kconfig (boot/config-<release>). Ten config jest
    źródłem prawdy: jeśli różni się od recipes/utils/<kernel>/config, zapisujemy
    go z powrotem (+ świeży SHA wpisu `config`) i commitujemy do recipes.git.
    Zwraca listę nazw zaktualizowanych jąder.
    """
    import yaml as _yaml
    import hashlib as _hl

    if verbose:
        print("🌱 PAGSYNC SAVE-KERNEL-CONFIG – aktualizacja configu jąder...\n")

    state = load_state()
    recipes = scan_recipes(state)
    changed = []
    for fp, meta in recipes:
        name = meta["name"]
        if not _kernel_like(name):
            continue
        recipe_dir = os.path.dirname(fp)
        cfg_path = os.path.join(recipe_dir, "config")
        if not os.path.isfile(cfg_path):
            continue
        pkg = _find_built_pkg(name)
        if not pkg:
            if verbose:
                print(f"  ⏭ {name}: brak zbudowanego {PKG_EXT} – pomijam")
            continue
        content, release = _extract_kernel_config(pkg)
        if not content:
            if verbose:
                print(f"  ⚠ {name}: brak boot/config-* w {os.path.basename(pkg)}")
            continue
        if not content.endswith("\n"):
            content += "\n"
        try:
            old = open(cfg_path, encoding="utf-8").read()
        except OSError:
            old = ""
        if content == old:
            if verbose:
                print(f"  ✅ {name}: config aktualny ({release})")
            continue

        with open(cfg_path, "w", encoding="utf-8") as f:
            f.write(content)
        sha = _hl.sha256(open(cfg_path, "rb").read()).hexdigest()

        # Odśwież wpis `config` w sha256sums (zachowaj resztę pliku 1:1).
        try:
            rtext = open(fp, encoding="utf-8").read()
            data = _yaml.safe_load(rtext) or {}
            srcs = data.get("source") or []
            if isinstance(srcs, str):
                srcs = [srcs]
            if "config" in srcs:
                new_text = _set_sha_entry(rtext, srcs.index("config"), sha)
                if new_text and new_text != rtext:
                    with open(fp, "w", encoding="utf-8") as f:
                        f.write(new_text)
        except Exception as e:
            if verbose:
                print(f"  ⚠ {name}: nie zaktualizowano sha ({e})")

        changed.append(name)
        if verbose:
            print(f"  💾 {name}: zaktualizowano config ({release}, sha {sha[:12]}…)")

    if changed:
        if verbose:
            print(f"\n📝 Zmieniono config: {', '.join(changed)}")
        _git_commit_recipes("Update kernel config: " + ", ".join(changed))
    elif verbose:
        print("\n✅ Wszystkie configi jąder aktualne.")
    return changed


def _maybe_save_kernel_config(name):
    """Hook po publikacji pakietu: dla jądra zsynchronizuj config z recepturą."""
    if not KERNEL_SAVE_CONFIG or not _kernel_like(name):
        return
    try:
        save_kernel_config(verbose=True)
    except Exception as e:
        print(f"   ⚠ save-kernel-config: {e}")


def _record_pending_kernel(name, state):
    """Po udanym buildzie konsumenta (w pętli) zapisz, pod jakim jądrem powstał."""
    if name in _PENDING_KERNEL_RECORD:
        state.setdefault("kernel_builds", {})[name] = _PENDING_KERNEL_RECORD.pop(name)
        save_state(state)


def _remember_kernel_build(name, deps, state):
    """Po udanym buildzie pakietu zależnego od jądra (np. nvidia-kernel-618
    zbudowanego SAMODZIELNIE po bumpie drivera 7.1.8, bez zmiany jądra)
    zapamiętaj wersję jądra z rootfs – żeby późniejszy bump jądra dał czysty
    pkgrel+1 zamiast podwójnego bumpu."""
    if not KERNEL_REBUILD:
        return
    _deps = set(str(d or "").strip() for d in (deps or []))
    _kb = state.setdefault("kernel_builds", {})
    _changed = False
    for _k in _kernel_index(state):
        if _k["name"] not in _deps:
            continue
        _cur = _kernel_key_from_rootfs(_k["name"]) or (
            "%s-%s-%s" % (_k["name"], _k.get("version"), _k.get("release")))
        if _cur and _kb.get(name) != _cur:
            _kb[name] = _cur
            _changed = True
    if _changed:
        save_state(state)


def _rebuild_kernel_consumers(kernel_name, kernel_meta, all_recipes, later_names, state):
    """Po udanym buildzie jądra: przebuduj (lub przygotuj) zależne moduły.
    Zwraca (ok_extra, fail_extra)."""
    if not KERNEL_REBUILD or not _kernel_like(kernel_name):
        return 0, 0
    consumers = _consumer_recipes_of(kernel_name, all_recipes)
    if not consumers:
        return 0, 0
    cur = _kernel_key_from_rootfs(kernel_name)
    if not cur:
        cur = "%s-%s-%s" % (kernel_name, kernel_meta.get("version"), kernel_meta.get("release"))
    kb = state.setdefault("kernel_builds", {})
    ok_n = fail_n = 0
    bumped = []
    for fp, meta in consumers:
        cname = meta["name"]
        if kb.get(cname) == cur:
            print(f"   ⏭ {cname}: moduły już zbudowane pod {cur} – pomijam")
            continue
        if cname in later_names:
            # konsument i tak jest w kolejce PO kernelu – tylko podbij pkgrel
            # (build zrobi główna pętla; zapis rekordu po jego buildzie)
            if kb.get(cname) is not None:
                b = _bump_pkgrel(fp)
                if b:
                    bumped.append(f"{cname}->{b}")
            _PENDING_KERNEL_RECORD[cname] = cur
            continue
        b = None
        if kb.get(cname) is not None:
            b = _bump_pkgrel(fp)
            if b is None:
                print(f"   ⚠ {cname}: pkgrel nie do podbicia – pomijam auto-rebuild")
                continue
            bumped.append(f"{cname}->{b}")
        print("🔁 %s: przebudowa modułów po %s%s" % (
            cname, cur, f" (pkgrel -> {b})" if b else " (pierwszy build)"))
        if run_single_build(cname, fp, state):
            kb[cname] = cur
            ok_n += 1
        else:
            fail_n += 1
    if bumped:
        _git_commit_recipes(f"auto: pkgrel+1 modułów po {cur} ({', '.join(bumped)})")
    save_state(state)
    return ok_n, fail_n


def sync_and_build(force=False, missing_only=False, single_pkg=None, rebuild_failed=None, fix_sha=False):
    state = load_state()
    state["last_sync"] = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S")
    save_state(state)

    # ── Self-heal: puste .pag nie mogą maskować probe zależności ──
    _purge_empty_from_installed_db()

    # ── Git Sync – pobierz najnowsze recipes ZANIM cokolwiek innego ──
    # Także dla --build <pkg>: panel buduje pojedyncze pakiety, a bez pulla
    # build szedłby na starym drzewie po pushu receptur (git.paganlinux.eu).
    if not git_sync_recipes():
        print("❌ Przerwano: nie udało się zsynchronizować recipes z git.")
        print("   Bez aktualnych receptur nie buduję – sprawdź /var/git/recipes.git")
        sys.exit(1)
    print()

    # ── Auto-fix SHA256 na pierwszym uruchomieniu (lub gdy --fix-sha) ──
    is_first_run = not state.get("builds") and not state.get("sha_fixed")
    if fix_sha or is_first_run:
        fixed, _ = auto_fix_sha256sums(verbose=True)
        state["sha_fixed"] = True
        save_state(state)
        if fix_sha:
            print(f"\n✅ SHA256 fix complete – {fixed} receptur poprawionych.")
            return
        print()

    # ── Pojedynczy pakiet ──
    if single_pkg:
        found = find_recipe_path(single_pkg)
        if found:
            ok = run_single_build(single_pkg, found, state)
            # Po udanym buildzie jądra (panel/CLI: --build kernel-*) przebuduj
            # zależne moduły (nvidia-kernel-*) od razu, w tej samej sesji.
            if ok and _kernel_like(single_pkg):
                _allr = scan_recipes(state)
                _kmeta = next((m for _fp, m in _allr if _fp == found), None)
                if _kmeta is not None:
                    _rebuild_kernel_consumers(single_pkg, _kmeta, _allr, set(), state)
                else:
                    print(f"   ⚠ brak metadanych receptury {single_pkg} – pomijam auto-rebuild modułów")
            elif ok:
                # samodzielny build konsumenta (np. bump drivera) – zapamiętaj,
                # pod jaką wersją jądra w rootfs powstał
                try:
                    _m0 = parse_recipe(found) or {}
                    _remember_kernel_build(single_pkg, _m0.get("depends") or [], state)
                except Exception:
                    pass
            # Po udanym pojedynczym buildzie odśwież i podpisz repo.json,
            # żeby pakiet od razu był widoczny dla klienta pag (bez pełnego syncu).
            # Odświeżamy TYLKO gdy podpisywanie jest włączone (DO_SIGN), żeby
            # nigdy nie nadpisać podpisanego repo.json wersją niepodpisaną.
            if ok and DO_SIGN:
                print("🔄 Aktualizacja repo.json po pojedynczym buildzie...")
                generate_repo_json(verbose=True)
            elif ok:
                print("ℹ Build OK, ale pomijam repo.json (brak DO_SIGN – nie chcę nadpisać podpisu).")
            else:
                print("⚠ Build nieudany – repo.json nieaktualizowany.")
        else:
            print(f"❌ Nie znaleziono przepisu: {single_pkg}")
        return

    # ── Odbudowa failed ──
    if rebuild_failed:
        failed = [b["name"] for b in state.get("builds", []) if b.get("status") == "failed"]
        if not failed:
            print("✅ Brak nieudanych buildów.")
            return
        print(f"🔄 Odbudowa {len(set(failed))} nieudanych pakietów...")
        for name in sorted(set(failed)):
            found = find_recipe_path(name)
            if found:
                run_single_build(name, found, state)
        return

    # ── Skanuj wszystkie receptury ──
    all_recipes = scan_recipes(state)

    # ── Określ co trzeba zbudować ──
    queue = []
    for fp, meta in all_recipes:
        name = meta["name"]
        ver = meta["version"]
        rel = meta["release"]
        cat = recipe_to_repo_category(fp)

        # Pakiety ze znanym martwym źródłem – pomiń (nie blokują kolejki).
        if name in SKIP_PACKAGES and missing_only:
            print(f"  ⚠ {name}-{ver} – pominięto (martwe źródło, na liście SKIP_PACKAGES)")
            continue

        # Sprawdź czy już zbudowane w repo (dowolna kategoria) LUB w BUILD_OUT
        already_built = False
        pkg_pattern = f"{name}-{ver}-{rel}{PKG_EXT}"

        # 1. Sprawdź BUILD_OUT (świeżo zbudowane, jeszcze nie przeniesione) –
        #    dokładnie name-ver-rel.pag (BUILD_OUT jest transient, nie wchodzi
        #    w grę pomijanie release-update przez glob name-ver*).
        build_out_pkg = os.path.join(BUILD_OUT, pkg_pattern)
        if os.path.exists(build_out_pkg) and not force:
            already_built = True

        # 2. Sprawdź katalogi repo – DOKŁADNIE name-ver-rel.pag, żeby wykryć
        #    aktualizacje (zmiana wersji LUB release'a) i nie pomijać przebudowy.
        if not already_built:
            for check_cat in REPO_CATEGORIES:
                pkg_dir = os.path.join(REPO_BASE, check_cat)
                if os.path.isdir(pkg_dir):
                    existing = os.path.join(pkg_dir, pkg_pattern)
                    if os.path.exists(existing) and not force:
                        already_built = True
                        break

        # 3. Sprawdź w state packages (szybciej niż skanowanie dysku)
        if not already_built and name in state.get("packages", {}):
            sp = state["packages"][name]
            if (sp.get("status") == "ok" and sp.get("version") == ver
                    and sp.get("release") == rel):
                already_built = True

        if force:
            print(f"  🔨 {name}-{ver} – kolejka (--force: wymuszona przebudowa)")
            queue.append((fp, meta))
        elif missing_only and already_built:
            continue
        elif not already_built:
            # Powód kolejkowania – pomaga znaleźć przyczynę niechcianych
            # przebudów (zmiana pkgver/pkgrel vs brak .pag w repo).
            import glob as _gg
            any_ver = _gg.glob(os.path.join(BUILD_OUT, name + "-*.pag"))
            if not any_ver:
                for _c in REPO_CATEGORIES:
                    _d = os.path.join(REPO_BASE, _c)
                    if os.path.isdir(_d):
                        any_ver += _gg.glob(os.path.join(_d, name + "-*.pag"))
            reason = "zmiana pkgver/pkgrel" if any_ver else "brak .pag w repo/BUILD_OUT"
            print(f"  🔨 {name}-{ver} – kolejka ({reason})")
            queue.append((fp, meta))
        else:
            print(f"  ⏭ {name}-{ver} – już zbudowane, pomijam")

    if not queue:
        print("✨ Wszystkie pakiety są aktualne. Nie ma nic do zrobienia.")
        return

    print(f"\n📊 Kolejka: {len(queue)} pakietów do zbudowania")

    # Sortowanie topologiczne
    ordered = resolve_build_order(queue)
    print(f"   Kolejność (topo-sort): {' → '.join(m[1]['name'] for m in ordered[:10])}{'...' if len(ordered)>10 else ''}")

    # ── Pre-flight: sprawdź makedepends/depends względem rootfs i receptur ──
    print("\n🩺 Pre-flight: sprawdzam makedepends/depends względem rootfs i receptur...")
    warn_unsatisfied_deps(ordered, all_recipes)

    ok_count = 0
    fail_count = 0

    for i, (fp, meta) in enumerate(ordered):
        success = run_single_build(meta["name"], fp, state)
        if success:
            ok_count += 1
            _record_pending_kernel(meta["name"], state)
            # każdy udany build (też samodzielny bump drivera) aktualizuje rekord
            _remember_kernel_build(
                meta["name"],
                list(meta.get("depends") or []) + list(meta.get("makedepends") or []),
                state)
            # kernel się (prze)budował → dołóż przebudowę konsumentów modułów
            if _kernel_like(meta["name"]):
                _later = {m[1]["name"] for m in ordered[i + 1:]}
                _ek, _ef = _rebuild_kernel_consumers(meta["name"], meta, all_recipes, _later, state)
                ok_count += _ek
                fail_count += _ef
        else:
            fail_count += 1

    # ── Generowanie repo.json dla każdej kategorii ──
    generate_repo_json(verbose=True)

    state["last_sync"] = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S")
    save_state(state)
    print(f"\n✨ Gotowe! ✅ {ok_count} | ❌ {fail_count} | 📦 repo zaktualizowane")

    # ── Cache źródeł: po pełnym przebiegu nie trzymamy pobranych archiwów ──
    purge_source_cache()

# ── AUTO-FIX SHA256SUMS – poprawia nieaktualne sumy w recepturach ──
def _looks_like_archive(path):
    """Sprawdza magic bytes – czy plik wygląda na prawdziwe archiwum (nie HTML/śmieci)."""
    try:
        with open(path, "rb") as f:
            magic = f.read(8)
    except Exception:
        return False
    return (
        magic.startswith(b"\x1f\x8b")          # gzip
        or magic.startswith(b"BZh")            # bzip2
        or magic.startswith(b"\xfd7zXZ\x00")   # xz
        or magic.startswith(b"PK\x03\x04")     # zip
    )


def _dump_recipe_yaml(data):
    """Zapisuje YAML receptury, zachowując bloki '|' i kolejność kluczy."""
    import yaml as _yaml
    class _FixDumper(_yaml.Dumper):
        pass
    def _str_rep(dumper, s):
        if '\n' in s:
            return dumper.represent_scalar('tag:yaml.org,2002:str', s, style='|')
        if re.match(r'^\d+\.\d+', s) or ':' in s or s.startswith('https://'):
            return dumper.represent_scalar('tag:yaml.org,2002:str', s, style="'")
        return dumper.represent_scalar('tag:yaml.org,2002:str', s)
    _FixDumper.add_representer(str, _str_rep)
    return _yaml.dump(data, Dumper=_FixDumper, default_flow_style=False,
                      allow_unicode=True, sort_keys=False, width=120)


def auto_fix_sha256sums(verbose=True):
    """
    Sprawdza wszystkie receptury i automatycznie poprawia sha256sums
    jeśli plik źródłowy istnieje w cache'u a suma się nie zgadza (lub jest SKIP).
    Zwraca (fixed_count, total_checked).
    """
    import yaml as _yaml
    import hashlib as _hl

    SRC_CACHE = "/var/cache/pagbuild/sources"
    state = load_state()

    if verbose:
        print("🔧 PAGSYNC AUTO-FIX SHA256 – sprawdzanie i poprawa sum kontrolnych...\n")

    recipes = scan_recipes(state)
    if not recipes:
        if verbose:
            print("❌ Brak receptur w", RECIPES_DIR)
        return 0, 0

    fixed = 0
    already_ok = 0
    skipped_no_cache = 0

    # ── OPT: cache SHA256 per plik — każdy plik w cache hashowany tylko RAZ.
    #    (np. linux-firmware ~200MB jest wcześniej czytany i hashowany ~245×,
    #    co powodowało przekroczenie limitu czasu. Teraz: 1×.) ──
    _sha_cache = {}

    def _file_sha(fpath):
        h = _sha_cache.get(fpath)
        if h is None:
            with open(fpath, "rb") as f:
                h = _hl.sha256(f.read()).hexdigest()
            _sha_cache[fpath] = h
        return h

    # ── OPT: indeks cache (nazwa bez rozszerzenia -> ścieżka) budowany RAZ,
    #    zamiast os.listdir() w pętli dla każdej brakującej sumy. ──
    cache_index = {}
    if os.path.isdir(SRC_CACHE):
        for cf in os.listdir(SRC_CACHE):
            cf_no_ext = re.sub(r'\.(tar\.(gz|bz2|xz)|tgz|zip)$', '', cf)
            cache_index.setdefault(cf_no_ext, os.path.join(SRC_CACHE, cf))

    n_total = len(recipes)
    for idx, (fp, meta) in enumerate(recipes, 1):
        name = meta["name"]
        ver = meta["version"]
        sources = meta.get("sources", [])
        sha256s = meta.get("sha256sums", [])

        needs_fix = False
        new_shas = []

        for i, src_url in enumerate(sources):
            if not src_url:
                new_shas.append("")
                continue

            src_url_expanded = src_url.replace("${pkgver}", ver).replace("$pkgver", ver)
            fname = os.path.basename(src_url_expanded.split("?")[0])
            cached = os.path.join(SRC_CACHE, fname)

            current_sha = sha256s[i] if i < len(sha256s) else ""

            if os.path.exists(cached) and _looks_like_archive(cached):
                actual = _file_sha(cached)
                new_shas.append(actual)
                if current_sha != actual:
                    needs_fix = True
            elif os.path.exists(cached):
                # Plik w cache uszkodzony (HTML/niekompletny) – nie wpisuj sumy śmieci
                if verbose:
                    print(f"  ⚠ {name}: {fname} w cache jest uszkodzony – usuń go: {cached}")
                new_shas.append(current_sha if current_sha else "SKIP")
                skipped_no_cache += 1
            else:
                # Brak pliku w cache – szukaj po nazwie bez rozszerzenia (przez indeks)
                base_no_ext = re.sub(r'\.(tar\.(gz|bz2|xz)|tgz|zip)$', '', fname)
                cached_alt = cache_index.get(base_no_ext)
                if cached_alt:
                    if _looks_like_archive(cached_alt):
                        actual = _file_sha(cached_alt)
                        new_shas.append(actual)
                        if current_sha != actual:
                            needs_fix = True
                    else:
                        if verbose:
                            print(f"  ⚠ {name}: {os.path.basename(cached_alt)} w cache jest uszkodzony – usuń go: {cached_alt}")
                        new_shas.append(current_sha if current_sha else "SKIP")
                        skipped_no_cache += 1
                else:
                    # Zachowaj istniejącą sumę (lub SKIP)
                    new_shas.append(current_sha if current_sha else "SKIP")
                    if not current_sha or current_sha == "SKIP":
                        skipped_no_cache += 1

        if needs_fix:
            # Wczytaj, zmodyfikuj, zapisz YAML
            try:
                with open(fp, "r", encoding="utf-8") as f:
                    data = _yaml.safe_load(f.read())
                if data:
                    data["sha256sums"] = new_shas
                    with open(fp, "w", encoding="utf-8") as f:
                        f.write(_dump_recipe_yaml(data))
                    fixed += 1
                    if verbose:
                        print(f"  🔧 {name}-{ver}: sha256sums poprawione")
            except Exception as e:
                print(f"  ⚠ {name}: błąd zapisu YAML – {e}")
        else:
            already_ok += 1

        # ── OPT: postęp co 100 receptur (żeby konsola pokazywała aktywność) ──
        if verbose and idx % 100 == 0:
            print(f"  … {idx}/{n_total} receptur sprawdzonych ({fixed} naprawionych)…")

    if verbose:
        print(f"\n📊 AUTO-FIX SHA256: {fixed} poprawionych, {already_ok} OK, "
              f"{skipped_no_cache} bez źródła w cache'u")

    # Commit i push zmian do git repo (jeśli coś naprawiono)
    if fixed > 0:
        try:
            add_r = subprocess.run(
                ["git", "-C", RECIPES_DIR, "add", "-A"],
                capture_output=True, text=True, errors='replace', timeout=30
            )
            commit_r = subprocess.run(
                ["git", "-C", RECIPES_DIR, "commit", "-m", f"auto-fix: SHA256 sums ({fixed} recipes)"],
                capture_output=True, text=True, errors='replace', timeout=30
            )
            # Unifikacja zapisu: commit -> pull --rebase -> push (rebase lokalnych
            # commitów na origin, żeby push nie byl odrzucany non-fast-forward).
            rebase_r = subprocess.run(
                ["git", "-C", RECIPES_DIR, "pull", "--rebase", "origin", "main"],
                capture_output=True, text=True, errors='replace', timeout=120
            )
            if rebase_r.returncode != 0:
                subprocess.run(["git", "-C", RECIPES_DIR, "rebase", "--abort"],
                               capture_output=True, timeout=10)
                print(f"   ⚠ Rebase przed pushem nieudany – zmiany zostają lokalnie")
            push_r = subprocess.run(
                ["git", "-C", RECIPES_DIR, "push", "origin", "main"],
                capture_output=True, text=True, errors='replace', timeout=30
            )
            if push_r.returncode != 0:
                # Ignorowany push powodował ciche kumulowanie lokalnych commitów
                # i rozjazd gałęzi (pull --ff-only potem padał). Sygnalizuj głośno.
                print(f"   ⚠ Push do {RECIPES_GIT_REMOTE} NIEUDANY: {push_r.stderr.strip()[:200]}")
                print(f"     Commit został lokalnie – następny pull zrobi rebase i wypchnie.")
            elif verbose:
                print(f"   📤 Zmiany wypchnięte do {RECIPES_GIT_REMOTE}")
        except Exception as e:
            if verbose:
                print(f"   ⚠ Nie udało się wypchnąć zmian: {e}")

    return fixed, len(recipes)


# ── CHECK MODE – walidacja bez budowania ──
# ── Kontrola i naprawa podpisów GPG ──
def _gpg_bin():
    return shutil.which("gpg2") or shutil.which("gpg") or ""


def _sign_file(path):
    """Podpisuje plik detached (armor) kluczem GPG_KEY. True gdy .asc powstał."""
    gpg = _gpg_bin()
    if not (gpg and DO_SIGN):
        return False
    sig = path + ".asc"
    try:
        if os.path.exists(sig):
            os.remove(sig)
    except OSError:
        pass
    key_args = ["--local-user", GPG_KEY] if GPG_KEY else []
    r = subprocess.run(
        [gpg, "--detach-sign", "--armor", "--batch", "--no-tty"] + key_args + [path],
        check=False, capture_output=True,
    )
    if r.returncode != 0 or not os.path.exists(sig):
        print(f"     ⚠ podpisywanie nieudane: {r.stderr.decode(errors='replace').strip()[:160]}")
        return False
    return True


def _verify_sig(path):
    """(ok, reason) – weryfikuje detached podpis <path>.asc."""
    gpg = _gpg_bin()
    if not gpg:
        return (False, "NO_GPG")
    sig = path + ".asc"
    if not os.path.isfile(sig):
        return (False, "MISSING_SIG")
    r = subprocess.run([gpg, "--verify", sig, path], check=False, capture_output=True)
    if r.returncode == 0:
        return (True, "OK")
    err = (r.stderr or b"").decode(errors="replace").lower()
    if "no public key" in err or "no_pubkey" in err:
        return (False, "NO_PUBKEY")
    return (False, "BAD_SIG")


def check_signatures(verbose=True, fix=False):
    """Kontrola podpisów GPG: klucz, repo.json.asc, wszystkie .pag.asc.

    Diagnozuje „sypanie kluczy”: brak klucza tajnego, niedziałające podpisywanie,
    stare/niepasujące .asc przy paczkach (klient dostaje wtedy
    „NIEPRAWIDŁOWY PODPIS GPG"). Z fix=True podpisuje ponownie problematyczne.
    """
    import concurrent.futures as _cf
    gpg = _gpg_bin()
    print("\n🔐 Kontrola podpisów GPG...")
    if not gpg:
        print("  ❌ Brak gpg/gpg2 w PATH – podpisywanie niemożliwe!")
        return 1
    rc = 0
    print(f"  🔑 Klucz: {GPG_KEY or '(nie ustawiony)'}  | DO_SIGN={'on' if DO_SIGN else 'off'}")
    if GPG_KEY:
        r = subprocess.run([gpg, "--list-secret-keys", "--with-colons", GPG_KEY],
                           check=False, capture_output=True, text=True)
        if r.returncode != 0 or "sec:" not in (r.stdout or ""):
            print(f"  ❌ BRAK klucza TAJNEGO {GPG_KEY} w keyringu – buildy NIE podpiszą paczek!")
            rc = 1
        else:
            print("  ✅ Klucz tajny obecny w keyringu")
    # Self-test podpisywania
    if DO_SIGN:
        try:
            fd, tp = tempfile.mkstemp(prefix="pag-sigtest-")
            os.write(fd, b"pagan signature self-test\n")
            os.close(fd)
            ok = _sign_file(tp)
            ok2, reason = _verify_sig(tp) if ok else (False, "SIGN_FAILED")
            print(f"  {'✅' if ok2 else '❌'} Test podpisywania: {reason}")
            if not ok2:
                rc = 1
            for _p in (tp, tp + ".asc"):
                try:
                    if os.path.exists(_p):
                        os.remove(_p)
                except OSError:
                    pass
        except Exception as e:
            print(f"  ⚠ Test podpisywania pominięty: {e}")

    total_ok = total_bad = total_fixed = 0
    for cat in REPO_CATEGORIES:
        cat_dir = os.path.join(REPO_BASE, cat)
        if not os.path.isdir(cat_dir):
            continue
        rj = os.path.join(cat_dir, "repo.json")
        if os.path.isfile(rj):
            ok, reason = _verify_sig(rj)
            print(f"  {'✅' if ok else '❌'} {cat}/repo.json: {reason}")
            if not ok:
                rc = 1
                if fix and _sign_file(rj):
                    ok2, r2 = _verify_sig(rj)
                    print(f"     {'✅ odtworzono podpis repo.json' if ok2 else '❌ ' + r2}")
        files = [f for f in sorted(os.listdir(cat_dir)) if f.endswith(PKG_EXT)]

        def _v(fn):
            ok, reason = _verify_sig(os.path.join(cat_dir, fn))
            return (fn, reason) if not ok else None

        bad = [res for res in _cf.ThreadPoolExecutor(max_workers=6).map(_v, files) if res]
        total_ok += len(files) - len(bad)
        total_bad += len(bad)
        if bad:
            print(f"  ❌ {cat}: {len(files)-len(bad)}/{len(files)} OK, {len(bad)} problemów:")
            for fn, reason in bad:
                print(f"       {reason:11s} {fn}")
            if fix:
                for fn, reason in bad:
                    if reason == "NO_PUBKEY":
                        print(f"       ⚠ {fn}: brak klucza publicznego w keyringu – pomijam")
                        continue
                    if _sign_file(os.path.join(cat_dir, fn)):
                        ok2, r2 = _verify_sig(os.path.join(cat_dir, fn))
                        if ok2:
                            total_fixed += 1
                            print(f"       ✅ {fn}: podpis odtworzony")
                        else:
                            print(f"       ❌ {fn}: nadal {r2}")
        else:
            print(f"  ✅ {cat}: wszystkie {len(files)} podpisów paczek poprawne")

    if total_bad:
        print(f"\n{'✅' if total_fixed >= total_bad else '⚠'} Podsumowanie: {total_ok} OK, "
              f"{total_bad} problemów, naprawiono {total_fixed}.")
        if total_fixed < total_bad:
            rc = 1
    else:
        print(f"\n✅ Wszystkie {total_ok} podpisów poprawne.")
    return rc


def check_recipes():
    """Sprawdza wszystkie receptury: YAML, SHA256, zależności, źródła."""
    import urllib.request, ssl as _ssl

    print("🔍 PAGSYNC CHECK – walidacja wszystkich receptur\n")

    state = load_state()
    state["last_sync"] = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S")
    save_state(state)

    recipes = scan_recipes(state)
    if not recipes:
        print("❌ Brak receptur w", RECIPES_DIR)
        return

    total = len(recipes)
    ok_yaml = 0
    ok_sha = 0
    ok_deps = 0
    ok_src = 0
    errors = []
    warnings = []

    # Zbierz wszystkie nazwy do sprawdzania zależności (+ indeks provides)
    all_names = {meta["name"] for _, meta in recipes}
    provides_index = {}
    for fp, meta in recipes:
        for p in meta.get("provides", []):
            provides_index.setdefault(p, []).append(meta["name"])

    print(f"📊 Sprawdzanie {total} receptur...\n")

    for fp, meta in recipes:
        name = meta["name"]
        ver = meta["version"]
        cat = recipe_to_repo_category(fp)
        issues = []

        # ── YAML już sparsowany (przez scan_recipes) ──
        ok_yaml += 1

        # ── SHA256 weryfikacja (tylko jeśli źródło jest w cache) ──
        src_cache = "/var/cache/pagbuild/sources"
        sources = meta.get("sources", [])
        sha256s = meta.get("sha256sums", [])

        for i, src_url in enumerate(sources):
            if not src_url:
                continue
            src_url = src_url.replace("${pkgver}", ver).replace("$pkgver", ver)
            fname = os.path.basename(src_url.split("?")[0])
            cached = os.path.join(src_cache, fname)

            if os.path.exists(cached):
                if i < len(sha256s) and sha256s[i]:
                    import hashlib
                    with open(cached, "rb") as f:
                        actual = hashlib.sha256(f.read()).hexdigest()
                    if actual == sha256s[i]:
                        ok_sha += 1
                    else:
                        # SHA256 w cache się nie zgadza – pagbuild i tak pobierze świeże
                        warnings.append(f"{name}: SHA256 w cache nieaktualne dla {fname} – będzie pobrane od nowa")
                        ok_sha += 1  # nie blokuje budowania
                else:
                    ok_sha += 1  # w cache, ale brak sumy do porównania
            else:
                warnings.append(f"{name}: źródło {fname} – jeszcze nie pobrane (OK, będzie przy buildzie)")

        # ── Wzorzec „cd w build + builddir w package bez cd” ──
        # Faza PACKAGE w pagbuild startuje ZAWSZE z katalogu workspace
        # (cd {chroot_work}/src-<pkg> przed package_phase). Receptura z
        # `cd ${pkgname}-${pkgver}` w build MUSI mieć to samo cd w package,
        # gdy odwołuje się do artefaktów builda (builddir/build) – inaczej
        # „Install data not found” / „cmake --install build: brak katalogu”.
        try:
            _raw = open(fp, encoding="utf-8", newline="").read()
            _mb = re.search(r"build:\s*[|\"]\s*(.*?)(?:\n\S.*?:|\Z)", _raw, re.S)
            _mp = re.search(r"package:\s*[|\"]\s*(.*?)(?:\n\S.*?:|\Z)", _raw, re.S)
            _b = _mb.group(1) if _mb else ""
            _p = _mp.group(1) if _mp else ""
            if ("cd ${pkgname}-${pkgver}" in _b or "cd ${pkgname}-v${pkgver}" in _b) \
               and re.search(r"(meson install -C builddir|cmake --install build|ninja -C build)", _p) \
               and "cd ${pkgname}-${pkgver}" not in _p:
                warnings.append(
                    f"{name}: package odwołuje się do builddir/build, ale nie ma "
                    f"'cd ${{pkgname}}-${{pkgver}}' – faza PACKAGE startuje z workspace "
                    f"(ryzyko: 'Install data not found')"
                )
        except Exception:
            pass

        # ── Sprawdzenie zależności ──
        missing_deps = []
        for dep in meta.get("depends", []):
            if dep in all_names:
                continue
            if any(dep in provides_index.get(n, []) for n in all_names):
                continue
            missing_deps.append(dep)
        if missing_deps:
            warnings.append(f"{name}: zależności spoza recipes: {', '.join(missing_deps)}")
        else:
            ok_deps += 1

        # ── Sprawdzenie dostępności URL (HEAD request, tylko pierwsze źródło) ──
        if sources and sources[0]:
            try:
                test_url = sources[0].replace("${pkgver}", ver).replace("$pkgver", ver)
                req = urllib.request.Request(test_url, method="HEAD")
                ctx = _ssl.create_default_context()
                ctx.check_hostname = False
                ctx.verify_mode = _ssl.CERT_NONE
                # Krótki timeout – nie chcemy wisieć
                urllib.request.urlopen(req, timeout=5, context=ctx)
                ok_src += 1
            except Exception as e:
                err_msg = str(e)[:80]
                warnings.append(f"{name}: URL niedostępny – {err_msg}")

        # ── Podsumowanie per-pakiet ──
        icon = "✅" if not issues else "❌"
        print(f"  {icon} {name}-{ver}  [{cat}]")

    # ── Raport końcowy ──
    print(f"\n{'═'*60}")
    print(f"📋 RAPORT KOŃCOWY")
    print(f"{'═'*60}")
    print(f"  Receptur:        {total}")
    print(f"  YAML OK:         {ok_yaml}/{total}")
    print(f"  SHA256 OK:       {ok_sha}")
    print(f"  Deps OK:         {ok_deps}/{total}")
    print(f"  URL dostępne:    {ok_src}/{total}")

    if errors:
        print(f"\n❌ BŁĘDY ({len(errors)}):")
        for e in errors:
            print(f"   {e}")

    if warnings:
        print(f"\n⚠ OSTRZEŻENIA ({len(warnings)}):")
        for w in warnings[:20]:
            print(f"   {w}")
        if len(warnings) > 20:
            print(f"   ... i {len(warnings)-20} więcej")

    if not errors:
        print(f"\n✅ Wszystkie receptury poprawne – można budować!")
        print(f"   pagsync --once")
    else:
        print(f"\n❌ Najpierw popraw {len(errors)} błędów przed budowaniem.")

# ── CLEAN CACHE – usuwa nieużywane źródła ──
# ── AUDYT – namcap-podobny: czego brakuje w makedepends po FAILach ──
# Szuka w logach nieudanych buildów śladów brakujących narzędzi / modułów
# pkg-config / nagłówków i podpowiada dopisanie ich do makedepends receptury.
AUDIT_PATTERNS = [
    (r"configure: error: Cannot find ([A-Za-z0-9_+.\-]+)", "tool"),
    (r"Cannot find ([A-Za-z0-9_+.\-]+) Is", "tool"),
    (r"([A-Za-z0-9_+.\-]+)-config could not be found", "tool"),
    (r"([A-Za-z0-9_+.\-]+): command not found", "tool"),
    (r"No package ([A-Za-z0-9_+.\-]+) found", "pkgconfig"),
    (r"fatal error: ([A-Za-z0-9_+./\-]+): No such file", "header"),
    (r"Run-time dependency ([A-Za-z0-9_+.\-]+) found: NO", "pkgconfig"),
]


def _audit_suggestions(limit=60):
    """Z logów ostatnich FAILów zwraca: pakiet -> (tokeny, proponowane receptury)."""
    state = load_state()
    name_to_path, _ = _recipes_maps(state)
    fails = {}
    for b in state.get("builds", []):
        if b.get("status") == "failed":
            fails.setdefault(b["name"], b)
    out = {}
    for pkg, b in list(fails.items())[:limit]:
        log = b.get("log", "") or ""
        tokens = set()
        for pat, kind in AUDIT_PATTERNS:
            for m in re.finditer(pat, log, re.IGNORECASE):
                tokens.add(m.group(1))
        if not tokens:
            continue
        prov = set()
        for t in sorted(tokens):
            if t.lower() in BUILD_SYSTEM_DEPS:
                continue
            hit = None
            for c in _provider_candidates(t, "tool"):
                if c in name_to_path:
                    hit = c
                    break
            if hit:
                prov.add(hit)
            else:
                # „Cannot find X” a receptura istnieje jako libX / X?
                for cand in (t, "lib" + t, "xorg-" + t):
                    if cand in name_to_path:
                        prov.add(cand)
                        break
        if prov:
            out[pkg] = (sorted(tokens), sorted(prov))
    return out


def audit_failed_builds(apply=False, limit=60):
    """Wypisuje (i opcjonalnie dopisuje) brakujące makedepends wg logów FAILów."""
    name_to_path, _ = _recipes_maps(load_state())
    sugg = _audit_suggestions(limit)
    if not sugg:
        print("✅ Brak pakietów z wykrywalnymi brakami makedepends (albo brak FAILów).")
        return
    for pkg in sorted(sugg):
        tokens, prov = sugg[pkg]
        print(f"• {pkg}: log mówi o: {', '.join(tokens)}")
        rp = name_to_path.get(pkg)
        if not rp:
            print(f"    (brak receptury {pkg} w drzewie)")
            continue
        already = set()
        try:
            with open(rp, encoding="utf-8") as fh:
                for ln in fh:
                    if ln.lstrip().startswith("- "):
                        already.add(ln.split("-", 1)[1].strip().strip("'\""))
        except OSError:
            pass
        add = [p for p in prov if p not in already]
        if add:
            print(f"    → proponuję dopisać do makedepends: {', '.join(add)}")
            if apply:
                _recipe_add_makedepends(rp, add)
        else:
            print(f"    (makedepends już pokryte: {', '.join(prov)} – problem może być w recepturze)")
    if apply:
        print("\n✅ Dopisano braki – sprawdź git diff w recipes/ przed wypchnięciem.")


def _recipe_add_makedepends(recipe_path, names):
    """Dopisuje nazwy do listy makedepends w PAGBUILD.yaml (prosta edycja tekstu)."""
    with open(recipe_path, encoding="utf-8") as fh:
        lines = fh.read().splitlines()
    idx = next((i for i, l in enumerate(lines)
                if l.rstrip() == "makedepends:" or l.rstrip().startswith("makedepends: [")), None)
    add = ["- '%s'" % n for n in names]
    if idx is None:
        pos = next((i for i, l in enumerate(lines) if l.rstrip().startswith("build:")),
                   len(lines))
        block = ["makedepends:"] + add
        lines = lines[:pos] + block + lines[pos:]
    elif "[" in lines[idx]:
        lines[idx] = "makedepends:"
        lines = lines[:idx + 1] + add + lines[idx + 1:]
    else:
        j = idx + 1
        while j < len(lines) and (lines[j].startswith("- ") or not lines[j].strip()):
            j += 1
        lines = lines[:j] + add + lines[j:]
    with open(recipe_path, "w", encoding="utf-8") as fh:
        fh.write("\n".join(lines) + "\n")
    print(f"    ✏️  {recipe_path}: dodano {', '.join(names)}")


def clean_source_cache():
    """Czyści cache źródeł pagbuild.

    Pobrane archiwa nie są trzymane „na zapas” – po zbudowaniu pakietu źródło
    nie jest już potrzebne (zawsze można pobrać ponownie), a cache potrafi
    urosnąć do kilkunastu GB. Domyślnie usuwamy WSZYSTKO; zachowanie selektywne
    (tylko pliki nieużywane przez receptury) po ustawieniu PAGAN_KEEP_SRC_CACHE=1.
    """
    SRC_CACHE = "/var/cache/pagbuild/sources"
    if not os.path.isdir(SRC_CACHE):
        print("📁 Cache źródeł jest pusty.")
        return

    keep_all = os.environ.get("PAGAN_KEEP_SRC_CACHE", "") == "1"
    needed_files = set()
    if keep_all:
        # Stare zachowanie: zachowaj pliki, których nazwa pasuje do którejkolwiek
        # receptury (przydatne przy budowaniu offline).
        state = load_state()
        all_recipes = scan_recipes(state)
        for fp, meta in all_recipes:
            ver = str(meta.get("version", ""))
            pkg = str(meta.get("name", ""))
            rel = str(meta.get("release", "1"))
            for src_url in meta.get("sources", []):
                u = (str(src_url)
                     .replace("${pkgver}", ver).replace("$pkgver", ver)
                     .replace("${pkgname}", pkg).replace("$pkgname", pkg)
                     .replace("${pkgrel}", rel).replace("$pkgrel", rel))
                if u.endswith(".git") or "://" not in u:
                    continue
                fname = os.path.basename(u.split("?")[0])
                if fname and not fname.startswith("${"):
                    needed_files.add(fname)

    total_size = 0
    removed = 0
    kept = 0

    for fname in sorted(os.listdir(SRC_CACHE)):
        fp = os.path.join(SRC_CACHE, fname)
        if not os.path.isfile(fp):
            continue
        size = os.path.getsize(fp)
        if keep_all and fname in needed_files:
            kept += 1
            continue
        os.remove(fp)
        removed += 1
        total_size += size
        print(f"  🗑 {fname} ({size//1048576} MB)")

    print(f"\n🧹 Usunięto {removed} plików ({total_size//1048576} MB)")
    if keep_all:
        print(f"📦 Zachowano {kept} plików w cache (PAGAN_KEEP_SRC_CACHE=1)")
    else:
        print("📦 Cache źródeł pusty – kolejne buildy pobiorą źródła na nowo")


def purge_source_cache():
    """Sprzątanie cache źródeł po zakończonym przebiegu (chyba że
    PAGAN_KEEP_SRC_CACHE=1) – nie trzymamy GB pobranych archiwów."""
    if os.environ.get("PAGAN_KEEP_SRC_CACHE", "") != "1":
        clean_source_cache()


# ── CHECK UPDATES – sprawdzanie nowych wersji upstream ──
def _version_tuple(ver):
    """Porównywalna krotka dla wersji tekstowych ('1.4rc5', '2.4.0', 'v3.1')."""
    parts = re.split(r"[.\-_+]", str(ver).lstrip("vV"))
    out = []
    for p in parts:
        m = re.match(r"^(\d+)(.*)$", p)
        if m:
            out.append((1, int(m.group(1)), m.group(2)))
        elif p:
            out.append((0, p, ""))
    return tuple(out)


def _cmp_versions(a, b):
    ta, tb = _version_tuple(a), _version_tuple(b)
    n = max(len(ta), len(tb))
    # Padding z elementem tekstowym (drugi element str), by nie porównywać
    # int ze str (np. "1.0.20" vs "1.0.18-stable" → TypeError przy (0,0,'') vs (0,'stable','')).
    ta += ((0, "", ""),) * (n - len(ta))
    tb += ((0, "", ""),) * (n - len(tb))
    for x, y in zip(ta, tb):
        if x != y:
            return -1 if x < y else 1
    return 0


def _latest_version(candidates):
    """Najnowsza wersja z listy; ignoruje sufiksy typu -stable/-msvc."""
    def key(v):
        s = str(v).lstrip("vV")
        parts = re.split(r"[.\-_+]", s)
        nums = []
        for p in parts:
            m = re.match(r"^(\d+)", p)
            nums.append(int(m.group(1)) if m else -1)
        return (tuple(nums), len(parts))

    best = None
    for c in candidates:
        if best is None or key(c) > key(best):
            best = c
    if best is None:
        return None
    b = str(best).lstrip("vV")
    b = re.sub(r"-.*$", "", b)   # 1.0.22-stable-msvc -> 1.0.22
    return b


def _fetch_dir_listing(url, timeout=20):
    import urllib.request, ssl as _ssl
    ctx = _ssl.create_default_context()
    ctx.check_hostname = False
    ctx.verify_mode = _ssl.CERT_NONE
    req = urllib.request.Request(url, headers={"User-Agent": "pagsync/check-updates"})
    try:
        with urllib.request.urlopen(req, timeout=timeout, context=ctx) as r:
            return r.read().decode("utf-8", "replace")
    except Exception:
        return ""


def _run_update_script(body, name, ver):
    try:
        env = {**os.environ, "pkgname": name, "pkgver": ver, "name": name, "version": ver}
        p = subprocess.run(["bash", "-c", body], capture_output=True, text=True, errors='replace',
                           timeout=60, env=env)
        for line in reversed(p.stdout.strip().splitlines()):
            m = re.search(r"(\d[\w.\-]*)", line.strip())
            if m:
                return m.group(1).lstrip("vV")
    except Exception:
        pass
    return None


def _git_latest(url):
    if not url:
        return None
    try:
        p = subprocess.run(["git", "ls-remote", "--tags", "--refs", url],
                           capture_output=True, text=True, errors='replace', timeout=60)
    except Exception:
        return None
    if p.returncode != 0:
        return None
    tags = []
    for line in p.stdout.splitlines():
        ref = line.split("\t")[-1]
        t = ref.removeprefix("refs/tags/").removesuffix("^{}")
        t = t.lstrip("vV")
        if t:
            tags.append(t)
    return _latest_version(tags)


def _clean_version_token(t, name=None):
    """Wycina czystą wersję z tokena tagu: 'v2.86.4'/'glib-2.86.4' -> '2.86.4'."""
    if not t:
        return None
    t = t.strip().lstrip("vV").strip()
    if name and t.lower().startswith(name.lower() + "-"):
        t = t[len(name) + 1:]
    m = re.match(r"^(\d+(?:[.\-_]\d+)*)", t)
    return m.group(1) if m else None


def _github_latest(url, name):
    """Najnowsza wersja z GitHub (strona /tags – bez limitu API)."""
    m = re.search(r"github\.com/([^/]+)/([^/?#]+)", url)
    if not m:
        return None
    owner, repo = m.group(1), m.group(2)
    html = _fetch_dir_listing(f"https://github.com/{owner}/{repo}/tags")
    if not html:
        return None
    tags = set()
    tags.update(re.findall(r'/releases/tag/([^"<]+)', html))
    tags.update(t.strip() for t in re.findall(r'tag-name[^>]*>\s*([^<]+)<', html))
    cands = set()
    for t in tags:
        c = _clean_version_token(t, name)
        if c:
            cands.add(c)
    return _latest_version(cands) if cands else None


def _sourceforge_latest(url, name):
    """Najnowsza wersja ze SourceForge (RSS projektu)."""
    m = re.search(r"(?:sourceforge\.net|sf\.net)[/:](?:projects?/)?([^/?#]+)", url)
    if not m:
        return None
    project = m.group(1).strip("/")
    html = _fetch_dir_listing(f"https://sourceforge.net/projects/{project}/rss")
    if not html:
        return None
    pat = re.compile(r"^" + re.escape(name) + r"-(\d[\w.\-]*)\.(tar\.(gz|xz|bz2|lz|zst)|tgz|zip)$",
                     re.IGNORECASE)
    cands = set()
    for l in re.findall(r"<link>([^<]+)</link>", html):
        base = l.split("?")[0].rstrip("/")
        base = re.sub(r"/download$", "", base)   # usuń /download PRZED basename
        base = base.rsplit("/", 1)[-1]
        mm = pat.match(base)
        if mm:
            cands.add(mm.group(1))
    for t in re.findall(r"<title>([^<]*)</title>", html):
        mt = re.match(r"^\s*" + re.escape(name) + r"\s*[\- ]\s*(\d[\w.\-]*)", t)
        if mt:
            cands.add(mt.group(1))
    return _latest_version(cands) if cands else None


def _default_update_check(name, ver, sources):
    """Heurystyka: WSZYSTKIE źródła HTTP (listing + GitHub + SourceForge).

    Dla każdego źródła próbuje wykryć najnowszą wersję:
      * GitHub      -> strona /tags (owner/repo)
      * SourceForge -> RSS projektu
      * inne        -> listing katalogu nadrzędnego (kernel.org, ftp.gnu.org, ...)
    Wybiera najnowszą wersję spośród WSZYSTKICH źródeł.
    """
    if not sources:
        return None, "brak źródła"
    pat = re.compile(r"^" + re.escape(name) + r"-(\d[\w.\-]*)\.(tar\.(gz|xz|bz2|lz|zst)|tgz|zip)$",
                     re.IGNORECASE)
    cands = set()
    tried = 0
    methods = set()
    for s in sources:
        urls = s if isinstance(s, (list, tuple)) else [s]
        for u in urls:
            if isinstance(u, dict):
                u = u.get("url") or u.get("source") or ""
            u = str(u).strip()
            if not u.startswith(("http://", "https://")):
                continue  # plik lokalny (patch, service, config, ...)
            src = (u.replace("${pkgname}", name).replace("$pkgname", name)
                    .replace("${pkgver}", str(ver)).replace("$pkgver", str(ver)))
            host = (src.split("://", 1)[1].split("/", 1)[0].lower()
                    if "://" in src else "")
            if host in ("github.com", "www.github.com"):
                gh = _github_latest(src, name)
                tried += 1
                if gh:
                    cands.add(gh)
                    methods.add("github")
                continue
            if "sourceforge.net" in host or "sf.net" in host:
                sf = _sourceforge_latest(src, name)
                tried += 1
                if sf:
                    cands.add(sf)
                    methods.add("sourceforge")
                continue
            base = src.rsplit("/", 1)[0] + "/"
            html = _fetch_dir_listing(base)
            tried += 1
            if not html:
                continue
            methods.add("listing")
            for f in re.findall(r'href="([^"]+)"', html):
                # listingi bywają względne: "/download/curl-8.21.0.tar.xz"
                b = f.rstrip("/").rsplit("/", 1)[-1]
                m = pat.match(b)
                if m:
                    cands.add(m.group(1))
    if not cands:
        return None, ("brak wersjonowanych plików" if tried else "tylko źródła lokalne")
    how = ("/".join(sorted(methods)) + " (%d źr.)" % tried) if methods else "listing"
    return _latest_version(cands), how


def check_updates(single=None, apply=False):
    """Sprawdza nowe wersje upstream dla receptur. Z --apply podbija pkgver."""
    import yaml as _yaml

    state = load_state()
    recipes = scan_recipes(state)
    if not recipes:
        print("❌ Brak receptur w", RECIPES_DIR)
        return

    print(f"🔎 CHECK UPDATES – {len(recipes)} receptur...\n")
    if apply:
        print("⚠ Tryb --apply: pkgver zostanie PODBITY, pkgrel=1, sha256sums=SKIP.\n")

    updated = 0
    up_to_date = 0
    failed = 0
    report = []

    for fp, meta in recipes:
        name = meta["name"]
        ver = str(meta["version"])
        if single and name != single:
            continue

        with open(fp, encoding="utf-8") as f:
            data = _yaml.safe_load(f) or {}
        up = data.get("update")

        latest = None
        how = "heuristics"
        if isinstance(up, str) and up.strip():
            how = "update-script"
            latest = _run_update_script(up, name, ver)
        elif isinstance(up, dict):
            utype = up.get("type", "git")
            how = f"update:{utype}"
            if utype == "git":
                latest = _git_latest(up.get("url", ""))
            elif utype == "script":
                latest = _run_update_script(up.get("script", ""), name, ver)
            elif utype == "httpdir":
                url = up.get("url", "")
                ext = up.get("ext", "tar.gz")
                html = _fetch_dir_listing(url)
                pat = re.compile(r"^" + re.escape(name) + r"-(\d[\w.\-]*)\." + re.escape(ext) + r"$")
                cands = set()
                for f in re.findall(r'href="([^"]+)"', html):
                    b = f.rstrip("/").rsplit("/", 1)[-1]
                    m = pat.match(b)
                    if m:
                        cands.add(m.group(1))
                if cands:
                    latest = _latest_version(cands)
        if latest is None:
            # Fallback: brak update lub metoda nie dała wyniku → heurystyka ze źródeł
            # Fallback: skrypt/listing nie dał wyniku → heurystyka ze źródła
            latest, how = _default_update_check(name, ver, meta.get("sources", []))

        if latest is None:
            failed += 1
            report.append((name, ver, None, how))
            continue

        newer = _cmp_versions(latest, ver) > 0
        if not newer:
            up_to_date += 1
            report.append((name, ver, latest, how))
            continue

        report.append((name, ver, latest, how))
        print(f"  🔄 {name}: {ver} → {latest}  [{how}]")
        if apply:
            try:
                data["pkgver"] = latest
                data["pkgrel"] = 1
                srcs = data.get("source", [])
                if isinstance(srcs, str):
                    srcs = [srcs]
                data["sha256sums"] = ["SKIP"] * len(srcs)
                with open(fp, "w", encoding="utf-8") as f:
                    f.write(_dump_recipe_yaml(data))
                updated += 1
            except Exception as e:
                print(f"  ⚠ {name}: błąd zapisu – {e}")

    print(f"\n📊 CHECK UPDATES: 🔄 {updated if apply else sum(1 for n, v, l, h in report if l and _cmp_versions(l, v) > 0)} "
          f"nowych | ✅ {up_to_date} aktualnych | ❓ {failed} bez wyniku")
    if not apply and not single:
        for n, v, l, h in report:
            if l and _cmp_versions(l, v) > 0:
                print(f"   {n}: {v} → {l}")
    if apply and updated:
        print(f"\n✅ Podbito wersje w {updated} recepturach (pkgrel=1, sha256sums=SKIP).")
        print("   Zbuduj ponownie: pagsync --once (lub --build <pkg>)")

if __name__ == "__main__":
    parser = argparse.ArgumentParser(description="PaganOS Build Sync Manager v2")
    parser.add_argument("--once", action="store_true", help="Jednorazowe uruchomienie")
    parser.add_argument("--force", action="store_true", help="Wymuś przebudowanie wszystkich")
    parser.add_argument("--missing", action="store_true", help="Buduj tylko brakujące")
    parser.add_argument("--build", type=str, default=None, help="Buduj konkretny pakiet")
    parser.add_argument("--rebuild-failed", action="store_true", help="Odbuduj tylko nieudane")
    parser.add_argument("--rootfs", type=str, default=ROOTFS_PATH, help="Ścieżka do rootfs")
    parser.add_argument("--sign", action="store_true", help="Podpisuj pakiety i repo.json GPG")
    parser.add_argument("--gpg-key", type=str, default=GPG_KEY, help="ID klucza GPG")
    parser.add_argument("--check", action="store_true", help="Sprawdź receptury bez budowania (YAML, SHA256, deps, URL)")
    parser.add_argument("--check-sigs", action="store_true", help="Sprawdź podpisy GPG: klucz, repo.json.asc, wszystkie .pag.asc (bez budowania)")
    parser.add_argument("--fix-sigs", action="store_true", help="Z --check-sigs: podpisz ponownie paczki z brakującym/nieprawidłowym .asc")
    parser.add_argument("--check-updates", action="store_true", help="Sprawdź nowe wersje upstream (z update: skryptem lub heurystyką ze źródła)")
    parser.add_argument("--apply", action="store_true", help="Z --check-updates: podbij pkgver w recepturach (pkgrel=1, sha256sums=SKIP)")
    parser.add_argument("--update-pkg", type=str, default=None, help="Z --check-updates: sprawdź tylko ten pakiet")
    parser.add_argument("--fix-sha", action="store_true", help="Sprawdź i automatycznie popraw sha256sums w recepturach")
    parser.add_argument("--save-kernel-config", dest="save_kernel_config", action="store_true",
                        help="Zapisz config jąder z ostatnio zbudowanych .pag do receptur (+SHA, commit/push)")
    parser.add_argument("--clean-cache", action="store_true", help="Wyczyść cache źródeł (pliki nieużywane przez żaden przepis)")
    parser.add_argument("--audit", action="store_true",
                        help="Audyt FAILów: podpowiedz brakujące makedepends (jak namcap)")
    parser.add_argument("--audit-apply", action="store_true",
                        help="--audit + dopisz znalezione braki do makedepends receptur")
    parser.add_argument("--gen-repo", action="store_true", help="Wygeneruj repo.json z istniejących pakietów w repo")
    parser.add_argument("--sync-only", action="store_true", help="Tylko sync: git pull recipes + skan + repo.json, BEZ budowania")
    parser.add_argument("--rescan", action="store_true",
                        help="Tylko odśwież indeks receptur (skan RECIPES_DIR -> state.json), bez git pull / repo.json / buildów")
    parser.add_argument("--install", type=str, default=None, help="Zainstaluj zbudowany pakiet (stable/<pkg>-*.pag) do rootfs buildera")
    parser.add_argument("--rebuild-deps", type=str, default=None,
                        help="Wymuś przebudowę całego łańcucha zależności pakietu (deps + makedeps + sam pakiet)")
    parser.add_argument("--auto-deps", dest="auto_deps", action="store_true", default=True,
                        help="Po nieudanym buildzie dobuduj brakujące zależności (jeśli mają receptury) i ponów (domyślnie ON)")
    parser.add_argument("--no-auto-deps", dest="auto_deps", action="store_false",
                        help="Wyłącz automatyczną dobudowę zależności")

    args = parser.parse_args()

    # Kontrola/naprawa podpisów jest bezpieczna i nie mutuje stanu buildów – nie
    # blokujemy jej globalnym lockiem, żeby dało się jej użyć TAKŻE w trakcie
    # budowania (właśnie wtedy najczęściej widać problemy z kluczami).
    if args.check_sigs:
        sys.exit(check_signatures(verbose=True, fix=args.fix_sigs))

    # ── Globalny lock: tylko jedna instancja pagsync naraz ──
    # Bez tego dwa równoległe wywołania (timer + ręczne `--once`, panel + CLI)
    # walczą o rootfs (flock) i nadpisują sobie state.json. run-pagsync.sh
    # mapuje exit 3 na „pominięto” – przebieg powtórzy się przy następnym
    # wyzwoleniu timera. Lock zwalnia się sam przy wyjściu procesu (zamknięcie fd).
    _PAGAN_LOCK = os.environ.get("PAGAN_LOCK", "/var/lib/pagan-sync/pagsync.lock")
    try:
        import fcntl as _fcntl
    except ImportError:
        _fcntl = None
    if _fcntl:
        try:
            _lock_fd = os.open(_PAGAN_LOCK, os.O_CREAT | os.O_RDWR, 0o644)
            _fcntl.flock(_lock_fd, _fcntl.LOCK_EX | _fcntl.LOCK_NB)
        except OSError:
            print("❌ Inna instancja pagsync już działa (globalny lock).")
            print("   Jeśli to nie Twoje uruchomienie, sprawdź: ps -eo pid,args | grep pagsync")
            sys.exit(3)

    AUTO_DEPS = args.auto_deps

    if args.rootfs:
        ROOTFS_PATH = args.rootfs
    if args.sign:
        DO_SIGN = True
    if args.gpg_key:
        GPG_KEY = args.gpg_key

    if args.check:
        check_recipes()
        sys.exit(0)
    if args.check_updates:
        check_updates(single=args.update_pkg, apply=args.apply)
        sys.exit(0)
    if args.fix_sha:
        auto_fix_sha256sums(verbose=True)
        sys.exit(0)
    if args.save_kernel_config:
        save_kernel_config(verbose=True)
        sys.exit(0)
    if args.clean_cache:
        clean_source_cache()
        sys.exit(0)
    if args.audit:
        audit_failed_builds(apply=args.audit_apply)
        sys.exit(0)
    if args.gen_repo:
        generate_repo_json(verbose=True)
        sys.exit(0)
    if args.rescan:
        state = load_state()
        recipes = scan_recipes(state)
        print(f"✅ Indeks receptur odświeżony: {len(recipes)} receptur (bez git pull i budowania).")
        sys.exit(0)
    if args.sync_only:
        state = load_state()
        if not git_sync_recipes():
            print("❌ Nie udało się zsynchronizować recipes.")
            sys.exit(1)
        scan_recipes(state)
        generate_repo_json(verbose=True)
        print("✅ Sync zakończony (bez budowania).")
        sys.exit(0)
    if args.install:
        install_pkg_to_rootfs(args.install)
        sys.exit(0)
    if args.rebuild_deps:
        state = load_state()
        rebuild_deps_chain(args.rebuild_deps, state)
        sys.exit(0)

    sync_and_build(
        force=args.force,
        missing_only=args.missing,
        single_pkg=args.build,
        rebuild_failed=args.rebuild_failed,
        fix_sha=args.fix_sha,
    )