atlus/backend/routers/updates.py
roberts 9c402e3726 Add package manager, text editor, file manager enhancements, auto-updates
Package Manager (new app):
- Search, install, remove apt packages via web UI
- Backend: apt-cache/dpkg-query/apt-get wrapper with input validation
- Frontend: searchable package list with expandable detail panels

Text Editor / File Viewer (new app):
- Opens from file manager, supports text editing with line numbers
- Image preview via authenticated blob URLs
- Binary file info display with download option
- Ctrl+S / Cmd+S save, dirty tracking, tab key support

File Manager enhancements:
- Toolbar: New File, New Folder, Upload, Delete, Refresh buttons
- Context menu: New File/Folder options, Open in Editor
- Double-click files to open in editor
- Right-click empty area for create options

Auto-update notification:
- Backend checks git repo for new commits (fetch + compare)
- One-click update: git pull + pip install + service restart
- Toast notification in right panel with dismiss option
- Polls every 30 minutes, retry logic for server restart

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-14 19:13:13 -05:00

166 lines
5.5 KiB
Python

"""Self-update — check Gitea repo for new commits and apply updates."""
import asyncio
import os
import shutil
from fastapi import APIRouter, Depends, HTTPException
from backend.auth import get_current_user
from backend.config import BASE_DIR
router = APIRouter(prefix="/api/updates", tags=["updates"])
# ---------------------------------------------------------------------------
# Guard
# ---------------------------------------------------------------------------
_IS_GIT = (BASE_DIR / ".git").is_dir()
_HAS_GIT = bool(shutil.which("git"))
def _require_git():
if not _HAS_GIT:
raise HTTPException(503, "git is not installed")
if not _IS_GIT:
raise HTTPException(503, "Atlus was not installed via git — updates unavailable")
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
async def _git(*args: str, timeout: float = 30) -> str:
"""Run a git command in the Atlus install directory."""
_require_git()
cmd = ["git", "-C", str(BASE_DIR)] + list(args)
proc = await asyncio.create_subprocess_exec(
*cmd,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
env={**os.environ, "LC_ALL": "C"},
)
try:
stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=timeout)
except asyncio.TimeoutError:
proc.kill()
await proc.wait()
raise HTTPException(504, "git operation timed out")
if proc.returncode != 0:
msg = stderr.decode().strip() or stdout.decode().strip()
raise HTTPException(500, f"git error: {msg}")
return stdout.decode().strip()
async def _git_nofail(*args: str, timeout: float = 30) -> tuple[int, str]:
"""Run a git command, return (returncode, stdout) without raising."""
_require_git()
cmd = ["git", "-C", str(BASE_DIR)] + list(args)
proc = await asyncio.create_subprocess_exec(
*cmd,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
env={**os.environ, "LC_ALL": "C"},
)
try:
stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=timeout)
except asyncio.TimeoutError:
proc.kill()
await proc.wait()
return 1, ""
return proc.returncode, stdout.decode().strip()
# ---------------------------------------------------------------------------
# Endpoints
# ---------------------------------------------------------------------------
@router.get("/check")
async def check_for_updates(_user: str = Depends(get_current_user)):
"""Check if there are new commits on the remote."""
# Get current local HEAD
local_hash = await _git("rev-parse", "HEAD")
# Fetch latest from remote (may take a few seconds)
try:
await _git("fetch", "origin", timeout=30)
except HTTPException:
# Fetch failed (no network, etc.) — report no update
return {
"available": False,
"local_hash": local_hash[:8],
"remote_hash": local_hash[:8],
"behind_count": 0,
"error": "Could not reach remote repository",
}
# Get remote HEAD
rc, remote_hash = await _git_nofail("rev-parse", "origin/main")
if rc != 0:
# Try origin/master as fallback
rc, remote_hash = await _git_nofail("rev-parse", "origin/master")
if rc != 0:
return {
"available": False,
"local_hash": local_hash[:8],
"remote_hash": "",
"behind_count": 0,
"error": "Could not determine remote branch",
}
# Count commits behind
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
return {
"available": behind_count > 0,
"local_hash": local_hash[:8],
"remote_hash": remote_hash[:8],
"behind_count": behind_count,
}
@router.post("/apply")
async def apply_update(_user: str = Depends(get_current_user)):
"""Pull latest changes and schedule a service restart."""
# Pull latest
pull_output = await _git("pull", "--ff-only", timeout=60)
# Reinstall Python dependencies
pip_bin = str(BASE_DIR / "venv" / "bin" / "pip")
if not os.path.exists(pip_bin):
# Fallback: try system pip
pip_bin = shutil.which("pip3") or shutil.which("pip") or "pip3"
req_file = str(BASE_DIR / "backend" / "requirements.txt")
if os.path.exists(req_file):
proc = await asyncio.create_subprocess_exec(
pip_bin, "install", "-r", req_file, "-q",
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
try:
await asyncio.wait_for(proc.communicate(), timeout=120)
except asyncio.TimeoutError:
pass # Non-fatal — deps might already be satisfied
# Schedule restart after response is sent
if shutil.which("systemctl"):
asyncio.create_task(_delayed_restart())
return {
"success": True,
"message": "Update applied. Restarting service...",
"pull_output": pull_output[-500:],
}
async def _delayed_restart(delay: float = 2.0):
"""Wait briefly then restart the atlus systemd service."""
await asyncio.sleep(delay)
proc = await asyncio.create_subprocess_exec(
"systemctl", "restart", "atlus",
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
await proc.wait()