pkget

Binary package manager for CRUX
git clone git://git.emmett1.my/pkget.git
Log | Files | Refs | README

commit 5372996f02b06a71eab17a17e4ac633a368ef135
Author: emmett1 <emmett1.2miligrams@protonmail.com>
Date:   Thu, 11 Jun 2026 23:48:04 +0800

initial commit

Diffstat:
A.gitignore | 2++
AMakefile | 25+++++++++++++++++++++++++
AREADME | 67+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Apkget | 525+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Apkget.1 | 134+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Apkgrepo | 245+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Apkgrepo.1 | 125+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
7 files changed, 1123 insertions(+), 0 deletions(-)

diff --git a/.gitignore b/.gitignore @@ -0,0 +1,2 @@ +CLAUDE.md +.claude/ diff --git a/Makefile b/Makefile @@ -0,0 +1,25 @@ +.POSIX: + +PREFIX ?= /usr/local +BINDIR ?= $(PREFIX)/bin +MANDIR ?= $(PREFIX)/share/man/man1 + +SCRIPTS := pkget pkgrepo +MANPAGES := pkget.1 pkgrepo.1 + +.PHONY: all install uninstall clean + +all: + @echo "usage: make install [PREFIX=/usr/local] [DESTDIR=...]" + +install: + mkdir -p "$(DESTDIR)$(BINDIR)" "$(DESTDIR)$(MANDIR)" + install -m 755 $(SCRIPTS) "$(DESTDIR)$(BINDIR)" + install -m 644 $(MANPAGES) "$(DESTDIR)$(MANDIR)" + +uninstall: + for f in $(SCRIPTS); do rm -f "$(DESTDIR)$(BINDIR)/$$f"; done + for f in $(MANPAGES); do rm -f "$(DESTDIR)$(MANDIR)/$$f"; done + +clean: + @echo "nothing to clean" diff --git a/README b/README @@ -0,0 +1,67 @@ +pkget - binary package manager +============================= + +pkget downloads, verifies, and installs binary packages from an HTTP +repository. pkgrepo generates the repository metadata from a CRUX +ports tree. + + +Quick start +----------- + + $ chmod +x pkget pkgrepo + + $ pkget -u https://pkg.example.com -s sync repo index + $ pkget -u https://pkg.example.com wget install wget + its dependencies + $ pkget -u https://pkg.example.com -U upgrade all outdated packages + $ pkget -u https://pkg.example.com -o check for outdated packages + + +Requirements +------------ + + Runtime: + - POSIX /bin/sh, awk, sed, grep, mktemp, sort, join, cat, tr + - curl or wget + - sha256sum, shasum -a 256, or sha256 -q + - pkgadd (CRUX pkgutils) + - pkginfo (optional; falls back to /var/lib/pkg/db) + + Server-side only (pkgrepo): + - A CRUX-style ports tree (name/Pkgfile per port) + - Built .pkg.tar.xz / .pkg.tar.gz / .pkg.tar.bz2 files + + +Repository format +----------------- + + The repo server exposes two files at its root: + + repo.db Blank-line-separated stanzas of field:value pairs. + Fields: name, version, release, file, deps, desc, + pre-install (base64), post-install (base64). + + repo.sha256 <sha256> <filename> lines (two spaces, sha256sum format). + + Package filenames: <name>#<version>-<release>.pkg.tar.xz + The # is URL-encoded to %23 when fetching (avoids fragment interpretation). + + Pre/post-install scripts are embedded directly in repo.db as base64. + Legacy repos with separate script files (pre-install:1) are supported. + + +Environment variables +--------------------- + + REPO_URL Repository base URL (required, or use -u flag). + CACHEDIR Local cache directory (default: /var/cache/pkget). + PKGADD Path to pkgadd (default: pkgadd). + PORTSDIR Ports tree root(s), space-separated (pkgrepo only). + PKGDIR Built package directory (default: /var/pkg/repo). + PKGEXT Package extensions (default: .pkg.tar.gz .pkg.tar.xz .pkg.tar.bz2). + + +See also +-------- + + pkget(1), pkgrepo(1) diff --git a/pkget b/pkget @@ -0,0 +1,525 @@ +#!/bin/sh +# pkget - binary package fetcher and installer +# Fetches packages from a binary repo, resolves deps, verifies, installs via pkgadd +# Usage: pkget [options] <package> [package ...] + +REPO_URL="${REPO_URL:-}" +CACHEDIR="${CACHEDIR:-/var/cache/pkget}" +DBCACHE="${DBCACHE:-$CACHEDIR/repo.db}" +SUMCACHE="${SUMCACHE:-$CACHEDIR/repo.sha256}" +PKGADD="${PKGADD:-pkgadd}" +VERBOSE=0 +DRY_RUN=0 +NO_DEPS=0 +FORCE=0 +UPGRADE=0 +FULL_UPGRADE=0 +DO_OUTDATED=0 +OUTDATED_NAMES="" + +usage() { + cat <<USAGE +Usage: pkget [options] <package> [package ...] + +Options: + -u <url> Repository base URL (required, or set REPO_URL) + -c <dir> Cache directory (default: /var/cache/pkget) + -s Sync repo DB from server + -n Dry run: resolve and print, no install + -N Skip dependency resolution + -f Force reinstall even if already installed + -U Upgrade mode (passes -u to pkgadd) + -o Show outdated packages (compare installed vs repo) + -v Verbose output + -h Show this help + +Environment: + REPO_URL Repository base URL (e.g. https://pkg.example.com) + CACHEDIR Local cache dir + +Examples: + pkget -u https://pkg.example.com -s # sync DB + pkget -u https://pkg.example.com wget # install wget + deps + pkget -n wget # dry run + pkget -U -u https://pkg.example.com curl # upgrade curl + pkget -o -u https://pkg.example.com # list outdated packages +USAGE + exit 0 +} + +log() { [ "$VERBOSE" -eq 1 ] && printf '[v] %s\n' "$*" >&2; } +info() { printf '==> %s\n' "$*" >&2; } +warn() { printf 'pkget: warning: %s\n' "$*" >&2; } +err() { printf 'pkget: error: %s\n' "$*" >&2; } +die() { err "$*"; exit 1; } + +# --------------------------------------------------------------------------- +# fetch <url> <dest> +# --------------------------------------------------------------------------- +fetch() { + _url="$1"; _dest="$2" + if command -v curl >/dev/null 2>&1; then + curl -fsSL -o "$_dest" "$_url" + elif command -v wget >/dev/null 2>&1; then + wget -q -O "$_dest" "$_url" + else + die "neither curl nor wget found" + fi +} + +# --------------------------------------------------------------------------- +# _mktemp -- portable temp file creation (GNU + BSD) +# --------------------------------------------------------------------------- +_mktemp() { + mktemp 2>/dev/null || mktemp -t pkget 2>/dev/null || \ + die "mktemp failed" +} + +# --------------------------------------------------------------------------- +# _sha256 <file> -- print SHA256 hash, portable across implementations +# --------------------------------------------------------------------------- +_sha256() { + if command -v sha256sum >/dev/null 2>&1; then + sha256sum "$1" | awk '{print $1}' + elif command -v shasum >/dev/null 2>&1; then + shasum -a 256 "$1" | awk '{print $1}' + elif command -v sha256 >/dev/null 2>&1; then + sha256 -q "$1" + else + die "no sha256 tool found (need sha256sum, shasum, or sha256)" + fi +} + +# --------------------------------------------------------------------------- +# DB helpers +# --------------------------------------------------------------------------- +db_has() { + # db_has <dbfile> <pkgname> + grep -Fx "name:${2}" "$1" >/dev/null 2>&1 +} + +db_get() { + # db_get <dbfile> <pkgname> <field> -> prints value + awk -v pkg="$2" -v field="$3" ' + /^$/ { in_pkg=0 } + /^name:/ { in_pkg=(substr($0,6)==pkg) } + in_pkg && $0 ~ "^" field ":" { print substr($0, length(field)+2); exit } + ' "$1" +} + +# --------------------------------------------------------------------------- +# Sync repo DB + checksums from server +# --------------------------------------------------------------------------- +sync_db() { + [ -z "$REPO_URL" ] && die "REPO_URL not set (use -u)" + info "syncing repo DB from $REPO_URL" + mkdir -p "$CACHEDIR" + fetch "$REPO_URL/repo.db" "$DBCACHE" || die "failed to fetch repo.db" + fetch "$REPO_URL/repo.sha256" "$SUMCACHE" || die "failed to fetch repo.sha256" + _count=$(grep -c '^name:' "$DBCACHE" 2>/dev/null || printf '0') + info "synced: $_count packages available" +} + +# --------------------------------------------------------------------------- +# verify_pkg <path> -- verify against repo.sha256 +# --------------------------------------------------------------------------- +verify_pkg() { + _pkgpath="$1" + _basename="${_pkgpath##*/}" + + _expected=$(awk -v f="$_basename" '$2==f{print $1}' "$SUMCACHE") + if [ -z "$_expected" ]; then + warn "no checksum entry for $_basename" + return 1 + fi + + _actual=$(_sha256 "$_pkgpath") + if [ "$_actual" = "$_expected" ]; then + log "checksum OK: $_basename" + return 0 + else + err "checksum MISMATCH: $_basename" + err " expected: $_expected" + err " actual: $_actual" + return 1 + fi +} + +# --------------------------------------------------------------------------- +# run_script <pkgname> <pre-install|post-install> +# --------------------------------------------------------------------------- +run_script() { + _pkg="$1"; _type="$2" + _script="$CACHEDIR/${_pkg}.${_type}" + + if [ ! -f "$_script" ]; then + _val=$(db_get "$DBCACHE" "$_pkg" "$_type") + case "$_val" in + b64:*) + # Embedded base64 script in DB (current format) + printf '%s' "${_val#b64:}" | base64 -d > "$_script" 2>/dev/null || { + rm -f "$_script"; return 0 + } + ;; + 1) + # Legacy: fetch separate script file from repo + fetch "$REPO_URL/${_pkg}.${_type}" "$_script" 2>/dev/null || { + rm -f "$_script"; return 0 + } + ;; + *) return 0 ;; + esac + fi + + [ -f "$_script" ] || return 0 + + info "running ${_type} for ${_pkg}" + chmod +x "$_script" + sh "$_script" || warn "${_type} script for ${_pkg} exited non-zero" +} + +# --------------------------------------------------------------------------- +# url_encode_path <string> -- encode characters unsafe in URL paths +# Only encodes # (fragment marker); extend if needed. +# --------------------------------------------------------------------------- +url_encode_path() { + # Encode chars unsafe in URL path: % first to avoid double-encoding + printf '%s' "$1" | sed \ + -e 's/%/%25/g' \ + -e 's/#/%23/g' \ + -e 's/ /%20/g' \ + -e 's/?/%3F/g' \ + -e 's/&/%26/g' +} + +# --------------------------------------------------------------------------- +# fetch_pkg <pkgname> -- fetch to cache, verify; prints local path +# --------------------------------------------------------------------------- +fetch_pkg() { + _pkg="$1" + _file=$(db_get "$DBCACHE" "$_pkg" "file") + [ -z "$_file" ] && die "no file entry for $_pkg in DB" + + _dest="$CACHEDIR/$_file" + # Encode # in filename for use in HTTP URL (curl/wget treat # as fragment) + _urlfile=$(url_encode_path "$_file") + + if [ -f "$_dest" ]; then + log "cached: $_file" + verify_pkg "$_dest" || { + info "re-fetching (bad cache): $_file" + rm -f "$_dest" + } + fi + + if [ ! -f "$_dest" ]; then + info "fetching: $_file" + fetch "$REPO_URL/$_urlfile" "$_dest" || die "fetch failed: $_file" + verify_pkg "$_dest" || die "checksum failed: $_file" + fi + + printf '%s' "$_dest" +} + +# --------------------------------------------------------------------------- +# is_installed <pkgname> +# --------------------------------------------------------------------------- +is_installed() { + if command -v pkginfo >/dev/null 2>&1; then + pkginfo -i | awk '{print $1}' | grep -Fx "$1" >/dev/null 2>&1 + else + [ -f /var/lib/pkg/db ] && grep -Fx "$1" /var/lib/pkg/db >/dev/null 2>&1 + fi +} + +# --------------------------------------------------------------------------- +# _list_outdated <outfile> +# Writes "name inst_verrel repo_verrel" for each outdated package to <outfile>. +# --------------------------------------------------------------------------- +_list_outdated() { + _out="$1" + _repo_tmp=$(_mktemp) + awk ' + /^name:/ { name=substr($0,6) } + /^version:/ { ver=substr($0,9) } + /^release:/ { rel=substr($0,9) } + /^$/ { if(name) { printf "%s %s %s\n", name, ver, rel } + name=""; ver=""; rel="" } + END { if(name) printf "%s %s %s\n", name, ver, rel } + ' "$DBCACHE" | LC_ALL=C sort > "$_repo_tmp" + + _inst_tmp=$(_mktemp) + if command -v pkginfo >/dev/null 2>&1; then + pkginfo -i 2>/dev/null | awk '{print $1, $2}' | LC_ALL=C sort > "$_inst_tmp" + else + while IFS= read -r _pkg; do + [ -z "$_pkg" ] && continue + _v=$(cat "/var/lib/pkg/$_pkg/version" 2>/dev/null) + _r=$(cat "/var/lib/pkg/$_pkg/release" 2>/dev/null) + [ -n "$_v" ] && printf '%s %s-%s\n' "$_pkg" "$_v" "$_r" + done < /var/lib/pkg/db | LC_ALL=C sort > "$_inst_tmp" + fi + + _joined=$(_mktemp) + LC_ALL=C join "$_repo_tmp" "$_inst_tmp" > "$_joined" + + while IFS=' ' read -r _name _repo_ver _repo_rel _inst_verrel; do + [ -z "$_name" ] && continue + _repo_verrel="${_repo_ver}-${_repo_rel}" + [ "$_inst_verrel" != "$_repo_verrel" ] && \ + printf '%s %s %s\n' "$_name" "$_inst_verrel" "$_repo_verrel" + done < "$_joined" > "$_out" + + rm -f "$_repo_tmp" "$_inst_tmp" "$_joined" +} + +# --------------------------------------------------------------------------- +# install_pkg <pkgname> <local_path> +# --------------------------------------------------------------------------- +install_pkg() { + _pkg="$1"; _path="$2" + + _has_pre=$(db_get "$DBCACHE" "$_pkg" "pre-install") + _has_post=$(db_get "$DBCACHE" "$_pkg" "post-install") + + [ -n "$_has_pre" ] && run_script "$_pkg" "pre-install" + + _flags="" + [ "$UPGRADE" -eq 1 ] && _flags="$_flags -u" + [ "$FORCE" -eq 1 ] && _flags="$_flags -f" + + info "installing: $_pkg" + # shellcheck disable=SC2086 + "$PKGADD" $_flags "$_path" || die "pkgadd failed: $_pkg" + + [ -n "$_has_post" ] && run_script "$_pkg" "post-install" +} + +# --------------------------------------------------------------------------- +# main +# --------------------------------------------------------------------------- +DO_SYNC=0 + +while getopts 'u:c:snNfUovh' _opt; do + case "$_opt" in + u) REPO_URL="$OPTARG" ;; + c) CACHEDIR="$OPTARG" + DBCACHE="$CACHEDIR/repo.db" + SUMCACHE="$CACHEDIR/repo.sha256" ;; + s) DO_SYNC=1 ;; + n) DRY_RUN=1 ;; + N) NO_DEPS=1 ;; + f) FORCE=1 ;; + U) UPGRADE=1 ;; + o) DO_OUTDATED=1 ;; + v) VERBOSE=1 ;; + h) usage ;; + *) usage ;; + esac +done +shift $((OPTIND - 1)) + +[ "$DO_SYNC" -eq 1 ] && sync_db +[ $# -eq 0 ] && [ "$DO_SYNC" -eq 1 ] && exit 0 +if [ $# -eq 0 ]; then + [ "$DO_OUTDATED" -ne 1 ] && [ "$UPGRADE" -ne 1 ] && usage +fi + +[ -z "$REPO_URL" ] && die "REPO_URL not set (use -u or export REPO_URL)" +[ -f "$DBCACHE" ] || die "repo DB not found at $DBCACHE -- run: pkget -s" +[ -f "$SUMCACHE" ] || die "checksum file not found at $SUMCACHE -- run: pkget -s" + +# ------------------------------------------------------------------- +# Outdated mode: compare installed versions against repo +# ------------------------------------------------------------------- +if [ "$DO_OUTDATED" -eq 1 ]; then + _outdated_raw=$(_mktemp) + _list_outdated "$_outdated_raw" + + # If named packages, filter to just those + if [ $# -gt 0 ]; then + _filtered=$(_mktemp) + for _pkg in "$@"; do + grep "^${_pkg} " "$_outdated_raw" >> "$_filtered" 2>/dev/null || \ + warn "'$_pkg' is not installed, not in repo, or up to date" + done + LC_ALL=C sort -u "$_filtered" > "$_outdated_raw" + rm -f "$_filtered" + fi + + _outdated=$(wc -l < "$_outdated_raw" | tr -d ' ') + if [ "$_outdated" -eq 0 ]; then + info "all packages up to date" + rm -f "$_outdated_raw" + exit 0 + fi + + info "outdated packages ($_outdated):" + while IFS=' ' read -r _name _inst_verrel _repo_verrel; do + printf ' %-24s %s -> %s\n' "$_name" "$_inst_verrel" "$_repo_verrel" + done < "$_outdated_raw" + + rm -f "$_outdated_raw" + exit 0 +fi + +# ------------------------------------------------------------------- +# Full upgrade: -U with no package args → upgrade all outdated +# ------------------------------------------------------------------- +if [ "$UPGRADE" -eq 1 ] && [ $# -eq 0 ]; then + _upgrade_raw=$(_mktemp) + _list_outdated "$_upgrade_raw" + + _count=$(wc -l < "$_upgrade_raw" | tr -d ' ') + if [ "$_count" -eq 0 ]; then + info "all packages up to date" + rm -f "$_upgrade_raw" + exit 0 + fi + + info "outdated packages ($_count):" + while IFS=' ' read -r _name _inst_verrel _repo_verrel; do + printf ' %-24s %s -> %s\n' "$_name" "$_inst_verrel" "$_repo_verrel" + done < "$_upgrade_raw" + + OUTDATED_NAMES=$(_mktemp) + awk '{print $1}' "$_upgrade_raw" > "$OUTDATED_NAMES" + FULL_UPGRADE=1 + + # Replace positional args with outdated package names (dep resolver runs below) + set -- + while IFS=' ' read -r _name _inst_verrel _repo_verrel; do + [ -n "$_name" ] && set -- "$@" "$_name" + done < "$_upgrade_raw" + rm -f "$_upgrade_raw" +fi + +mkdir -p "$CACHEDIR" + +_raw_list=$(_mktemp) + +if [ "$NO_DEPS" -eq 1 ]; then + for _pkg in "$@"; do + db_has "$DBCACHE" "$_pkg" || die "package not found in repo: $_pkg" + printf '%s\n' "$_pkg" + done >> "$_raw_list" +else + # Pre-extract deps from DB + _deps_cache=$(_mktemp) + awk ' + /^name:/ { name=substr($0,6) } + /^deps:/ { deps=substr($0,6) + if (name) { printf "%s:%s\n", name, deps; name="" } } + /^$/ { name="" } + ' "$DBCACHE" > "$_deps_cache" + + # Validate roots exist in the cache + for _pkg in "$@"; do + grep "^${_pkg}:" "$_deps_cache" >/dev/null 2>&1 || \ + die "package not found in repo: $_pkg" + done + + printf '%s\n' "$@" | awk -v depsfile="$_deps_cache" ' + BEGIN { + while ((getline < depsfile) > 0) { + col = index($0, ":") + name = substr($0, 1, col-1) + deps[name] = substr($0, col+1) + } + close(depsfile) + } + { roots[++nr] = $0 } + END { + for (i = 1; i <= nr; i++) visit(roots[i]) + for (i = 1; i <= oi; i++) print order[i] + } + function visit(pkg, j, n, a) { + if (pkg in visited) return + if (pkg in stack) { + printf "pkget: warning: circular dependency: %s\n", pkg > "/dev/stderr" + return + } + if (pkg in deps) { + stack[pkg] = 1 + n = split(deps[pkg], a, " ") + for (j = 1; j <= n; j++) { + if (a[j] != "") visit(a[j]) + } + delete stack[pkg] + } + visited[pkg] = 1 + order[++oi] = pkg + } + ' >> "$_raw_list" + + rm -f "$_deps_cache" +fi + +_full_list=$(_mktemp) +awk '!seen[$0]++' "$_raw_list" > "$_full_list" +rm -f "$_raw_list" + +_to_install=$(_mktemp) +while IFS= read -r _pkg; do + [ -z "$_pkg" ] && continue + if is_installed "$_pkg" && [ "$FORCE" -eq 0 ] && [ "$UPGRADE" -eq 0 ] && [ "$FULL_UPGRADE" -eq 0 ]; then + log "already installed: $_pkg" + elif [ "$FULL_UPGRADE" -eq 1 ] && is_installed "$_pkg"; then + if grep -Fx "$_pkg" "$OUTDATED_NAMES" >/dev/null 2>&1; then + printf '%s\n' "$_pkg" >> "$_to_install" + else + log "up to date: $_pkg" + fi + else + printf '%s\n' "$_pkg" >> "$_to_install" + fi +done < "$_full_list" +rm -f "$_full_list" + +_count=$(wc -l < "$_to_install" | tr -d ' ') +if [ "$_count" -eq 0 ]; then + info "nothing to do (all packages already installed)" + rm -f "$_to_install" + [ -n "$OUTDATED_NAMES" ] && rm -f "$OUTDATED_NAMES" + exit 0 +fi + +info "packages to install ($_count):" +while IFS= read -r _pkg; do + _ver=$(db_get "$DBCACHE" "$_pkg" "version") + _rel=$(db_get "$DBCACHE" "$_pkg" "release") + printf ' %-20s %s-%s\n' "$_pkg" "$_ver" "$_rel" +done < "$_to_install" + +[ "$DRY_RUN" -eq 1 ] && { info "dry run, not installing"; rm -f "$_to_install"; exit 0; } + +printf 'Proceed? [y/N] ' +read -r _ans +case "$_ans" in + [Yy]|[Yy][Ee][Ss]) ;; + *) info "aborted"; rm -f "$_to_install"; exit 0 ;; +esac + +info "fetching packages..." +_fetch_map=$(_mktemp) + +while IFS= read -r _pkg; do + [ -z "$_pkg" ] && continue + _path=$(fetch_pkg "$_pkg") || { + rm -f "$_to_install" "$_fetch_map" + die "fetch failed, aborting" + } + printf '%s\t%s\n' "$_pkg" "$_path" >> "$_fetch_map" +done < "$_to_install" + +# Install phase +info "installing..." +while IFS= read -r _pkg; do + [ -z "$_pkg" ] && continue + _path=$(awk -v p="$_pkg" -F'\t' '$1==p{print $2}' "$_fetch_map") + install_pkg "$_pkg" "$_path" +done < "$_to_install" + +rm -f "$_to_install" "$_fetch_map" +[ -n "$OUTDATED_NAMES" ] && rm -f "$OUTDATED_NAMES" +info "done" diff --git a/pkget.1 b/pkget.1 @@ -0,0 +1,134 @@ +.TH PKGET 1 "2026-06-11" "pkget 1.0" "User Commands" +.SH NAME +pkget \- binary package fetcher and installer +.SH SYNOPSIS +.B pkget +[\fB\-u\fR \fIurl\fR] +[\fB\-c\fR \fIdir\fR] +[\fB\-snNfUovh\fR] +[\fIpackage\fR ...] +.SH DESCRIPTION +\fBpkget\fR downloads binary packages from a remote HTTP repository, +resolves dependencies, verifies SHA256 checksums, and installs via +\fBpkgadd\fR(8). +.PP +The repository must contain a \fBrepo.db\fR (package metadata) and a +\fBrepo.sha256\fR (checksums) at its root. Run \fBpkget \-s\fR first +to sync these files to the local cache. +.SH OPTIONS +.TP +\fB\-u\fR \fIurl\fR +Repository base URL (required, or set \fBREPO_URL\fR in the environment). +.TP +\fB\-c\fR \fIdir\fR +Cache directory (default: \fB/var/cache/pkget\fR). +.TP +\fB\-s\fR +Sync the repo database and checksum file from the server. Exits +after syncing unless packages are also specified. +.TP +\fB\-n\fR +Dry run: resolve dependencies, print the install plan, and exit +without downloading or installing anything. +.TP +\fB\-N\fR +Skip dependency resolution. Only the named packages are processed; +their dependencies are ignored. +.TP +\fB\-f\fR +Force reinstall. Packages that are already installed are reinstalled +instead of being skipped. Passes \fB\-f\fR to \fBpkgadd\fR. +.TP +\fB\-U\fR +Upgrade mode. With explicit package names, passes \fB\-u\fR to +\fBpkgadd\fR for each package. With \fIno package arguments\fR, +upgrades \fIall\fR outdated packages (dependencies resolved, new +dependencies fetched, up-to-date packages skipped). +.TP +\fB\-o\fR +Show outdated packages. Compares installed versions against the +repo and prints packages with available updates. With package +arguments, checks only those packages. With no arguments, checks +all installed packages. +.TP +\fB\-v\fR +Verbose output. Prints additional progress messages prefixed with +\fB[v]\fR. +.TP +\fB\-h\fR +Print usage and exit. +.SH ENVIRONMENT +.TP +\fBREPO_URL\fR +Repository base URL. Must be set if \fB\-u\fR is not used. +.TP +\fBCACHEDIR\fR +Local cache directory (default: \fB/var/cache/pkget\fR). The repo +database and downloaded packages are stored here. +.TP +\fBPKGADD\fR +Path to \fBpkgadd\fR (default: \fBpkgadd\fR). +.SH FILES +.TP +\fB/var/cache/pkget/repo.db\fR +Cached repository database. +.TP +\fB/var/cache/pkget/repo.sha256\fR +Cached checksum file. +.TP +\fB/var/lib/pkg/db\fR +Installed package list (fallback when \fBpkginfo\fR is unavailable). +.SH EXAMPLES +.TP +Sync the repo index: +\fBpkget \-u https://pkg.example.com \-s\fR +.TP +Install a package and its dependencies: +\fBpkget \-u https://pkg.example.com wget\fR +.TP +Dry-run an install: +\fBpkget \-n \-u https://pkg.example.com curl\fR +.TP +Reinstall a package (force): +\fBpkget \-f \-u https://pkg.example.com bash\fR +.TP +Check for outdated packages: +\fBpkget \-o \-u https://pkg.example.com\fR +.TP +Upgrade all outdated packages: +\fBpkget \-U \-u https://pkg.example.com\fR +.TP +Install a standalone package (skip deps): +\fBpkget \-N \-u https://pkg.example.com mypkg\fR +.SH "INSTALL PIPELINE" +Each install follows this sequence: +.IP 1. 3 +\fBResolve\fR \[em] Build the full ordered package list (dependencies +first) via topological sort. +.IP 2. +\fBFilter\fR \[em] Skip already-installed packages (unless \fB\-f\fR +or \fB\-U\fR). In full-upgrade mode, up-to-date transitive +dependencies are also skipped. +.IP 3. +\fBPlan\fR \[em] Print what will be installed, prompt for confirmation. +.IP 4. +\fBFetch\fR \[em] Download all packages and verify SHA256 checksums +\fIbefore\fR installing anything (atomicity). +.IP 5. +\fBInstall\fR \[em] Run pre-install script, call \fBpkgadd\fR, run +post-install script for each package in dependency order. +.SH "PRE/POST-INSTALL SCRIPTS" +Scripts are embedded in \fBrepo.db\fR as base64-encoded +\fBpre-install:b64:...\fR and \fBpost-install:b64:...\fR lines. +Legacy repos that store scripts as separate files +(\fBpre-install:1\fR) are still supported. +.SH "EXIT STATUS" +.IP 0 +Success. +.IP 1 +An error occurred (fetch failure, checksum mismatch, missing package, etc.). +.SH "SEE ALSO" +\fBpkgrepo\fR(1), \fBpkgadd\fR(8), \fBpkginfo\fR(8), \fBprt\-get\fR(8) +.SH BUGS +Circular dependencies are detected and warned about; the involved +packages are skipped rather than causing an infinite loop. diff --git a/pkgrepo b/pkgrepo @@ -0,0 +1,245 @@ +#!/bin/sh +# pkgrepo - binary package repository database generator +# Reads a CRUX-style ports tree and generates a repo DB + checksums +# Usage: pkgrepo [options] + +PORTSDIR="${PORTSDIR:-}" +PKGDIR="${PKGDIR:-/var/pkg/repo}" +PKGEXT="${PKGEXT:-.pkg.tar.gz .pkg.tar.xz .pkg.tar.bz2}" +VERBOSE=0 + +usage() { + cat <<EOF +Usage: pkgrepo [options] + +Options: + -p <dir> Ports tree directory (auto-detected from prt-get if omitted) + -r <dir> Package repo directory (default: $PKGDIR) + -d <file> Output DB file (default: \$PKGDIR/repo.db) + -e <exts> Package extensions, space-separated (default: .pkg.tar.gz .pkg.tar.xz .pkg.tar.bz2) + -v Verbose output + -h Show this help + +Environment: + PORTSDIR Ports tree root(s), space-separated (auto-detected from prt-get if not set) + PKGDIR Built packages directory + PKGEXT Package extensions to search, space-separated (default: .pkg.tar.gz .pkg.tar.xz .pkg.tar.bz2) + + +The ports tree should follow standard CRUX layout: + \$PORTSDIR/<name>/Pkgfile + \$PORTSDIR/<name>/pre-install (optional) + \$PORTSDIR/<name>/post-install (optional) + +Dependencies are read from the Pkgfile comment: + # Depends on: foo bar baz +EOF + exit 0 +} + +log() { + [ "$VERBOSE" -eq 1 ] && printf '%s\n' "$*" >&2 +} + +err() { + printf 'pkgrepo: error: %s\n' "$*" >&2 +} + +die() { + err "$*" + exit 1 +} + +# --------------------------------------------------------------------------- +# _mktemp -- portable temp file creation (GNU + BSD) +# --------------------------------------------------------------------------- +_mktemp() { + mktemp 2>/dev/null || mktemp -t pkgrepo 2>/dev/null || \ + die "mktemp failed" +} + +# --------------------------------------------------------------------------- +# _sha256 <file> -- print SHA256 hash, portable across implementations +# --------------------------------------------------------------------------- +_sha256() { + if command -v sha256sum >/dev/null 2>&1; then + sha256sum "$1" | awk '{print $1}' + elif command -v shasum >/dev/null 2>&1; then + shasum -a 256 "$1" | awk '{print $1}' + elif command -v sha256 >/dev/null 2>&1; then + sha256 -q "$1" + else + die "no sha256 tool found (need sha256sum, shasum, or sha256)" + fi +} + +# Parse a Pkgfile and emit fields to stdout +# Output: name, version, release, deps (space-separated), desc +parse_pkgfile() { + _pkgfile="$1" + _name="" _version="" _release="" _deps="" _desc="" + + while IFS= read -r _line; do + case "$_line" in + name=*) _name="${_line#name=}" ;; + version=*) _version="${_line#version=}" ;; + release=*) _release="${_line#release=}" ;; + '# Depends on:'*) _deps="${_line#*# Depends on:}" + # trim leading whitespace + _deps="${_deps#"${_deps%%[! ]*}"}" + # normalize to space-separated (handles commas) + _deps=$(printf '%s' "$_deps" | tr ',' ' ' | tr -s ' ') ;; + '# Description:'*) _desc="${_line#*# Description:}" + _desc="${_desc#"${_desc%%[! ]*}"}" ;; + esac + done < "$_pkgfile" + + printf 'name:%s\nversion:%s\nrelease:%s\ndeps:%s\ndesc:%s\n' \ + "$_name" "$_version" "$_release" "$_deps" "$_desc" +} + +# Find the built package file for a given name/version/release +# PKGEXT is a space-separated list of extensions to try +find_pkg() { + _n="$1" _v="$2" _r="$3" + for _ext in $PKGEXT; do + # Try exact match first + _path="$PKGDIR/${_n}#${_v}-${_r}${_ext}" + [ -f "$_path" ] && { printf '%s' "$_path"; return 0; } + # Try glob (version/release may differ if pkg was rebuilt) + for _f in "$PKGDIR/${_n}#"*"${_ext}"; do + [ -f "$_f" ] && { printf '%s' "$_f"; return 0; } + done + done + return 1 +} + +# Generate repo DB +gen_db() { + _tmpdb=$(_mktemp) || die "failed to create temp file" + _tmpsum=$(_mktemp) || die "failed to create temp file" + _count=0 + _missing=0 + + for _portsdir in $PORTSDIR; do + [ -d "$_portsdir" ] || continue + for _pkgfile in "$_portsdir"/*/Pkgfile; do + [ -f "$_pkgfile" ] || continue + _portdir="${_pkgfile%/Pkgfile}" + _portname="${_portdir##*/}" + + log "processing: $_portname" + + # Parse fields from Pkgfile + _info=$(parse_pkgfile "$_pkgfile") + _name=$(printf '%s' "$_info" | awk -F: '/^name:/{print $2}') + _version=$(printf '%s' "$_info" | awk -F: '/^version:/{print $2}') + _release=$(printf '%s' "$_info" | awk -F: '/^release:/{print $2}') + _deps=$(printf '%s' "$_info" | awk -F: '/^deps:/{print $2}') + _desc=$(printf '%s' "$_info" | awk -F: '/^desc:/{print $2}') + + # Skip if no name/version (malformed Pkgfile) + [ -z "$_name" ] || [ -z "$_version" ] && { + err "skipping $_portname: missing name or version" + continue + } + + # Find built package + _pkgpath=$(find_pkg "$_name" "$_version" "$_release") + if [ $? -ne 0 ] || [ -z "$_pkgpath" ]; then + log " WARNING: no built package found for $_name-$_version-$_release" + _missing=$((_missing + 1)) + continue + fi + + _pkgfile_basename="${_pkgpath##*/}" + + # Checksum the package + _sum=$(_sha256 "$_pkgpath") + + # Embed pre/post install scripts as base64 in the DB stanza + _pre="" + _post="" + if [ -f "$_portdir/pre-install" ]; then + _pre=$(base64 < "$_portdir/pre-install" | tr -d '\n') + fi + if [ -f "$_portdir/post-install" ]; then + _post=$(base64 < "$_portdir/post-install" | tr -d '\n') + fi + + # Write stanza to DB + { + printf 'name:%s\n' "$_name" + printf 'version:%s\n' "$_version" + printf 'release:%s\n' "$_release" + printf 'file:%s\n' "$_pkgfile_basename" + printf 'deps:%s\n' "$_deps" + printf 'desc:%s\n' "$_desc" + [ -n "$_pre" ] && printf 'pre-install:b64:%s\n' "$_pre" + [ -n "$_post" ] && printf 'post-install:b64:%s\n' "$_post" + printf '\n' + } >> "$_tmpdb" + + # Write checksum + printf '%s %s\n' "$_sum" "$_pkgfile_basename" >> "$_tmpsum" + + _count=$((_count + 1)) + log " -> $_pkgfile_basename [$_sum]" + done + done + + mv "$_tmpdb" "$DBFILE" + mv "$_tmpsum" "$SUMFILE" + + printf 'pkgrepo: wrote %d packages to %s\n' "$_count" "$DBFILE" + [ "$_missing" -gt 0 ] && \ + printf 'pkgrepo: WARNING: %d ports skipped (no built package)\n' "$_missing" +} + +# --- main --- + +while getopts 'p:r:d:e:vh' _opt; do + case "$_opt" in + p) PORTSDIR="$OPTARG" ;; + r) PKGDIR="$OPTARG" ;; + d) DBFILE="$OPTARG" ;; + e) PKGEXT="$OPTARG" ;; + v) VERBOSE=1 ;; + h) usage ;; + *) usage ;; + esac +done + +# Strip trailing slashes for clean path concatenation +PKGDIR="${PKGDIR%/}" + +# Auto-detect ports directory from prt-get if not set by env or -p flag +if [ -z "$PORTSDIR" ]; then + if command -v prt-get >/dev/null 2>&1 && [ -f /etc/prt-get.conf ]; then + PORTSDIR=$(grep '^prtdir ' /etc/prt-get.conf | awk '{print $2}' | tr '\n' ' ') + PORTSDIR="${PORTSDIR% }" + fi + : "${PORTSDIR:=/usr/ports}" +fi + +# Strip trailing slashes from each ports directory word +_PDIRS="" +for _pd in $PORTSDIR; do + _PDIRS="${_PDIRS} ${_pd%/}" +done +PORTSDIR="${_PDIRS# }" + +# Validate at least one ports directory exists +_ports_ok=0 +for _pd in $PORTSDIR; do + [ -d "$_pd" ] && { _ports_ok=1; break; } +done +[ "$_ports_ok" -eq 1 ] || die "no ports directory found (tried: $PORTSDIR)" + +[ -d "$PKGDIR" ] || die "package repo dir not found: $PKGDIR" + +# Default DBFILE/SUMFILE after PKGDIR is finalized (so -r takes effect) +: "${DBFILE:=$PKGDIR/repo.db}" +: "${SUMFILE:=$PKGDIR/repo.sha256}" + +gen_db diff --git a/pkgrepo.1 b/pkgrepo.1 @@ -0,0 +1,125 @@ +.TH PKGREPO 1 "2026-06-11" "pkgrepo 1.0" "User Commands" +.SH NAME +pkgrepo \- binary package repository database generator +.SH SYNOPSIS +.B pkgrepo +[\fB\-p\fR \fIdir\fR] +[\fB\-r\fR \fIdir\fR] +[\fB\-d\fR \fIfile\fR] +[\fB\-e\fR \fIexts\fR] +[\fB\-vh\fR] +.SH DESCRIPTION +\fBpkgrepo\fR reads a CRUX-style ports tree (\fBPkgfile\fR per port) +and generates the two files needed by \fBpkget\fR(1) to serve as a +binary package repository: +.TP +\fBrepo.db\fR +Blank-line-separated stanzas with fields: \fBname\fR, \fBversion\fR, +\fBrelease\fR, \fBfile\fR, \fBdeps\fR, \fBdesc\fR, and optionally +\fBpre-install\fR and \fBpost-install\fR (base64-encoded). +.TP +\fBrepo.sha256\fR +Lines of \fB<sha256> <filename>\fR (two spaces), compatible with +\fBsha256sum\fR(1) output. +.PP +Pre- and post-install scripts (\fBpre-install\fR, \fBpost-install\fR) +found alongside the \fBPkgfile\fR are embedded directly in the DB +stanza as base64-encoded values. No separate script files need to +be served. +.SH OPTIONS +.TP +\fB\-p\fR \fIdir\fR +Ports tree directory. May be specified multiple times for +multiple trees (space-separated). If not set, auto-detected from +\fBprt-get\fR(8) configuration (\fB/etc/prt-get.conf\fR), falling +back to \fB/usr/ports\fR. +.TP +\fB\-r\fR \fIdir\fR +Package repository directory containing built \fB.pkg.tar.*\fR files +(default: \fB/var/pkg/repo\fR). +.TP +\fB\-d\fR \fIfile\fR +Output path for the repository database (default: +\fB$PKGDIR/repo.db\fR). +.TP +\fB\-e\fR \fIexts\fR +Package file extensions to search, space-separated (default: +\fB\&.pkg.tar.gz .pkg.tar.xz .pkg.tar.bz2\fR). +.TP +\fB\-v\fR +Verbose output. Prints each port as it is processed. +.TP +\fB\-h\fR +Print usage and exit. +.SH ENVIRONMENT +.TP +\fBPORTSDIR\fR +Ports tree root(s), space-separated. Must be set or auto-detected. +.TP +\fBPKGDIR\fR +Directory containing built package files (default: +\fB/var/pkg/repo\fR). +.TP +\fBPKGEXT\fR +Package extensions to search, space-separated (default: +\fB\&.pkg.tar.gz .pkg.tar.xz .pkg.tar.bz2\fR). +.SH "PORTS TREE LAYOUT" +The ports tree must follow standard CRUX layout: +.PP +.RS +\fB$PORTSDIR/\fR\fIname\fR\fB/Pkgfile\fR +.br +\fB$PORTSDIR/\fR\fIname\fR\fB/pre-install\fR (optional) +.br +\fB$PORTSDIR/\fR\fIname\fR\fB/post-install\fR (optional) +.RE +.SS Pkgfile format +A \fBPkgfile\fR is a shell fragment with variable assignments: +.PP +.RS +\fBname=\fR\fIpkgname\fR +.br +\fBversion=\fR\fI1.2.3\fR +.br +\fBrelease=\fR\fI1\fR +.RE +.PP +Metadata is read from comment annotations: +.PP +.RS +\fB# Depends on:\fR \fIfoo bar baz\fR +.br +\fB# Description:\fR \fIShort description\fR +.RE +.PP +Dependencies may be comma- or space-separated. +.SH "PACKAGE FILE DISCOVERY" +For each port, \fBpkgrepo\fR looks for a built package in \fBPKGDIR\fR: +.IP 1. 3 +Exact match: \fB<name>#<version>\-<release><ext>\fR +.IP 2. +Fallback glob: \fB<name>#*<ext>\fR (allows rebuilt packages with +different version/release). +.PP +Each extension in \fBPKGEXT\fR is tried in order. +.SH EXAMPLES +.TP +Generate a repo from a ports tree: +\fBpkgrepo \-p /usr/ports \-r /var/pkg/repo\fR +.TP +Custom output path and extensions: +\fBpkgrepo \-p ./ports \-r ./repo \-d ./repo/custom.db \-e \(dq.pkg.tar.xz .pkg.tar.zst\(dq\fR +.TP +Multiple ports trees: +\fBpkgrepo \-p \(dq/usr/ports/core /usr/ports/opt\(dq \-r /var/pkg/repo \-v\fR +.SH "EXIT STATUS" +.IP 0 +Success. +.IP 1 +An error occurred (missing ports directory, no packages found, etc.). +.SH "SEE ALSO" +\fBpkget\fR(1), \fBprt\-get\fR(8), \fBpkgmk\fR(8), \fBsha256sum\fR(1) +.SH NOTES +Ports without a corresponding built package are skipped with a +warning; they do not cause a fatal error. A summary of skipped +ports is printed at the end.