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 asyncio
import logging import logging
import os import os
import re
import shutil import shutil
from fastapi import APIRouter, Depends, HTTPException from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel
from backend.auth import get_current_user from backend.auth import get_current_user
from backend.config import BASE_DIR 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() 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 # 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}") 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 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 { return {
"available": behind_count > 0, "available": behind_count > 0,
"local_hash": local_hash[:8], "local_hash": local_hash[:8],
"remote_hash": remote_hash[:8], "remote_hash": remote_hash[:8],
"behind_count": behind_count, "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; opacity: 0.6;
cursor: not-allowed; 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) { 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 = ` panel.innerHTML = `
<div class="update-status update-available"> <div class="update-status update-available">
<span class="update-status-dot available"></span> <span class="update-status-dot available"></span>
@ -506,13 +523,40 @@
</div> </div>
<button class="update-install-btn" title="Install update">Update</button> <button class="update-install-btn" title="Install update">Update</button>
</div> </div>
${pkgHtml}
`; `;
panel.querySelector('.update-install-btn').addEventListener('click', async (e) => { panel.querySelector('.update-install-btn').addEventListener('click', async (e) => {
const btn = e.target; const btn = e.target;
btn.disabled = true; 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 { try {
const res = await Atlus.apiFetch('/api/updates/apply', { method: 'POST' }); const res = await Atlus.apiFetch('/api/updates/apply', { method: 'POST' });
if (res.ok) { if (res.ok) {