Notification for required packages install prompt added.

This commit is contained in:
roberts 2026-03-14 23:51:03 -05:00
parent 23e4906d08
commit 983857bd5b
3 changed files with 222 additions and 1 deletions

View file

@ -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:],
}

View file

@ -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;
}

View file

@ -497,6 +497,23 @@
}
function showUpdateAvailable(panel, data) {
const pkgs = data.new_packages || [];
const hasPkgs = pkgs.length > 0;
let pkgHtml = '';
if (hasPkgs) {
pkgHtml = `
<div class="update-packages">
<div class="update-packages-title">New system packages needed:</div>
<div class="update-packages-list">${pkgs.join(', ')}</div>
<label class="update-packages-toggle">
<input type="checkbox" class="update-install-pkgs-cb" checked>
<span>Install packages with update</span>
</label>
</div>
`;
}
panel.innerHTML = `
<div class="update-status update-available">
<span class="update-status-dot available"></span>
@ -506,13 +523,40 @@
</div>
<button class="update-install-btn" title="Install update">Update</button>
</div>
${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) {