commit 5372996f02b06a71eab17a17e4ac633a368ef135
Author: emmett1 <emmett1.2miligrams@protonmail.com>
Date: Thu, 11 Jun 2026 23:48:04 +0800
initial commit
Diffstat:
| A | .gitignore | | | 2 | ++ |
| A | Makefile | | | 25 | +++++++++++++++++++++++++ |
| A | README | | | 67 | +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ |
| A | pkget | | | 525 | +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ |
| A | pkget.1 | | | 134 | +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ |
| A | pkgrepo | | | 245 | +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ |
| A | pkgrepo.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.