🔒 Repository is read-only – file editing is disabled.
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429143014311432143314341435143614371438143914401441144214431444144514461447144814491450145114521453145414551456145714581459146014611462146314641465146614671468146914701471147214731474147514761477147814791480148114821483148414851486148714881489149014911492149314941495149614971498149915001501150215031504150515061507150815091510151115121513151415151516151715181519152015211522152315241525152615271528152915301531153215331534153515361537153815391540154115421543154415451546154715481549155015511552155315541555155615571558155915601561156215631564156515661567156815691570157115721573157415751576157715781579158015811582158315841585158615871588158915901591159215931594159515961597159815991600160116021603160416051606160716081609161016111612161316141615161616171618161916201621162216231624162516261627162816291630163116321633163416351636163716381639164016411642164316441645164616471648164916501651165216531654165516561657165816591660166116621663166416651666166716681669167016711672167316741675167616771678167916801681168216831684168516861687168816891690169116921693169416951696169716981699170017011702170317041705170617071708170917101711171217131714171517161717171817191720172117221723172417251726172717281729173017311732173317341735173617371738173917401741174217431744174517461747174817491750175117521753175417551756175717581759176017611762176317641765176617671768176917701771177217731774177517761777177817791780178117821783178417851786178717881789179017911792179317941795179617971798179918001801180218031804180518061807180818091810181118121813181418151816181718181819182018211822182318241825182618271828182918301831183218331834183518361837183818391840184118421843184418451846184718481849185018511852185318541855185618571858185918601861186218631864186518661867186818691870187118721873187418751876187718781879188018811882188318841885188618871888188918901891189218931894189518961897189818991900190119021903190419051906190719081909191019111912191319141915191619171918191919201921192219231924192519261927192819291930193119321933193419351936193719381939194019411942194319441945194619471948194919501951195219531954195519561957195819591960196119621963196419651966196719681969197019711972197319741975197619771978197919801981198219831984198519861987198819891990199119921993199419951996199719981999200020012002200320042005200620072008200920102011201220132014201520162017201820192020202120222023202420252026202720282029203020312032203320342035203620372038203920402041204220432044204520462047204820492050205120522053205420552056205720582059206020612062206320642065206620672068206920702071207220732074207520762077207820792080208120822083208420852086208720882089209020912092209320942095209620972098209921002101210221032104210521062107210821092110211121122113211421152116211721182119212021212122212321242125212621272128212921302131213221332134213521362137213821392140214121422143214421452146214721482149215021512152215321542155215621572158215921602161216221632164216521662167216821692170217121722173217421752176217721782179218021812182218321842185218621872188218921902191219221932194219521962197219821992200220122022203220422052206220722082209221022112212221322142215221622172218221922202221222222232224222522262227222822292230223122322233223422352236223722382239224022412242224322442245224622472248224922502251225222532254225522562257225822592260226122622263226422652266226722682269227022712272227322742275227622772278227922802281228222832284228522862287228822892290229122922293229422952296229722982299230023012302230323042305230623072308230923102311231223132314231523162317231823192320232123222323232423252326232723282329233023312332233323342335233623372338233923402341234223432344234523462347234823492350235123522353235423552356235723582359236023612362236323642365236623672368236923702371237223732374237523762377237823792380238123822383238423852386238723882389239023912392239323942395239623972398239924002401240224032404240524062407240824092410241124122413241424152416241724182419242024212422242324242425242624272428242924302431243224332434243524362437243824392440244124422443244424452446244724482449245024512452245324542455245624572458245924602461246224632464246524662467246824692470247124722473247424752476247724782479248024812482248324842485248624872488248924902491249224932494249524962497249824992500250125022503250425052506250725082509251025112512251325142515251625172518251925202521252225232524252525262527252825292530253125322533253425352536253725382539254025412542254325442545254625472548254925502551255225532554255525562557255825592560256125622563256425652566256725682569257025712572257325742575257625772578257925802581258225832584258525862587258825892590259125922593259425952596259725982599260026012602260326042605260626072608260926102611261226132614261526162617261826192620262126222623262426252626262726282629263026312632263326342635263626372638263926402641264226432644264526462647264826492650265126522653265426552656265726582659266026612662266326642665266626672668266926702671267226732674267526762677267826792680268126822683268426852686268726882689269026912692269326942695269626972698269927002701270227032704270527062707270827092710271127122713271427152716271727182719272027212722272327242725272627272728272927302731273227332734273527362737273827392740274127422743274427452746274727482749275027512752275327542755275627572758275927602761276227632764276527662767276827692770277127722773277427752776277727782779278027812782278327842785278627872788278927902791279227932794279527962797279827992800280128022803280428052806280728082809281028112812281328142815281628172818281928202821282228232824282528262827282828292830283128322833283428352836283728382839284028412842284328442845284628472848284928502851285228532854285528562857285828592860286128622863286428652866286728682869287028712872287328742875287628772878287928802881288228832884288528862887288828892890289128922893289428952896289728982899290029012902290329042905290629072908290929102911291229132914291529162917291829192920292129222923292429252926292729282929293029312932293329342935293629372938293929402941294229432944294529462947294829492950295129522953295429552956295729582959296029612962296329642965296629672968296929702971297229732974297529762977297829792980298129822983298429852986298729882989299029912992299329942995299629972998299930003001300230033004300530063007300830093010301130123013301430153016301730183019302030213022302330243025302630273028302930303031303230333034303530363037303830393040304130423043304430453046304730483049305030513052305330543055305630573058305930603061306230633064306530663067306830693070307130723073307430753076307730783079308030813082308330843085308630873088308930903091309230933094309530963097309830993100310131023103310431053106310731083109311031113112311331143115311631173118311931203121312231233124312531263127312831293130313131323133313431353136313731383139314031413142314331443145314631473148314931503151315231533154315531563157315831593160316131623163316431653166316731683169317031713172317331743175317631773178317931803181318231833184318531863187318831893190319131923193319431953196319731983199320032013202320332043205320632073208320932103211321232133214321532163217321832193220322132223223322432253226322732283229323032313232323332343235323632373238323932403241324232433244324532463247324832493250325132523253325432553256325732583259326032613262326332643265326632673268326932703271327232733274327532763277327832793280328132823283328432853286328732883289329032913292329332943295329632973298329933003301330233033304330533063307330833093310331133123313331433153316331733183319332033213322332333243325332633273328332933303331333233333334333533363337333833393340334133423343334433453346334733483349335033513352335333543355335633573358335933603361336233633364336533663367336833693370337133723373337433753376337733783379338033813382338333843385338633873388338933903391339233933394339533963397339833993400340134023403340434053406340734083409341034113412341334143415341634173418341934203421342234233424342534263427342834293430343134323433343434353436343734383439344034413442344334443445344634473448344934503451345234533454345534563457345834593460346134623463346434653466346734683469347034713472347334743475347634773478347934803481348234833484348534863487348834893490349134923493349434953496349734983499350035013502350335043505350635073508350935103511351235133514351535163517351835193520352135223523352435253526352735283529353035313532353335343535353635373538353935403541354235433544354535463547354835493550355135523553355435553556355735583559356035613562356335643565356635673568356935703571357235733574357535763577357835793580358135823583358435853586358735883589359035913592359335943595359635973598359936003601360236033604360536063607360836093610361136123613361436153616361736183619362036213622362336243625362636273628362936303631363236333634363536363637363836393640364136423643364436453646364736483649365036513652365336543655365636573658365936603661366236633664366536663667366836693670367136723673367436753676367736783679368036813682368336843685368636873688368936903691369236933694369536963697369836993700370137023703370437053706370737083709371037113712371337143715371637173718371937203721372237233724372537263727372837293730373137323733373437353736373737383739374037413742374337443745374637473748374937503751375237533754375537563757375837593760376137623763376437653766376737683769377037713772377337743775377637773778377937803781378237833784378537863787378837893790379137923793379437953796379737983799380038013802380338043805380638073808380938103811381238133814381538163817
#!/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
# ── Osierocone locki gita ──
# index.lock/HEAD.lock zostaja po UBITYM procesie git (SIGKILL, timeout, restart)
# i blokuja kazda operacje zapisu do indeksu: git stash, pull, commit - wszystko
# pada na "Unable to create ...index.lock: File exists". Objaw w logu byl mylacy
# ("Lokalne zmiany odlozone na stash", a chwile pozniej "cannot pull with rebase:
# You have unstaged changes"), a kolejka buildow stala do recznej naprawy.
def _git_pids_for(repo_dir):
"""PID-y procesow git pracujacych na tym repo (po cmdline ALBO cwd).
Zwraca None, gdy nie da sie ustalic - wtedy nie ruszamy zadnego locka.
"""
try:
out = subprocess.run(["pgrep", "-x", "git"], capture_output=True,
text=True, timeout=5).stdout
except Exception:
return None
pids = []
for tok in out.split():
if not tok.isdigit():
continue
pid = tok
cmd = ""
try:
with open(f"/proc/{pid}/cmdline", "rb") as f:
cmd = f.read().replace(b"\0", b" ").decode("utf-8", "replace")
except OSError:
pass
if repo_dir in cmd:
pids.append(pid)
continue
try:
cwd = os.readlink(f"/proc/{pid}/cwd")
except OSError:
cwd = ""
if cwd == repo_dir or cwd.startswith(repo_dir + os.sep):
pids.append(pid)
return pids
def _clear_orphan_git_locks(repo_dir, min_age=60):
"""Usuwa osierocone .git/*.lock. Tylko gdy lock jest stary i nic nie pracuje."""
pids = _git_pids_for(repo_dir)
if pids is None:
print(" \u26a0 Nie moge sprawdzic procesow git - nie ruszam lockow")
return []
if pids:
return []
cleared = []
for rel in (".git/index.lock", ".git/HEAD.lock", ".git/packed-refs.lock"):
p = os.path.join(repo_dir, rel)
if not os.path.exists(p):
continue
try:
if time.time() - os.path.getmtime(p) < min_age:
continue
os.remove(p)
cleared.append(rel)
except OSError:
pass
return cleared
# ── 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)
# Najpierw osierocone locki gita – inaczej stash/pull padna.
cleared = _clear_orphan_git_locks(RECIPES_DIR)
if cleared:
print(f" 🧹 Usunięto osierocony lock gita: {', '.join(cleared)}")
# 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
st = subprocess.run(
["git", "-C", RECIPES_DIR, "stash", "--include-untracked"],
capture_output=True, text=True, errors='replace', timeout=30
)
if st.returncode != 0:
print(f" ⚠ Nie udało się odłożyć zmian na stash: "
f"{(st.stderr or st.stdout).strip()[:200]}")
return False
# Sprawdź, czy drzewo jest FAKTYCZNIE czyste – bez tego pull/rebase
# pada na "You have unstaged changes", a komunikat wyżej kłamie.
st2 = subprocess.run(
["git", "-C", RECIPES_DIR, "status", "--porcelain"],
capture_output=True, text=True, errors='replace', timeout=10
)
left = st2.stdout.strip()
if left:
print(f" ⚠ Po stashu nadal {len(left.splitlines())} zmienionych "
f"plików – przerywam, żeby nie zgubić zmian")
return False
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", "--autostash",
"origin", "main"],
capture_output=True, text=True, errors='replace', timeout=180
)
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).
`--autostash` jest tu kluczowe: `git pull --rebase` ODMAWIA pracy, gdy w
drzewie są niezacommitowane zmiany ("error: cannot pull with rebase: You have
unstaged changes"). A w recipes taki stan jest normalny – równolegle chodzą
automaty (--missing co 6h, --fix-sha, check-updates) i mogą dopisać plik
między naszym commitem a pullem. Wcześniej kończyło się to komunikatem
"Rebase przed pushem nieudany", a zapis receptury NIGDY nie docierał do
origin. --autostash sam odklada zmiany przed rebase i przywraca je po.
"""
try:
cleared = _clear_orphan_git_locks(RECIPES_DIR)
if cleared:
print(f" 🧹 Usunięto osierocony lock gita: {', '.join(cleared)}")
add_r = subprocess.run(["git", "-C", RECIPES_DIR, "add", "-A"],
capture_output=True, text=True, errors='replace', timeout=30)
if add_r.returncode != 0:
print(f" ⚠ git add receptur nieudany: {(add_r.stderr or '').strip()[:200]}")
c_r = subprocess.run(["git", "-C", RECIPES_DIR, "commit", "-m", msg],
capture_output=True, text=True, errors='replace', timeout=30)
if c_r.returncode != 0:
c_out = ((c_r.stdout or "") + (c_r.stderr or "")).strip()
if "nothing to commit" in c_out or "nic do zacommitowania" in c_out:
print(" ℹ Brak zmian do zacommitowania w recepturach")
else:
print(f" ⚠ git commit receptur nieudany: {c_out[:200]}")
rebase_r = subprocess.run(
["git", "-C", RECIPES_DIR, "pull", "--rebase", "--autostash", "origin", "main"],
capture_output=True, text=True, errors='replace', timeout=180)
if rebase_r.returncode != 0:
subprocess.run(["git", "-C", RECIPES_DIR, "rebase", "--abort"],
capture_output=True, timeout=15)
print(f" ⚠ Rebase przed pushem nieudany: "
f"{(rebase_r.stderr or rebase_r.stdout).strip()[:200]}")
print(" Zmiany receptur zostają lokalnie (nic nie zgubione).")
push_r = subprocess.run(["git", "-C", RECIPES_DIR, "push", "origin", "main"],
capture_output=True, text=True, errors='replace', timeout=60)
if push_r.returncode != 0:
print(f" ⚠ Push receptur NIEUDANY (commit lokalnie): "
f"{(push_r.stderr or '').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:
# Osierocony index.lock cicho blokowalby add/commit (kody wyjscia
# nie byly sprawdzane) i zmiany nigdy nie trafialy do repo.
_cleared = _clear_orphan_git_locks(RECIPES_DIR)
if _cleared:
print(f" \U0001f9f9 Usunięto osierocony lock gita: {', '.join(_cleared)}")
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", "--autostash", "origin", "main"],
capture_output=True, text=True, errors='replace', timeout=180
)
if rebase_r.returncode != 0:
subprocess.run(["git", "-C", RECIPES_DIR, "rebase", "--abort"],
capture_output=True, timeout=15)
print(f" ⚠ Rebase przed pushem nieudany: "
f"{(rebase_r.stderr or rebase_r.stdout).strip()[:200]}")
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,
)