commit 5a5b1a190bb4333e58567fe1696c574b1a1fe10e Author: Zacharias Date: Mon May 18 00:46:02 2026 +0200 IDK :) userman or smt.. and inital commit diff --git a/.idea/.gitignore b/.idea/.gitignore new file mode 100644 index 0000000..b58b603 --- /dev/null +++ b/.idea/.gitignore @@ -0,0 +1,5 @@ +# Default ignored files +/shelf/ +/workspace.xml +# Editor-based HTTP Client requests +/httpRequests/ diff --git a/.idea/inspectionProfiles/profiles_settings.xml b/.idea/inspectionProfiles/profiles_settings.xml new file mode 100644 index 0000000..105ce2d --- /dev/null +++ b/.idea/inspectionProfiles/profiles_settings.xml @@ -0,0 +1,6 @@ + + + + \ No newline at end of file diff --git a/.idea/misc.xml b/.idea/misc.xml new file mode 100644 index 0000000..bf5217a --- /dev/null +++ b/.idea/misc.xml @@ -0,0 +1,7 @@ + + + + + + \ No newline at end of file diff --git a/.idea/modules.xml b/.idea/modules.xml new file mode 100644 index 0000000..3dc1353 --- /dev/null +++ b/.idea/modules.xml @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/.idea/userman.iml b/.idea/userman.iml new file mode 100644 index 0000000..a14a94d --- /dev/null +++ b/.idea/userman.iml @@ -0,0 +1,10 @@ + + + + + + + + + + \ No newline at end of file diff --git a/.idea/vcs.xml b/.idea/vcs.xml new file mode 100644 index 0000000..94a25f7 --- /dev/null +++ b/.idea/vcs.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/config.py b/config.py new file mode 100644 index 0000000..80c7219 --- /dev/null +++ b/config.py @@ -0,0 +1,30 @@ +import configparser +import platform +from pathlib import Path + +CONFIG_FILE = Path.home() / ".config/userman/userman.conf" + +def load_config(): + config = configparser.ConfigParser(allow_no_value=True) + config.read(str(CONFIG_FILE)) + + options = config["options"] if "options" in config else {} + + return { + "db_path": Path(options.get("DBPath", "~/.local/share/userman/db")).expanduser(), + "cache_dir": Path(options.get("CacheDir", "~/.cache/userman/pkg")).expanduser(), + "gpg_dir": Path(options.get("GPGDir", "~/.config/userman/gnupg")).expanduser(), + "hook_dir": Path(options.get("HookDir", "~/.config/userman/hooks")).expanduser(), + "log_file": Path(options.get("LogFile", "~/.local/share/userman/pacman.log")).expanduser(), + "root_dir": Path(options.get("RootDir", "~")).expanduser(), + "arch": options.get("Architecture", "auto").replace("auto", platform.machine()), + } + +def get_repos() -> dict: + config = configparser.ConfigParser(allow_no_value=True) + config.read(str(CONFIG_FILE)) + return { + section: config[section]["server"] + for section in config.sections() + if section.lower() != "options" and "server" in config[section] + } \ No newline at end of file diff --git a/db.py b/db.py new file mode 100644 index 0000000..86b28ec --- /dev/null +++ b/db.py @@ -0,0 +1,75 @@ +import subprocess +import tarfile +import os + +from config import load_config, CONFIG_FILE, get_repos + +DB_PATH = os.path.expanduser(load_config()["db_path"]) + +def get_package_meta(pkgname: str) -> dict | None: + for dbfile in os.listdir(DB_PATH): + if not dbfile.endwith(".db"): + continue + meta = _search_db(os.path.join(DB_PATH, dbfile), pkgname) + if meta: + return meta + return None + +def _search_db(dbfile: str, pkgname: str) -> dict | None: + with tarfile.open(dbfile, "r:gz") as tar: + for member in tar.getmembers(): + parts = member.name.split("/") + if len(parts) != 2 or parts[1] != "desc": + continue + entry_name = parts[0].rsplit("-", 2)[0] + if entry_name != pkgname: + continue + f = tar.extractfile(member) + if f: + return _parse_desc(f.read().decode("utf-8")) + +def _parse_desc(content: str) -> dict | None: + meta = {} + lines = content.strip().split("\n") + i = 0 + while i < len(lines): + if lines[i].startswith("%") and lines[i].endswith("%"): + key = lines[i].strip("%").lower() + i += 1 + values = [] + while i < len(lines) and lines[i] != "": + values.append(lines[i]) + i += 1 + meta[key] = values[0] if len(values) == 1 else values + i += 1 + return meta + +def ensure_db(): + sync_dir = load_config()["db_path"] / "sync" + + if not sync_dir.exists() or not any(sync_dir.glob("*.db")): + print(":: First run detected, syncing databases...") + _sync_db() + +def _sync_db(): + sync_dir = load_config()["db_path"] / "sync" + sync_dir.mkdir(parents=True, exist_ok=True) + + + for repo_name, server in get_repos().items(): + url = resolve_server_url(server, repo_name) + db_url = f"{url}/{repo_name}.db" + dest = sync_dir / f"{repo_name}.db" + + print(f":: Syncing {repo_name}...") + try: + subprocess.run(["curl", "-L", "-o", str(dest), db_url], check=True) + except subprocess.CalledProcessError as e: + print(f":: Probobly failed to find repo '{repo_name}' at {db_url}") + +def resolve_server_url(template: str, repo: str, arch: str = load_config()["arch"]) -> str: + return (template + .replace("$repo", repo) + .replace("$arch", arch) + .replace("${repo}", repo) + .replace("${arch}", arch)) diff --git a/fetch.py b/fetch.py new file mode 100644 index 0000000..9652b22 --- /dev/null +++ b/fetch.py @@ -0,0 +1,25 @@ +import subprocess +import os +from pathlib import Path + +from config import load_config + +CACHE_DIR = os.path.expanduser(load_config()["cache_dir"]) + +def fetch_package(meta: dict) -> tuple[Path, Path]: + CACHE_DIR.mkdir(parents=True, exist_ok=True) + url = meta["url"] + filename = meta["filename"] + sig_url = url + ".sig" + + pkg_path = CACHE_DIR / filename + sig_path = CACHE_DIR / (filename + ".sig") + + _curl(url, pkg_path) + _curl(sig_url, sig_path) + + return pkg_path, sig_path + +def _curl(url: str, dest: Path): + print(f":: Fetching {url}") + subprocess.run(["curl", "-L", "-o", str(dest), url], check=True) diff --git a/handoff.py b/handoff.py new file mode 100644 index 0000000..31a3565 --- /dev/null +++ b/handoff.py @@ -0,0 +1,6 @@ +import subprocess +import sys + +def delegate_to_pacman(pkgname: str): + result = subprocess.run(["sudo", "pacman", "-S", pkgname]) + sys.exit(result.returncode) \ No newline at end of file diff --git a/main.py b/main.py new file mode 100755 index 0000000..1be0356 --- /dev/null +++ b/main.py @@ -0,0 +1,43 @@ +#! /bin/env python +import sys +from db import get_package_meta, ensure_db +from handoff import delegate_to_pacman +from fetch import fetch_package +from verify import verify_sig + +def main(): + + ensure_db() + + if len(sys.argv) < 3: + print("usage: userman -S ") + sys.exit(1) + + action = sys.argv[1] + pkgname = sys.argv[2] + + if action == "-S": + install(pkgname) + +def install(pkgname: str): + meta = get_package_meta(pkgname) + + if meta is None: + print(f"error: package '{pkgname}' not found is userman db") + sys.exit(1) + + if meta.get("install_mode", "system") != "user": + print(f":: Handing off '{pkgname}' to pacman") + delegate_to_pacman(pkgname) + return + + pkg_path, sig_path = fetch_package(meta) + + if not verify_sig(pkg_path, sig_path): + print("error: GPG verification failed, aborting") + sys.exit(1) + + # Send to pacman with userman flags + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/userman.conf b/userman.conf new file mode 100644 index 0000000..0a57f70 --- /dev/null +++ b/userman.conf @@ -0,0 +1,82 @@ +# +# ~/.config/userman/userman.conf +# +# See the pacman.conf(5) manpage for option and repository directives + +# +# GENERAL OPTIONS +# +[options] +# The following paths are commented out with their default values listed. +# If you wish to use different paths, uncomment and update the paths. +#RootDir = ~ +#DBPath = ~/.local/share/userman/db +#CacheDir = ~/.cache/userman/pkg +#LogFile = ~/.local/share/userman/pacman.log +#GPGDir = ~/.config/userman/gnupg +#HookDir = ~/.config/userman/hooks +HoldPkg = +#XferCommand = /usr/bin/curl -L -C - -f -o %o %u +#XferCommand = /usr/bin/wget --passive-ftp -c -O %o %u +#CleanMethod = KeepInstalled +Architecture = auto + +# Pacman won't upgrade packages listed in IgnorePkg and members of IgnoreGroup +#IgnorePkg = +#IgnoreGroup = + +#NoUpgrade = +#NoExtract = + +# Misc options +#UseSyslog +#Color +#NoProgressBar +CheckSpace +#VerbosePkgLists +ParallelDownloads = 5 +DownloadUser = alpm +#DisableSandboxFilesystem +#DisableSandboxSyscalls + +# By default, pacman accepts packages signed by keys that its local keyring +# trusts (see pacman-key and its man page), as well as unsigned packages. +SigLevel = Required DatabaseOptional +LocalFileSigLevel = Optional +#RemoteFileSigLevel = Required + +# NOTE: You must run `pacman-key --init` before first using pacman; the local +# keyring can then be populated with the keys of all official Arch Linux +# packagers with `pacman-key --populate archlinux`. + +# +# REPOSITORIES +# - can be defined here or included from another file +# - pacman will search repositories in the order defined here +# - local/custom mirrors can be added here or in separate files +# - repositories listed first will take precedence when packages +# have identical names, regardless of version number +# - URLs will have $repo replaced by the name of the current repo +# - URLs will have $arch replaced by the name of the architecture +# +# Repository entries are of the format: +# [repo-name] +# Server = ServerName +# Include = IncludePath +# +# The header [repo-name] is crucial - it must be present and +# uncommented to enable the repo. +# + +# The testing repositories are disabled by default. To enable, uncomment the +# repo name header and Include lines. You can add preferred servers immediately +# after the header, and they will be used before the default mirrors. + +[hearth] +Server = file:///home/zacharias/Documents/userman-repo + +# An example of a custom package repository. See the pacman manpage for +# tips on creating your own repositories. +#[custom] +#SigLevel = Optional TrustAll +#Server = file:///home/custompkgs diff --git a/userman.sh b/userman.sh new file mode 100644 index 0000000..b8e686f --- /dev/null +++ b/userman.sh @@ -0,0 +1,18 @@ +#! /bin/env bash + +## The --root points to ~/ as a userman package is expected to follow this +# pacman dir | userman dir | comment +# /etc/ | ~/.local/share/ or ~/.config/ | I have seen where /etc contains things less config like and more runtime stuff wise so there for both +# /usr/bin | ~/.local/bin +# etc +# etc +# +# The isea is to replace UNIX root conventiosn with XDG conventions + +pacman --config ~/.config/userman/pacman.conf \ + --root ~/ \ + --dbpath ~/.local/share/userman/db \ + --cachedir ~/.cache/userman/pkg \ + --gpgdir ~/.config/userman/gnupg \ + --hookdir ~/.config/userman/hooks \ + --logfile ~/.local/share/userman/pacman.log \ No newline at end of file diff --git a/verify.py b/verify.py new file mode 100644 index 0000000..fba4544 --- /dev/null +++ b/verify.py @@ -0,0 +1,15 @@ +import subprocess +import os +from pathlib import Path + +from config import load_config + +GPGDIR = os.path.expanduser(load_config()["gpg_dir"]) + +def verify_sig(pkg_path: Path, sig_path: Path) -> bool: + result = subprocess.run([ + "gpg", + "--homedir", str(GPGDIR), + "--verify", str(sig_path), str(pkg_path) + ], capture_output=True) + return result.returncode == 0 \ No newline at end of file