mirror of
https://github.com/0x5t4l1n/AURHub.git
synced 2026-05-26 19:26:35 +00:00
Initial commit: ArchStore package manager for Arch Linux
This commit is contained in:
@@ -0,0 +1 @@
|
||||
# services package
|
||||
@@ -0,0 +1,164 @@
|
||||
"""
|
||||
AUR RPC API service for ArchStore.
|
||||
Handles all interactions with the Arch User Repository via the official RPC API
|
||||
and yay for installations.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import httpx
|
||||
from typing import Optional
|
||||
from utils.sanitize import sanitize_package_name, sanitize_search_query
|
||||
|
||||
AUR_RPC_BASE = "https://aur.archlinux.org/rpc/v5"
|
||||
AUR_PACKAGE_URL = "https://aur.archlinux.org/packages"
|
||||
|
||||
|
||||
async def _run_command(cmd: list[str], timeout: int = 30) -> tuple[str, str, int]:
|
||||
"""Run a shell command safely."""
|
||||
try:
|
||||
proc = await asyncio.create_subprocess_exec(
|
||||
*cmd,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
)
|
||||
stdout, stderr = await asyncio.wait_for(
|
||||
proc.communicate(), timeout=timeout
|
||||
)
|
||||
return (
|
||||
stdout.decode("utf-8", errors="replace"),
|
||||
stderr.decode("utf-8", errors="replace"),
|
||||
proc.returncode,
|
||||
)
|
||||
except asyncio.TimeoutError:
|
||||
proc.kill()
|
||||
return "", "Command timed out", -1
|
||||
except Exception as e:
|
||||
return "", str(e), -1
|
||||
|
||||
|
||||
async def search_packages(query: str) -> list[dict]:
|
||||
"""Search AUR packages using the RPC API."""
|
||||
query = sanitize_search_query(query)
|
||||
|
||||
async with httpx.AsyncClient(timeout=15) as client:
|
||||
try:
|
||||
response = await client.get(
|
||||
f"{AUR_RPC_BASE}/search/{query}",
|
||||
params={"by": "name-desc"},
|
||||
)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
|
||||
if data.get("type") != "search":
|
||||
return []
|
||||
|
||||
packages = []
|
||||
for pkg in data.get("results", []):
|
||||
packages.append(_normalize_aur_package(pkg))
|
||||
|
||||
# Sort by popularity descending
|
||||
packages.sort(key=lambda p: p.get("popularity", 0), reverse=True)
|
||||
return packages[:100] # Limit results
|
||||
|
||||
except (httpx.HTTPError, Exception):
|
||||
return []
|
||||
|
||||
|
||||
async def get_package_info(name: str) -> Optional[dict]:
|
||||
"""Get detailed info about an AUR package."""
|
||||
name = sanitize_package_name(name)
|
||||
|
||||
async with httpx.AsyncClient(timeout=10) as client:
|
||||
try:
|
||||
response = await client.get(
|
||||
f"{AUR_RPC_BASE}/info",
|
||||
params={"arg[]": name},
|
||||
)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
|
||||
results = data.get("results", [])
|
||||
if not results:
|
||||
return None
|
||||
|
||||
pkg = _normalize_aur_package(results[0])
|
||||
|
||||
# Check if installed
|
||||
_, _, code = await _run_command(["pacman", "-Q", name], timeout=5)
|
||||
pkg["installed"] = code == 0
|
||||
|
||||
return pkg
|
||||
|
||||
except (httpx.HTTPError, Exception):
|
||||
return None
|
||||
|
||||
|
||||
async def install_package(name: str) -> dict:
|
||||
"""Install an AUR package using yay."""
|
||||
name = sanitize_package_name(name)
|
||||
stdout, stderr, code = await _run_command(
|
||||
["yay", "-S", "--noconfirm", name], timeout=600
|
||||
)
|
||||
return {
|
||||
"success": code == 0,
|
||||
"message": stdout if code == 0 else stderr,
|
||||
"package": name,
|
||||
}
|
||||
|
||||
|
||||
async def check_updates() -> list[dict]:
|
||||
"""Check for AUR package updates."""
|
||||
stdout, _, code = await _run_command(
|
||||
["yay", "-Qua"], timeout=30
|
||||
)
|
||||
if code != 0:
|
||||
return []
|
||||
|
||||
updates = []
|
||||
for line in stdout.strip().split("\n"):
|
||||
if line.strip():
|
||||
parts = line.split()
|
||||
if len(parts) >= 4:
|
||||
updates.append({
|
||||
"name": parts[0],
|
||||
"current_version": parts[1],
|
||||
"new_version": parts[3],
|
||||
"source": "aur",
|
||||
})
|
||||
return updates
|
||||
|
||||
|
||||
async def get_pkgbuild(name: str) -> Optional[str]:
|
||||
"""Fetch the PKGBUILD content for an AUR package."""
|
||||
name = sanitize_package_name(name)
|
||||
|
||||
async with httpx.AsyncClient(timeout=15) as client:
|
||||
try:
|
||||
url = f"https://aur.archlinux.org/cgit/aur.git/plain/PKGBUILD?h={name}"
|
||||
response = await client.get(url)
|
||||
if response.status_code == 200:
|
||||
return response.text
|
||||
return None
|
||||
except httpx.HTTPError:
|
||||
return None
|
||||
|
||||
|
||||
def _normalize_aur_package(pkg: dict) -> dict:
|
||||
"""Convert AUR RPC response to our standard package format."""
|
||||
return {
|
||||
"name": pkg.get("Name", ""),
|
||||
"version": pkg.get("Version", ""),
|
||||
"description": pkg.get("Description", ""),
|
||||
"maintainer": pkg.get("Maintainer", "Orphaned"),
|
||||
"url": pkg.get("URL", ""),
|
||||
"votes": pkg.get("NumVotes", 0),
|
||||
"popularity": pkg.get("Popularity", 0),
|
||||
"out_of_date": pkg.get("OutOfDate") is not None,
|
||||
"first_submitted": pkg.get("FirstSubmitted", 0),
|
||||
"last_modified": pkg.get("LastModified", 0),
|
||||
"source": "aur",
|
||||
"repository": "aur",
|
||||
"aur_url": f"{AUR_PACKAGE_URL}/{pkg.get('Name', '')}",
|
||||
"package_base": pkg.get("PackageBase", ""),
|
||||
"installed": False,
|
||||
}
|
||||
@@ -0,0 +1,228 @@
|
||||
"""
|
||||
Unified package service for ArchStore.
|
||||
Merges pacman and AUR results, handles deduplication and ranking.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
from typing import Optional
|
||||
from services import pacman_service, aur_service
|
||||
from database.db import db
|
||||
from utils.sanitize import sanitize_search_query, sanitize_package_name, validate_source_filter
|
||||
|
||||
|
||||
# Category definitions — maps friendly names to pacman groups
|
||||
CATEGORIES = {
|
||||
"Development": {
|
||||
"icon": "code",
|
||||
"description": "Programming tools, compilers, and IDEs",
|
||||
"groups": ["base-devel"],
|
||||
"keywords": ["gcc", "git", "python", "nodejs", "rust", "go", "vim", "neovim", "code"],
|
||||
},
|
||||
"System": {
|
||||
"icon": "monitor",
|
||||
"description": "Core system utilities and tools",
|
||||
"groups": ["base", "sys-utils"],
|
||||
"keywords": ["systemd", "kernel", "grub", "filesystem", "coreutils"],
|
||||
},
|
||||
"Network": {
|
||||
"icon": "wifi",
|
||||
"description": "Networking tools, browsers, and servers",
|
||||
"groups": ["network"],
|
||||
"keywords": ["firefox", "chromium", "curl", "wget", "nginx", "ssh"],
|
||||
},
|
||||
"Multimedia": {
|
||||
"icon": "music",
|
||||
"description": "Audio, video, and image tools",
|
||||
"groups": ["multimedia"],
|
||||
"keywords": ["vlc", "mpv", "ffmpeg", "gimp", "audacity", "obs"],
|
||||
},
|
||||
"Games": {
|
||||
"icon": "gamepad-2",
|
||||
"description": "Games and gaming tools",
|
||||
"groups": ["games"],
|
||||
"keywords": ["steam", "lutris", "wine", "gamemode"],
|
||||
},
|
||||
"Desktop": {
|
||||
"icon": "layout-dashboard",
|
||||
"description": "Desktop environments and window managers",
|
||||
"groups": ["gnome", "kde-applications", "xfce4"],
|
||||
"keywords": ["gnome", "kde", "xfce", "i3", "sway", "hyprland"],
|
||||
},
|
||||
"Fonts": {
|
||||
"icon": "type",
|
||||
"description": "Fonts and typography",
|
||||
"groups": ["fonts"],
|
||||
"keywords": ["ttf", "otf", "nerd-fonts", "noto"],
|
||||
},
|
||||
"Security": {
|
||||
"icon": "shield",
|
||||
"description": "Security and privacy tools",
|
||||
"groups": [],
|
||||
"keywords": ["firewall", "gpg", "openssl", "wireguard", "tor"],
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
async def search_packages(query: str, source: str = "all") -> list[dict]:
|
||||
"""
|
||||
Search packages from pacman, AUR, or both.
|
||||
Results are deduplicated, merged, and ranked.
|
||||
"""
|
||||
query = sanitize_search_query(query)
|
||||
source = validate_source_filter(source)
|
||||
|
||||
# Check cache first
|
||||
cached = await db.get_cached_search(query, source)
|
||||
if cached:
|
||||
return cached
|
||||
|
||||
results = []
|
||||
|
||||
if source in ("all", "pacman"):
|
||||
pacman_task = pacman_service.search_packages(query)
|
||||
else:
|
||||
pacman_task = asyncio.coroutine(lambda: [])()
|
||||
|
||||
if source in ("all", "aur"):
|
||||
aur_task = aur_service.search_packages(query)
|
||||
else:
|
||||
aur_task = asyncio.coroutine(lambda: [])()
|
||||
|
||||
# Run both searches concurrently
|
||||
if source == "all":
|
||||
pacman_results, aur_results = await asyncio.gather(
|
||||
pacman_service.search_packages(query),
|
||||
aur_service.search_packages(query),
|
||||
return_exceptions=True,
|
||||
)
|
||||
if isinstance(pacman_results, Exception):
|
||||
pacman_results = []
|
||||
if isinstance(aur_results, Exception):
|
||||
aur_results = []
|
||||
results = _merge_results(pacman_results, aur_results)
|
||||
elif source == "pacman":
|
||||
results = await pacman_service.search_packages(query)
|
||||
elif source == "aur":
|
||||
results = await aur_service.search_packages(query)
|
||||
|
||||
# Cache results
|
||||
await db.set_cached_search(query, source, results)
|
||||
|
||||
return results
|
||||
|
||||
|
||||
async def get_package_info(name: str) -> Optional[dict]:
|
||||
"""Get detailed package info, trying pacman first then AUR."""
|
||||
name = sanitize_package_name(name)
|
||||
|
||||
# Check cache
|
||||
cached = await db.get_cached_package(name)
|
||||
if cached:
|
||||
return cached
|
||||
|
||||
# Try pacman first
|
||||
info = await pacman_service.get_package_info(name)
|
||||
if not info:
|
||||
# Try AUR
|
||||
info = await aur_service.get_package_info(name)
|
||||
if not info:
|
||||
# Check if installed locally
|
||||
info = await pacman_service.get_installed_info(name)
|
||||
|
||||
if info:
|
||||
await db.set_cached_package(name, info)
|
||||
|
||||
return info
|
||||
|
||||
|
||||
async def install_package(name: str) -> dict:
|
||||
"""Install a package, auto-detecting source."""
|
||||
name = sanitize_package_name(name)
|
||||
|
||||
# Try pacman first
|
||||
info = await pacman_service.get_package_info(name)
|
||||
if info:
|
||||
return await pacman_service.install_package(name)
|
||||
|
||||
# Fall back to AUR via yay
|
||||
return await aur_service.install_package(name)
|
||||
|
||||
|
||||
async def remove_package(name: str) -> dict:
|
||||
"""Remove an installed package."""
|
||||
name = sanitize_package_name(name)
|
||||
return await pacman_service.remove_package(name)
|
||||
|
||||
|
||||
async def list_installed() -> list[dict]:
|
||||
"""List all installed packages."""
|
||||
return await pacman_service.list_installed()
|
||||
|
||||
|
||||
async def check_updates() -> list[dict]:
|
||||
"""Check for all available updates (pacman + AUR)."""
|
||||
pacman_updates, aur_updates = await asyncio.gather(
|
||||
pacman_service.check_updates(),
|
||||
aur_service.check_updates(),
|
||||
return_exceptions=True,
|
||||
)
|
||||
if isinstance(pacman_updates, Exception):
|
||||
pacman_updates = []
|
||||
if isinstance(aur_updates, Exception):
|
||||
aur_updates = []
|
||||
|
||||
return pacman_updates + aur_updates
|
||||
|
||||
|
||||
async def get_categories() -> list[dict]:
|
||||
"""Get list of package categories."""
|
||||
return [
|
||||
{"name": name, **data}
|
||||
for name, data in CATEGORIES.items()
|
||||
]
|
||||
|
||||
|
||||
async def get_category_packages(category: str) -> list[dict]:
|
||||
"""Get packages for a specific category using search."""
|
||||
cat = CATEGORIES.get(category)
|
||||
if not cat:
|
||||
return []
|
||||
|
||||
# Search using category keywords
|
||||
all_results = []
|
||||
for keyword in cat.get("keywords", [])[:5]:
|
||||
try:
|
||||
results = await search_packages(keyword, "all")
|
||||
all_results.extend(results)
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
# Deduplicate by name
|
||||
seen = set()
|
||||
unique = []
|
||||
for pkg in all_results:
|
||||
if pkg["name"] not in seen:
|
||||
seen.add(pkg["name"])
|
||||
unique.append(pkg)
|
||||
|
||||
return unique[:50]
|
||||
|
||||
|
||||
def _merge_results(pacman: list[dict], aur: list[dict]) -> list[dict]:
|
||||
"""Merge and deduplicate pacman + AUR results."""
|
||||
seen = {}
|
||||
|
||||
# Pacman results take priority
|
||||
for pkg in pacman:
|
||||
seen[pkg["name"]] = pkg
|
||||
|
||||
# Add AUR results if not already from pacman
|
||||
for pkg in aur:
|
||||
if pkg["name"] not in seen:
|
||||
seen[pkg["name"]] = pkg
|
||||
|
||||
# Sort: installed first, then by name
|
||||
results = list(seen.values())
|
||||
results.sort(key=lambda p: (not p.get("installed", False), p.get("name", "")))
|
||||
|
||||
return results
|
||||
@@ -0,0 +1,246 @@
|
||||
"""
|
||||
Pacman service for ArchStore.
|
||||
Handles all interactions with the pacman package manager via subprocess.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import re
|
||||
from typing import Optional
|
||||
from utils.sanitize import sanitize_package_name
|
||||
|
||||
|
||||
async def _run_command(cmd: list[str], timeout: int = 30) -> tuple[str, str, int]:
|
||||
"""
|
||||
Run a shell command safely using argument list (no shell=True).
|
||||
Returns (stdout, stderr, returncode).
|
||||
"""
|
||||
try:
|
||||
proc = await asyncio.create_subprocess_exec(
|
||||
*cmd,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
)
|
||||
stdout, stderr = await asyncio.wait_for(
|
||||
proc.communicate(), timeout=timeout
|
||||
)
|
||||
return (
|
||||
stdout.decode("utf-8", errors="replace"),
|
||||
stderr.decode("utf-8", errors="replace"),
|
||||
proc.returncode,
|
||||
)
|
||||
except asyncio.TimeoutError:
|
||||
proc.kill()
|
||||
return "", "Command timed out", -1
|
||||
except Exception as e:
|
||||
return "", str(e), -1
|
||||
|
||||
|
||||
def _parse_search_results(output: str) -> list[dict]:
|
||||
"""Parse pacman -Ss output into structured results."""
|
||||
packages = []
|
||||
lines = output.strip().split("\n")
|
||||
i = 0
|
||||
while i < len(lines):
|
||||
line = lines[i]
|
||||
# Match: repo/name version [installed] or repo/name version
|
||||
match = re.match(
|
||||
r'^(\S+)/(\S+)\s+(\S+)(?:\s+\[installed(?::?\s*(\S+))?\])?\s*$',
|
||||
line
|
||||
)
|
||||
if match:
|
||||
repo = match.group(1)
|
||||
name = match.group(2)
|
||||
version = match.group(3)
|
||||
installed = match.group(4) is not None or "[installed" in line
|
||||
description = ""
|
||||
if i + 1 < len(lines) and lines[i + 1].startswith(" "):
|
||||
description = lines[i + 1].strip()
|
||||
i += 1
|
||||
packages.append({
|
||||
"name": name,
|
||||
"version": version,
|
||||
"description": description,
|
||||
"repository": repo,
|
||||
"source": "pacman",
|
||||
"installed": installed,
|
||||
})
|
||||
i += 1
|
||||
return packages
|
||||
|
||||
|
||||
def _parse_package_info(output: str) -> dict:
|
||||
"""Parse pacman -Si or -Qi output into a dict."""
|
||||
info = {}
|
||||
current_key = None
|
||||
current_value = []
|
||||
|
||||
for line in output.split("\n"):
|
||||
if ":" in line and not line.startswith(" "):
|
||||
if current_key:
|
||||
info[current_key] = " ".join(current_value).strip()
|
||||
parts = line.split(":", 1)
|
||||
current_key = parts[0].strip().lower().replace(" ", "_")
|
||||
current_value = [parts[1].strip()] if len(parts) > 1 else []
|
||||
elif line.startswith(" ") and current_key:
|
||||
current_value.append(line.strip())
|
||||
|
||||
if current_key:
|
||||
info[current_key] = " ".join(current_value).strip()
|
||||
|
||||
return info
|
||||
|
||||
|
||||
async def search_packages(query: str) -> list[dict]:
|
||||
"""Search pacman repositories."""
|
||||
stdout, stderr, code = await _run_command(
|
||||
["pacman", "-Ss", query], timeout=15
|
||||
)
|
||||
if code != 0:
|
||||
return []
|
||||
return _parse_search_results(stdout)
|
||||
|
||||
|
||||
async def get_package_info(name: str) -> Optional[dict]:
|
||||
"""Get detailed info about a package from sync db."""
|
||||
name = sanitize_package_name(name)
|
||||
|
||||
# Try sync database first
|
||||
stdout, stderr, code = await _run_command(
|
||||
["pacman", "-Si", name], timeout=10
|
||||
)
|
||||
if code == 0:
|
||||
info = _parse_package_info(stdout)
|
||||
info["source"] = "pacman"
|
||||
info["installed"] = await is_installed(name)
|
||||
return info
|
||||
|
||||
return None
|
||||
|
||||
|
||||
async def get_installed_info(name: str) -> Optional[dict]:
|
||||
"""Get info about an installed package."""
|
||||
name = sanitize_package_name(name)
|
||||
stdout, stderr, code = await _run_command(
|
||||
["pacman", "-Qi", name], timeout=10
|
||||
)
|
||||
if code == 0:
|
||||
info = _parse_package_info(stdout)
|
||||
info["source"] = "pacman"
|
||||
info["installed"] = True
|
||||
return info
|
||||
return None
|
||||
|
||||
|
||||
async def is_installed(name: str) -> bool:
|
||||
"""Check if a package is installed."""
|
||||
name = sanitize_package_name(name)
|
||||
_, _, code = await _run_command(["pacman", "-Q", name], timeout=5)
|
||||
return code == 0
|
||||
|
||||
|
||||
async def list_installed() -> list[dict]:
|
||||
"""List all explicitly installed packages."""
|
||||
stdout, _, code = await _run_command(
|
||||
["pacman", "-Qe"], timeout=15
|
||||
)
|
||||
if code != 0:
|
||||
return []
|
||||
|
||||
packages = []
|
||||
for line in stdout.strip().split("\n"):
|
||||
if line.strip():
|
||||
parts = line.split()
|
||||
if len(parts) >= 2:
|
||||
packages.append({
|
||||
"name": parts[0],
|
||||
"version": parts[1],
|
||||
"source": "pacman",
|
||||
"installed": True,
|
||||
})
|
||||
return packages
|
||||
|
||||
|
||||
async def check_updates() -> list[dict]:
|
||||
"""Check for available updates using checkupdates."""
|
||||
stdout, _, code = await _run_command(
|
||||
["checkupdates"], timeout=60
|
||||
)
|
||||
# checkupdates returns 2 if no updates, 0 if updates available
|
||||
if code not in (0,):
|
||||
return []
|
||||
|
||||
updates = []
|
||||
for line in stdout.strip().split("\n"):
|
||||
if line.strip():
|
||||
parts = line.split()
|
||||
if len(parts) >= 4:
|
||||
updates.append({
|
||||
"name": parts[0],
|
||||
"current_version": parts[1],
|
||||
"new_version": parts[3],
|
||||
"source": "pacman",
|
||||
})
|
||||
return updates
|
||||
|
||||
|
||||
async def install_package(name: str) -> dict:
|
||||
"""Install a package using pacman (requires pkexec)."""
|
||||
name = sanitize_package_name(name)
|
||||
stdout, stderr, code = await _run_command(
|
||||
["pkexec", "pacman", "-S", "--noconfirm", name], timeout=300
|
||||
)
|
||||
return {
|
||||
"success": code == 0,
|
||||
"message": stdout if code == 0 else stderr,
|
||||
"package": name,
|
||||
}
|
||||
|
||||
|
||||
async def remove_package(name: str) -> dict:
|
||||
"""Remove a package using pacman (requires pkexec)."""
|
||||
name = sanitize_package_name(name)
|
||||
stdout, stderr, code = await _run_command(
|
||||
["pkexec", "pacman", "-R", "--noconfirm", name], timeout=120
|
||||
)
|
||||
return {
|
||||
"success": code == 0,
|
||||
"message": stdout if code == 0 else stderr,
|
||||
"package": name,
|
||||
}
|
||||
|
||||
|
||||
async def get_package_groups() -> list[str]:
|
||||
"""Get list of all package groups."""
|
||||
stdout, _, code = await _run_command(
|
||||
["pacman", "-Sg"], timeout=10
|
||||
)
|
||||
if code != 0:
|
||||
return []
|
||||
|
||||
groups = set()
|
||||
for line in stdout.strip().split("\n"):
|
||||
if line.strip():
|
||||
groups.add(line.strip().split()[0])
|
||||
return sorted(groups)
|
||||
|
||||
|
||||
async def get_group_packages(group: str) -> list[dict]:
|
||||
"""Get packages in a specific group."""
|
||||
stdout, _, code = await _run_command(
|
||||
["pacman", "-Sg", group], timeout=10
|
||||
)
|
||||
if code != 0:
|
||||
return []
|
||||
|
||||
packages = []
|
||||
for line in stdout.strip().split("\n"):
|
||||
parts = line.strip().split()
|
||||
if len(parts) >= 2:
|
||||
is_inst = await is_installed(parts[1])
|
||||
packages.append({
|
||||
"name": parts[1],
|
||||
"source": "pacman",
|
||||
"installed": is_inst,
|
||||
"group": parts[0],
|
||||
})
|
||||
return packages
|
||||
Reference in New Issue
Block a user