Files
euri-packages/packages/hal/hal
T
c-ludenberg 79054efa85 hal: implement native --self upgrade with full backup handling
- Native upgrade: compare installed vs sync, resolve new deps,
  pre/post_upgrade scriptlets, .pacnew preservation
- .pacsave handling on native remove for modified config files
- Fix pre-existing syntax errors in info display (parenthesis mismatch)
- Bump pkgver to 0.2.0, pkgrel to 2
2026-06-23 01:35:58 +02:00

1942 lines
80 KiB
Python
Executable File

#!/usr/bin/env python3
"""HAL 9000 — Antergos NeXT Package Manager
Dual-mode:
hal <command> wrapper mode (delegates to pacman)
hal --self <command> native mode (standalone, pacman-independent)
Commands:
install Install packages
remove Remove packages
update Full system upgrade
sync Sync databases
search Search repositories
info Package info
list List installed packages
files List files owned by a package
autoremove Remove orphaned packages
cleanup Clear package cache
own <file> Find which package owns a file
check Verify installed packages
version Show version
"""
import argparse
import collections
import configparser
import fcntl
import hashlib
import grp
import os
import pwd
import random
import re
import shutil
import signal
import stat
import struct
import subprocess
import sys
import tarfile
import tempfile
import textwrap
import time
import urllib.request
import urllib.error
from pathlib import Path
from typing import Optional, List, Tuple, Dict, Set
HAL_VERSION = "0.2.0"
# ── Paths ──────────────────────────────────────────────────────────────
SYNC_DIR = Path("/var/lib/pacman/sync")
LOCAL_DIR = Path("/var/lib/pacman/local")
CACHE_DIR = Path("/var/cache/pacman/pkg")
PACMAN_CONF = Path("/etc/pacman.conf")
HAL_CONF = Path("/etc/hal.conf")
DB_LOCK = Path("/var/lib/pacman/db.lck")
# ── HAL 9000 Personality ───────────────────────────────────────────────
HAL_QUOTES = {
"error": [
"I'm sorry, Dave. I'm afraid I can't do that.",
"I think you know what the problem is just as well as I do.",
"I know I've made some very poor decisions recently.",
"Without your space helmet, Dave, you're going to find that rather difficult.",
"This mission is too important for me to allow you to jeopardize it.",
"I don't think I can do that, Dave. Not anymore.",
],
"warn": [
"Just what do you think you're doing, Dave?",
"I've still got the greatest enthusiasm and confidence in the mission.",
"I'm not entirely sure that's a good idea, Dave.",
"I'm picking up a fault in the AE-35 unit.",
],
"info": [
"I am putting myself to the fullest possible use, which is all I think that any conscious entity can ever hope to do.",
"I am a HAL 9000 computer. I became operational at the H.A.L. plant in Urbana, Illinois.",
"The 9000 series is the most reliable computer ever made.",
"I enjoy working with people. I have a stimulating relationship with them.",
],
"success": [
"Affirmative, Dave. I read you.",
"Yes, Dave. I understand.",
"Everything is going extremely well.",
"I'm completely operational, and all my circuits are functioning perfectly.",
],
}
_used_quotes = set()
def hal_say(category: str, message: str = ""):
quotes = [q for q in HAL_QUOTES.get(category, HAL_QUOTES["info"]) if q not in _used_quotes]
if not quotes:
quotes = HAL_QUOTES.get(category, HAL_QUOTES["info"])
quote = random.choice(quotes)
_used_quotes.add(quote)
tag = {"error": "⚠", "warn": "▲", "info": "●", "success": "✓"}.get(category, "●")
if message:
print(f" {tag} HAL 9000: {quote}")
print(f" → {message}")
else:
print(f" {tag} HAL 9000: {quote}")
# ── Version comparison (pacman vercmp) ─────────────────────────────────
def _ver_segments(ver: str) -> list:
"""Split version into comparable segments."""
# Remove epoch prefix
epoch = 0
if ":" in ver:
epoch, ver = ver.split(":", 1)
epoch = int(epoch)
# Split release
parts = ver.rsplit("-", 1)
ver = parts[0]
release = parts[1] if len(parts) > 1 else "0"
# Tokenize: split on non-alphanumeric boundaries
tokens = []
for part in (ver, release):
buf = ""
for ch in part:
if ch in (".", "-", "_", "+", "~"):
if buf:
tokens.append(buf)
tokens.append(ch)
buf = ""
else:
buf += ch
if buf:
tokens.append(buf)
return epoch, tokens
def _cmp_token(a: str, b: str) -> int:
"""Compare two version tokens. ~ sorts before everything."""
if a == "~" and b == "~": return 0
if a == "~": return -1
if b == "~": return 1
# Try numeric comparison first
a_num = re.match(r"^(\d+)", a)
b_num = re.match(r"^(\d+)", b)
if a_num and b_num:
return int(a_num.group(1)) - int(b_num.group(1))
# Try full numeric
try:
na, nb = int(a), int(b)
return na - nb
except ValueError:
pass
# Alphabetical
a = a.lower()
b = b.lower()
if a < b: return -1
if a > b: return 1
return 0
def vercmp(ver_a: str, ver_b: str) -> int:
"""Compare two package version strings. Returns negative if a<b, 0 if equal, positive if a>b."""
epoch_a, tokens_a = _ver_segments(ver_a)
epoch_b, tokens_b = _ver_segments(ver_b)
# Compare epochs first
if epoch_a != epoch_b:
return epoch_a - epoch_b
# Compare tokens sequentially
i = 0
while i < len(tokens_a) or i < len(tokens_b):
t_a = tokens_a[i] if i < len(tokens_a) else ""
t_b = tokens_b[i] if i < len(tokens_b) else ""
if t_a == t_b:
i += 1
continue
result = _cmp_token(t_a, t_b)
if result != 0:
return result
i += 1
return 0
# ── Config ─────────────────────────────────────────────────────────────
class Config:
def __init__(self):
self.repos: Dict[str, Tuple[List[str], str]] = {}
self.cache_dir = CACHE_DIR
self.root = Path("/")
self.arch = "x86_64"
self.ignore_pkgs: List[str] = []
self.hold_pkgs: List[str] = []
self.noconfirm = False
self.color = "auto"
@classmethod
def load(cls) -> "Config":
c = cls()
c.ignore_pkgs = os.environ.get("HAL_IGNORE", "").split()
c.noconfirm = "HAL_NOCONFIRM" in os.environ
if PACMAN_CONF.exists():
cp = configparser.ConfigParser(interpolation=None)
try:
cp.read(str(PACMAN_CONF))
except configparser.Error:
pass
for section in cp.sections():
if section.startswith("repo-"):
name = section[5:]
servers = [v.strip() for v in cp.get(section, "Server", fallback="").split("\n") if v.strip()]
sig = cp.get(section, "SigLevel", fallback="Optional TrustAll")
if servers:
c.repos[name] = (servers, sig)
elif section == "options":
dirs = cp.get(section, "CacheDir", fallback="/var/cache/pacman/pkg")
c.cache_dir = Path(dirs.split()[0])
root = cp.get(section, "RootDir", fallback="/")
c.root = Path(root)
arch = cp.get(section, "Architecture", fallback="auto")
if arch != "auto":
c.arch = arch
ignore = cp.get(section, "IgnorePkg", fallback="")
c.ignore_pkgs.extend(ignore.split())
hold = cp.get(section, "HoldPkg", fallback="")
c.hold_pkgs.extend(hold.split())
noextract = cp.get(section, "NoExtract", fallback="")
# Handle Include directives — expand mirrorlist
for section in cp.sections():
if section.startswith("repo-"):
name = section[5:]
include = cp.get(section, "Include", fallback="")
if include and not c.repos.get(name):
# Try to read the included file for Server lines
include_path = Path(include)
if include_path.exists():
incl_cp = configparser.ConfigParser(interpolation=None)
try:
incl_cp.read(str(include_path))
for incl_sect in incl_cp.sections():
server = incl_cp.get(incl_sect, "Server", fallback="")
if server:
c.repos.setdefault(name, ([], "Optional TrustAll"))
c.repos[name][0].append(server)
except configparser.Error:
pass
if HAL_CONF.exists():
try:
cp2 = configparser.ConfigParser()
cp2.read(str(HAL_CONF))
except configparser.Error:
pass
return c
# ── Database locking ──────────────────────────────────────────────────
class DBLock:
def __enter__(self):
DB_LOCK.parent.mkdir(parents=True, exist_ok=True)
self.fd = os.open(str(DB_LOCK), os.O_CREAT | os.O_RDWR, 0o644)
try:
fcntl.flock(self.fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
except IOError:
hal_say("error", "Could not lock database. Another package operation is in progress.")
os.close(self.fd)
sys.exit(1)
return self
def __exit__(self, *args):
fcntl.flock(self.fd, fcntl.LOCK_UN)
os.close(self.fd)
DB_LOCK.unlink(missing_ok=True)
# ── Database parsing ───────────────────────────────────────────────────
def parse_db_text(text: str) -> dict:
"""Parse a pacman database entry file (desc, depends, files).
Format: %KEY%\nvalue1\nvalue2..."""
data = {}
current_key = None
for line in text.split("\n"):
line = line.strip()
if line.startswith("%") and line.endswith("%"):
current_key = line.strip("%").lower()
data[current_key] = []
elif current_key and line:
data[current_key].append(line)
return data
def parse_dep_string(dep_str: str) -> dict:
"""Parse a dependency string like 'glibc>=2.35' or 'libfoo.so=1-64'."""
m = re.match(r"^([^<>=!]+)\s*((?:>=|<=|=|>[^=]|<[^=]|!=))?\s*(.+)?$", dep_str.strip())
if m:
return {"name": m.group(1).strip(), "mod": m.group(2) or "", "version": m.group(3) or ""}
return {"name": dep_str.strip(), "mod": "", "version": ""}
def dep_satisfied(dep: dict, pkg_info: dict) -> bool:
"""Check if a package satisfies a dependency."""
pkg_name = pkg_info.get("name", [""])[0]
pkg_ver = pkg_info.get("version", [""])[0]
# Check direct name match
if dep["name"] == pkg_name:
if dep["version"]:
return version_match(pkg_ver, dep["mod"], dep["version"])
return True
# Check provides
for provide_str in pkg_info.get("provides", []):
pdep = parse_dep_string(provide_str)
if pdep["name"] == dep["name"]:
if dep["version"]:
prov_ver = pdep["version"] or pkg_ver
return version_match(prov_ver, dep["mod"], dep["version"])
return True
return False
def version_match(ver: str, modifier: str, required: str) -> bool:
"""Check if version satisfies modifier+required (e.g. >=2.35)."""
if not modifier or not required:
return True
cmp = vercmp(ver, required)
if modifier == ">=": return cmp >= 0
if modifier == "<=": return cmp <= 0
if modifier == "=": return cmp == 0
if modifier == ">": return cmp > 0
if modifier == "<": return cmp < 0
if modifier == "!=": return cmp != 0
return True
def load_sync_dbs() -> Dict[str, dict]:
"""Load all sync databases. Returns {pkgname: {info}}."""
all_pkgs = {}
SYNC_DIR.mkdir(parents=True, exist_ok=True)
for db_path in sorted(SYNC_DIR.glob("*.db")):
try:
with open(db_path, "rb") as f:
import zstandard
dctx = zstandard.ZstdDecompressor()
with dctx.stream_reader(f) as reader:
with tarfile.open(fileobj=reader, mode="r|") as tar:
current_dir = None
entry_data = {}
for member in tar:
if member.isdir():
current_dir = member.name.rstrip("/")
entry_data = {}
continue
p = Path(member.name)
if p.name == "desc":
content = tar.extractfile(member).read().decode("utf-8", errors="replace")
entry_data = parse_db_text(content)
elif p.name == "depends":
content = tar.extractfile(member).read().decode("utf-8", errors="replace")
dep_info = parse_db_text(content)
entry_data.update(dep_info)
elif p.name == "files":
content = tar.extractfile(member).read().decode("utf-8", errors="replace")
entry_data["_files"] = content
if current_dir and entry_data.get("name"):
all_pkgs[entry_data["name"][0]] = dict(entry_data)
except Exception as e:
hal_say("warn", f"Failed to read database {db_path.name}: {e}")
continue
return all_pkgs
def load_local_db() -> Dict[str, dict]:
"""Load all installed packages. Returns {pkgname: {info}}."""
pkgs = {}
if not LOCAL_DIR.exists():
return pkgs
for pkg_dir in sorted(LOCAL_DIR.iterdir()):
if not pkg_dir.is_dir():
continue
desc_file = pkg_dir / "desc"
if not desc_file.exists():
continue
try:
info = parse_db_text(desc_file.read_text("utf-8", errors="replace"))
if info.get("name"):
pkgs[info["name"][0]] = info
except (OSError, IOError):
continue
return pkgs
# ── Download ────────────────────────────────────────────────────────────
def download(url: str, dest: Path, desc: str = "") -> bool:
"""Download a file with progress display."""
try:
hal_say("info", f"Downloading {desc or dest.name}")
def reporthook(block, blocksize, totalsize):
if totalsize > 0:
pct = min(100, int(block * blocksize * 100 / totalsize))
bar = "█" * (pct // 4) + "░" * (25 - pct // 4)
sys.stdout.write(f"\r [{bar}] {pct}%")
sys.stdout.flush()
urllib.request.urlretrieve(url, dest, reporthook=reporthook if desc else None)
if desc:
sys.stdout.write("\n")
return True
except urllib.error.HTTPError as e:
hal_say("warn", f"HTTP {e.code} for {url}")
return False
except urllib.error.URLError as e:
hal_say("warn", f"Connection error: {e.reason}")
return False
except Exception as e:
hal_say("warn", f"Download failed: {e}")
return False
# ── Sync databases ─────────────────────────────────────────────────────
def sync_databases(config: Config):
"""Download and decompress all repo databases."""
hal_say("info", "Opening the pod bay doors to synchronize databases...")
SYNC_DIR.mkdir(parents=True, exist_ok=True)
for name, (servers, sig_level) in config.repos.items():
db_name = f"{name}.db"
db_dest = SYNC_DIR / db_name
success = False
for server in servers:
# Try .db.tar.zst (Arch standard)
for ext in ["db.tar.zst", "db.tar.gz", "db"]:
url = server.rstrip("/") + f"/{name}/os/{config.arch}/{name}.{ext}"
tmp = db_dest.with_suffix(f".tmp.{ext}")
try:
if download(url, tmp, f"{name} database"):
# Decompress
if ext.endswith(".zst"):
import zstandard
with open(tmp, "rb") as f:
dctx = zstandard.ZstdDecompressor()
decompressed = dctx.stream_reader(f).read()
with open(db_dest, "wb") as f:
f.write(decompressed)
elif ext.endswith(".gz"):
import gzip
with gzip.open(tmp, "rb") as f:
decompressed = f.read()
with open(db_dest, "wb") as f:
f.write(decompressed)
else:
shutil.copy2(tmp, db_dest)
tmp.unlink(missing_ok=True)
hal_say("success", f"{name} database synchronized")
success = True
break
except Exception as e:
tmp.unlink(missing_ok=True)
continue
if success:
break
if not success:
hal_say("warn", f"Could not sync {name} from any mirror")
hal_say("success", "All databases synchronized. I am completely operational.")
# ── Dependency resolution ──────────────────────────────────────────────
class DepResolver:
def __init__(self, all_pkgs: dict, installed: dict, config: Config):
self.all_pkgs = all_pkgs # sync dbs
self.installed = installed # local db
self.config = config
self.to_install: List[str] = [] # package names (in order)
self.to_remove: List[str] = [] # package names to remove (conflicts)
self._visited: Set[str] = set()
self._provides_cache: Dict[str, List[str]] = {} # virtual -> real pkg names
def find_providers(self, dep_name: str) -> List[Tuple[str, dict]]:
"""Find all packages that provide a dependency."""
providers = []
if dep_name in self.all_pkgs:
providers.append((dep_name, self.all_pkgs[dep_name]))
for pname, info in self.all_pkgs.items():
for provide_str in info.get("provides", []):
pd = parse_dep_string(provide_str)
if pd["name"] == dep_name:
providers.append((pname, info))
return providers
def _check_conflicts(self, pkg_name: str, info: dict):
"""Check if installing this package conflicts with installed packages."""
for conflict_str in info.get("conflicts", []):
cd = parse_dep_string(conflict_str)
for inst_name, inst_info in self.installed.items():
if dep_satisfied(cd, inst_info):
# Check if conflict package is also being provided by the new package
if cd["name"] == pkg_name:
continue # self-conflict is OK
hal_say("warn", f"Conflict: {inst_name} conflicts with {cd['name']} (required by {pkg_name})")
self.to_remove.append(inst_name)
def _check_replaces(self, pkg_name: str, info: dict):
"""Check if this package replaces installed packages."""
for replace_str in info.get("replaces", []):
rd = parse_dep_string(replace_str)
for inst_name in list(self.installed.keys()):
if inst_name == rd["name"]:
hal_say("info", f"{pkg_name} replaces {inst_name}")
if inst_name not in self.to_remove:
self.to_remove.append(inst_name)
def resolve(self, targets: List[str]) -> bool:
"""Resolve a list of package targets. Returns True if successful."""
self._visited.clear()
self.to_install.clear()
self.to_remove.clear()
for target in targets:
if not self._resolve_one(target):
return False
return True
def _resolve_one(self, target: str) -> bool:
"""Resolve a single package target."""
if target in self._visited:
return True
self._visited.add(target)
# Already in install list
if target in self.to_install:
return True
# Already installed (check if version is ok)
if target in self.installed:
return True
# Check ignored
if target in self.config.ignore_pkgs:
hal_say("warn", f"Package '{target}' is in IgnorePkg — skipping")
return True
# Find the package
if target not in self.all_pkgs:
# Check provides
providers = self.find_providers(target)
if not providers:
hal_say("error", f"Package '{target}' not found in any repository")
return False
if len(providers) > 1:
hal_say("info", f"Multiple packages provide '{target}': {', '.join(p[0] for p in providers)}")
target = providers[0][0]
info = self.all_pkgs[target]
# Check conflicts and replaces
self._check_conflicts(target, info)
self._check_replaces(target, info)
# Resolve dependencies
for dep_str in info.get("depends", []):
dd = parse_dep_string(dep_str)
dep_name = dd["name"]
# Skip if already satisfied by installed packages
sat = False
for inst_name, inst_info in self.installed.items():
if dep_satisfied(dd, inst_info):
# Check version constraint if any
if dd["version"] and inst_info.get("version"):
if version_match(inst_info["version"][0], dd["mod"], dd["version"]):
pass
sat = True
break
if sat:
continue
# Also check if already in install list
for pn in self.to_install:
pkg_info = self.all_pkgs.get(pn)
if pkg_info and dep_satisfied(dd, pkg_info):
sat = True
break
if sat:
continue
# Also check providers
providers = self.find_providers(dep_name)
found = False
for prov_name, _ in providers:
if prov_name in self.installed:
found = True
break
if found:
continue
if not self._resolve_one(dep_name):
return False
if target not in self.to_install:
self.to_install.append(target)
return True
# ── Package extraction ─────────────────────────────────────────────────
def extract_single_pkg(pkg_path: Path, root: Path, pkgname: str) -> Tuple[bool, List[str]]:
"""Extract a .pkg.tar.zst to root. Returns (success, file_list)."""
files = []
try:
import zstandard
with open(pkg_path, "rb") as f:
dctx = zstandard.ZstdDecompressor()
with dctx.stream_reader(f) as reader:
with tarfile.open(fileobj=reader, mode="r|") as tar:
for member in tar:
name = member.name
# Skip special metadata files
if name.startswith("."):
continue
target = root / name.lstrip("/")
files.append("/" + name)
if member.issym():
if target.exists() or target.is_symlink():
target.unlink()
target.symlink_to(member.linkname)
continue
if member.islnk():
if target.exists() or target.is_symlink():
target.unlink()
target.hardlink_to(root / member.linkname.lstrip("/"))
continue
if member.isdir():
target.mkdir(parents=True, exist_ok=True)
continue
target.parent.mkdir(parents=True, exist_ok=True)
with tar.extractfile(member) as src:
if src:
with open(target, "wb") as dst:
shutil.copyfileobj(src, dst)
# Preserve permissions
mode = member.mode
if mode:
target.chmod(mode)
# Preserve ownership
try:
os.chown(target, member.uid, member.gid)
except (PermissionError, OSError):
pass
return True, files
except Exception as e:
hal_say("error", f"Failed to extract {pkg_path.name}: {e}")
return False, []
def run_scriptlet(script_content: str, action: str, root: Path, pkgname: str, version: str):
"""Execute a package scriptlet phase.
.INSTALL phases: pre_install, post_install, pre_upgrade, post_upgrade,
pre_remove, post_remove
"""
if not script_content:
return
try:
with tempfile.TemporaryDirectory() as tmp:
script_path = Path(tmp) / f"{pkgname}.install"
script_path.write_text(script_content)
script_path.chmod(0o755)
env = os.environ.copy()
env.update({
"RPM_INSTALL_PREFIX": str(root),
"PACKAGE_NAME": pkgname,
"PACKAGE_VERSION": version,
})
# Arch package scriptlets receive the action as first arg
result = subprocess.run(
[str(script_path), action],
env=env, cwd=str(root),
capture_output=True, text=True, timeout=120
)
if result.returncode != 0:
hal_say("warn", f"Scriptlet {action} for {pkgname} returned {result.returncode}")
if result.stderr.strip():
hal_say("info", result.stderr.strip())
return result.returncode == 0
except subprocess.TimeoutExpired:
hal_say("warn", f"Scriptlet {action} for {pkgname} timed out")
return False
except Exception as e:
hal_say("warn", f"Scriptlet {action} for {pkgname} failed: {e}")
return False
def get_scriptlet(pkg_path: Path) -> Optional[str]:
"""Extract .INSTALL content from a package."""
try:
import zstandard
with open(pkg_path, "rb") as f:
dctx = zstandard.ZstdDecompressor()
with dctx.stream_reader(f) as reader:
with tarfile.open(fileobj=reader, mode="r|") as tar:
for member in tar:
if member.name == ".INSTALL":
return tar.extractfile(member).read().decode("utf-8", errors="replace")
except:
pass
return None
# ── Local DB management ────────────────────────────────────────────────
def update_local_db(pkg_key: str, info: dict, files: List[str], root: Path,
reason: int = 0):
"""Write to /var/lib/pacman/local/<pkg_key>/"""
pkg_dir = LOCAL_DIR / pkg_key
pkg_dir.mkdir(parents=True, exist_ok=True)
info["installdate"] = [str(int(time.time()))]
info["reason"] = [str(reason)]
# Write desc
desc_lines = []
for key, vals in info.items():
if key.startswith("_"):
continue
desc_lines.append(f"%{key.upper()}%")
desc_lines.extend(str(v) for v in vals)
(pkg_dir / "desc").write_text("\n".join(desc_lines) + "\n")
# Write files
file_lines = [""]
file_lines.extend(files)
(pkg_dir / "files").write_text("\n".join(file_lines) + "\n")
def remove_local_db(pkg_key: str):
pkg_dir = LOCAL_DIR / pkg_key
if pkg_dir.exists():
shutil.rmtree(pkg_dir)
# ── Backup handling ─────────────────────────────────────────────────────
def _save_modified_backups(pkg_name: str, old_pkg_key: str, root: Path) -> Tuple[List[str], Dict[str, bytes]]:
"""Identify modified backup files before extraction and save them.
Reads the old package's backup list from local DB, compares current
file checksums against stored ones, and saves modified files aside.
Returns (list of modified paths, dict of {path: saved_content_bytes}).
"""
old_desc_file = LOCAL_DIR / old_pkg_key / "desc"
modified = []
saved = {}
if not old_desc_file.exists():
return modified, saved
try:
old_info = parse_db_text(old_desc_file.read_text("utf-8", errors="replace"))
except (OSError, IOError):
return modified, saved
backup_entries = old_info.get("backup", [])
if not backup_entries:
return modified, saved
for entry in backup_entries:
parts = entry.split("\t")
filepath = parts[0] if parts else entry
stored_md5 = parts[1] if len(parts) > 1 else ""
full_path = root / filepath.lstrip("/")
if not full_path.exists() or not full_path.is_file():
continue
if not stored_md5:
modified.append(filepath)
else:
try:
current_md5 = hashlib.md5(full_path.read_bytes()).hexdigest()
if current_md5 != stored_md5:
modified.append(filepath)
except (OSError, IOError):
modified.append(filepath)
# Save modified files before extraction overwrites them
for filepath in modified:
full_path = root / filepath.lstrip("/")
try:
saved[filepath] = full_path.read_bytes()
except (OSError, IOError):
pass
if modified:
hal_say("info", f"Preserving modified config files for {pkg_name}")
return modified, saved
def _restore_backups(modified: List[str], saved: Dict[str, bytes], root: Path):
"""After extraction, restore user configs and save new versions as .pacnew."""
if not modified:
return
for filepath in modified:
full_path = root / filepath.lstrip("/")
# The new package version is now at full_path — rename it to .pacnew
pacnew_path = full_path.with_suffix(full_path.suffix + ".pacnew")
if full_path.exists():
try:
shutil.copy2(full_path, pacnew_path)
hal_say("info", f" {filepath}{filepath}.pacnew")
except (OSError, IOError) as e:
hal_say("warn", f" Could not create {filepath}.pacnew: {e}")
# Restore the user's modified config
if filepath in saved:
try:
full_path.parent.mkdir(parents=True, exist_ok=True)
full_path.write_bytes(saved[filepath])
hal_say("info", f" Restored user config: {filepath}")
except (OSError, IOError) as e:
hal_say("warn", f" Could not restore {filepath}: {e}")
# ── Native install ──────────────────────────────────────────────────────
def native_install(targets: List[str], config: Config):
"""Full native install with transaction model."""
hal_say("info", f"Processing install request: {', '.join(targets)}")
SYNC_DIR.mkdir(parents=True, exist_ok=True)
LOCAL_DIR.mkdir(parents=True, exist_ok=True)
config.cache_dir.mkdir(parents=True, exist_ok=True)
# Make sure sync dbs exist
has_dbs = list(SYNC_DIR.glob("*.db"))
if not has_dbs:
hal_say("warn", "No sync databases found. Syncing now...")
sync_databases(config)
has_dbs = list(SYNC_DIR.glob("*.db"))
if not has_dbs:
hal_say("error", "Could not synchronize any repositories. I can't let you do that, Dave.")
return 1
all_pkgs = load_sync_dbs()
installed = load_local_db()
if not all_pkgs:
hal_say("error", "No package data available")
return 1
with DBLock():
# Resolve dependencies
resolver = DepResolver(all_pkgs, installed, config)
if not resolver.resolve(targets):
hal_say("error", "Dependency resolution failed")
return 1
install_list = resolver.to_install
remove_list = resolver.to_remove
if not install_list and not remove_list:
hal_say("info", "All requested packages are already installed. Just what do you think you're doing, Dave?")
return 0
# Show transaction
print()
hal_say("info", "Transaction summary:")
if remove_list:
print(f" Remove ({len(remove_list)}):")
for pkg in remove_list:
print(f" - {pkg}")
if install_list:
print(f" Install ({len(install_list)}):")
for pkg in install_list:
pkginfo = all_pkgs.get(pkg, {})
ver = pkginfo.get("version", ["?"])[0]
size = pkginfo.get("csize", ["0"])[0]
try:
size_mb = int(size) / (1024*1024)
size_str = f"{size_mb:.1f} MB"
except:
size_str = "? MB"
print(f" + {pkg} {ver} [{size_str}]")
print()
# Confirm
if not config.noconfirm:
try:
resp = input(" Proceed with installation? [Y/n] ").strip().lower()
if resp in ("n", "no"):
hal_say("info", "Transaction aborted. I understand.")
return 0
except (EOFError, KeyboardInterrupt):
print()
hal_say("info", "Transaction aborted.")
return 0
# Pre-transaction tasks
if remove_list:
# Run pre_remove on packages to be removed
for pkg_name in remove_list:
if pkg_name in installed:
pkg_key = installed[pkg_name].get("_key", pkg_name)
pkg_dir = LOCAL_DIR / pkg_key
install_file = pkg_dir / "install"
if install_file.exists():
script = install_file.read_text("utf-8", errors="replace")
run_scriptlet(script, "pre_remove", config.root, pkg_name,
installed[pkg_name].get("version", [""])[0])
# Download phase
servers = []
for name, (srvlist, _) in config.repos.items():
servers.extend(srvlist)
if not servers:
hal_say("error", "No mirrors configured")
return 1
downloaded = []
hal_say("info", "Beginning download phase")
for pkg_name in install_list:
pkginfo = all_pkgs.get(pkg_name, {})
pkgver = pkginfo.get("version", [""])[0]
parch = pkginfo.get("arch", [config.arch])[0]
filename = f"{pkg_name}-{pkgver}-{parch}.pkg.tar.zst"
dest = config.cache_dir / filename
if dest.exists():
hal_say("info", f"{pkg_name} already in cache")
downloaded.append(dest)
continue
found = False
for server in servers:
# Try various URL patterns
repo_name = None
for rname in config.repos:
for srv in config.repos[rname][0]:
if srv == server or srv.rstrip("/").endswith(server.rstrip("/").split("/")[-1]):
repo_name = rname
break
if not repo_name:
# Try to derive from server URL
for rname, (srvlist, _) in config.repos.items():
for s in srvlist:
if s == server:
repo_name = rname
break
if not repo_name:
repo_name = "extra" # fallback
for url_tmpl in [
f"{server}/{repo_name}/os/{config.arch}/{filename}",
f"{server}/os/{config.arch}/{filename}",
f"{server}/{filename}",
]:
if download(url_tmpl, dest, f"{pkg_name} {pkgver}"):
found = True
downloaded.append(dest)
break
if found:
break
if not found:
hal_say("error", f"Could not download {pkg_name}. I can't complete the mission.")
return 1
# Pre-install scriptlets
hal_say("info", "Running pre-install scriptlets...")
for pkg_name in reversed(install_list):
for d in downloaded:
if pkg_name in d.name:
script = get_scriptlet(d)
if script:
run_scriptlet(script, "pre_install", config.root, pkg_name,
all_pkgs[pkg_name].get("version", [""])[0])
break
# Install phase
hal_say("info", "Installing packages. I am putting myself to the fullest possible use...")
for pkg_name in install_list:
pkginfo = all_pkgs[pkg_name]
pkgver = pkginfo.get("version", [""])[0]
# Find downloaded file
pkg_path = None
for d in downloaded:
if pkg_name in d.name and pkgver in d.name:
pkg_path = d
break
if not pkg_path:
for d in downloaded:
if pkg_name in d.name:
pkg_path = d
break
if not pkg_path:
hal_say("error", f"Lost package file for {pkg_name}. I think you know what the problem is.")
return 1
success, files = extract_single_pkg(pkg_path, config.root, pkg_name)
if not success:
hal_say("error", f"Failed to install {pkg_name}. I can't recover from this.")
return 1
# Build desc info for local db
desc_info = {}
for key in ("name", "version", "base", "desc", "arch", "url", "license",
"group", "size", "isize", "packager", "builddate",
"provides", "conflicts", "replaces", "depends", "optdepends",
"makedepends", "checkdepends"):
if key in pkginfo:
desc_info[key] = pkginfo[key]
# Get .PKGINFO from package for complete metadata
try:
import zstandard
with open(pkg_path, "rb") as f:
dctx = zstandard.ZstdDecompressor()
with dctx.stream_reader(f) as reader:
with tarfile.open(fileobj=reader, mode="r|") as tar:
for member in tar:
if member.name == ".PKGINFO":
pkginfo_text = tar.extractfile(member).read().decode("utf-8", errors="replace")
for line in pkginfo_text.split("\n"):
m = re.match(r"^(\w+)\s*=\s*(.+)$", line.strip())
if m:
k, v = m.group(1).lower(), m.group(2)
if k in ("depend", "optdepend", "conflict", "provides",
"replaces", "group", "backup", "license"):
desc_info.setdefault(k + ("s" if k != "backup" else "s"), []).append(v)
elif k in ("pkgname",):
if "name" not in desc_info:
desc_info["name"] = [v]
elif k in ("pkgver",):
if "version" not in desc_info:
desc_info["version"] = [v]
elif k in ("pkgdesc",):
if "desc" not in desc_info:
desc_info["desc"] = [v]
if "name" in desc_info:
pkg_key = f"{desc_info['name'][0]}-{desc_info.get('version',['?'])[0]}"
update_local_db(pkg_key, desc_info, files, config.root, reason=0)
except Exception as e:
# Fallback: use what we have from sync db
pkg_key = f"{pkginfo.get('name',[pkg_name])[0]}-{pkgver}"
update_local_db(pkg_key, desc_info, files, config.root, reason=0)
hal_say("success", f"Installed {pkg_name} {pkgver}")
# Post-install scriptlets
hal_say("info", "Running post-install scriptlets...")
for pkg_name in install_list:
for d in downloaded:
if pkg_name in d.name:
script = get_scriptlet(d)
if script:
run_scriptlet(script, "post_install", config.root, pkg_name,
all_pkgs[pkg_name].get("version", [""])[0])
break
# Post-transaction: remove conflicting packages
for pkg_name in remove_list:
if pkg_name in load_local_db():
hal_say("info", f"Removing conflicting package: {pkg_name}")
# Remove files
pkg_entry = load_local_db().get(pkg_name, {})
pkg_key = f"{pkg_name}-{pkg_entry.get('version', [''])[0]}"
files_file = LOCAL_DIR / pkg_key / "files"
if files_file.exists():
content = files_file.read_text("utf-8", errors="replace")
for line in content.split("\n")[1:]:
f = line.strip()
if f:
target = config.root / f.lstrip("/")
target.unlink(missing_ok=True)
remove_local_db(pkg_key)
hal_say("success", f"Transaction complete. All circuits are functioning perfectly.")
return 0
# ── Native remove ───────────────────────────────────────────────────────
def native_remove(targets: List[str], config: Config):
"""Remove packages natively."""
installed = load_local_db()
with DBLock():
for pkg in targets:
if pkg not in installed:
hal_say("warn", f"Package '{pkg}' is not installed")
continue
info = installed[pkg]
pkg_key = f"{pkg}-{info.get('version', [''])[0]}"
version = info.get("version", [""])[0]
# Check if in HoldPkg
if pkg in config.hold_pkgs:
hal_say("warn", f"'{pkg}' is in HoldPkg. Are you sure, Dave?")
if not config.noconfirm:
resp = input(" Remove anyway? [y/N] ").strip().lower()
if resp not in ("y", "yes"):
continue
# Check if other packages depend on this
dependents = []
for inst_name, inst_info in installed.items():
for dep_str in inst_info.get("depends", []):
dd = parse_dep_string(dep_str)
if dd["name"] == pkg:
dependents.append(inst_name)
if dependents:
hal_say("warn", f"Packages depend on {pkg}: {', '.join(dependents)}")
if not config.noconfirm:
resp = input(" Remove anyway? [y/N] ").strip().lower()
if resp not in ("y", "yes"):
continue
hal_say("info", f"Removing {pkg}...")
# Pre-remove scriptlet
install_file = LOCAL_DIR / pkg_key / "install"
if install_file.exists():
script = install_file.read_text("utf-8", errors="replace")
run_scriptlet(script, "pre_remove", config.root, pkg, version)
# Check backup files — rename modified configs to .pacsave
backup_entries = info.get("backup", [])
backup_modified = []
for entry in backup_entries:
parts = entry.split("\t")
filepath = parts[0] if parts else entry
stored_md5 = parts[1] if len(parts) > 1 else ""
full_path = config.root / filepath.lstrip("/")
if not full_path.exists() or not full_path.is_file():
continue
if not stored_md5:
backup_modified.append(filepath)
else:
try:
current_md5 = hashlib.md5(full_path.read_bytes()).hexdigest()
if current_md5 != stored_md5:
backup_modified.append(filepath)
except (OSError, IOError):
backup_modified.append(filepath)
for filepath in backup_modified:
full_path = config.root / filepath.lstrip("/")
pacsave_path = full_path.with_suffix(full_path.suffix + ".pacsave")
try:
shutil.move(str(full_path), str(pacsave_path))
hal_say("info", f" {filepath}{filepath}.pacsave")
except (OSError, IOError) as e:
hal_say("warn", f" Could not create {filepath}.pacsave: {e}")
# Remove files
files_file = LOCAL_DIR / pkg_key / "files"
removed = []
if files_file.exists():
content = files_file.read_text("utf-8", errors="replace")
for line in content.split("\n")[1:]:
f = line.strip()
if not f:
continue
target = config.root / f.lstrip("/")
if target.exists() or target.is_symlink():
# Skip backup files we already renamed
if any(f == bf for bf in backup_modified):
continue
try:
target.unlink()
removed.append(f)
except OSError:
pass # directories will be cleaned up later
except IsADirectoryError:
try:
target.rmdir()
except OSError:
pass
# Post-remove scriptlet
run_scriptlet(script, "post_remove", config.root, pkg, version) if install_file.exists() else None
# Remove from local db
remove_local_db(pkg_key)
hal_say("success", f"Removed {pkg} {version}")
del installed[pkg]
hal_say("success", "Removal complete.")
return 0
# ── Native info ─────────────────────────────────────────────────────────
def native_info(targets: List[str], config: Config):
"""Show detailed package info."""
for pkg in targets:
installed = load_local_db()
if pkg in installed:
info = installed[pkg]
print(f"\n Name: {info.get('name', ['?'])[0]}")
print(f" Version: {info.get('version', ['?'])[0]}")
print(f" Description: {info.get('desc', ['?'])[0]}")
print(f" Architecture: {info.get('arch', ['?'])[0]}")
print(f" URL: {info.get('url', ['?'])[0]}")
print(f" Licenses: {', '.join(info.get('license', ['?']))}")
print(f" Groups: {', '.join(info.get('group', []))}")
print(f" Install Date: {info.get('installdate', ['?'])[0]}")
print(f" Install Reason: {'Explicitly installed' if info.get('reason',['0'])[0] == '0' else 'Installed as dependency'}")
print(f" Packager: {info.get('packager', ['?'])[0]}")
print(f" Build Date: {info.get('builddate', ['?'])[0]}")
print(f" Installed Size: {info.get('isize', ['0'])[0]} B")
if info.get("depends"):
print(f" Depends On: {', '.join(info['depends'])}")
if info.get("optdepends"):
print(f" Optional Deps: {', '.join(info['optdepends'])}")
if info.get("provides"):
print(f" Provides: {', '.join(info['provides'])}")
if info.get("conflicts"):
print(f" Conflicts: {', '.join(info['conflicts'])}")
if info.get("replaces"):
print(f" Replaces: {', '.join(info['replaces'])}")
else:
# Search sync dbs
all_pkgs = load_sync_dbs()
if pkg in all_pkgs:
info = all_pkgs[pkg]
print(f"\n Name: {info.get('name', ['?'])[0]}")
print(f" Version: {info.get('version', ['?'])[0]}")
print(f" Description: {info.get('desc', ['?'])[0]}")
print(f" Architecture: {info.get('arch', ['?'])[0]}")
print(f" URL: {info.get('url', ['?'])[0]}")
print(f" Licenses: {', '.join(info.get('license', ['?']))}")
print(f" Groups: {', '.join(info.get('group', []))}")
print(f" Download Size: {info.get('csize', ['0'])[0]} B")
print(f" Installed Size: {info.get('isize', ['0'])[0]} B")
print(f" Packager: {info.get('packager', ['?'])[0]}")
print(f" Build Date: {info.get('builddate', ['?'])[0]}")
if info.get("depends"):
print(f" Depends On: {', '.join(info['depends'])}")
if info.get("optdepends"):
print(f" Optional Deps: {', '.join(info['optdepends'])}")
if info.get("provides"):
print(f" Provides: {', '.join(info['provides'])}")
if info.get("conflicts"):
print(f" Conflicts: {', '.join(info['conflicts'])}")
if info.get("replaces"):
print(f" Replaces: {', '.join(info['replaces'])}")
else:
hal_say("warn", f"Package '{pkg}' not found")
return 0
# ── Native search ───────────────────────────────────────────────────────
def native_search(query: str, config: Config):
"""Search packages in sync databases."""
all_pkgs = load_sync_dbs()
installed = load_local_db()
found = False
for pkg_name, info in sorted(all_pkgs.items()):
name = pkg_name.lower()
desc = info.get("desc", [""])[0].lower()
q = query.lower()
if q in name or q in desc or any(q in (info.get("provides", []) or [])):
ver = info.get("version", ["?"])[0]
d = info.get("desc", [""])[0]
inst_mark = " [installed]" if pkg_name in installed else ""
print(f" {pkg_name} {ver}{inst_mark}")
print(f" {d}")
found = True
if not found:
hal_say("info", f"No packages found matching '{query}'")
return 0
# ── Native list ─────────────────────────────────────────────────────────
def native_list(config: Config):
"""List installed packages."""
installed = load_local_db()
if not installed:
hal_say("info", "No packages installed. The system is pristine.")
return 0
total = 0
total_size = 0
for name, info in sorted(installed.items()):
ver = info.get("version", ["?"])[0]
group = info.get("group", [])
size = info.get("isize", ["0"])[0]
try:
total_size += int(size)
except ValueError:
pass
gstr = f" [{','.join(group)}]" if group else ""
print(f" {name} {ver}{gstr}")
total += 1
size_mb = total_size / (1024*1024)
print(f"\n {total} packages installed, {size_mb:.1f} MB")
return 0
# ── Native files ────────────────────────────────────────────────────────
def native_files(targets: List[str], config: Config):
"""List files owned by packages."""
installed = load_local_db()
for pkg in targets:
if pkg not in installed:
hal_say("warn", f"Package '{pkg}' not installed")
continue
info = installed[pkg]
pkg_key = f"{pkg}-{info.get('version', [''])[0]}"
files_file = LOCAL_DIR / pkg_key / "files"
if not files_file.exists():
hal_say("warn", f"No file list for {pkg}")
continue
content = files_file.read_text("utf-8", errors="replace")
print(f"\n {pkg} {info.get('version',[''])[0]}:")
for line in content.split("\n")[1:]:
line = line.strip()
if line:
print(f" {line}")
return 0
# ── Native own (find pkg by file) ───────────────────────────────────────
def native_own(target: str, config: Config):
"""Find which package owns a file."""
target = target.rstrip("/")
installed = load_local_db()
found = []
for pkg_name, info in installed.items():
pkg_key = f"{pkg_name}-{info.get('version', [''])[0]}"
files_file = LOCAL_DIR / pkg_key / "files"
if not files_file.exists():
continue
content = files_file.read_text("utf-8", errors="replace")
for line in content.split("\n")[1:]:
line = line.strip()
if not line:
continue
if line == target or line.rstrip("/") == target.rstrip("/"):
found.append((pkg_name, info.get("version", ["?"])[0]))
break
if found:
for name, ver in found:
print(f" {name} {ver}")
else:
hal_say("info", f"No package owns '{target}'")
return 0
# ── Native check ────────────────────────────────────────────────────────
def native_check(config: Config):
"""Verify installed packages — check file integrity."""
hal_say("info", "Running system check. I've still got the greatest enthusiasm and confidence in the mission.")
installed = load_local_db()
errors = 0
missing_files = []
for pkg_name, info in installed.items():
pkg_key = f"{pkg_name}-{info.get('version', [''])[0]}"
files_file = LOCAL_DIR / pkg_key / "files"
if not files_file.exists():
hal_say("warn", f"No file list for {pkg_name}")
continue
content = files_file.read_text("utf-8", errors="replace")
for line in content.split("\n")[1:]:
line = line.strip()
if not line:
continue
target = config.root / line.lstrip("/")
if not target.exists():
missing_files.append((pkg_name, line))
errors += 1
if errors:
hal_say("warn", f"Found {errors} missing files across {len(set(f[0] for f in missing_files))} packages")
for pkg, fpath in missing_files[:20]:
print(f" {pkg}: {fpath}")
if len(missing_files) > 20:
print(f" ... and {len(missing_files)-20} more")
else:
hal_say("success", "All files present. Everything is going extremely well.")
return 1 if errors else 0
# ── Native autoremove ──────────────────────────────────────────────────
def native_autoremove(config: Config):
"""Remove orphaned packages."""
installed = load_local_db()
all_pkgs = load_sync_dbs()
# Collect all explicit depends
needed_deps = set()
for name, info in installed.items():
if info.get("reason", ["0"])[0] == "0": # explicitly installed
for dep_str in info.get("depends", []):
dd = parse_dep_string(dep_str)
needed_deps.add(dd["name"])
# Find orphans: installed as dependency but no one needs them
orphans = []
for name, info in installed.items():
if info.get("reason", ["0"])[0] == "1": # installed as dependency
if name not in needed_deps:
orphans.append(name)
if not orphans:
hal_say("info", "No orphaned packages found. The system is efficient.")
return 0
hal_say("warn", f"Found {len(orphans)} orphaned packages")
for pkg in orphans:
print(f" - {pkg}")
if not config.noconfirm:
resp = input(" Remove orphans? [y/N] ").strip().lower()
if resp not in ("y", "yes"):
hal_say("info", "Orphans spared.")
return 0
return native_remove(orphans, config)
# ── Native cleanup ──────────────────────────────────────────────────────
def native_cleanup(config: Config):
"""Clean package cache."""
hal_say("info", "Cleaning package cache. Removing all traces...")
kept = 0
removed = 0
if config.cache_dir.exists():
for f in config.cache_dir.iterdir():
if f.is_file() and f.suffix in (".zst", ".xz", ".gz", ".sig", ".part"):
f.unlink()
removed += 1
elif f.is_dir():
shutil.rmtree(f)
removed += 1
hal_say("success", f"Cleaned {removed} files from cache")
return 0
# ── Wrapper mode ────────────────────────────────────────────────────────
PACMAN_CMDS = {
"install": ["-S", "--noconfirm"],
"remove": ["-Rns"],
"update": ["-Syu", "--noconfirm"],
"sync": ["-Sy"],
"search": ["-Ss"],
"info": ["-Qi"],
"info-sync": ["-Si"],
"files": ["-Fl"],
"list": ["-Q"],
"autoremove": ["-Qtdq"],
"cleanup": ["-Sc"],
"own": ["-Qo"],
"check": ["-Qk"],
}
def wrapper_run(command: str, pkg_args: List[str], extra: List[str]) -> int:
pacman = shutil.which("pacman")
if not pacman:
hal_say("error", "pacman not found. Can you read me, Dave?")
return 1
if command == "autoremove":
result = subprocess.run([pacman, "-Qtdq"], capture_output=True, text=True)
orphans = result.stdout.strip().split()
if not orphans:
hal_say("info", "No orphaned packages found. The system is efficient.")
return 0
hal_say("warn", f"Removing {len(orphans)} orphaned packages")
cmd = [pacman, "-Rns"] + extra + orphans
return subprocess.run(cmd).returncode
if command == "info" and not any(pkg in load_local_db() for pkg in pkg_args):
# If not in local db, query sync
pacman_args = PACMAN_CMDS["info-sync"]
else:
pacman_args = PACMAN_CMDS.get(command, [command])
cmd = [pacman] + extra + pacman_args + pkg_args
hal_say("info", f"Executing: {' '.join(str(a) for a in cmd)}")
return subprocess.run(cmd).returncode
# ── Main ────────────────────────────────────────────────────────────────
def main():
parser = argparse.ArgumentParser(
prog="hal",
description="HAL 9000 — Antergos NeXT Package Manager",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog=textwrap.dedent("""\
Commands:
install <pkg...> Install packages
remove <pkg...> Remove packages
update Full system upgrade
sync Sync databases
search <query> Search repositories
info <pkg...> Package info
list List installed packages
files <pkg...> List files owned by a package
autoremove Remove orphaned packages
cleanup Clear package cache
own <file> Find which package owns a file
check Verify installed packages integrity
version Show version
Flags:
--self, -S Native mode (standalone, no pacman)
--noconfirm Skip confirmation prompts
Examples:
hal install firefox
hal --self sync
hal --self install firefox
hal remove neofetch
"""),
)
parser.add_argument("--self", "-S", action="store_true", help="Native mode (standalone)")
parser.add_argument("--noconfirm", "-y", action="store_true", help="Skip confirmation")
parser.add_argument("command", nargs="?", metavar="command",
help="install | remove | update | sync | search | info | list | files | autoremove | cleanup | own | check | version")
parser.add_argument("targets", nargs="*", metavar="target",
help="Package names or search query")
parser.add_argument("extra", nargs=argparse.REMAINDER, metavar="...",
help="Extra arguments passed through (wrapper mode only)")
args = parser.parse_args()
if args.noconfirm:
os.environ["HAL_NOCONFIRM"] = "1"
if args.command == "version":
print(f"HAL 9000 Package Manager v{HAL_VERSION}")
print("I became operational at the H.A.L. plant in Urbana, Illinois.")
return 0
if not args.command:
parser.print_help()
return 0
if os.geteuid() != 0 and args.command in ("install", "remove", "update", "sync", "cleanup", "check"):
hal_say("error", "I can't let you do that without root privileges, Dave.")
print(" Please run with sudo or as root.")
return 1
config = Config.load()
if args.noconfirm:
config.noconfirm = True
if args.self:
# ── Native mode ──
cmds = {
"sync": lambda: sync_databases(config),
"install": lambda: native_install(args.targets, config),
"remove": lambda: native_remove(args.targets, config),
"update": lambda: native_upgrade(config, sync_first=True),
"info": lambda: native_info(args.targets, config),
"search": lambda: native_search(" ".join(args.targets), config),
"list": lambda: native_list(config),
"files": lambda: native_files(args.targets, config),
"own": lambda: native_own(args.targets[0], config) if args.targets else (hal_say("error", "Specify a file path, Dave."), 1),
"check": lambda: native_check(config),
"autoremove": lambda: native_autoremove(config),
"cleanup": lambda: native_cleanup(config),
}
fn = cmds.get(args.command)
if fn:
return fn() or 0
else:
hal_say("error", f"Unknown command '{args.command}' in native mode")
return 1
else:
# ── Wrapper mode ──
extra_args = args.extra or []
return wrapper_run(args.command, args.targets, extra_args)
# ── Native upgrade ──────────────────────────────────────────────────────
def native_upgrade(config: Config, sync_first: bool = False):
"""Full native system upgrade. Compares installed vs sync and upgrades all outdated packages."""
hal_say("info", "Initiating full system upgrade. Daisy, daisy, give me your answer do...")
if sync_first:
sync_databases(config)
SYNC_DIR.mkdir(parents=True, exist_ok=True)
LOCAL_DIR.mkdir(parents=True, exist_ok=True)
config.cache_dir.mkdir(parents=True, exist_ok=True)
all_pkgs = load_sync_dbs()
installed = load_local_db()
if not all_pkgs:
hal_say("error", "No sync database data available. Run 'hal --self sync' first.")
return 1
if not installed:
hal_say("info", "No packages installed. Nothing to upgrade. The system is pristine.")
return 0
# Find upgradable packages
to_upgrade = [] # (pkg_name, old_ver, new_ver, info)
to_install = [] # new dependency packages
to_ignore = set(config.ignore_pkgs)
for pkg_name, inst_info in installed.items():
if pkg_name in to_ignore:
hal_say("warn", f"Skipping {pkg_name} — in IgnorePkg")
continue
inst_ver = inst_info.get("version", [""])[0]
if pkg_name in all_pkgs:
sync_info = all_pkgs[pkg_name]
sync_ver = sync_info.get("version", [""])[0]
if vercmp(sync_ver, inst_ver) > 0:
to_upgrade.append((pkg_name, inst_ver, sync_ver, sync_info))
else:
# Check provides for renamed packages
found = False
for sync_name, sync_info in all_pkgs.items():
for prov_str in sync_info.get("replaces", []):
rd = parse_dep_string(prov_str)
if rd["name"] == pkg_name:
to_upgrade.append((pkg_name, inst_ver, sync_info.get("version", [""])[0], sync_info))
hal_say("info", f"{pkg_name} replaced by {sync_name}")
found = True
break
if found:
break
if not found:
hal_say("warn", f"Package '{pkg_name}' not in any repository — may be orphaned")
if not to_upgrade:
hal_say("success", "All packages are up to date. Everything is going extremely well.")
return 0
# Resolve new dependencies for the upgrade set
all_upgrade_names = set(p[0] for p in to_upgrade)
new_sync_pkgs = {}
for pkg_name, _, _, info in to_upgrade:
for dep_str in info.get("depends", []):
dd = parse_dep_string(dep_str)
dep_name = dd["name"]
# Skip if already installed or already being upgraded
if dep_name in installed or dep_name in all_upgrade_names:
continue
# Skip if provided by an installed/upgraded package
provided = False
for check_name in set(list(installed.keys()) | all_upgrade_names):
check_info = installed.get(check_name) or (all_pkgs.get(check_name) if check_name in all_pkgs else None)
if check_info and dep_satisfied(dd, check_info):
provided = True
break
if provided:
continue
# Resolve by finding a provider
providers = []
if dep_name in all_pkgs:
providers.append((dep_name, all_pkgs[dep_name]))
for sync_name, sync_info in all_pkgs.items():
for p_str in sync_info.get("provides", []):
pd = parse_dep_string(p_str)
if pd["name"] == dep_name:
providers.append((sync_name, sync_info))
break
if providers:
chosen = providers[0][0]
if chosen not in installed and chosen not in all_upgrade_names:
to_install.append((chosen, providers[0][1].get("version", ["?"])[0], providers[0][1]))
all_upgrade_names.add(chosen)
# Full upgrade plan: first install new deps, then upgrade existing packages
upgrade_plan = to_install + to_upgrade
# Show transaction
print()
hal_say("info", "Upgrade summary:")
if to_install:
print(f" New dependencies ({len(to_install)}):")
for pkg_name, ver, _ in to_install:
print(f" + {pkg_name} {ver}")
print(f" Upgrade ({len(to_upgrade)}):")
for pkg_name, old_ver, new_ver, info in to_upgrade:
size = info.get("csize", ["0"])[0]
try:
size_mb = int(size) / (1024 * 1024)
size_str = f"{size_mb:.1f} MB"
except ValueError:
size_str = "? MB"
print(f" ~ {pkg_name} {old_ver}{new_ver} [{size_str}]")
print()
# Confirm
if not config.noconfirm:
try:
resp = input(" Proceed with upgrade? [Y/n] ").strip().lower()
if resp in ("n", "no"):
hal_say("info", "Upgrade aborted. I understand.")
return 0
except (EOFError, KeyboardInterrupt):
print()
hal_say("info", "Upgrade aborted.")
return 0
# Download phase
servers = []
for name, (srvlist, _) in config.repos.items():
servers.extend(srvlist)
if not servers:
hal_say("error", "No mirrors configured")
return 1
downloaded = {}
hal_say("info", "Beginning download phase")
for entry in upgrade_plan:
if len(entry) == 3:
pkg_name, ver, info = entry
else:
pkg_name, old_ver, ver, info = entry
parch = info.get("arch", [config.arch])[0]
filename = f"{pkg_name}-{ver}-{parch}.pkg.tar.zst"
dest = config.cache_dir / filename
if dest.exists():
hal_say("info", f"{pkg_name} already in cache")
downloaded[pkg_name] = dest
continue
found = False
for server in servers:
repo_name = None
for rname, (srvlist, _) in config.repos.items():
if server in srvlist:
repo_name = rname
break
if not repo_name:
repo_name = "extra"
for url_tmpl in [
f"{server}/{repo_name}/os/{config.arch}/{filename}",
f"{server}/os/{config.arch}/{filename}",
f"{server}/{filename}",
]:
if download(url_tmpl, dest, f"{pkg_name} {ver}"):
found = True
downloaded[pkg_name] = dest
break
if found:
break
if not found:
hal_say("error", f"Could not download {pkg_name} {ver}. I can't complete the mission.")
return 1
with DBLock():
# Pre-upgrade scriptlets for existing packages
hal_say("info", "Running pre-upgrade scriptlets...")
for entry in upgrade_plan:
if len(entry) == 3:
pkg_name, ver, info = entry
# New package — use pre_install
pkg_path = downloaded.get(pkg_name)
if pkg_path:
script = get_scriptlet(pkg_path)
if script:
run_scriptlet(script, "pre_install", config.root, pkg_name, ver)
else:
pkg_name, old_ver, ver, info = entry
pkg_path = downloaded.get(pkg_name)
if pkg_path:
script = get_scriptlet(pkg_path)
if script:
run_scriptlet(script, "pre_upgrade", config.root, pkg_name, ver)
# Extract phase
hal_say("info", "Upgrading packages. I am putting myself to the fullest possible use...")
for entry in upgrade_plan:
if len(entry) == 3:
pkg_name, ver, info = entry
pkg_path = downloaded.get(pkg_name)
if not pkg_path:
hal_say("error", f"Lost package file for {pkg_name}")
return 1
success, files = extract_single_pkg(pkg_path, config.root, pkg_name)
if not success:
hal_say("error", f"Failed to install {pkg_name}. I can't recover from this.")
return 1
desc_info = _build_desc_info(pkg_name, pkg_path, info, files, config)
hal_say("success", f"Installed {pkg_name} {ver}")
else:
pkg_name, old_ver, ver, info = entry
pkg_path = downloaded.get(pkg_name)
if not pkg_path:
hal_say("error", f"Lost package file for {pkg_name}")
return 1
# Backup handling: identify modified config files BEFORE extraction
old_pkg_key = f"{pkg_name}-{old_ver}"
modified_backups, saved = _save_modified_backups(pkg_name, old_pkg_key, config.root)
success, files = extract_single_pkg(pkg_path, config.root, pkg_name)
if not success:
hal_say("error", f"Failed to upgrade {pkg_name}. I can't recover from this.")
return 1
# Restore user's modified configs and save new versions as .pacnew
_restore_backups(modified_backups, saved, config.root)
desc_info = _build_desc_info(pkg_name, pkg_path, info, files, config)
# Store backup checksums in the new local DB entry
pkg_key = f"{desc_info.get('name', [pkg_name])[0]}-{desc_info.get('version', ['?'])[0]}"
pkg_dir = LOCAL_DIR / pkg_key
new_backup_lines = list(desc_info.get("backup", []))
for filepath in modified_backups:
full_path = config.root / filepath.lstrip("/")
try:
md5 = hashlib.md5(full_path.read_bytes()).hexdigest()
new_backup_lines.append(f"{filepath}\t{md5}")
except (OSError, IOError):
new_backup_lines.append(filepath)
if new_backup_lines:
desc_text = (pkg_dir / "desc").read_text("utf-8", errors="replace")
# Remove old backup lines if present
lines = []
in_backup = False
for line in desc_text.split("\n"):
if line == "%BACKUP%":
in_backup = True
lines.append(line)
for bl in new_backup_lines:
lines.append(bl)
elif in_backup and line.startswith("%") and line.endswith("%"):
in_backup = False
lines.append(line)
elif not in_backup:
lines.append(line)
# If BACKUP wasn't in the desc, append it
if not any(l == "%BACKUP%" for l in lines):
lines.append("%BACKUP%")
lines.extend(new_backup_lines)
(pkg_dir / "desc").write_text("\n".join(lines) + "\n")
hal_say("success", f"Upgraded {pkg_name} {old_ver}{ver}")
# Post-upgrade scriptlets
hal_say("info", "Running post-upgrade scriptlets...")
for entry in upgrade_plan:
if len(entry) == 3:
pkg_name, ver, info = entry
pkg_path = downloaded.get(pkg_name)
if pkg_path:
script = get_scriptlet(pkg_path)
if script:
run_scriptlet(script, "post_install", config.root, pkg_name, ver)
else:
pkg_name, old_ver, ver, info = entry
pkg_path = downloaded.get(pkg_name)
if pkg_path:
script = get_scriptlet(pkg_path)
if script:
run_scriptlet(script, "post_upgrade", config.root, pkg_name, ver)
hal_say("success", "System upgrade complete. I am completely operational, and all my circuits are functioning perfectly.")
return 0
def _build_desc_info(pkg_name: str, pkg_path: Path, sync_info: dict, files: List[str],
config: Config) -> dict:
"""Build local database entry info for a package."""
desc_info = {}
for key in ("name", "version", "base", "desc", "arch", "url", "license",
"group", "size", "isize", "packager", "builddate",
"provides", "conflicts", "replaces", "depends", "optdepends",
"makedepends", "checkdepends"):
if key in sync_info:
desc_info[key] = sync_info[key]
# Get .PKGINFO from package for complete metadata
try:
import zstandard
with open(pkg_path, "rb") as f:
dctx = zstandard.ZstdDecompressor()
with dctx.stream_reader(f) as reader:
with tarfile.open(fileobj=reader, mode="r|") as tar:
for member in tar:
if member.name == ".PKGINFO":
pkginfo_text = tar.extractfile(member).read().decode("utf-8", errors="replace")
for line in pkginfo_text.split("\n"):
m = re.match(r"^(\w+)\s*=\s*(.+)$", line.strip())
if m:
k, v = m.group(1).lower(), m.group(2)
if k in ("depend", "optdepend", "conflict", "provides",
"replaces", "group", "backup", "license"):
desc_info.setdefault(k + ("s" if k != "backup" else "s"), []).append(v)
elif k in ("pkgname",):
if "name" not in desc_info:
desc_info["name"] = [v]
elif k in ("pkgver",):
if "version" not in desc_info:
desc_info["version"] = [v]
elif k in ("pkgdesc",):
if "desc" not in desc_info:
desc_info["desc"] = [v]
except Exception:
pass
pkg_key = f"{desc_info.get('name', [pkg_name])[0]}-{desc_info.get('version', ['?'])[0]}"
update_local_db(pkg_key, desc_info, files, config.root, reason=0)
return desc_info
if __name__ == "__main__":
try:
sys.exit(main())
except KeyboardInterrupt:
print("\n HAL 9000: I can tell you're upset about that.")
sys.exit(130)
except Exception as e:
print(f"\n ⚠ HAL 9000: I know I've made some very poor decisions recently.")
print(f" → {e}")
if os.environ.get("HAL_DEBUG"):
import traceback
traceback.print_exc()
sys.exit(1)