#!/usr/bin/env python3 import os import subprocess import shutil import requests from db import load_db, register_package, is_installed from utils import download_extract, version_satisfies, PKG_DIR from tqdm import tqdm # --- basic dirs --- TMP_RECIPE_DIR = "/tmp/fempkg" os.makedirs(TMP_RECIPE_DIR, exist_ok=True) RECIPE_CACHE_DIR = os.path.expanduser("/var/lib/fempkg/repo") MANIFEST_CACHE_DIR = "/var/lib/fempkg/manifests" # current manifests (pkgname.txt) VERSIONED_MANIFEST_DIR = "/var/lib/fempkg/manifest-versions" # versioned manifests pkgname-version.txt LOCAL_MANIFESTS_DIR = "/var/lib/fempkg/local-manifests" # temporary snapshots used during install BINPKG_CACHE_DIR = "/var/lib/fempkg/binpkg" for d in (RECIPE_CACHE_DIR, MANIFEST_CACHE_DIR, VERSIONED_MANIFEST_DIR, LOCAL_MANIFESTS_DIR, BINPKG_CACHE_DIR): os.makedirs(d, exist_ok=True) # --- helpers --- def safe_rmtree(path): """Remove path recursively or symlink target.""" if os.path.islink(path): real_path = os.path.realpath(path) if os.path.exists(real_path): shutil.rmtree(real_path, ignore_errors=True) os.unlink(path) elif os.path.exists(path): shutil.rmtree(path, ignore_errors=True) def _ensure_symlink(src: str, target: str): if os.path.islink(src): if os.readlink(src) == target: return else: os.unlink(src) elif os.path.exists(src): shutil.rmtree(src, ignore_errors=True) os.makedirs(target, exist_ok=True) os.symlink(target, src) _ensure_symlink("/tmp/fempkg", "/var/tmp/fempkg") _ensure_symlink("/tmp/fempkgbuild", "/var/tmp/fempkgbuild") # -------------------------- # Fetch recipes & manifests # -------------------------- def fetch_all_recipes(repo_url="https://rocketleaguechatp.duckdns.org/recipes"): index_url = f"{repo_url}/index.txt" print(f"Fetching recipe index from {index_url}") try: with requests.Session() as session: resp = session.get(index_url) resp.raise_for_status() entries = [e for e in resp.text.splitlines() if e.endswith(".recipe.py")] for entry in tqdm(entries, desc="Recipes", unit="file"): url = f"{repo_url}/{entry}" dest_path = os.path.join(RECIPE_CACHE_DIR, entry) r = session.get(url) r.raise_for_status() with open(dest_path, "wb") as f: f.write(r.content) except Exception as e: print(f"Error fetching index/recipes: {e}") def fetch_all_manifests(repo_url="https://rocketleaguechatp.duckdns.org/manifests"): index_url = f"{repo_url}/index.txt" print(f"Fetching manifest index from {index_url}") try: with requests.Session() as session: resp = session.get(index_url) resp.raise_for_status() entries = [e for e in resp.text.splitlines() if e.endswith(".txt")] for entry in tqdm(entries, desc="Manifests", unit="file"): url = f"{repo_url}/{entry}" dest_path = os.path.join(MANIFEST_CACHE_DIR, entry) r = session.get(url) r.raise_for_status() with open(dest_path, "wb") as f: f.write(r.content) except Exception as e: print(f"Error fetching index/manifests: {e}") def fetch_binpkg_index(repo_url="https://rocketleaguechatp.duckdns.org/binpkg"): index_url = f"{repo_url}/index.txt" print(f"Fetching binpkg index from {index_url}") try: with requests.Session() as session: resp = session.get(index_url) resp.raise_for_status() dest_path = os.path.join(BINPKG_CACHE_DIR, "index.txt") with open(dest_path, "wb") as f: f.write(resp.content) print("Binpkg index downloaded successfully.") except Exception as e: print(f"Error fetching binpkg index: {e}") def fetch_all(repo_url_recipes=None, repo_url_manifests=None, repo_url_binpkg=None): os.system(f"rm -rf {RECIPE_CACHE_DIR}/*") fetch_all_recipes(repo_url_recipes or "https://rocketleaguechatp.duckdns.org/recipes") fetch_all_manifests(repo_url_manifests or "https://rocketleaguechatp.duckdns.org/manifests") fetch_binpkg_index(repo_url_binpkg or "https://rocketleaguechatp.duckdns.org/binpkg") print("All recipes, manifests and the binpkg index have been fetched successfully.") # -------------------------- # Recipe / manifest helpers # -------------------------- def fetch_recipe(pkgname): path = os.path.join(RECIPE_CACHE_DIR, f"{pkgname}.recipe.py") if not os.path.exists(path): raise FileNotFoundError(f"Recipe for {pkgname} not found in cache. Run `fempkg update` first.") return path def fetch_manifest(pkgname, pkgver=None): path = os.path.join(MANIFEST_CACHE_DIR, f"{pkgname}.txt") if not os.path.exists(path): raise FileNotFoundError(f"Manifest for {pkgname} not found. Run `fempkg update` first.") if not pkgver: return path temp_path = os.path.join(MANIFEST_CACHE_DIR, f"{pkgname}-resolved.txt") with open(path) as f: content = f.read() content = content.replace("{pkgver}", pkgver) with open(temp_path, "w") as f: f.write(content) return temp_path # -------------------------- # Manifest/version utilities # -------------------------- def save_local_manifest_snapshot(pkgname, version, src_manifest_path): os.makedirs(LOCAL_MANIFESTS_DIR, exist_ok=True) dest = os.path.join(LOCAL_MANIFESTS_DIR, f"{pkgname}-{version}.txt") shutil.copy(src_manifest_path, dest) return dest def promote_local_to_versioned(pkgname, version): local = os.path.join(LOCAL_MANIFESTS_DIR, f"{pkgname}-{version}.txt") if not os.path.exists(local): return None os.makedirs(VERSIONED_MANIFEST_DIR, exist_ok=True) versioned = os.path.join(VERSIONED_MANIFEST_DIR, f"{pkgname}-{version}.txt") shutil.move(local, versioned) current = os.path.join(MANIFEST_CACHE_DIR, f"{pkgname}.txt") shutil.copy(versioned, current) return versioned def remove_versioned_manifest(pkgname, version): p = os.path.join(VERSIONED_MANIFEST_DIR, f"{pkgname}-{version}.txt") if os.path.exists(p): os.remove(p) def read_manifest_paths(manifest_path): if not os.path.exists(manifest_path): return [] with open(manifest_path) as f: return [line.strip() for line in f if line.strip()] def delete_file_and_prune_dirs(path): try: if os.path.islink(path) or os.path.isfile(path): os.remove(path) elif os.path.isdir(path): try: os.rmdir(path) except OSError: return parent = os.path.dirname(path) while parent and parent != "/" and parent != "": try: os.rmdir(parent) except OSError: break parent = os.path.dirname(parent) except Exception as e: print(f"[fempkg] Warning: failed to remove {path}: {e}") # -------------------------- # Rebuild helper # -------------------------- def rebuild_package(packages, repo_dir=None): if isinstance(packages, str): packages = [packages] for pkg in packages: print(f"[fempkg] Rebuilding dependency: {pkg}") dep_recipe = None if repo_dir: dep_recipe = os.path.join(repo_dir, f"{pkg}.recipe.py") if not os.path.exists(dep_recipe): print(f"Warning: recipe for {pkg} not found in {repo_dir}.") dep_recipe = None if not dep_recipe: dep_recipe = fetch_recipe(pkg) build_package(dep_recipe, repo_dir, force_rebuild=True) def extract_tar_zst_with_progress(tar_path, dest="/"): try: result = subprocess.run( ["tar", "--use-compress-program=zstd", "-tf", tar_path], stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, check=True ) files = result.stdout.splitlines() total_files = len(files) except subprocess.CalledProcessError as e: print(f"[fempkg] Failed to list tar.zst archive: {e.stderr}") raise with tqdm(total=total_files, unit="file", desc=f"Extracting {tar_path}") as pbar: proc = subprocess.Popen( ["tar", "--use-compress-program=zstd", "-xvf", tar_path, "-C", dest], stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True ) for line in proc.stdout: if line.strip(): pbar.update(1) proc.wait() if proc.returncode != 0: raise subprocess.CalledProcessError(proc.returncode, proc.args) # -------------------------- # Main build function (SOURCE ONLY) # -------------------------- def build_package(recipe_file, repo_dir=None, force_rebuild=True): """ Fully source-based build. Ignores deps, db checks, triggers, and binpkg. """ _ensure_symlink("/tmp/fempkg", "/var/tmp/fempkg") _ensure_symlink("/tmp/fempkgbuild", "/var/tmp/fempkgbuild") # Load recipe recipe = {} with open(recipe_file, "r") as f: exec(f.read(), recipe) name, version = recipe["pkgname"], recipe["pkgver"] source_type = recipe.get("source_type") print(f"[fempkg] Building {name}-{version} from source (binpkg skipped, deps ignored)...") manifest_path = fetch_manifest(name, pkgver=version) with open(manifest_path) as f: files = sorted(line.strip() for line in f if line.strip()) print(f"[fempkg] Using manifest for {name} ({len(files)} files)") # Download + extract download_extract(recipe["source"], source_type) # Run build commands for cmd in recipe.get("build", []): print(f"> {cmd}") subprocess.run(f". /etc/profile && mkdir -p /tmp/fempkg && {cmd}", shell=True, check=True) print(f"[fempkg] Source build completed for {name}-{version}.") print(f"[fempkg] Skipping triggers, manifest registration, and db registration.") # Cleanup temp dirs and tarballs for cleanup_path in ["/tmp/fempkg", "/tmp/fempkgbuild"]: target = os.path.realpath(cleanup_path) if os.path.exists(target): shutil.rmtree(target, ignore_errors=True) os.makedirs(target, exist_ok=True) basename = os.path.basename(recipe["source"]) tarball_path = os.path.join(PKG_DIR, basename) if os.path.exists(tarball_path): os.remove(tarball_path)