"""Git operations — clone, pull, scan for stacks and modules.""" import json, os, subprocess, shutil from pathlib import Path from core.docker import COMPOSE_NAMES from core import boilerplates as bp CACHE = Path(os.environ.get("SU_GIT_CACHE", "/data/git")) # Fix git "dubious ownership" — container draait als root, cache kan door andere uid zijn _git_safe_set = False def _ensure_git_safe(): global _git_safe_set if not _git_safe_set: os.environ["GIT_CONFIG_COUNT"] = "1" os.environ["GIT_CONFIG_KEY_0"] = "safe.directory" os.environ["GIT_CONFIG_VALUE_0"] = "*" _git_safe_set = True # Run immediately at import _ensure_git_safe() def cache_dir(repo_id: str) -> Path: return CACHE / repo_id def clone_or_pull(repo: dict, log_fn=None) -> tuple[bool, str]: _ensure_git_safe() url = repo.get("url", "").strip() if not url: return False, "Geen URL" branch = repo.get("branch", "main") token = repo.get("token", "").strip() rid = repo.get("id", "repo") dest = cache_dir(rid) clone_url = url if token and url.startswith("https://"): host = url.split("://", 1)[1] clone_url = f"https://x-access-token:{token}@{host}" env = os.environ.copy() env["GIT_TERMINAL_PROMPT"] = "0" try: if dest.exists() and (dest / ".git").exists(): if log_fn: log_fn(f"Pull {rid}…") r = subprocess.run(["git", "pull", "--rebase", "--autostash"], cwd=str(dest), capture_output=True, text=True, env=env, timeout=60) else: if log_fn: log_fn(f"Clone {url}…") dest.parent.mkdir(parents=True, exist_ok=True) if dest.exists(): shutil.rmtree(dest) r = subprocess.run(["git", "clone", "--branch", branch, "--depth", "1", clone_url, str(dest)], capture_output=True, text=True, env=env, timeout=120) output = (r.stdout + "\n" + r.stderr).strip() # Verwijder token uit output voordat het gelogd of teruggegeven wordt safe_output = output.replace(clone_url, url) if clone_url != url else output for line in safe_output.splitlines(): if line.strip() and log_fn: log_fn(line) if r.returncode != 0: return False, safe_output[:200] return True, "OK" except subprocess.TimeoutExpired: return False, "Timeout" except Exception as e: return False, str(e) def scan_stacks(repo_id: str, lib: Path, subdir: str = "") -> list[dict]: base = cache_dir(repo_id) if subdir: base = base / subdir if not base.exists(): return [] stacks = _scan_compose_dirs(base, lib) # Fallback: als geen stacks gevonden en geen subdir opgegeven, # zoek automatisch in bekende submappen if not stacks and not subdir: for fallback in ("apps", "stacks", "docker"): fb = cache_dir(repo_id) / fallback if fb.exists(): stacks = _scan_compose_dirs(fb, lib) if stacks: break return stacks def scan_dir(base: Path, lib: Path) -> list[dict]: """Scan een lokale map voor stacks/boilerplates zonder git (voor ingebouwde apps).""" if not base.exists(): return [] return _scan_compose_dirs(base, lib) def _scan_compose_dirs(base: Path, lib: Path) -> list[dict]: """Scan één directory voor compose stacks. Detecteert zowel het klassieke Server Up formaat (compose.yml + stack.json) als het ChristianLempa Boilerplates formaat (template.json + files/).""" stacks = [] for d in sorted(base.iterdir()): if not d.is_dir() or d.name.startswith((".", "_")): continue is_legacy = any((d / n).exists() for n in COMPOSE_NAMES) is_bp = bp.is_boilerplate(d) if not (is_legacy or is_bp): continue if is_bp: meta = bp.metadata(d) # boilerplate met draft=true wordt overgeslagen tenzij expliciet if meta.get("draft"): continue else: meta = _meta(d) meta.setdefault("format", "compose") installed = [] if lib.exists(): for ld in lib.iterdir(): if ld.is_dir() and (ld.name == d.name or ld.name.startswith(d.name + "-")): installed.append(ld.name) entry = { "dir": d.name, "name": meta.pop("name", d.name), "path": str(d), "instances": sorted(installed), } entry.update(meta) stacks.append(entry) return stacks def scan_modules(repo_id: str, subdir: str = "") -> list[dict]: base = cache_dir(repo_id) if subdir: base = base / subdir if not base.exists(): return [] # Fallback: als base geen subdirectories heeft, probeer bekende submappen if not any(d.is_dir() for d in base.iterdir()): if not subdir: for fb in ("modules",): fb_path = cache_dir(repo_id) / fb if fb_path.exists(): base = fb_path break mods = [] for d in sorted(base.iterdir()): if not d.is_dir() or d.name.startswith((".", "_")): continue # Vereist module.json mf = d / "module.json" if not mf.exists(): continue init = d / "__init__.py" if not init.exists(): continue # Valideer dat __init__.py daadwerkelijk een Module subclass bevat try: src = init.read_text() if "Module" not in src or "class " not in src: continue except Exception: continue meta = {} try: meta = json.loads(mf.read_text()) except Exception: pass mid = meta.get("id", d.name) # Skip core features from core.modules import CORE if mid in CORE: continue mods.append({ "id": mid, "name": meta.get("name", d.name), "icon": meta.get("icon", "🧩"), "desc": meta.get("description", ""), "version": meta.get("version", "1.0.0"), "tags": meta.get("tags", []), "author": meta.get("author", ""), "repo_id": repo_id, "_src": str(d), }) return mods def _meta(d: Path) -> dict: for n in ("app.json", "stack.json", "meta.json"): f = d / n if f.exists(): try: return json.loads(f.read_text()) except Exception: pass return {"description": "", "icon": "", "tags": [], "ports": []}