Initial commit: ArchStore package manager for Arch Linux

This commit is contained in:
2026-05-21 02:42:03 +05:30
commit 027847fbac
51 changed files with 6993 additions and 0 deletions
+1
View File
@@ -0,0 +1 @@
# services package
+164
View File
@@ -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,
}
+228
View File
@@ -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
+246
View File
@@ -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