diff --git a/backend/routers/updates.py b/backend/routers/updates.py index 27bc391..c0f6a81 100644 --- a/backend/routers/updates.py +++ b/backend/routers/updates.py @@ -3,9 +3,11 @@ import asyncio import logging import os +import re import shutil from fastapi import APIRouter, Depends, HTTPException +from pydantic import BaseModel from backend.auth import get_current_user from backend.config import BASE_DIR @@ -110,6 +112,83 @@ async def _git_nofail(*args: str, timeout: float = 30) -> tuple[int, str]: return proc.returncode, stdout.decode().strip() +# --------------------------------------------------------------------------- +# Package dependency detection +# --------------------------------------------------------------------------- + +def _parse_apt_packages(install_sh_content: str) -> set[str]: + """Extract package names from apt-get install lines in install.sh.""" + packages = set() + # Match lines with apt-get install (possibly multi-line with backslash continuations) + # First join backslash-continued lines + joined = install_sh_content.replace("\\\n", " ") + for line in joined.split("\n"): + line = line.strip() + if "apt-get install" not in line: + continue + # Remove everything before the first package name + # apt-get install [-y] [-qq] [--no-install-recommends] package1 package2 ... + # Strip the apt-get install part and flags + after_install = re.split(r"apt-get\s+install\s+", line, maxsplit=1) + if len(after_install) < 2: + continue + tokens = after_install[1].split() + for tok in tokens: + # Skip flags and redirections + if tok.startswith("-") or tok.startswith(">") or tok in ("2>&1",): + continue + # Stop at pipe, semicolon, redirection + if tok in ("|", ";", "&&", "||") or tok.startswith(">"): + break + # Valid package name + if re.match(r"^[a-zA-Z0-9][a-zA-Z0-9.+\-]+$", tok): + packages.add(tok) + return packages + + +async def _get_installed_packages(packages: set[str]) -> set[str]: + """Check which packages from the set are already installed via dpkg.""" + if not packages: + return set() + installed = set() + for pkg in packages: + proc = await asyncio.create_subprocess_exec( + "dpkg", "-s", pkg, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + env=_safe_env(), + ) + try: + stdout, _ = await asyncio.wait_for(proc.communicate(), timeout=5) + except asyncio.TimeoutError: + continue + if proc.returncode == 0: + # Verify it's actually installed (Status: install ok installed) + output = stdout.decode() + if "Status:" in output and "installed" in output: + installed.add(pkg) + return installed + + +async def _detect_new_packages(remote_ref: str) -> list[str]: + """Compare remote install.sh against locally installed packages. + Returns list of package names that the update requires but aren't installed.""" + try: + # Get install.sh content from the remote ref + rc, remote_install_sh = await _git_nofail("show", f"{remote_ref}:install.sh", timeout=10) + if rc != 0: + return [] + remote_packages = _parse_apt_packages(remote_install_sh) + if not remote_packages: + return [] + installed = await _get_installed_packages(remote_packages) + missing = sorted(remote_packages - installed) + return missing + except Exception as e: + log.debug("Package detection failed: %s", e) + return [] + + # --------------------------------------------------------------------------- # Endpoints # --------------------------------------------------------------------------- @@ -153,11 +232,64 @@ async def check_for_updates(_user: str = Depends(get_current_user)): rc, count_str = await _git_nofail("rev-list", "--count", f"HEAD..{remote_hash}") behind_count = int(count_str) if rc == 0 and count_str.isdigit() else 0 + # Detect new system packages needed by the update + new_packages = [] + if behind_count > 0: + new_packages = await _detect_new_packages(remote_hash) + return { "available": behind_count > 0, "local_hash": local_hash[:8], "remote_hash": remote_hash[:8], "behind_count": behind_count, + "new_packages": new_packages, + } + + +class InstallDepsRequest(BaseModel): + packages: list[str] + + +@router.post("/install-deps") +async def install_deps(req: InstallDepsRequest, _user: str = Depends(get_current_user)): + """Install system packages via apt-get. Used before applying updates.""" + if not req.packages: + return {"success": True, "message": "No packages to install", "output": ""} + + # Validate package names — only allow safe characters + for pkg in req.packages: + if not re.match(r"^[a-zA-Z0-9][a-zA-Z0-9.+\-]+$", pkg): + raise HTTPException(400, f"Invalid package name: {pkg}") + + apt_bin = shutil.which("apt-get") or "/usr/bin/apt-get" + cmd = [apt_bin, "install", "-y"] + req.packages + log.info("Installing system packages: %s", " ".join(req.packages)) + + proc = await asyncio.create_subprocess_exec( + *cmd, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + env=_safe_env(), + ) + try: + stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=300) + except asyncio.TimeoutError: + proc.kill() + await proc.wait() + raise HTTPException(504, "Package installation timed out") + + output = stdout.decode().strip() + err_output = stderr.decode().strip() + + if proc.returncode != 0: + log.warning("apt-get install failed (rc=%d): %s", proc.returncode, err_output) + raise HTTPException(500, f"Package installation failed: {err_output[-500:]}") + + log.info("System packages installed successfully") + return { + "success": True, + "message": f"Installed {len(req.packages)} package(s)", + "output": output[-500:], } diff --git a/frontend/css/panel.css b/frontend/css/panel.css index ef875e2..bcbbeec 100644 --- a/frontend/css/panel.css +++ b/frontend/css/panel.css @@ -306,3 +306,48 @@ opacity: 0.6; cursor: not-allowed; } + +/* New packages needed by update */ +.update-packages { + margin-top: 10px; + padding: 8px 10px; + background: color-mix(in srgb, var(--status-amber) 10%, transparent); + border: 1px solid color-mix(in srgb, var(--status-amber) 30%, transparent); + border-radius: var(--radius-sm); +} + +.update-packages-title { + font-family: var(--font-mono); + font-size: 10px; + font-weight: 500; + color: var(--status-amber); + letter-spacing: 0.5px; + margin-bottom: 4px; +} + +.update-packages-list { + font-family: var(--font-mono); + font-size: 11px; + color: var(--text-primary); + word-break: break-word; + line-height: 1.4; + margin-bottom: 6px; +} + +.update-packages-toggle { + display: flex; + align-items: center; + gap: 6px; + cursor: pointer; + font-family: var(--font-mono); + font-size: 11px; + color: var(--text-secondary); + user-select: none; +} + +.update-packages-toggle input[type="checkbox"] { + width: 13px; + height: 13px; + accent-color: var(--accent); + cursor: pointer; +} diff --git a/frontend/js/atlus.js b/frontend/js/atlus.js index 1130df2..8d7708c 100644 --- a/frontend/js/atlus.js +++ b/frontend/js/atlus.js @@ -497,6 +497,23 @@ } function showUpdateAvailable(panel, data) { + const pkgs = data.new_packages || []; + const hasPkgs = pkgs.length > 0; + + let pkgHtml = ''; + if (hasPkgs) { + pkgHtml = ` +
+
New system packages needed:
+
${pkgs.join(', ')}
+ +
+ `; + } + panel.innerHTML = `
@@ -506,13 +523,40 @@
+ ${pkgHtml} `; panel.querySelector('.update-install-btn').addEventListener('click', async (e) => { const btn = e.target; btn.disabled = true; - btn.textContent = 'Updating…'; + const installPkgs = hasPkgs && panel.querySelector('.update-install-pkgs-cb')?.checked; + + // Step 1: Install system packages if needed + if (installPkgs) { + btn.textContent = 'Installing packages…'; + try { + const depRes = await Atlus.apiFetch('/api/updates/install-deps', { + method: 'POST', + body: { packages: pkgs }, + }); + if (!depRes.ok) { + const err = await depRes.json().catch(() => ({})); + btn.textContent = 'Pkg install failed'; + btn.disabled = false; + console.error('Package install failed:', err); + return; + } + } catch (err) { + btn.textContent = 'Pkg install failed'; + btn.disabled = false; + console.error('Package install error:', err); + return; + } + } + + // Step 2: Apply the update (git pull + pip + restart) + btn.textContent = 'Updating…'; try { const res = await Atlus.apiFetch('/api/updates/apply', { method: 'POST' }); if (res.ok) {