#!/bin/sh
# scraps - ScrapLinux Package Manager
#
# Binary packages are .spz - Scraps Package Zip - which is a
# tar.xz holding .PKGINFO, .FILES, an optional .INSTALL, and the payload rooted
# at /. No GNU utilities are required at runtime.
# shellcheck shell=sh disable=SC2039,SC2044,SC2086

set -u

# Where this copy of scraps lives, so its companions can be run without
# depending on PATH. `scraps ins -s` used to run "scraps build ..." by name: fine
# for an installed system, and a flat "scraps: command not found" for anyone
# running it out of a checkout or from an absolute path - which is every
# source build during development, and the installer's own target_scraps.
SCRAPS_SELFDIR=$(cd "$(dirname "$0")" 2>/dev/null && pwd) || SCRAPS_SELFDIR=/usr/bin
# $SCRAPS_SELFDIR/../lib/scraps/libscraps.sh matters on its own, not just as a
# fallback: scraps-strap invokes this binary by absolute path
# ($T/usr/bin/scraps) from a live environment that is not ScrapLinux at all, so
# neither the host-absolute /usr/lib/scraps/libscraps.sh (nothing ScrapLinux is
# installed at the real root) nor $SCRAPS_SELFDIR/libscraps.sh (that file
# actually lives in usr/lib/scraps, a sibling of usr/bin, not the same
# directory as the scraps binary) ever matched - only ./libscraps.sh did, and
# only by accident of whatever the caller's cwd happened to be.
#
# Recorded before libscraps.sh runs, because it is about to give all three a
# default derived from SCRAPS_ROOT and there is no way to tell afterwards
# whether a value came from the caller or from that default. --root= is parsed
# much further down, long after this, and has to re-derive them - but only the
# ones the caller did not set deliberately. See the re-derivation after the
# argument loop.
_env_db=${SCRAPS_DB+set} _env_cache=${SCRAPS_CACHE+set} _env_log=${SCRAPS_LOG+set}
for p in /usr/lib/scraps/libscraps.sh ./libscraps.sh \
	"$SCRAPS_SELFDIR/libscraps.sh" "$SCRAPS_SELFDIR/../lib/scraps/libscraps.sh"; do
	[ -f "$p" ] && { . "$p"; break; }
done
[ -n "${SCRAPS_VERSION:-}" ] || { echo "scraps: cannot locate libscraps.sh" >&2; exit 1; }

scraps_load_conf

# Anything half-built when scraps stops - a resolver scratch directory, a
# database entry an interrupted install_one was still assembling - is removed
# on the way out. A leftover .incoming-<pkg> is harmless (nothing reads it,
# and is_installed only counts a directory with a PKGINFO in it), but there
# is no reason to leave it lying in the database either.
scraps_cleanup() {
	[ -n "${RES_DIR:-}" ] && rm -rf "$RES_DIR" 2>/dev/null
	[ -n "${SCRAPS_DB:-}" ] && rm -rf "$SCRAPS_DB"/local/.incoming-* 2>/dev/null
	return 0
}
trap 'scraps_cleanup' EXIT
trap 'scraps_cleanup; exit 130' INT
trap 'scraps_cleanup; exit 143' TERM

ARCH=${SCRAPS_ARCH:-x86_64}
NOMOD=0
# Binary is the default install path - source is the opt-in (-s), not the
# other way around: a source-first default meant `scraps add <pkg>` failed
# outright for anything without a source recipe, even when a perfectly good
# binary was one flag away. SCRAPS_BUILD_MODE (scraps.conf) picks the
# default; -s/-b on the command line always win over it either way.
SOURCE=0
[ "${SCRAPS_BUILD_MODE:-binary}" = "source" ] && SOURCE=1
RAW=0
# run_hooks reads this, and every command that reaches it has to have set it
# first. "scraps commit" did not, so with set -u it died on "HOOK_KERNEL:
# unbound variable" *after* activating the package - the work was done and the
# command still reported failure. Declared once here so no path can miss it.
HOOK_KERNEL=""
REASON=explicit

# ------------------------------------------------------------------------ usage

usage() {
	cat <<EOF
${A_TEAL}${C_B}scraps${C_R} ${A_GREY}${SCRAPS_VERSION}${C_R}  ${A_GREY}the ScrapLinux package manager${C_R}

  ${C_B}scraps add${C_R} <pkg>              install
  ${C_B}scraps add -s${C_R} <pkg>           build it from source instead
  ${C_B}scraps rem${C_R} <pkg>              remove
  ${C_B}scraps update${C_R}                 upgrade everything
  ${C_B}scraps fetch all${C_R}              sync the repositories

${A_ICE}────${C_R} ${C_B}packages${C_R}
  add <pkg>...              install
  add -s <pkg>...           build from the port and install that
  add -nomod <pkg>...       install without putting it in place - no hooks, no
                            bootloader or initramfs wiring. for kernels and
                            other pieces you want to stage first
  commit <pkg>...           put a -nomod install into place
  reins <pkg>...            reinstall, repairing anything modified
  update [pkg]...           upgrade everything, or only what is named

${A_ICE}────${C_R} ${C_B}removing${C_R}
  rem <pkg>...              remove
  rem -a <pkg>...           and the config and cache it leaves behind
  rem -d <pkg>...           and the dependencies nothing else needs
  rem -a -d <pkg>...        both
  rem -f <pkg>...           even if something still depends on it
  autoremove                every orphan on the system

${A_ICE}────${C_R} ${C_B}repositories${C_R}
  repo add <url>            add one
  repo del <name>           remove one
  repo list                 show what is configured
  fetch all                 sync every enabled repository
  fetch <repo>...           sync only these
  fetch ports               refresh the recipe list only
  mirror <repo>             show and pick the fastest mirror

${A_ICE}────${C_R} ${C_B}the system${C_R}
  system get                the installed release, kernel and generation
  system check              what system upgrade would do - nothing applied
  system check <what>       one area (bootloader, kernel, pkg, init, net) or
                            one level (major, minor, change, bugfix)
  system upgrade            sync, upgrade packages and kernel, update the
                            bootloader, apply pending fixes, reconcile
                            system.conf - in place, nothing removed, no
                            new tarball needed
  system upgrade <what>     the same, but only one area or level of fix
  system fix, system update  same as upgrade - here for the name
  system boot limine-efi <partition>  install Limine (UEFI) to a partition
  system boot grub-bios <partition>   install GRUB (BIOS) to that partition's disk

${A_ICE}────${C_R} ${C_B}asking${C_R}
  list [-e]                 what is installed (-e: only what you asked for)
  index [repo]              what is available
  search <pattern>          names and descriptions
  info <pkg>                details
  files <pkg>               what it owns
  owns <path>               what owns this
  deps / rdeps <pkg>        what it needs / what needs it
  why <pkg>                 why it is here
  orphans                   what nothing needs
  stats                     sizes, counts, where things came from

${A_ICE}────${C_R} ${C_B}looking after it${C_R}
  verify [pkg]              check every installed file against the database
  doctor                    find problems and offer to fix them
  clean                     empty the download cache
  hold / unhold <pkg>       pin against upgrades
  mark <pkg> explicit|dep   change why it is installed
  snapshot [name]           save the package state
  rollback [id]             undo a transaction, restoring what it replaced
  log                       what has been done
  build <recipe>            build an .spz from a port recipe
  get <pkg>...              unpack a package here without installing it
  get -r <pkg>...           fetch the raw .spz here instead, unpacked
  get -s <pkg>...           fetch its upstream source here
  offline <dir> <pkg>...    fetch <pkg> and its full dependency closure as
                             raw .spz into <dir>, indexed and ready to serve
                             as an offline repo
  version                   print the version

${A_ICE}────${C_R} ${C_B}anywhere${C_R}
  -y, --yes                 answer every prompt with yes
  -s, --source              build from source (the default - see scraps.conf)
  -b, --binary              install a binary instead of building from source
  -r, --raw                 with get: keep the raw .spz instead of unpacking
  --root=<dir>              work on another root instead of /

${A_GREY}Anything that changes the system needs root.${C_R}
EOF
}

# scraps-build, found the same way libscraps.sh is: beside this script first,
# then the installed location, then PATH.
scraps_build_path() {
	if [ -x "$SCRAPS_SELFDIR/scraps-build" ]; then printf '%s\n' "$SCRAPS_SELFDIR/scraps-build"
	elif [ -x /usr/bin/scraps-build ]; then printf '%s\n' /usr/bin/scraps-build
	else printf 'scraps-build\n'; fi
}

scraps_build_tool() { sh "$(scraps_build_path)" "$@"; }

# scraps-repo, found the same way: beside this script first, then the
# installed location, then PATH.
scraps_repo_path() {
	if [ -x "$SCRAPS_SELFDIR/scraps-repo" ]; then printf '%s\n' "$SCRAPS_SELFDIR/scraps-repo"
	elif [ -x /usr/bin/scraps-repo ]; then printf '%s\n' /usr/bin/scraps-repo
	else printf 'scraps-repo\n'; fi
}

scraps_repo_tool() { sh "$(scraps_repo_path)" "$@"; }

# ------------------------------------------------------------- dependency logic

# Resolution happens in two passes, and neither of them runs in a command
# substitution: a die() inside a subshell only kills the subshell, so the old
# single-pass version could fail to resolve a dependency and still hand back
# an empty-but-successful list for scraps to carry on with.
#
# Pass one walks the graph breadth-first and is where every bit of the work
# is - one index lookup per package. It writes the graph, the closure and
# anything unresolvable into $RES_DIR, and drives the "Calculating
# dependencies..." bar from packages-read over packages-discovered: two real
# counts, so the bar can sit still while a big subtree opens up and never
# moves without something having actually happened.
#
# Pass two orders that closure deps-first. It reads nothing new - just the
# graph pass one already wrote - so it is instant and needs no progress of
# its own.
RES_DIR=""

resolve_targets() {
	RES_DIR="$SCRAPS_CACHE/build/.resolve.$$"
	rm -rf "$RES_DIR"; mkdir -p "$RES_DIR"
	: >"$RES_DIR/graph"; : >"$RES_DIR/missing"; : >"$RES_DIR/order"

	_rt_frontier="$*"
	_rt_seen=""
	_rt_read=0
	_rt_found=0
	for _rt_p in $_rt_frontier; do _rt_found=$((_rt_found+1)); done

	calc_begin
	while [ -n "$_rt_frontier" ]; do
		_rt_next=""
		for _rt_p in $_rt_frontier; do
			case " $_rt_seen " in *" $_rt_p "*) continue ;; esac
			_rt_seen="$_rt_seen $_rt_p"
			_rt_deps=""
			if _rt_e=$(idx_lookup "$_rt_p"); then
				_rt_deps=$(printf '%s' "$_rt_e" | cut -f9)
			elif is_installed "$_rt_p"; then
				# Already on the system and no longer in any index (a repo
				# it came from is gone, or it was built from source). Its own
				# recorded dependencies are still the truth for it.
				# Braces around the redirect: a failed input redirection is
				# reported by the shell itself, so "2>/dev/null" on tr alone
				# still printed "can't open .../DEPS: no such file" across the
				# resolver's progress bar. An entry with no DEPS at all is a
				# package installed by something that did not write one (the
				# live image used to do exactly that) - no dependencies
				# recorded is not an error.
				_rt_deps=$({ tr '\n' ' ' <"$SCRAPS_DB/local/$_rt_p/DEPS"; } 2>/dev/null || :)
			else
				printf '%s\n' "$_rt_p" >>"$RES_DIR/missing"
			fi
			printf '%s\t%s\n' "$_rt_p" "$_rt_deps" >>"$RES_DIR/graph"
			_rt_read=$((_rt_read+1))
			for _rt_d in $_rt_deps; do
				[ "$_rt_d" = "-" ] && continue
				case " $_rt_seen $_rt_next " in *" $_rt_d "*) continue ;; esac
				_rt_next="$_rt_next $_rt_d"
				_rt_found=$((_rt_found+1))
			done
			calc_step "$_rt_read" "$_rt_found"
		done
		_rt_frontier=$_rt_next
	done
	calc_done

	_topo_seen=""
	for _rt_p in $*; do _topo "$_rt_p"; done
	return 0
}

_g_deps() { awk -F'\t' -v p="$1" '$1==p{print $2; exit}' "$RES_DIR/graph"; }

_topo() {
	# local, because this recurses: without it the inner call overwrites the
	# caller's package name and the wrong entry lands in the install order.
	local p deps d
	p=$1
	case " $_topo_seen " in *" $p "*) return 0 ;; esac
	_topo_seen="$_topo_seen $p"
	deps=$(_g_deps "$p")
	for d in $deps; do
		[ "$d" = "-" ] && continue
		_topo "$d"
	done
	printf '%s\n' "$p" >>"$RES_DIR/order"
}

# Who pulled a package in - used to explain a dependency that cannot be
# resolved instead of just naming it.
_needed_by() {
	awk -F'\t' -v d="$1" '{n=split($2,a," "); for(i=1;i<=n;i++) if(a[i]==d){print $1; exit}}' \
		"$RES_DIR/graph"
}

# ------------------------------------------------------------------ transactions

txid() { date '+%Y%m%d-%H%M%S'; }

TX=""
TX_DIR=""

# A btrfs snapshot before the transaction, if the system is set up for it.
# This is separate from scraps's own rollback: that restores the files scraps
# replaced, whereas this catches anything a package's install script did too.
tx_snapshot() {
	[ "${SCRAPS_SNAPSHOT:-auto}" = "off" ] && return 0
	[ "$SCRAPS_ROOT" = "/" ] || return 0          # never for --root installs
	have scraplinux-snapshot || return 0
	[ "$(stat -f -c %T / 2>/dev/null)" = "btrfs" ] || return 0
	scraplinux-snapshot create "$1" >/dev/null 2>&1 || :
}

tx_begin() {
	TX=$(txid)
	TX_DIR="$SCRAPS_DB/snapshots/$TX"
	mkdir -p "$TX_DIR/replaced"
	: >"$TX_DIR/added"
	: >"$TX_DIR/packages"
	printf 'id = %s\nstarted = %s\ncommand = %s\n' \
		"$TX" "$(date '+%Y-%m-%d %H:%M:%S')" "$SCRAPS_CMDLINE" >"$TX_DIR/TXINFO"
}

# Preserve a file we are about to clobber so rollback can put it back.
tx_save() {
	f=$1
	[ -n "$TX_DIR" ] || return 0
	[ -e "$SCRAPS_ROOT$f" ] || return 0
	d=$(dirname "$TX_DIR/replaced$f")
	mkdir -p "$d"
	cp -a "$SCRAPS_ROOT$f" "$TX_DIR/replaced$f" 2>/dev/null || :
}

tx_added() { [ -n "$TX_DIR" ] && printf '%s\n' "$1" >>"$TX_DIR/added" || :; }

# The package database is not part of the filesystem snapshot above: tx_save
# only covers files a package payload replaces, and $SCRAPS_DB/local is written
# by scraps itself. Without these two, rollback put every *file* back and left
# the database describing the state it had just undone - a rolled-back
# install stayed "installed" with no files, and a rolled-back upgrade kept the
# new version's entry.
#
# tx_db_save records what the entry looked like before this transaction
# touched it; tx_db_new records that there was no entry at all, so rollback
# knows to delete rather than restore.
tx_db_save() {
	[ -n "$TX_DIR" ] || return 0
	if [ -f "$SCRAPS_DB/local/$1/PKGINFO" ]; then
		mkdir -p "$TX_DIR/db"
		rm -rf "$TX_DIR/db/$1"
		cp -a "$SCRAPS_DB/local/$1" "$TX_DIR/db/$1"
	else
		printf '%s\n' "$1" >>"$TX_DIR/db-new"
	fi
}

tx_end() {
	[ -n "$TX_DIR" ] || return 0
	printf 'finished = %s\nstatus = %s\n' "$(date '+%Y-%m-%d %H:%M:%S')" "${1:-ok}" \
		>>"$TX_DIR/TXINFO"
	# Keep the last 20 transactions; older ones are pruned.
	#
	# "head -n -20" is a GNU extension. toybox - which is what an ScrapLinux
	# system actually runs - rejects it outright with "head: -n < 0", and
	# since this runs at the end of every single transaction, every install
	# ended with that error printed after its own success line. Count first,
	# then ask for a positive number of lines.
	_tx_keep=20
	_tx_n=$(ls -1d "$SCRAPS_DB"/snapshots/*/ 2>/dev/null | wc -l | tr -d ' ')
	if [ "${_tx_n:-0}" -gt "$_tx_keep" ]; then
		ls -1d "$SCRAPS_DB"/snapshots/*/ 2>/dev/null | sort | \
			head -n "$(( _tx_n - _tx_keep ))" | \
			while read -r old; do rm -rf "$old"; done
	fi
}

# ----------------------------------------------------------------------- hooks

run_hooks() {
	[ "$NOMOD" = "1" ] && { info "skipping system hooks (-nomod)"; return 0; }
	msg2 "running system hooks"
	if have ldconfig; then ldconfig 2>/dev/null || :; fi
	for k in "$SCRAPS_ROOT"/lib/modules/*; do
		[ -d "$k" ] || continue
		have depmod && depmod -a "$(basename "$k")" 2>/dev/null || :
	done
	if [ -n "$HOOK_KERNEL" ]; then
		if have scraplinux-mkinitramfs; then
			msg2 "regenerating initramfs"
			scraplinux-mkinitramfs || warn "initramfs generation failed"
		fi
		if have scraplinux-bootctl; then
			msg2 "updating bootloader entries"
			scraplinux-bootctl update || warn "bootloader update failed"
		fi
	fi
	have update-desktop-database && update-desktop-database -q 2>/dev/null || :
	have gtk-update-icon-cache && \
		gtk-update-icon-cache -qtf "$SCRAPS_ROOT/usr/share/icons/hicolor" 2>/dev/null || :
	have makewhatis && makewhatis "$SCRAPS_ROOT/usr/share/man" 2>/dev/null || :
	:
}

pkg_script() {
	pkgdir=$1 phase=$2 name=$3
	# Two different directory shapes reach this function, and the hook is
	# named differently in each. An unpacked package still has its metadata
	# dotfiles, so it is "$tmp/.INSTALL" (pre_install/pre_upgrade, called
	# before the payload is committed). The installed-package database keeps
	# the same script as "$db/INSTALL", without the dot, alongside FILES and
	# PKGINFO - that is what post_install/post_upgrade/pre_remove/post_remove
	# are handed.
	#
	# Only the dotted name was ever checked, so every hook invoked from the
	# database - which is all of them except the two pre_* phases - silently
	# returned here and did nothing. Nothing in the tree shipped an install
	# hook, so nothing noticed: the first package to actually use one
	# (nvidia-drivers, whose whole configuration step is a post_install) would
	# have installed to a completely unconfigured system with no error to
	# explain why.
	_ps_f=""
	[ -f "$pkgdir/.INSTALL" ] && _ps_f="$pkgdir/.INSTALL"
	[ -z "$_ps_f" ] && [ -f "$pkgdir/INSTALL" ] && _ps_f="$pkgdir/INSTALL"
	[ -n "$_ps_f" ] || return 0
	[ "$NOMOD" = "1" ] && return 0
	# The script is sourced in a subshell so a bad hook cannot poison scraps.
	( . "$_ps_f"
	  if command -v "$phase" >/dev/null 2>&1; then
		msg2 "$name: $phase"
		"$phase" || warn "$name: $phase failed"
	  fi ) || :
}

# ---------------------------------------------------------------------- install

# Fetches one package and leaves the result in FETCH_FILE. Deliberately not
# a function that prints the path for a caller's $(...) to capture: it draws
# a progress bar on stdout, and the two cannot share a channel.
#
# $2 is "package" for something the user actually asked for and "dependency"
# for anything dragged in behind it - the only difference is how the line
# above the bar reads.
FETCH_FILE=""
FETCH_INDEX=1

fetch_pkg() {
	pkg=$1 role=${2:-package}
	entry=$(idx_lookup "$pkg") || die "package not found: $pkg"
	repo=$(printf '%s' "$entry" | cut -f1)
	ver=$(printf '%s' "$entry" | cut -f3)
	rel=$(printf '%s' "$entry" | cut -f4)
	arch=$(printf '%s' "$entry" | cut -f5)
	dlsize=$(printf '%s' "$entry" | cut -f6)
	want=$(printf '%s' "$entry" | cut -f8)
	# name/version/release/arch all end up as path components below (and in
	# the URL fetched from). A repo index is remote input - refuse to build a
	# path out of a field that could walk it somewhere other than
	# SCRAPS_CACHE/pkg.
	for _c in "$pkg" "$ver" "$rel" "$arch"; do
		safe_component "$_c" || die "package index entry for $pkg has an unsafe field ('$_c') - refusing to fetch"
	done
	file="$pkg-$ver-$rel.$arch.spz"
	dest="$SCRAPS_CACHE/pkg/$file"
	url=$(scraps_repo_url "$repo")
	pkgbase=$(scraps_repo_pkgurl "$repo")
	FETCH_FILE=$dest

	# "-" in the checksum column means the index does not know the hash. That
	# is normal for a local repository built on this machine - scraps-repo wrote
	# the index from the packages sitting next to it - and it is not
	# acceptable for anything arriving over a network, where the index is the
	# only thing vouching for the package. Left unconditional, the weakest
	# possible index entry disabled verification: anyone able to serve an
	# index could write "-" and skip the check entirely, and the package's
	# install script runs as root.
	case "$url" in
	file://*|/*) _local=1 ;;
	*)           _local=0 ;;
	esac
	if [ "$want" = "-" ] && [ "$_local" = "0" ]; then
		die "$repo has no checksum for $file - refusing to fetch it over the network"
	fi

	if [ -s "$dest" ]; then
		if [ "$want" = "-" ] || [ "$(sha256 "$dest")" = "$want" ]; then
			ui_cached "$role" "$pkg" "${FETCH_INDEX:-1}"
			return 0
		fi
		warn "cached $file failed checksum, refetching"
		rm -f "$dest"
	fi

	ui_fetch "$role" "$pkg" "$pkgbase/$file" "${dlsize:-0}" "${FETCH_INDEX:-1}"
	dl_bar "$pkgbase/$file" "$dest" "${dlsize:-0}" "$pkg" || die "download failed: $file"
	if [ "$want" != "-" ] && [ "$(sha256 "$dest")" != "$want" ]; then
		rm -f "$dest"
		die "checksum mismatch on $file - refusing to install"
	fi
}

install_one() {
	archive=$1
	tmp="$SCRAPS_CACHE/build/.unpack.$$"
	rm -rf "$tmp"; mkdir -p "$tmp"
	untar "$archive" "$tmp" || die "cannot unpack $archive"
	[ -f "$tmp/.PKGINFO" ] || die "$archive is not a valid .spz (no .PKGINFO)"

	name=$(meta "$tmp/.PKGINFO" name)
	ver=$(meta "$tmp/.PKGINFO" version)
	rel=$(meta "$tmp/.PKGINFO" release)
	deps=$(metaall "$tmp/.PKGINFO" depend | tr '\n' ' ')
	desc=$(meta "$tmp/.PKGINFO" desc)

	# name becomes $SCRAPS_DB/local/$name below - a .PKGINFO is part of the
	# package payload itself, not something scraps generated, so a hostile
	# .spz could otherwise put "../" in its own name and write its database
	# entry (and everything install_one files under $db) outside SCRAPS_DB.
	safe_component "$name" || die "$archive has an unsafe package name in .PKGINFO ('$name')"

	# File-conflict check against other packages' manifests.
	( cd "$tmp" && find . -type f -o -type l ) | sed 's|^\.||' | \
		grep -v '^/\.\(PKGINFO\|INSTALL\|FILES\)$' >"$tmp.files"
	: >"$tmp.takeover"
	while read -r f; do
		[ -n "$f" ] || continue
		# -e follows links, a dangling one still has an owner
		[ -e "$SCRAPS_ROOT$f" ] || [ -L "$SCRAPS_ROOT$f" ] || continue
		# a package may always rewrite what it already owns
		grep -qxF "$f" "$SCRAPS_DB/local/$name/FILES" 2>/dev/null && continue
		owner=$(grep -lxF "$f" "$SCRAPS_DB"/local/*/FILES 2>/dev/null | head -1) || :
		if [ -n "$owner" ]; then
			o=$(basename "$(dirname "$owner")")
			# identical content is shared, both keep it listed
			same_file "$tmp$f" "$SCRAPS_ROOT$f" && continue
			# An incoming package that says it replaces the current owner is
			# allowed to take the path. This is what a multi-call binary needs:
			# toybox and busybox ship fallback applets - bunzip2, xxd, chrt -
			# and the dedicated package carrying the real implementation is
			# meant to win. Without it every such package stopped its
			# transaction dead, and an install finished reporting that some
			# base utilities could not be installed.
			if metaall "$tmp/.PKGINFO" replaces 2>/dev/null | grep -qx "$o"; then
				printf '%s\t%s\n' "$o" "$f" >>"$tmp.takeover"
				continue
			fi
			die "file conflict: $f is owned by $o
  both would install a different $f - remove $o first, or add
  replaces=\"$o\" to $name's recipe if it is meant to take it over"
		fi
	done <"$tmp.files"

	old=""
	if is_installed "$name"; then
		old=$(installed_version "$name")
		# Kept aside because the database entry is replaced below, and the
		# old manifest is what says which files this upgrade drops.
		cp -f "$SCRAPS_DB/local/$name/FILES" "$tmp.oldfiles" 2>/dev/null || :
		# Same reason, for backup=: a file this exact package used to mark
		# backup= has to stay protected from the stale-file removal below
		# even on the one upgrade that stops shipping it, or dropping it
		# from a recipe becomes indistinguishable from actually wanting it
		# deleted.
		metaall "$SCRAPS_DB/local/$name/PKGINFO" backup >"$tmp.oldbackup" 2>/dev/null || : >"$tmp.oldbackup"
	fi

	kind=$(meta "$tmp/.PKGINFO" kind)
	if [ "$kind" = "profile" ]; then
		printf '%sWARNING: This is a profile package it installs more than one package and configures it rather than being a basic installation.%s\n' \
			"$A_AMB$C_B" "$C_R"
	fi

	# The version detail line belongs to the default style only - apt says
	# "Setting up <pkg> (<version>) ..." and aeryn prints the version in its
	# own status line, so printing it here as well just doubles it.
	if [ -n "$old" ]; then
		[ "$SCRAPS_STYLE" = scraplinux ] && \
			printf '    %s%s %s -> %s-%s%s\n' "$A_GREY" "$name" "$old" "$ver" "$rel" "$C_R"
		pkg_script "$tmp" pre_upgrade "$name"
	else
		[ "$SCRAPS_STYLE" = scraplinux ] && \
			printf '    %s%s %s-%s%s\n' "$A_GREY" "$name" "$ver" "$rel" "$C_R"
		pkg_script "$tmp" pre_install "$name"
	fi

	# Snapshot anything we are about to replace, then lay the payload down.
	while read -r f; do
		[ -n "$f" ] || continue
		if [ -e "$SCRAPS_ROOT$f" ]; then tx_save "$f"; else tx_added "$f"; fi
	done <"$tmp.files"

	rm -f "$tmp/.PKGINFO.keep"
	cp -a "$tmp/.PKGINFO" "$tmp.PKGINFO"
	[ -f "$tmp/.INSTALL" ] && cp -a "$tmp/.INSTALL" "$tmp.INSTALL"
	rm -f "$tmp/.PKGINFO" "$tmp/.INSTALL" "$tmp/.FILES"

	# backup= files are never blindly overwritten: if the path already
	# exists on the target, whatever is there wins. A fresh install still
	# gets the package's own copy (there is nothing local to preserve
	# yet), but a later upgrade never again silently stamps real, live
	# state back to a package's static baseline - this is exactly how
	# /etc/passwd, /etc/group and /etc/shadow got wiped by an ordinary
	# scraplinux-base upgrade before backup= was wired up to do anything at
	# all: it was recorded in every package's own .PKGINFO the whole time
	# and never once consulted.
	for _bk_f in $(metaall "$tmp.PKGINFO" backup); do
		[ -n "$_bk_f" ] || continue
		case "$_bk_f" in /*) ;; *) _bk_f="/$_bk_f" ;; esac
		if [ -e "$SCRAPS_ROOT$_bk_f" ] || [ -L "$SCRAPS_ROOT$_bk_f" ]; then
			rm -f "$tmp$_bk_f"
			info "kept existing $_bk_f (backup=, not overwriting local copy)"
		fi
	done

	if have bsdtar; then
		( cd "$tmp" && bsdtar -cf - . ) | ( cd "$SCRAPS_ROOT" && bsdtar -xpf - )
	else
		( cd "$tmp" && tar -cf - . ) | ( cd "$SCRAPS_ROOT" && tar -xpf - )
	fi
	fix_usrmerge "$SCRAPS_ROOT"

	# Record it.
	#
	# The whole entry is assembled beside the real one and moved into place at
	# the end, with PKGINFO - the file is_installed() keys off - written last
	# of all. Written straight into $SCRAPS_DB/local/$name, an install that died
	# or was interrupted anywhere in here left a half-built directory behind
	# that every later command counted as an installed package: `scraps ins vim`
	# would say vim was already there, `scraps deps vim` died on the DEPS file
	# that never got written, and the resolver marked it satisfied for
	# everything that needed it. Assembling to the side also means a failed
	# upgrade leaves the *old* entry intact instead of a broken one.
	db="$SCRAPS_DB/local/$name"
	dbnew="$SCRAPS_DB/local/.incoming-$name"
	rm -rf "$dbnew"; mkdir -p "$dbnew"
	[ -f "$tmp.INSTALL" ] && cp -a "$tmp.INSTALL" "$dbnew/INSTALL"
	cp -a "$tmp.files" "$dbnew/FILES"
	# "-" is the index's placeholder for "no dependencies", not a package.
	# Written through to DEPS verbatim, it made every dependency-free package
	# permanently unsatisfied: `scraps doctor` reported "1 unsatisfied" on a
	# perfectly healthy system and pointed at a package named "-".
	: >"$dbnew/DEPS"
	for _d in $deps; do
		[ "$_d" = "-" ] && continue
		printf '%s\n' "$_d" >>"$dbnew/DEPS"
	done
	printf 'installed = %s\nsize = %s\ntx = %s\n' \
		"$(date '+%Y-%m-%d %H:%M:%S')" "$(wc -c <"$archive")" "$TX" >"$dbnew/STATE"
	[ "$NOMOD" = "1" ] && printf 'activated = no\n' >>"$dbnew/STATE"
	if [ -f "$db/REASON" ]; then cp -a "$db/REASON" "$dbnew/REASON"
	else printf '%s\n' "${REASON:-explicit}" >"$dbnew/REASON"; fi

	# Per-file checksums, so 'scraps verify' can detect tampering or bit rot.
	: >"$dbnew/HASHES"
	while read -r f; do
		[ -f "$SCRAPS_ROOT$f" ] || continue
		printf '%s  %s\n' "$(sha256 "$SCRAPS_ROOT$f")" "$f" >>"$dbnew/HASHES"
	done <"$tmp.files"

	cp -a "$tmp.PKGINFO" "$dbnew/PKGINFO"
	tx_db_save "$name"
	rm -rf "$db"
	mv "$dbnew" "$db"

	# On an upgrade, remove what the old version owned and the new one does
	# not. Nothing did this before, so a package that stopped shipping a file
	# left it on disk owned by nobody - libressl dropping /etc/ssl/cert.pem
	# would otherwise leave the stale bundle sitting there, still shadowing
	# the one ca-certificates installs. Saved to the transaction first, so
	# rollback puts them back.
	if [ -n "$old" ] && [ -s "$tmp.oldfiles" ]; then
		while read -r _st_f; do
			[ -n "$_st_f" ] || continue
			grep -qxF "$_st_f" "$db/FILES" 2>/dev/null && continue
			[ -e "$SCRAPS_ROOT$_st_f" ] || [ -L "$SCRAPS_ROOT$_st_f" ] || continue
			# still shipped by another package
			grep -lxF "$_st_f" "$SCRAPS_DB"/local/*/FILES 2>/dev/null \
				| grep -qvxF "$db/FILES" && continue
			# A file the old version itself marked backup= does not get
			# deleted just because this release stopped shipping it -
			# dropping something from a recipe is not the same thing as
			# deciding to destroy whatever live state ended up in it.
			if [ -s "$tmp.oldbackup" ] && grep -qxF "${_st_f#/}" "$tmp.oldbackup" 2>/dev/null; then
				info "kept $_st_f (was backup= in $name $old, no longer shipped - not deleting)"
				continue
			fi
			tx_save "$_st_f"
			rm -f "$SCRAPS_ROOT$_st_f"
			info "removed $_st_f (no longer in $name $ver-$rel)"
		done <"$tmp.oldfiles"
	fi
	rm -f "$tmp.oldfiles" "$tmp.oldbackup"

	# Ownership of any identical file handed over above.
	if [ -s "$tmp.takeover" ]; then
		while IFS='	' read -r _to_o _to_f; do
			[ -n "$_to_o" ] || continue
			_to_m="$SCRAPS_DB/local/$_to_o/FILES"
			[ -f "$_to_m" ] || continue
			# grep -v exits 1 when it drops the last line
			{ grep -vxF "$_to_f" "$_to_m" || :; } >"$_to_m.new"
			mv -f "$_to_m.new" "$_to_m"
			info "$_to_f: now owned by $name, was $_to_o"
		done <"$tmp.takeover"
	fi

	if [ -n "$old" ]; then pkg_script "$db" post_upgrade "$name"
	else pkg_script "$db" post_install "$name"; fi

	case "$name" in *kernel*|linux-*) HOOK_KERNEL=1 ;; esac
	printf '%s %s-%s\n' "$name" "$ver" "$rel" >>"$TX_DIR/packages"
	scraps_log "install $name $ver-$rel nomod=$NOMOD"

	rm -rf "$tmp" "$tmp.files" "$tmp.PKGINFO" "$tmp.INSTALL" "$tmp.takeover" "$tmp.oldfiles" "$tmp.oldbackup"
}

# Rewrites pkgs.conf's pkgs.scraplinux[] block to exactly match every
# REASON=explicit package actually installed right now. A mirror, not a
# diff target - nothing ever reconciles the machine *to* this file, so
# there is no separate "declared" state that can drift from reality and
# get enforced against it later. That drift is what once caused a system
# upgrade to quietly remove packages (dwm, fastfetch) that had been
# installed by hand outside of it. See conf.lib for the file format.
sync_declared_packages() {
	_sdp_conf="$SCRAPS_ROOT/etc/scraplinux/pkgs.conf"
	_sdp_lib="$SCRAPS_ROOT/etc/scraplinux/conf.lib"
	[ -f "$_sdp_conf" ] && [ -f "$_sdp_lib" ] && [ -d "$SCRAPS_DB/local" ] || return 0
	# shellcheck disable=SC1090
	. "$_sdp_lib"

	_sdp_list=$(
		for _sdp_d in "$SCRAPS_DB"/local/*/; do
			[ -f "$_sdp_d/PKGINFO" ] || continue
			[ -f "$_sdp_d/REASON" ] && [ "$(cat "$_sdp_d/REASON")" = "explicit" ] || continue
			basename "$_sdp_d"
		done | sort -u
	)
	printf '%s\n' "$_sdp_list" | conf_set "$_sdp_conf" pkgs.scraplinux
}

# Before any source build: make sure the toolchain itself (clang/lld from
# llvm, bmake) is actually on disk. A source build always failed with
# "missing: bmake" even when bmake was a sibling package in the very same
# request (`scraps add base-devel`, where cmake and meson are source-only
# and bmake has a binary) - both source-build paths below run before any
# of the transaction's ordinary binary installs, so a build tool being
# part of the same request never actually helped it. This installs the
# toolchain directly and immediately, ahead of everything else, rather
# than waiting for its normal turn in a transaction that cannot reach
# that turn without it.
ensure_build_tools() {
	# REASON=dep, not the global default of explicit: nobody asked for
	# llvm or bmake by name here, a source build just needs them present.
	_ebt_need=""
	if ! { have clang && have ld.lld; } && idx_lookup llvm >/dev/null 2>&1 && ! is_installed llvm; then
		_ebt_need="$_ebt_need llvm"
	fi
	if ! { have make || have bmake; } && idx_lookup bmake >/dev/null 2>&1 && ! is_installed bmake; then
		_ebt_need="$_ebt_need bmake"
	fi
	[ -n "$_ebt_need" ] || return 0

	# install_one() unconditionally appends to "$TX_DIR/packages" - called
	# outside a transaction that is empty, not unset, and writes end up at
	# the filesystem root. Open a real one, same as every other caller.
	tx_begin
	REASON=dep
	for _ebt_p in $_ebt_need; do
		msg2 "installing $_ebt_p first - a source build needs it"
		fetch_pkg "$_ebt_p" package && install_one "$FETCH_FILE"
	done
	tx_end ok
}

cmd_ins() {
	need_root; scraps_init_db
	[ $# -gt 0 ] || die "no packages named. See 'scraps' for usage."

	if [ "$SOURCE" = "1" ]; then cmd_ins_source "$@"; return; fi

	# Nothing below can tell "there is no binary for this" from "no index has
	# been read yet" once it is past this point, and the difference decides
	# whether a package gets offered for compilation. Stop here instead: an
	# unsynced system offering to compile something the repo has a binary for
	# is the whole bug.
	ls "$SCRAPS_DB"/sync/*.idx >/dev/null 2>&1 || 		die "no repository index on this system - run 'scraps fetch all' first"

	requested="$*"
	ui_reading

	# Sort the requested names into three groups before anything else: ones a
	# synced repo has a binary for, ones only the ports tree has a recipe for,
	# and ones nothing has ever heard of. A package with a binary is never
	# offered for compilation, whatever the ports tree says about it.
	need_build=""; keep=""
	for p in "$@"; do
		if idx_lookup "$p" >/dev/null 2>&1 || is_installed "$p"; then
			keep="$keep $p"; continue
		fi
		if ports_have "$p"; then
			printf "\n  %sno binary for '%s' in any synced repository - only a source recipe.%s\n" \
				"$A_AMB$C_B" "$p" "$C_R"
			printf '  %scompiling it here can take anywhere from seconds to hours.%s\n\n' \
				"$A_GREY" "$C_R"
			printf '  %sCompile it now? [Y/n]%s ' "$C_B" "$C_R"
			if [ "${SCRAPS_YES:-0}" = "1" ]; then
				printf 'y\n'
				ans=y
			# Read from the terminal so the prompt still works when stdin is a
			# pipe. The braces matter: a failed redirect is reported by the
			# shell itself, so redirecting only read's stderr would not
			# suppress it on a machine with no controlling terminal.
			elif { read -r ans </dev/tty; } 2>/dev/null; then
				:
			else
				read -r ans 2>/dev/null || ans=n
			fi
			case "$ans" in
			n|N|no|NO|No) printf '\n'; msg "skipping $p" ;;
			*)            need_build="$need_build $p" ;;
			esac
			continue
		fi
		die "package not found: $p$(not_found_hint)"
	done

	# Anything with only a recipe goes through the source path.
	if [ -n "$need_build" ]; then
		ensure_build_tools
		# shellcheck disable=SC2086
		cmd_ins_source $need_build
	fi

	# Only the packages we actually have binaries for continue from here.
	# shellcheck disable=SC2086
	set -- $keep
	[ $# -gt 0 ] || { [ -n "$need_build" ] || msg "nothing to do"; return 0; }

	# shellcheck disable=SC2086
	resolve_targets $keep
	if [ -s "$RES_DIR/missing" ]; then
		# Sort what is missing into "the ports tree can build it" and "nobody
		# has ever heard of it". The first is an ordinary situation - most of
		# the desktop is still source-only - and answering it with a flat
		# "unresolved dependencies" made a perfectly installable package look
		# broken. The second is a real error.
		_src=""; _gone=""
		while read -r m; do
			[ -n "$m" ] || continue
			if ports_have "$m"; then _src="$_src $m"; else _gone="$_gone $m"; fi
		done <"$RES_DIR/missing"

		if [ -n "$_gone" ]; then
			for m in $_gone; do
				by=$(_needed_by "$m")
				if [ -n "$by" ] && [ "$by" != "$m" ]; then
					err "$by needs $m, and nothing has it - not a repository, not the ports tree"
				else
					err "package not found: $m$(not_found_hint)"
				fi
			done
			die "unresolved dependencies - nothing was installed"
		fi

		# Everything missing can be built. Ask once, for all of them.
		_n=0; _list=""
		for m in $_src; do
			_n=$((_n+1))
			[ -n "$_list" ] && _list="$_list, $m" || _list="$m"
		done
		printf '\n'
		if [ "$_n" = 1 ]; then
			printf '  %sDependency %s is only in ports as of right now.%s\n' \
				"$A_AMB$C_B" "$_list" "$C_R"
		else
			printf '  %sDependencies %s are only in ports as of right now.%s\n' \
				"$A_AMB$C_B" "$_list" "$C_R"
		fi
		printf '  %scompiling them here can take anywhere from seconds to hours.%s\n' \
			"$A_GREY" "$C_R"
		printf '  %sDo you wish to compile?%s %s[Y/n]%s ' "$C_B" "$C_R" "$A_GREY" "$C_R"
		if [ "${SCRAPS_YES:-0}" = "1" ]; then
			printf 'y\n'; _ans=y
		elif { read -r _ans </dev/tty; } 2>/dev/null; then
			:
		else
			read -r _ans 2>/dev/null || _ans=n
		fi
		case "$_ans" in
		n|N|no|NO|No) printf '\n'; die "nothing was installed" ;;
		esac

		# Build them, then start over: with those installed the resolver has
		# a complete graph and the rest of the transaction is an ordinary
		# binary install.
		ensure_build_tools
		# shellcheck disable=SC2086
		cmd_ins_source $_src
		# shellcheck disable=SC2086
		resolve_targets $keep
		if [ -s "$RES_DIR/missing" ]; then
			while read -r m; do
				[ -n "$m" ] || continue
				err "still missing after building from source: $m"
			done <"$RES_DIR/missing"
			die "unresolved dependencies - nothing further was installed"
		fi
	fi

	# Split the resolved set into what is already satisfied and what actually
	# has to be fetched. An installed package at the version the index offers
	# is a met dependency, not a reinstall - re-fetching glibc every time
	# something depends on it is exactly what this avoids.
	todo=""; met=""
	while read -r p; do
		[ -n "$p" ] || continue
		if is_installed "$p"; then
			cur=$(installed_version "$p")
			e=$(idx_lookup "$p") || { met="$met $p"; continue; }
			nv="$(printf '%s' "$e" | cut -f3)-$(printf '%s' "$e" | cut -f4)"
			[ "$cur" = "$nv" ] && { met="$met $p"; continue; }
		fi
		todo="$todo $p"
	done <"$RES_DIR/order"

	if [ -z "$todo" ]; then
		for p in $met; do
			case " $requested " in
			*" $p "*) info "$p $(installed_version "$p") is already installed" ;;
			esac
		done
		msg "nothing to do"
		return 0
	fi

	n=0; total=0; isize=0
	for p in $todo; do
		e=$(idx_lookup "$p") || continue
		total=$(( total + $(printf '%s' "$e" | cut -f6) ))
		isize=$(( isize + $(printf '%s' "$e" | cut -f7) ))
		n=$((n+1))
	done
	ui_summary "$todo" "$met" "$total" "$n" "$isize"
	[ "$NOMOD" = "1" ] && printf '  %sMode:%s           staged (-nomod, not put into place)\n' "$A_AMB" "$C_R"

	# Profile packages are not ordinary packages: they pull in a whole set and
	# then write configuration on top. Say so before anything is downloaded.
	profiles=""
	for p in $todo; do
		e=$(idx_lookup "$p") || continue
		[ "$(printf '%s' "$e" | cut -f1)" = "profile" ] && profiles="$profiles $p"
	done
	if [ -n "$profiles" ]; then
		printf '\n%sWARNING: This is a profile package it installs more than one package and configures it rather than being a basic installation.%s\n' \
			"$A_AMB$C_B" "$C_R"
		printf '\n  %sprofile package(s):%s%s\n' "$A_GREY" "$C_R" "$profiles"
		printf '  %sconfiguration under /etc and in the user home directory will be\n' "$A_GREY"
		printf '  written. Whatever is replaced is saved by the transaction, so\n'
		printf "  'scraps rollback' puts it back.%s\n" "$C_R"
	fi

	printf '\n'
	confirm "$(ui_confirm_text)" || { msg "aborted"; return 1; }
	printf '\n'

	tx_snapshot "before installing:$(printf '%s' "$todo" | cut -c1-60)"
	tx_begin
	HOOK_KERNEL=""
	ndeps=0

	for p in $met; do ui_met "$p"; done

	# Everything is downloaded before anything is installed.
	#
	# Interleaving the two means a transaction that upgrades a library the
	# downloader itself links against strands halfway: upgrade libressl and
	# the curl still on disk is the old one, now missing the soname it was
	# built against, so every remaining package in the same transaction
	# fails to download and the system is left with some of an upgrade
	# applied. Fetching first also means a mirror going away mid-transaction
	# costs nothing - nothing has been touched yet.
	getn=0
	for p in $todo; do
		case " $requested " in
		*" $p "*) role=package ;;
		*)        role=dependency; ndeps=$((ndeps+1)) ;;
		esac
		getn=$((getn+1))
		FETCH_INDEX=$getn
		fetch_pkg "$p" "$role"
		eval "_file_$(printf '%s' "$p" | tr -c 'a-zA-Z0-9' '_')=\$FETCH_FILE"
	done

	ui_phase_install
	insn=0
	for p in $todo; do
		case " $requested " in
		*" $p "*) role=package; REASON=explicit ;;
		*)        role=dependency; REASON=dep ;;
		esac
		eval "_f=\$_file_$(printf '%s' "$p" | tr -c 'a-zA-Z0-9' '_')"
		e=$(idx_lookup "$p") || e=""
		insn=$((insn+1))
		ui_install "$role" "$p" \
			"$(printf '%s' "$e" | cut -f3)-$(printf '%s' "$e" | cut -f4)" \
			"$insn" "$n"
		install_one "$_f"
	done

	run_hooks
	tx_end ok
	sync_declared_packages
	printf '\n'
	# One closing line for the transaction, not one per requested package.
	# Installing the base system asks for twenty-odd packages at once, and a
	# line each - every one of them claiming the same dependency count -
	# filled the screen with "Package 'x' installed with 7 dependencies"
	# twenty times over.
	_done_n=0; _done_first=""
	for p in $requested; do
		is_installed "$p" || continue
		_done_n=$((_done_n+1))
		[ -n "$_done_first" ] || _done_first=$p
	done
	# Companions worth knowing about, gathered from what was actually
	# installed. Anything already present is dropped, so this only ever names
	# things that are genuinely not on the system yet.
	_recs=""
	for p in $requested; do
		is_installed "$p" || continue
		[ -f "$SCRAPS_DB/local/$p/PKGINFO" ] || continue
		for _r in $(sed -n 's/^recommend = //p' "$SCRAPS_DB/local/$p/PKGINFO"); do
			is_installed "$_r" && continue
			case " $_recs " in *" $_r "*) ;; *) _recs="$_recs $_r" ;; esac
		done
	done
	if [ "$_done_n" -gt 1 ]; then
		ui_done "$_done_n packages" "$ndeps" "$TX"
	elif [ "$_done_n" = 1 ]; then
		ui_done "$_done_first" "$ndeps" "$TX"
	fi
	[ "$SCRAPS_STYLE" = scraplinux ] && \
		printf '  %stransaction %s - undo it with: scraps rollback%s\n' "$A_GREY" "$TX" "$C_R"
	ui_recommends "$_recs"
	:
}

cmd_ins_source() {
	need_root; scraps_init_db
	msg "Source build requested"
	for p in "$@"; do
		recipe=""
		for d in /var/lib/scraps/ports/*/"$p"/recipe "$SCRAPS_CACHE/src/$p/recipe"; do
			[ -f "$d" ] && { recipe=$d; break; }
		done
		if [ -z "$recipe" ]; then
			# Recipes come from the ports tree, which is not one of the
			# repositories in repos.d and does not share their layout: it is
			# ALL/<ports-repo>/<name>/recipe, so the ports repo the package
			# lives under has to be looked up before the URL can be built.
			# The old code asked repos.d for a "source" repo and fetched
			# <url>/recipes/<name>/recipe, a path that does not exist on that
			# host - and since a static host answers a missing file with its
			# own index page rather than a 404, the download "succeeded" and
			# left an HTML document sitting where the recipe should be.
			safe_component "$p" || die "invalid package name: $p"
			msg2 "fetching the recipe for $p from the ports tree"
			pr=$(ports_repo_of "$p") || die "no source recipe for $p"
			mkdir -p "$SCRAPS_CACHE/src/$p"
			dlq "$SCRAPS_PORTS/$pr/$p/recipe" "$SCRAPS_CACHE/src/$p/recipe" || \
				die "no source recipe for $p"
			looks_like_recipe "$SCRAPS_CACHE/src/$p/recipe" || {
				rm -f "$SCRAPS_CACHE/src/$p/recipe"
				die "what the ports tree returned for $p is not a recipe"; }
			recipe="$SCRAPS_CACHE/src/$p/recipe"
		fi
		msg2 "building $p from $recipe"
		scraps_build_tool "$recipe" || die "build of $p failed"
		# The same directory scraps-build writes to. Hardcoding
		# $SCRAPS_CACHE/build/out meant that whenever SCRAPS_BUILDROOT pointed
		# anywhere else - which is exactly what the batch builds and any
		# scratch root do - the build succeeded, wrote its .spz, and then
		# `scraps ins -s` said "build produced no package" and threw it away.
		out=$(ls -t "${SCRAPS_BUILDROOT:-$SCRAPS_CACHE/build}/out/$p"-*.spz 2>/dev/null | head -1)
		[ -n "$out" ] || die "build produced no package for $p"
		tx_begin; HOOK_KERNEL=""; REASON=explicit
		install_one "$out"
		run_hooks; tx_end ok
		sync_declared_packages
		ok "$p built from source and installed"
	done
}

cmd_reins() {
	need_root; scraps_init_db
	tx_begin; HOOK_KERNEL=""
	for p in "$@"; do
		is_installed "$p" || die "$p is not installed"
		fetch_pkg "$p" package
		REASON=$(cat "$SCRAPS_DB/local/$p/REASON" 2>/dev/null || echo explicit)
		install_one "$FETCH_FILE"
	done
	run_hooks; tx_end ok
	ok "reinstalled: $*"
}

cmd_commit() {
	need_root
	for p in "$@"; do
		is_installed "$p" || die "$p is not installed"
		grep -q 'activated = no' "$SCRAPS_DB/local/$p/STATE" 2>/dev/null || {
			info "$p is already in place"; continue; }
		msg2 "activating $p"
		sed -i '/activated = no/d' "$SCRAPS_DB/local/$p/STATE"
		NOMOD=0
		pkg_script "$SCRAPS_DB/local/$p" post_install "$p"
		case "$p" in *kernel*|linux-*) HOOK_KERNEL=1 ;; esac
	done
	run_hooks
	ok "activated: $*"
}

# ----------------------------------------------------------------------- remove

remove_files() {
	name=$1
	db="$SCRAPS_DB/local/$name"
	[ -f "$db/FILES" ] || return 0
	# paths another installed package also lists stay on disk
	_rf_keep="$SCRAPS_CACHE/.keep.$$"
	for _rf_m in "$SCRAPS_DB"/local/*/FILES; do
		[ "$_rf_m" = "$db/FILES" ] || cat "$_rf_m"
	done 2>/dev/null | grep -xF -f "$db/FILES" >"$_rf_keep" 2>/dev/null || :
	# Files first, then directories deepest-first so empties disappear.
	while read -r f; do
		[ -n "$f" ] || continue
		[ -L "$SCRAPS_ROOT$f" ] || [ -f "$SCRAPS_ROOT$f" ] || continue
		[ -s "$_rf_keep" ] && grep -qxF "$f" "$_rf_keep" && continue
		tx_save "$f"
		rm -f "$SCRAPS_ROOT$f"
	done <"$db/FILES"
	rm -f "$_rf_keep"
	sed 's|/[^/]*$||' "$db/FILES" | sort -ru | while read -r d; do
		[ -n "$d" ] || continue
		rmdir "$SCRAPS_ROOT$d" 2>/dev/null || :
	done
}

purge_junk() {
	# Every local here is prefixed _pj_: called directly (not in a subshell)
	# from do_remove()'s own "for p in ..." package loop, and a plain "p" used
	# to double as this function's own leftover-path loop variable too -
	# do_remove would come back from purge_junk with its "p" overwritten to
	# the last leftover path checked instead of the package name, so the
	# "rm -rf $SCRAPS_DB/local/$p" and log line right after it ran against that
	# path instead. The package's own database entry never actually got
	# removed by 'scraps del+junk' - it stayed "installed" with its files gone.
	name=$1
	msg2 "$name: purging leftovers"
	for _pj_p in \
		"$SCRAPS_ROOT/etc/$name" "$SCRAPS_ROOT/var/cache/$name" \
		"$SCRAPS_ROOT/var/lib/$name" "$SCRAPS_ROOT/var/log/$name" \
		"$SCRAPS_ROOT/usr/share/$name"
	do
		[ -e "$_pj_p" ] && { info "removing $_pj_p"; rm -rf "$_pj_p"; }
	done
	# .scrapsnew / .scrapssave files left by config merges.
	find "$SCRAPS_ROOT/etc" -name "*.scrapsnew" -o -name "*.scrapssave" 2>/dev/null | \
		while read -r _pj_f; do rm -f "$_pj_f"; done
	rm -f "$SCRAPS_CACHE/pkg/$name"-*.spz
}

find_orphans() {
	for d in "$SCRAPS_DB"/local/*/; do
		[ -d "$d" ] || continue
		p=$(basename "$d")
		is_installed "$p" || continue
		[ "$(cat "$d/REASON" 2>/dev/null)" = "dep" ] || continue
		needed=0
		for o in "$SCRAPS_DB"/local/*/DEPS; do
			[ -f "$o" ] || continue
			[ "$(basename "$(dirname "$o")")" = "$p" ] && continue
			if grep -qx "$p" "$o" 2>/dev/null; then needed=1; break; fi
		done
		[ "$needed" = "0" ] && printf '%s\n' "$p"
	done
}

do_remove() {
	withdeps=$1; withjunk=$2; shift 2
	need_root; scraps_init_db
	[ $# -gt 0 ] || die "no packages named."

	for p in "$@"; do
		is_installed "$p" || die "$p is not installed"
	done

	# Refuse to remove anything protected by pkg.save[] in pkgs.conf,
	# regardless of why it's being removed (explicit or orphan sweep).
	_save_conf="$SCRAPS_ROOT/etc/scraplinux/pkgs.conf"
	_save_lib="$SCRAPS_ROOT/etc/scraplinux/conf.lib"
	if [ -f "$_save_conf" ] && [ -f "$_save_lib" ]; then
		# shellcheck disable=SC1090
		. "$_save_lib"
		_saved=" $(conf_get "$_save_conf" pkg.save | tr '\n' ' ') "
		for p in "$@"; do
			case "$_saved" in
			*" $p "*)
				[ "${SCRAPS_FORCE:-0}" = "1" ] || \
					die "$p is protected by pkg.save in pkgs.conf (use SCRAPS_FORCE=1 to override)" ;;
			esac
		done
	fi

	# Refuse to break the system: nothing installed may still depend on a target.
	for p in "$@"; do
		for o in "$SCRAPS_DB"/local/*/DEPS; do
			[ -f "$o" ] || continue
			on=$(basename "$(dirname "$o")")
			case " $* " in *" $on "*) continue ;; esac
			if grep -qx "$p" "$o" 2>/dev/null; then
				warn "$on depends on $p"
				[ "${SCRAPS_FORCE:-0}" = "1" ] || \
					die "refusing to break dependencies (use SCRAPS_FORCE=1 to override)"
			fi
		done
	done

	printf '\n  %sRemoving (%s)%s\n  ' "$C_B" "$#" "$C_R"
	tot=0
	for p in "$@"; do
		printf '%s-%s ' "$p" "$(installed_version "$p")"
		s=$(meta "$SCRAPS_DB/local/$p/STATE" size 2>/dev/null || echo 0)
		tot=$(( tot + ${s:-0} ))
	done
	printf '\n\n  %sFreed:%s %s\n\n' "$A_GREY" "$C_R" "$(human $tot)"
	confirm "Remove these packages?" || { msg "aborted"; return 1; }

	tx_snapshot "before removing: $*"
	tx_begin
	for p in "$@"; do
		msg2 "removing $p"
		pkg_script "$SCRAPS_DB/local/$p" pre_remove "$p"
		remove_files "$p"
		pkg_script "$SCRAPS_DB/local/$p" post_remove "$p"
		[ "$withjunk" = "1" ] && purge_junk "$p"
		tx_db_save "$p"
		rm -rf "$SCRAPS_DB/local/$p"
		scraps_log "remove $p junk=$withjunk"
	done

	if [ "$withdeps" = "1" ]; then
		orph=$(find_orphans)
		if [ -n "$orph" ]; then
			msg "Orphaned dependencies"
			printf '  %s\n\n' "$(printf '%s' "$orph" | tr '\n' ' ')"
			if confirm "Remove orphaned dependencies too?"; then
				for p in $orph; do
					msg2 "removing orphan $p"
					pkg_script "$SCRAPS_DB/local/$p" pre_remove "$p"
					remove_files "$p"
					[ "$withjunk" = "1" ] && purge_junk "$p"
					rm -rf "$SCRAPS_DB/local/$p"
					scraps_log "remove-orphan $p"
				done
			fi
		else
			info "no orphans left behind"
		fi
	fi
	HOOK_KERNEL=""
	run_hooks
	tx_end ok
	sync_declared_packages
	ok "done. Transaction ${A_TEAL}$TX${C_R}"
}

cmd_autoremove() {
	need_root
	orph=$(find_orphans)
	[ -n "$orph" ] || { ok "no orphans on this system"; return 0; }
	# shellcheck disable=SC2086
	do_remove 0 1 $orph
}

# -------------------------------------------------------------------------- get

cmd_get() {
	scraps_init_db
	[ $# -gt 0 ] || die "no packages named."
	if [ "$SOURCE" = "1" ]; then
		for p in "$@"; do
			msg "Fetching source for $p"
			safe_component "$p" || die "invalid package name: $p"
			pr=$(ports_repo_of "$p") || die "no recipe for $p in the ports tree"
			e=$(idx_lookup "$p") || die "package not found: $p"
			v=$(printf '%s' "$e" | cut -f3)
			mkdir -p "$p-$v"
			for f in recipe sources.list patches.tar install; do
				dlq "$SCRAPS_PORTS/$pr/$p/$f" "$p-$v/$f" 2>/dev/null && info "got $f" || :
			done
			# A static host serves its own index page for a file that is not
			# there, so an unchecked "success" leaves HTML in the recipe's
			# place. Nothing below should read that as a recipe.
			looks_like_recipe "$p-$v/recipe" || {
				rm -f "$p-$v/recipe"
				die "what the ports tree returned for $p is not a recipe"; }
			# Pull the upstream tarballs the recipe names.
			if [ -f "$p-$v/sources.list" ]; then
				while read -r nm url; do
					[ -n "$nm" ] || continue
					msg2 "fetching $nm"
					dl "$url" "$p-$v/$nm" || warn "failed: $nm"
				done <"$p-$v/sources.list"
			fi
			ok "source for $p in ./$p-$v"
		done
		return 0
	fi
	for p in "$@"; do
		msg "Fetching $p"
		fetch_pkg "$p" package
		if [ "$RAW" = "1" ]; then
			out="./$(basename "$FETCH_FILE")"
			cp -f "$FETCH_FILE" "$out"
			ok "$p saved as $out (nothing was installed)"
			continue
		fi
		e=$(idx_lookup "$p"); v=$(printf '%s' "$e" | cut -f3)
		d="$p-$v"
		rm -rf "$d"; mkdir -p "$d"
		untar "$FETCH_FILE" "$d"
		ok "contents of $p unpacked into ./$d (nothing was installed)"
		printf '  %s%s files, %s%s\n' "$A_GREY" \
			"$(find "$d" -type f | wc -l)" "$(du -sh "$d" | cut -f1)" "$C_R"
	done
}

# Fetches <pkg>... and everything they depend on - the same closure `add`
# would install - as raw, checksum-verified .spz files laid out under
# <dir>/ALL/<repo>/<arch>/ and reindexed, so the result is a repo `scraps-repo
# add` or a plain repos.d entry can point at directly. `get -r` fetches one
# package; this is the "build a whole offline mirror" version of the same
# idea, using the identical resolver `add` uses so nothing needed at install
# time is left out. Named `offline`, not `mirror` - `mirror <repo>` already
# means picking the fastest of a repo's several mirror URLs, a different
# thing entirely.
cmd_offline() {
	scraps_init_db
	dir=${1:-}
	[ -n "$dir" ] || die "usage: scraps offline <dir> <package>..."
	shift
	[ $# -gt 0 ] || die "usage: scraps offline <dir> <package>..."
	mkdir -p "$dir" || die "cannot create $dir"

	msg "resolving dependency closure for: $*"
	resolve_targets "$@"
	if [ -s "$RES_DIR/missing" ]; then
		die "unresolved: $(tr '\n' ' ' <"$RES_DIR/missing")"
	fi

	_mirror_touched=""
	while read -r p; do
		[ -n "$p" ] || continue
		msg "Fetching $p"
		fetch_pkg "$p" dependency
		e=$(idx_lookup "$p") || die "package not found: $p"
		repo=$(printf '%s' "$e" | cut -f1)
		arch=$(printf '%s' "$e" | cut -f5)
		safe_component "$repo" || die "unsafe repo name for $p: $repo"
		safe_component "$arch" || die "unsafe arch for $p: $arch"
		d="$dir/ALL/$repo/$arch"
		mkdir -p "$d"
		cp -f "$FETCH_FILE" "$d/$(basename "$FETCH_FILE")"
		case " $_mirror_touched " in
		*" $repo/$arch "*) ;;
		*) _mirror_touched="$_mirror_touched $repo/$arch" ;;
		esac
	done <"$RES_DIR/order"

	for ra in $_mirror_touched; do
		repo=${ra%/*}; arch=${ra#*/}
		scraps_repo_tool gen "$dir/ALL/$repo" "$arch" >/dev/null \
			|| warn "could not reindex $repo/$arch"
		ok "reindexed $repo/$arch"
	done

	printf '\n  %s::%s offline mirror ready at %s\n' "$A_TEAL" "$C_R" "$dir/ALL"
	printf '  serve it over plain HTTPS, then per repo:\n'
	for ra in $_mirror_touched; do
		repo=${ra%/*}
		printf '\n  # /etc/scraps/repos.d/%s.repo\n  name = %s\n  url = https://your-host/ALL/%s\n  enabled = yes\n  priority = 50\n  sig = optional\n' \
			"$repo" "$repo" "$repo"
	done
}

# ------------------------------------------------------------------------ fetch

sync_repo() {
	name=$1 url=$2
	idx="$SCRAPS_DB/sync/$name.idx"
	tmp="$SCRAPS_DB/sync/.$name.new"
	old="$SCRAPS_DB/sync/.$name.old"

	# The line is written before the transfer starts and rewritten when it
	# finishes, so a slow or dead mirror is visibly *being waited on* rather
	# than the whole command sitting silent and then printing a verdict.
	printf '  %s->%s %-14s %sfetching...%s' "$A_ICE$C_B" "$C_R" "$name" "$A_GREY" "$C_R"

	if ! dlq "$url/$ARCH/INDEX" "$tmp" 2>/dev/null; then
		rm -f "$tmp"
		printf '\r  %s->%s %-14s %sunreachable%s   \n' \
			"$A_ICE$C_B" "$C_R" "$name" "$A_RED" "$C_R"
		# Whatever the downloader actually said. Without it every failure
		# reads the same, and a broken curl looks exactly like a mirror
		# being down.
		[ -n "${DL_ERR:-}" ] && printf '     %s%s%s\n' "$A_GREY" \
			"$(printf '%s' "$DL_ERR" | head -2 | tr '\n' ' ')" "$C_R"
		# The one failure worth naming: an https repo on a system with no
		# TLS-capable downloader fails identically to a mirror being down,
		# and the fix is completely different.
		case "$url" in
		https://*) have curl || printf '     %sno https support here - curl is not installed%s\n' \
			"$A_GREY" "$C_R" ;;
		esac
		return 1
	fi

	# Before it is believed, and before it replaces the index already on
	# disk: a rejected index leaves the last good one in place rather than
	# taking the machine's idea of the repository down with it.
	if ! scraps_verify_index "$name" "$url" "$tmp"; then
		rm -f "$tmp"
		# "fetch all" runs each repository in a subshell, so the summary at
		# the end cannot be told about this in a variable.
		: >"$SCRAPS_DB/sync/.sigfail"
		printf '\r  %s->%s %-14s %sunsigned%s      \n' \
			"$A_ICE$C_B" "$C_R" "$name" "$A_RED" "$C_R"
		printf '     %s%s%s\n' "$A_GREY" "$VERIFY_ERR" "$C_R"
		return 1
	fi
	[ -n "${VERIFY_WARN:-}" ] && \
		printf '\r  %s->%s %-14s %sunverified%s %s%s%s\n' \
			"$A_ICE$C_B" "$C_R" "$name" "$A_AMB" "$C_R" \
			"$A_GREY" "$VERIFY_WARN" "$C_R"

	[ -f "$idx" ] && cp -f "$idx" "$old" 2>/dev/null || : >"$old"
	# Strip comments and blank lines; keep it a clean TSV.
	grep -v '^#' "$tmp" | grep -v '^[[:space:]]*$' >"$idx" || :
	bytes=$(wc -c <"$tmp" | tr -d ' ')
	rm -f "$tmp"
	printf '%s\n' "$url" >"$SCRAPS_DB/sync/$name.url"

	c=$(wc -l <"$idx" | tr -d ' ')
	# What actually changed, rather than just how many lines came back. An
	# index that is byte-for-byte what it was last time is worth saying so.
	# Plain temp files, not "comm -13 <(...) <(...)": process substitution is
	# a bashism and this has to run under busybox ash.
	cut -f1 "$old" | sort -u >"$old.n"
	cut -f1 "$idx" | sort -u >"$idx.n"
	cut -f1,2,3 "$old" | sort -u >"$old.v"
	cut -f1,2,3 "$idx" | sort -u >"$idx.v"
	added=$(comm -13 "$old.n" "$idx.n" 2>/dev/null | wc -l | tr -d ' ')
	gone=$(comm -23 "$old.n" "$idx.n" 2>/dev/null | wc -l | tr -d ' ')
	upd=$(comm -13 "$old.v" "$idx.v" 2>/dev/null | wc -l | tr -d ' ')
	upd=$(( ${upd:-0} - ${added:-0} )); [ "$upd" -lt 0 ] && upd=0
	rm -f "$old" "$old.n" "$old.v" "$idx.n" "$idx.v"

	detail=""
	[ "${added:-0}" -gt 0 ] && detail="$detail +$added new"
	[ "${upd:-0}" -gt 0 ]   && detail="$detail $upd updated"
	[ "${gone:-0}" -gt 0 ]  && detail="$detail $gone removed"
	[ -n "$detail" ] || detail=" unchanged"

	printf '\r  %s->%s %-14s %sok%s  %s packages, %s  %s%s%s\n' \
		"$A_ICE$C_B" "$C_R" "$name" "$A_MINT" "$C_R" "$c" "$(human "$bytes")" \
		"$A_GREY" "$detail" "$C_R"
	# cmd_fetch runs this inside a pipeline for "fetch all", which is a
	# subshell - totals go to a file it reads afterwards rather than to
	# variables that would not survive.
	[ -n "${SYNC_TALLY:-}" ] && \
		printf '%s %s %s %s\n' "${added:-0}" "${upd:-0}" "${gone:-0}" "${bytes:-0}" \
			>>"$SYNC_TALLY"
	:
}

cmd_fetch() {
	need_root; scraps_init_db
	which=${1:-all}
	msg "${T_SYNCING:-Synchronizing repositories}"

	# Count what actually succeeded. Reporting "554 packages available" after
	# every repository failed is worse than useless - it reads as success when
	# nothing was reached and the numbers came from a stale cached index.
	okc=0; failc=0
	tmpres=$SCRAPS_DB/.fetch.$$
	: >"$tmpres"
	SYNC_TALLY=$SCRAPS_DB/.fetchtally.$$
	: >"$SYNC_TALLY"
	export SYNC_TALLY

	if [ "$which" = "all" ]; then
		scraps_repos | while IFS='	' read -r n u; do
			if sync_repo "$n" "$u"; then echo ok >>"$tmpres"; else echo fail >>"$tmpres"; fi
		done
	else
		for w in "$@"; do
			u=$(scraps_repo_url "$w")
			if [ -z "$u" ]; then
				err "no such repo: $w"; echo fail >>"$tmpres"; continue
			fi
			if sync_repo "$w" "$u"; then echo ok >>"$tmpres"; else echo fail >>"$tmpres"; fi
		done
	fi
	okc=$(grep -c '^ok$'   "$tmpres" 2>/dev/null); okc=${okc:-0}
	failc=$(grep -c '^fail$' "$tmpres" 2>/dev/null); failc=${failc:-0}
	rm -f "$tmpres"
	tadd=0; tupd=0; tgone=0; tbytes=0
	while read -r a u g b; do
		tadd=$(( tadd + ${a:-0} )); tupd=$(( tupd + ${u:-0} ))
		tgone=$(( tgone + ${g:-0} )); tbytes=$(( tbytes + ${b:-0} ))
	done <"$SYNC_TALLY"
	rm -f "$SYNC_TALLY"

	# The ports tree is not a repository - it holds recipes, on its own host,
	# and `scraps ins -s` is the only thing that reads it. It is still worth
	# refreshing and reporting here: "fetch all" that silently ignored it
	# meant `ins -s` was the first thing to discover the ports manifest was
	# missing or stale, in the middle of a build.
	if [ "$which" = "all" ] || [ "$which" = "ports" ]; then
		printf '  %s->%s %-14s %sfetching...%s' "$A_ICE$C_B" "$C_R" "ports" "$A_GREY" "$C_R"
		rm -f "$SCRAPS_CACHE/ports/ports.idx" "$SCRAPS_CACHE/ports/manifest.tsv"
		if pm=$(ports_index); then
			pc=$(grep -vc '^#' "$pm" 2>/dev/null | tr -d ' ')
			printf '\r  %s->%s %-14s %sok%s  %s recipes  %ssource builds: scraps add -s <pkg>%s\n' \
				"$A_ICE$C_B" "$C_R" "ports" "$A_MINT" "$C_R" "${pc:-0}" "$A_GREY" "$C_R"
		else
			printf '\r  %s->%s %-14s %sunreachable%s   %s(%s)%s\n' \
				"$A_ICE$C_B" "$C_R" "ports" "$A_RED" "$C_R" "$A_GREY" "$SCRAPS_PORTS" "$C_R"
		fi
	fi

	tot=$(cat "$SCRAPS_DB"/sync/*.idx 2>/dev/null | wc -l | tr -d ' ')
	printf '\n'

	if [ "$okc" = "0" ] && [ "$failc" -gt 0 ]; then
		# A rejected signature is not a network problem, and telling someone
		# to check their cabling when the index downloaded perfectly well and
		# was then thrown away sends them a long way in the wrong direction.
		if [ -f "$SCRAPS_DB/sync/.sigfail" ]; then
			rm -f "$SCRAPS_DB/sync/.sigfail"
			warn "no repository passed its signature check"
			printf '  %sThe index downloaded and was refused, which is not a network\n' "$A_GREY"
			printf '  fault. Either the public keys in /etc/scraps/keys are not the ones\n'
			printf '  these repositories are signed with, or what answered is not the\n'
			printf '  repository it claims to be.%s\n' "$C_R"
			return 1
		fi
		warn "no repository could be reached"
		if [ "${tot:-0}" -gt 0 ]; then
			printf '  %s%s package(s) are known from the last sync, which may be out\n' \
				"$A_GREY" "$tot"
			printf '  of date. Anything already on this medium can still be installed.%s\n' "$C_R"
		fi
		printf '  %scheck the network:  ip addr   ping 1.1.1.1   wifi-connect%s\n' "$A_GREY" "$C_R"
		scraps_log "fetch failed: no repository reachable"
		return 1
	fi

	if [ "$failc" -gt 0 ]; then
		warn "$failc of $((okc + failc)) repositories could not be reached"
	fi
	if [ "$okc" = "1" ]; then
		ok "$tot packages available from 1 repository"
	else
		ok "$tot packages available from $okc repositories"
	fi
	# What the sync actually changed, not just how many packages exist.
	if [ $(( tadd + tupd + tgone )) -gt 0 ]; then
		printf '  %s%s new, %s updated, %s withdrawn - %s of index downloaded%s\n' \
			"$A_GREY" "$tadd" "$tupd" "$tgone" "$(human "$tbytes")" "$C_R"
	else
		printf '  %severy index was already up to date (%s downloaded)%s\n' \
			"$A_GREY" "$(human "$tbytes")" "$C_R"
	fi
	scraps_log "fetch $* ok=$okc fail=$failc"

	# Tell the user if upgrades are waiting, but do not act on it.
	up=0
	for d in "$SCRAPS_DB"/local/*/; do
		[ -d "$d" ] || continue
		p=$(basename "$d"); is_held "$p" && continue
		e=$(idx_lookup "$p") || continue
		nv="$(printf '%s' "$e" | cut -f3)-$(printf '%s' "$e" | cut -f4)"
		[ "$(installed_version "$p")" != "$nv" ] && up=$((up+1))
	done
	[ "$up" -gt 0 ] && msg "$up package(s) can be upgraded - run 'scraps update'"
	return 0
}

# ----------------------------------------------------------------------- update

cmd_update() {
	need_root; scraps_init_db
	if [ $# -gt 0 ]; then cmd_ins "$@"; return; fi
	msg "Checking for upgrades"
	todo=""
	for d in "$SCRAPS_DB"/local/*/; do
		[ -d "$d" ] || continue
		p=$(basename "$d")
		if is_held "$p"; then info "$p is held back"; continue; fi
		e=$(idx_lookup "$p") || continue
		cur=$(installed_version "$p")
		nv="$(printf '%s' "$e" | cut -f3)-$(printf '%s' "$e" | cut -f4)"
		[ "$cur" = "$nv" ] && continue
		printf '  %s%-24s%s %s -> %s%s%s\n' "$C_B" "$p" "$C_R" "$cur" "$A_MINT" "$nv" "$C_R"
		todo="$todo $p"
	done
	[ -n "$todo" ] || { ok "system is up to date"; return 0; }
	printf '\n'
	# shellcheck disable=SC2086
	cmd_ins $todo
}

# ----------------------------------------------------------------------- system
#
# Package upgrades move a system from one release of a package to the next.
# They cannot carry a correction that does not change a version: a fix to a
# shell script inside scraps, a service file that was wrong, a default that
# turned out to be harmful. Those used to reach an installed machine only by
# rebuilding it from a newer image.
#
# The fix repository is where those corrections live. It is an ordinary binary
# repository - the packages in it are .spz files like any other, so applying
# a fix is still an install and the system stays binary - with one extra file,
# FIXES, that says which package carries which correction, how much it matters
# and what part of the system it touches:
#
#   <id>  <severity>  <area>  <package>  <version>  <summary>
#
# severity is major, minor or change. area is whatever part of the system the
# correction lands on - bootloader, kernel, pkg, init, net - and is what makes
# "scraps system upgrade bootloader" possible: a machine that will not boot can
# take the fixes that might explain it without pulling in everything else
# pending.
#
# Applied ids are remembered so a fix is offered once, and so a machine can be
# asked what it has already taken.
FIX_REPO=${SCRAPS_FIX_REPO:-fix}

fix_state() { printf '%s/fixes.applied' "$SCRAPS_DB"; }

fix_applied() {
	_fs=$(fix_state)
	[ -f "$_fs" ] || return 1
	grep -q "^$1	" "$_fs" 2>/dev/null
}

# The manifest sits beside the index of the fix repository, so it is fetched
# from wherever that repository is served rather than from a second place that
# could disagree with it.
# Returns 2 when there is no such repository and 1 when there is one but its
# manifest could not be fetched. Collapsing those into one failure reported a
# mirror that could not be reached as a repository that was not configured,
# which sends you to edit repos.d over a network problem.
fix_manifest() {
	_url=$(scraps_repo_url "$FIX_REPO")
	[ -n "$_url" ] || return 2
	_out="$SCRAPS_DB/sync/$FIX_REPO.fixes"
	mkdir -p "$SCRAPS_DB/sync" 2>/dev/null || :
	dlq "$_url/$ARCH/FIXES" "$_out" || return 1
	printf '%s' "$_out"
}

# A selector narrows what is offered. It is matched against the severity and
# the area of each pending fix, so "major" and "bootloader" are both valid and
# neither has to know about the other; "bugfix" is the one alias, meaning the
# entries that correct something wrong rather than merely change it.
fix_selected() {
	# $1 severity, $2 area
	case "$FIX_SEL" in
	"")                    return 0 ;;
	major|minor|change)    [ "$1" = "$FIX_SEL" ] ;;
	bugfix|bugfixes|fixes) [ "$1" = "major" ] || [ "$1" = "minor" ] ;;
	*)                     [ "$2" = "$FIX_SEL" ] ;;
	esac
}

# Lists pending corrections from the fix repository, and - unless $1 is 1,
# meaning a dry run - confirms and applies them. Shared by `system check`
# (dry) and `system upgrade` (not) so the two can never disagree about what
# counts as pending: the same manifest, the same fix_applied() ledger, read
# the same way.
system_fixes() {
	_dry=$1; shift
	need_root; scraps_init_db
	FIX_SEL=${1:-}

	# A machine with no fix repository configured is not broken, it just has
	# nowhere to take corrections from - say which, rather than reporting
	# "no changes" and leaving it looking up to date.
	_mf=$(fix_manifest); _mf_rc=$?
	if [ "$_mf_rc" = "2" ]; then
		warn "no '$FIX_REPO' repository configured - nothing to check"
		return 0
	elif [ "$_mf_rc" != "0" ] || [ -z "$_mf" ]; then
		err "could not fetch the '$FIX_REPO' manifest"
		[ -n "${DL_ERR:-}" ] && printf '  %s%s%s\n' "$A_GREY" \
			"$(printf '%s' "$DL_ERR" | head -2 | tr '\n' ' ')" "$C_R"
		return 1
	fi

	# A selector that names nothing the repository has ever heard of is a
	# typo, and answering it with "No changes." would report a healthy system
	# on the strength of a misspelling. Checked against every entry, applied
	# or not, so a correct selector whose fixes are all applied still reads as
	# up to date rather than as a mistake.
	case "$FIX_SEL" in
	""|major|minor|change|bugfix|bugfixes|fixes) ;;
	*)
		if ! awk -F'	' -v s="$FIX_SEL" \
			'$1 !~ /^#/ && $3 == s { found = 1 } END { exit !found }' "$_mf"; then
			err "no such area or severity: $FIX_SEL"
			_areas=$(awk -F'	' '$1 !~ /^#/ && NF >= 3 { print $3 }' "$_mf" \
				| sort -u | tr '\n' ' ')
			info "areas in '$FIX_REPO': ${_areas:-none}"
			info "severities: major minor change (or bugfix for both fix levels)"
			return 1
		fi
		;;
	esac

	_maj=0 _min=0 _chg=0 _pkgs="" _ids=""
	while IFS='	' read -r id sev area pkg ver _summary; do
		case "$id" in ''|\#*) continue ;; esac
		fix_applied "$id" && continue
		fix_selected "$sev" "$area" || continue
		case "$sev" in
		major)  _maj=$((_maj + 1)) ;;
		minor)  _min=$((_min + 1)) ;;
		*)      _chg=$((_chg + 1)) ;;
		esac
		_ids="$_ids $id"
		case " $_pkgs " in *" $pkg "*) ;; *) _pkgs="$_pkgs $pkg" ;; esac
		[ -n "$ver" ] || :
	done <"$_mf"

	if [ -z "$_ids" ]; then
		if [ -n "$FIX_SEL" ]; then
			printf 'No %s changes.\n' "$FIX_SEL"
		else
			printf 'No changes.\n'
		fi
		return 0
	fi

	# Only the categories that have something in them, each agreeing with its
	# own count, so the line never reads "1 changes" or lists a zero.
	_phr=""
	_add() { [ -z "$_phr" ] && _phr=$1 || _phr="$_phr, $1"; }
	[ "$_maj" -gt 0 ] && _add "$_maj major fix$([ "$_maj" -eq 1 ] || echo es)"
	[ "$_min" -gt 0 ] && _add "$_min minor fix$([ "$_min" -eq 1 ] || echo es)"
	[ "$_chg" -gt 0 ] && _add "$_chg change$([ "$_chg" -eq 1 ] || echo s)"
	# The last separator reads better as "and".
	_phr=$(printf '%s' "$_phr" | sed 's/\(.*\), /\1 and /')
	_are=are; [ $((_maj + _min + _chg)) -eq 1 ] && _are=is
	# An area is worth naming in the sentence, a severity is not: the counts
	# already say "2 major fixes", and "2 major fixes for major" says it twice.
	case "$FIX_SEL" in
	""|major|minor|change|bugfix|bugfixes|fixes) ;;
	*) _phr="$_phr for $FIX_SEL" ;;
	esac

	printf '\n'
	while IFS='	' read -r id sev area pkg ver summary; do
		case "$id" in ''|\#*) continue ;; esac
		fix_applied "$id" && continue
		fix_selected "$sev" "$area" || continue
		case "$sev" in
		major) _c=$A_RED ;; minor) _c=$A_AMB ;; *) _c=$A_GREY ;;
		esac
		printf '  %s%-6s%s %-11s %-20s %s\n' \
			"$_c$C_B" "$sev" "$C_R" "$area" "$pkg" "$summary"
	done <"$_mf"
	printf '\n'

	if [ "$_dry" = 1 ]; then
		info "run 'scraps system upgrade' to apply"
		return 0
	fi

	confirm "$_phr $_are in the '$FIX_REPO' repository, would you like to proceed?" \
		|| { info "no fixes applied"; return 1; }

	# shellcheck disable=SC2086
	cmd_ins $_pkgs || return 1

	_fs=$(fix_state)
	for id in $_ids; do
		printf '%s	%s\n' "$id" "$(date '+%Y-%m-%d %H:%M:%S')" >>"$_fs"
	done
	ok "applied $(printf '%s' "$_ids" | wc -w | tr -d ' ') fixes"
}

# name/release/build/kernel/generation of the system as it stands - the same
# facts fastfetch/neofetch read out of /etc/scraplinux-release, gathered for scripting
# rather than a person, and the one place that also says which generation
# scraplinux-rebuild left the machine on.
cmd_system_get() {
	rel=${SCRAPS_ROOT}/etc/scraplinux-release
	[ -f "$rel" ] || die "no $rel on this system"
	name=$(sed -n 's/^PRETTY_NAME="\{0,1\}\([^"]*\)"\{0,1\}$/\1/p' "$rel" | head -1)
	verid=$(sed -n 's/^VERSION_ID=//p'  "$rel" | tr -d '"' | head -1)
	build=$(sed -n 's/^BUILD_ID=//p'    "$rel" | head -1)
	kern=$(sed -n 's/^KERNEL=//p'       "$rel" | head -1)
	installed=$(sed -n 's/^INSTALLED=//p' "$rel" | head -1)

	printf '%s%s%s\n' "$C_B" "${name:-ScrapLinux}" "$C_R"
	printf '  release      %s\n' "${verid:-unknown}"
	printf '  built        %s\n' "${build:-unknown}"
	printf '  installed    %s\n' "${installed:-unknown}"
	printf '  kernel       %s (running %s)\n' "${kern:-unknown}" "$(uname -r 2>/dev/null || echo unknown)"
	if [ -f "$SCRAPS_DB/local/scraplinux-base/PKGINFO" ]; then
		v=$(awk -F' *= *' '$1=="version"{print $2}' "$SCRAPS_DB/local/scraplinux-base/PKGINFO" 2>/dev/null)
		r=$(awk -F' *= *' '$1=="release"{print $2}' "$SCRAPS_DB/local/scraplinux-base/PKGINFO" 2>/dev/null)
		printf '  scraplinux-base  %s-%s\n' "${v:-?}" "${r:-?}"
	fi
	if have scraplinux-generation; then
		gen=$(scraplinux-generation current 2>/dev/null)
		printf '  generation   %s\n' "${gen:-none}"
	fi
}

# Everything pending, applied to nothing: the same index sync `upgrade` does
# (an index has to be current before "up to date" means anything), then the
# same two comparisons `upgrade` makes - package versions against what is
# installed, fix ids against what is recorded applied - printed rather than
# acted on.
cmd_system_check() {
	need_root; scraps_init_db
	cmd_fetch all
	printf '\n'

	msg "Checking for package upgrades"
	_todo=""
	for d in "$SCRAPS_DB"/local/*/; do
		[ -d "$d" ] || continue
		p=$(basename "$d")
		is_held "$p" && continue
		e=$(idx_lookup "$p") || continue
		cur=$(installed_version "$p")
		nv="$(printf '%s' "$e" | cut -f3)-$(printf '%s' "$e" | cut -f4)"
		[ "$cur" = "$nv" ] && continue
		printf '  %s%-24s%s %s -> %s%s%s\n' "$C_B" "$p" "$C_R" "$cur" "$A_MINT" "$nv" "$C_R"
		_todo="$_todo $p"
	done
	[ -n "$_todo" ] || ok "packages are up to date"

	printf '\n'
	msg "Checking the '$FIX_REPO' repository"
	system_fixes 1 "$@"
}

# Everything an installed machine needs to be current, in the order that keeps
# it bootable: indexes first, then packages (a kernel among them is what makes
# the bootloader step necessary), then the corrections that no version bump
# would have carried, then the declarative config - a package upgrade and a
# system.conf edit both wait for the same command now instead of two.
cmd_system_upgrade() {
	need_root; scraps_init_db

	cmd_fetch all
	printf '\n'
	cmd_update

	# cmd_ins runs this itself when a kernel is among the packages it
	# installed. Doing it here as well covers the machine whose kernel was
	# already current but whose bootloader entries are not.
	if have scraplinux-bootctl; then
		msg "Updating bootloader entries"
		scraplinux-bootctl update >/dev/null 2>&1 || warn "bootloader update failed"
	fi

	printf '\n'
	system_fixes 0 "$@" || :

	if have scraplinux-rebuild; then
		printf '\n'
		msg "Reconciling against system.conf"
		scraplinux-rebuild || warn "scraplinux-rebuild reported a problem - see above"
	fi
}

cmd_system() {
	case "${1:-}" in
	get)     shift; cmd_system_get "$@" ;;
	check)   shift; cmd_system_check "$@" ;;
	boot)    shift; cmd_system_boot "$@" ;;
	# fix/update: not separate behavior, just names people reach for out of
	# habit from other package managers - both do exactly what upgrade does.
	# "fix" specifically used to be a real, different command here (see
	# docs/STATUS.md - it conflated "what's pending" with "apply it", which
	# is exactly why it was split into check/upgrade in the first place).
	# Bringing the *word* back as an alias for upgrade doesn't reintroduce
	# that bug: it's not a separate code path, it's the same one check/
	# upgrade already agree on.
	upgrade|up|fix|update) shift; cmd_system_upgrade "$@" ;;
	"")      die "usage: scraps system <get|check|upgrade|boot>" ;;
	*)       die "unknown system command: $1 (try get, check, upgrade or boot)" ;;
	esac
}

# Whole disk from a partition device name - nvme/mmcblk/loop use a "pN"
# suffix after a digit (nvme0n1p5 -> nvme0n1), sd/vd/hd don't (sda5 -> sda).
# BIOS GRUB installs to a disk's MBR, never a partition, so grub-bios needs
# this even though the user hands both boot targets the same partition -
# the one they'd naturally point at the boot filesystem either way.
_whole_disk() {
	case "$1" in
	*[0-9]p[0-9]*) printf '%s\n' "$1" | sed 's/p[0-9]*$//' ;;
	*[a-z][0-9]*)  printf '%s\n' "$1" | sed 's/[0-9]*$//' ;;
	*)             printf '%s\n' "$1" ;;
	esac
}

# scraps system boot limine-efi <partition>   Limine, UEFI, onto the ESP at <partition>
# scraps system boot grub-bios <partition>    GRUB, legacy BIOS, onto the disk <partition> is on
#
# Formalizes what was previously a hand-run sequence during install (copy
# Limine's EFI stub in twice, register an efibootmgr entry; grub-install for
# BIOS) into one command, and always regenerates fstab from genfstab first -
# a bootloader pointed at a filesystem that changed since the last fstab
# write is a worse failure mode than a slow install step.
cmd_system_boot() {
	need_root
	target=${1:-} part=${2:-}
	case "$target" in
	limine-efi|grub-bios) ;;
	*) die "usage: scraps system boot <limine-efi|grub-bios> <partition>" ;;
	esac
	[ -n "$part" ] || die "usage: scraps system boot <limine-efi|grub-bios> <partition>"
	[ -b "$part" ] || die "not a block device: $part"

	if have genfstab && [ -d "$SCRAPS_ROOT/etc" ]; then
		msg "regenerating fstab"
		genfstab "$SCRAPS_ROOT" >"$SCRAPS_ROOT/etc/fstab.new" \
			&& mv "$SCRAPS_ROOT/etc/fstab.new" "$SCRAPS_ROOT/etc/fstab" \
			&& ok "fstab written"
	else
		warn "genfstab not installed (scraps add genfstab) - fstab left as-is"
	fi

	case "$target" in
	limine-efi)
		have limine || die "limine is not installed (scraps add limine)"
		efi="$SCRAPS_ROOT/usr/share/limine/BOOTX64.EFI"
		[ -f "$efi" ] || die "no $efi - is the limine package actually installed under \$SCRAPS_ROOT?"
		bootdir="$SCRAPS_ROOT/boot"
		mkdir -p "$bootdir/EFI/BOOT" "$bootdir/EFI/scraplinux"
		cp -f "$efi" "$bootdir/EFI/BOOT/BOOTX64.EFI"
		cp -f "$efi" "$bootdir/EFI/scraplinux/scraplinuxx64.efi"
		disk=$(_whole_disk "$part")
		partnum=$(printf '%s' "$part" | sed 's/^.*[^0-9]//')
		if have efibootmgr; then
			_before=$(efibootmgr | sed -n 's/^BootOrder: //p')
			if efibootmgr --create --disk "$disk" --part "$partnum" \
				--loader '\EFI\scraplinux\scraplinuxx64.efi' --label 'ScrapLinux' >/dev/null; then
				# efibootmgr --create prepends the new entry to BootOrder by
				# default - on any machine that dual-boots something else,
				# that silently makes a fresh ScrapLinux install the very
				# next thing that boots, ahead of whatever was already
				# there, with a firmware timeout usually far too short to
				# notice and pick something else before it happens. Put the
				# order back the way it was and append the one entry that
				# is new (whatever is in the after-list but was not in the
				# before-list) at the end instead: reachable from the
				# firmware's own boot menu, never the default on its own.
				_after=$(efibootmgr | sed -n 's/^BootOrder: //p')
				_new="" _oldcomma=",$_before,"
				for _e in $(printf '%s' "$_after" | tr ',' ' '); do
					case "$_oldcomma" in
					*",$_e,"*) ;;
					*) _new=$_e ;;
					esac
				done
				if [ -n "$_new" ] && [ -n "$_before" ]; then
					efibootmgr -o "${_before},${_new}" >/dev/null \
						&& ok "ScrapLinux added to the UEFI boot menu (Boot$_new), placed last - it will not become the default on its own" \
						|| warn "efibootmgr -o failed - ScrapLinux's boot entry may rank ahead of your other OSes; check 'efibootmgr -v' and reorder by hand"
				fi
			else
				warn "efibootmgr failed - the ESP has the right files, but no NVRAM boot entry was created"
			fi
		else
			warn "efibootmgr not installed - files are in place on the ESP, but no NVRAM boot entry was created"
		fi
		ok "limine-efi installed to $part"
		;;
	grub-bios)
		have grub-install || die "grub is not installed (scraps add grub)"
		disk=$(_whole_disk "$part")
		grub-install --target=i386-pc --boot-directory="$SCRAPS_ROOT/boot" "$disk" \
			|| die "grub-install failed"
		ok "grub-bios installed to $disk"
		;;
	esac
}

# ------------------------------------------------------------------------ repos

cmd_addrepo() {
	need_root; scraps_init_db
	url=${1:-}
	[ -n "$url" ] || die "usage: scraps add repo <url>"
	case "$url" in http://*|https://*|ftp://*|file://*) ;; *) url="https://$url" ;; esac
	name=$(basename "$url" | sed 's/^scraplinux-//; s/-repo$//; s/\..*$//')
	[ -n "$name" ] || name=custom
	f="$SCRAPS_REPOD/$name.repo"
	mkdir -p "$SCRAPS_REPOD"
	[ -f "$f" ] && die "repo '$name' already exists ($f)"
	cat >"$f" <<EOF
# added by scraps on $(date '+%Y-%m-%d')
name = $name
url = $url
enabled = yes
priority = 50
EOF
	ok "added repo '$name' -> $url"
	msg2 "syncing it now"
	sync_repo "$name" "$url" || warn "could not reach $url yet"
}

cmd_delrepo() {
	need_root
	n=${1:-}; [ -n "$n" ] || die "usage: scraps repo del <name>"
	f="$SCRAPS_REPOD/$n.repo"
	[ -f "$f" ] || die "no such repo: $n"
	rm -f "$f" "$SCRAPS_DB/sync/$n.idx" "$SCRAPS_DB/sync/$n.url"
	ok "removed repo '$n'"
}

cmd_listrepos() {
	printf '\n  %s%-14s %-9s %-8s %s%s\n' "$C_B" "REPO" "PACKAGES" "PRIORITY" "URL" "$C_R"
	for f in "$SCRAPS_REPOD"/*.repo; do
		[ -f "$f" ] || continue
		n=$(meta "$f" name); u=$(meta "$f" url)
		pr=$(meta "$f" priority); en=$(meta "$f" enabled)
		[ -n "$n" ] || n=$(basename "$f" .repo)
		c=0; [ -f "$SCRAPS_DB/sync/$n.idx" ] && c=$(wc -l <"$SCRAPS_DB/sync/$n.idx" | tr -d ' ')
		col=$A_MINT; [ "$en" = "no" ] && col=$A_GREY
		printf '  %s%-14s%s %-9s %-8s %s%s%s\n' \
			"$col" "$n" "$C_R" "$c" "${pr:-50}" "$A_GREY" "$u" "$C_R"
	done
	printf '\n'
}

cmd_mirror() {
	repo=${1:-main}
	f="$SCRAPS_REPOD/$repo.repo"
	[ -f "$f" ] || die "no such repo: $repo"
	msg "Timing mirrors for $repo"
	metaall "$f" mirror | while read -r m; do
		[ -n "$m" ] || continue
		s=$(date '+%s%N' 2>/dev/null || echo 0)
		if dlq "$m/$ARCH/INDEX" /dev/null 2>/dev/null; then
			e=$(date '+%s%N' 2>/dev/null || echo 0)
			ms=$(( (e - s) / 1000000 ))
			printf '  %s%6s ms%s  %s\n' "$A_MINT" "$ms" "$C_R" "$m"
		else
			printf '  %s   down%s   %s\n' "$A_RED" "$C_R" "$m"
		fi
	done
}

# ---------------------------------------------------------------------- queries

cmd_list() {
	only_explicit=0
	[ "${1:-}" = "-e" ] && only_explicit=1
	n=0; tot=0
	printf '\n'
	for d in "$SCRAPS_DB"/local/*/; do
		[ -d "$d" ] || continue
		p=$(basename "$d")
		# A directory with no PKGINFO is a half-written entry, not a package
		# (see is_installed) - listing it prints a name with no version and
		# counts it in the totals. 'scraps doctor' is where those get cleaned up.
		is_installed "$p" || continue
		r=$(cat "$d/REASON" 2>/dev/null || echo explicit)
		[ "$only_explicit" = "1" ] && [ "$r" != "explicit" ] && continue
		v=$(installed_version "$p")
		s=$(meta "$d/STATE" size 2>/dev/null || echo 0); tot=$(( tot + ${s:-0} ))
		mark=" "; [ "$r" = "dep" ] && mark="${A_GREY}d${C_R}"
		is_held "$p" && mark="${A_AMB}h${C_R}"
		grep -q 'activated = no' "$d/STATE" 2>/dev/null && mark="${A_VIO}s${C_R}"
		printf '  %s %s%-28s%s %s\n' "$mark" "$A_SNOW" "$p" "$C_R" "$v"
		n=$((n+1))
	done
	printf '\n  %s%s packages, %s%s\n' "$A_GREY" "$n" "$(human $tot)" "$C_R"
	printf '  %sd = dependency  h = held  s = staged (-nomod)%s\n\n' "$A_GREY" "$C_R"
}

cmd_index() {
	r=${1:-}
	printf '\n  %s%-24s %-14s %-11s %s%s\n' "$C_B" "PACKAGE" "VERSION" "REPO" "DESCRIPTION" "$C_R"
	for f in "$SCRAPS_DB"/sync/*.idx; do
		[ -f "$f" ] || continue
		rn=$(basename "$f" .idx)
		[ -n "$r" ] && [ "$r" != "$rn" ] && continue
		awk -F'\t' -v rn="$rn" -v c1="$A_SNOW" -v c0="$C_R" -v cg="$A_GREY" \
			'{printf "  %s%-24s%s %-14s %-11s %s%.40s%s\n", c1,$1,c0,$2"-"$3,rn,cg,$9,c0}' "$f"
	done
	printf '\n'
}

cmd_search() {
	pat=${1:-}
	[ -n "$pat" ] || die "usage: scraps search <pattern>"
	found=0
	printf '\n'
	for f in "$SCRAPS_DB"/sync/*.idx; do
		[ -f "$f" ] || continue
		rn=$(basename "$f" .idx)
		while IFS='	' read -r nm ver rel arch sz isz sha deps desc; do
			case "$nm$desc" in
			*"$pat"*)
				st=""
				is_installed "$nm" && st=" ${A_MINT}[installed]${C_R}"
				printf '  %s%s/%s%s%s %s%s%s\n' "$A_VIO" "$rn" "$A_SNOW$C_B" "$nm" "$C_R" \
					"$A_TEAL" "$ver-$rel" "$C_R$st"
				printf '      %s%s%s\n' "$A_GREY" "$desc" "$C_R"
				found=$((found+1)) ;;
			esac
		done <"$f"
	done
	[ "$found" = "0" ] && { printf '  no match for %s\n\n' "$pat"; return 1; }
	printf '\n  %s%s result(s)%s\n\n' "$A_GREY" "$found" "$C_R"
}

cmd_info() {
	p=${1:-}; [ -n "$p" ] || die "usage: scraps info <pkg>"
	printf '\n'
	if is_installed "$p"; then
		d="$SCRAPS_DB/local/$p"
		row() { printf '  %s%-16s%s %s\n' "$C_B" "$1" "$C_R" "$2"; }
		row Name "$p"
		row Version "$(installed_version "$p")"
		row Description "$(meta "$d/PKGINFO" desc)"
		row URL "$(meta "$d/PKGINFO" url)"
		row License "$(meta "$d/PKGINFO" license)"
		row Architecture "$(meta "$d/PKGINFO" arch)"
		row Depends "$(tr '\n' ' ' <"$d/DEPS" 2>/dev/null)"
		row "Installed" "$(meta "$d/STATE" installed)"
		row "Install size" "$(human "$(meta "$d/STATE" size 2>/dev/null || echo 0)")"
		row Files "$(wc -l <"$d/FILES" | tr -d ' ')"
		row Reason "$(cat "$d/REASON" 2>/dev/null)"
		is_held "$p" && row Held yes
		grep -q 'activated = no' "$d/STATE" 2>/dev/null && \
			row Staged "yes - not in place, run 'scraps commit $p'"
		rd=$(cmd_rdeps "$p" 2>/dev/null | tr '\n' ' ')
		row "Required by" "${rd:-nothing}"
	else
		e=$(idx_lookup "$p") || die "package not found: $p"
		row() { printf '  %s%-16s%s %s\n' "$C_B" "$1" "$C_R" "$2"; }
		row Repository "$(printf '%s' "$e" | cut -f1)"
		row Name "$p"
		row Version "$(printf '%s' "$e" | cut -f3)-$(printf '%s' "$e" | cut -f4)"
		row Architecture "$(printf '%s' "$e" | cut -f5)"
		row "Download size" "$(human "$(printf '%s' "$e" | cut -f6)")"
		row "Install size" "$(human "$(printf '%s' "$e" | cut -f7)")"
		row Depends "$(printf '%s' "$e" | cut -f9)"
		row Description "$(printf '%s' "$e" | cut -f10)"
		row Status "not installed"
	fi
	printf '\n'
}

cmd_files() {
	p=${1:-}; [ -n "$p" ] || die "usage: scraps files <pkg>"
	is_installed "$p" || die "$p is not installed"
	cat "$SCRAPS_DB/local/$p/FILES"
}

cmd_owns() {
	path=${1:-}; [ -n "$path" ] || die "usage: scraps owns <path>"
	# Resolve to an absolute path the way the database stores it.
	case "$path" in
	/*) ;;
	*) w=$(command -v "$path" 2>/dev/null) && path=$w || path="$(pwd)/$path" ;;
	esac
	hit=0
	for f in "$SCRAPS_DB"/local/*/FILES; do
		[ -f "$f" ] || continue
		if grep -qx "$path" "$f" 2>/dev/null; then
			p=$(basename "$(dirname "$f")")
			printf '  %s is owned by %s%s %s%s\n' "$path" "$A_SNOW$C_B" "$p" \
				"$(installed_version "$p")" "$C_R"
			hit=1
		fi
	done
	# Not a bare "[ ... ] && { ... }" as the last command: when the file *was*
	# found the test is false, so the function returned 1 and `scraps owns`
	# exited non-zero having just printed the right answer.
	if [ "$hit" = "0" ]; then
		err "no package owns $path"
		return 1
	fi
	return 0
}

_dt_seen=""
_deptree() {
	local p ind st deps d
	p=$1 ind=$2
	case " $_dt_seen " in *" $p "*)
		printf '%s%s%s %s(already shown)%s\n' "$ind" "$A_GREY" "$p" "$A_GREY" "$C_R"
		return 0 ;;
	esac
	_dt_seen="$_dt_seen $p"
	st=""
	is_installed "$p" && st=" ${A_MINT}*${C_R}"
	printf '%s%s%s%s\n' "$ind" "$A_SNOW" "$p" "$C_R$st"
	deps=""
	if is_installed "$p"; then deps=$({ tr '\n' ' ' <"$SCRAPS_DB/local/$p/DEPS"; } 2>/dev/null || :)
	else e=$(idx_lookup "$p") 2>/dev/null && deps=$(printf '%s' "$e" | cut -f9); fi
	for d in $deps; do
		[ "$d" = "-" ] && continue
		_deptree "$d" "$ind  "
	done
}

cmd_deps() {
	p=${1:-}; [ -n "$p" ] || die "usage: scraps deps <pkg>"
	printf '\n'; _dt_seen=""; _deptree "$p" "  "
	printf '\n  %s* = installed%s\n\n' "$A_GREY" "$C_R"
}

cmd_rdeps() {
	p=${1:-}; [ -n "$p" ] || die "usage: scraps rdeps <pkg>"
	for f in "$SCRAPS_DB"/local/*/DEPS; do
		[ -f "$f" ] || continue
		grep -qx "$p" "$f" 2>/dev/null && basename "$(dirname "$f")"
	done
}

cmd_why() {
	p=${1:-}; [ -n "$p" ] || die "usage: scraps why <pkg>"
	is_installed "$p" || die "$p is not installed"
	r=$(cat "$SCRAPS_DB/local/$p/REASON" 2>/dev/null)
	printf '\n'
	if [ "$r" = "explicit" ]; then
		printf '  %s%s%s was installed explicitly - you asked for it.\n\n' \
			"$A_SNOW$C_B" "$p" "$C_R"
		return 0
	fi
	printf '  %s%s%s is here as a dependency. Chains that need it:\n\n' \
		"$A_SNOW$C_B" "$p" "$C_R"
	# Walk upward from p to explicitly-installed roots.
	depth=0
	cur="$p"
	while [ "$depth" -lt 12 ]; do
		parents=$(cmd_rdeps "$cur" | tr '\n' ' ')
		[ -n "$parents" ] || break
		printf '    %s%s%s <- %s\n' "$A_TEAL" "$cur" "$C_R" "$parents"
		nxt=""
		for q in $parents; do
			[ "$(cat "$SCRAPS_DB/local/$q/REASON" 2>/dev/null)" = "explicit" ] && {
				printf '      %sroot: %s (explicit)%s\n' "$A_MINT" "$q" "$C_R"; continue; }
			nxt="$q"
		done
		[ -n "$nxt" ] || break
		cur=$nxt; depth=$((depth+1))
	done
	printf '\n'
}

cmd_orphans() {
	o=$(find_orphans)
	[ -n "$o" ] || { ok "no orphans"; return 0; }
	printf '\n  %sOrphans (nothing depends on these):%s\n\n' "$C_B" "$C_R"
	for p in $o; do printf '    %s %s\n' "$p" "$(installed_version "$p")"; done
	printf '\n  %sremove them with: scraps autoremove%s\n\n' "$A_GREY" "$C_R"
}

cmd_stats() {
	n=0; tot=0; expl=0; dep=0
	for d in "$SCRAPS_DB"/local/*/; do
		[ -d "$d" ] || continue
		is_installed "$(basename "$d")" || continue
		n=$((n+1))
		s=$(meta "$d/STATE" size 2>/dev/null || echo 0); tot=$(( tot + ${s:-0} ))
		if [ "$(cat "$d/REASON" 2>/dev/null)" = "dep" ]; then dep=$((dep+1)); else expl=$((expl+1)); fi
	done
	printf '\n  %sScrapLinux - package statistics%s\n\n' "$A_TEAL$C_B" "$C_R"
	printf '  %-22s %s\n' "Installed packages" "$n"
	printf '  %-22s %s\n' "  explicit" "$expl"
	printf '  %-22s %s\n' "  dependencies" "$dep"
	printf '  %-22s %s\n' "Total size" "$(human $tot)"
	printf '  %-22s %s\n' "Cache size" "$(du -sh "$SCRAPS_CACHE" 2>/dev/null | cut -f1)"
	printf '  %-22s %s\n' "Transactions kept" "$(ls -1d "$SCRAPS_DB"/snapshots/*/ 2>/dev/null | wc -l | tr -d ' ')"
	printf '\n  %sAvailable by repo%s\n' "$C_B" "$C_R"
	for f in "$SCRAPS_DB"/sync/*.idx; do
		[ -f "$f" ] || continue
		printf '  %-22s %s\n' "  $(basename "$f" .idx)" "$(wc -l <"$f" | tr -d ' ')"
	done
	printf '\n'
}

# ------------------------------------------------------------------ maintenance

cmd_verify() {
	targets=""
	if [ $# -gt 0 ]; then targets="$*"
	else
		for d in "$SCRAPS_DB"/local/*/; do
			[ -d "$d" ] && targets="$targets $(basename "$d")"
		done
	fi
	bad=0; miss=0; checked=0
	msg "Verifying installed files"
	for p in $targets; do
		h="$SCRAPS_DB/local/$p/HASHES"
		[ -f "$h" ] || continue
		while read -r want f; do
			[ -n "$f" ] || continue
			checked=$((checked+1))
			if [ ! -e "$SCRAPS_ROOT$f" ]; then
				printf '  %smissing%s  %s (%s)\n' "$A_RED" "$C_R" "$f" "$p"; miss=$((miss+1))
			elif [ -f "$SCRAPS_ROOT$f" ] && [ "$(sha256 "$SCRAPS_ROOT$f")" != "$want" ]; then
				printf '  %saltered%s  %s (%s)\n' "$A_AMB" "$C_R" "$f" "$p"; bad=$((bad+1))
			fi
		done <"$h"
	done
	printf '\n'
	if [ "$bad" = "0" ] && [ "$miss" = "0" ]; then
		ok "$checked files verified, all intact"
	else
		warn "$checked checked: $bad altered, $miss missing"
		printf '  %srepair with: scraps reins <pkg>%s\n' "$A_GREY" "$C_R"
		return 1
	fi
}

cmd_doctor() {
	msg "ScrapLinux system check"
	probs=0
	chk() { printf '  %s%-40s%s ' "$A_SNOW" "$1" "$C_R"; }
	pass() { printf '%sok%s\n' "$A_MINT" "$C_R"; }
	fail() { printf '%s%s%s\n' "$A_RED" "$1" "$C_R"; probs=$((probs+1)); }
	nag()  { printf '%s%s%s\n' "$A_AMB" "$1" "$C_R"; }

	chk "package database readable"
	[ -d "$SCRAPS_DB/local" ] && pass || fail "missing $SCRAPS_DB/local"

	chk "repositories configured"
	c=$(scraps_repos | wc -l | tr -d ' ')
	[ "$c" -gt 0 ] && pass || fail "none - use 'scraps add repo'"

	chk "repository indexes present"
	c=$(ls -1 "$SCRAPS_DB"/sync/*.idx 2>/dev/null | wc -l | tr -d ' ')
	[ "$c" -gt 0 ] && pass || nag "none - run 'scraps fetch all'"

	chk "no half-finished transactions"
	inc=$(grep -L 'finished' "$SCRAPS_DB"/snapshots/*/TXINFO 2>/dev/null | wc -l | tr -d ' ')
	[ "$inc" = "0" ] && pass || fail "$inc incomplete - consider 'scraps rollback'"

	chk "no half-written database entries"
	part=$(scraps_partials | wc -l | tr -d ' ')
	if [ "$part" = "0" ]; then pass
	else
		fail "$part"
		scraps_partials | while read -r d; do
			printf '    %s%s%s\n' "$A_GREY" "$d" "$C_R"
		done
		if [ "$(id -u)" = "0" ] && confirm "Remove them?"; then
			scraps_partials | while read -r d; do rm -rf "$d"; done
			probs=$((probs-1))
		fi
	fi

	chk "no staged (-nomod) packages pending"
	st=$(grep -l 'activated = no' "$SCRAPS_DB"/local/*/STATE 2>/dev/null | wc -l | tr -d ' ')
	[ "$st" = "0" ] && pass || nag "$st waiting for 'scraps commit'"

	chk "no broken dependencies"
	broke=0
	for f in "$SCRAPS_DB"/local/*/DEPS; do
		[ -f "$f" ] || continue
		while read -r d; do
			[ -n "$d" ] || continue
			[ "$d" = "-" ] && continue      # placeholder, see install_one
			is_installed "$d" || { broke=$((broke+1)); }
		done <"$f"
	done
	[ "$broke" = "0" ] && pass || fail "$broke unsatisfied"

	chk "no orphaned packages"
	o=$(find_orphans | wc -l | tr -d ' ')
	[ "$o" = "0" ] && pass || nag "$o - see 'scraps orphans'"

	chk "a kernel is installed and in place"
	k=$(ls -1 "$SCRAPS_ROOT"/boot/vmlinuz-* 2>/dev/null | wc -l | tr -d ' ')
	[ "$k" -gt 0 ] && pass || fail "no kernel in /boot"

	chk "bootloader present"
	if [ -d "$SCRAPS_ROOT/boot/EFI" ] || [ -f "$SCRAPS_ROOT/boot/limine.conf" ]; then pass
	else nag "no limine config found"; fi

	chk "no GNU userland crept in"
	gnu=""
	for b in "$SCRAPS_ROOT"/bin/ls "$SCRAPS_ROOT"/bin/cat; do
		[ -e "$b" ] || continue
		"$b" --version 2>/dev/null | grep -qi 'GNU coreutils' && gnu="yes"
	done
	[ -z "$gnu" ] && pass || nag "GNU coreutils detected in /bin"

	chk "free space on /"
	av=$(df -k "$SCRAPS_ROOT" 2>/dev/null | awk 'NR==2{print $4}')
	if [ "${av:-0}" -gt 524288 ]; then pass; else nag "$(human $((av*1024))) left"; fi

	printf '\n'
	if [ "$probs" = "0" ]; then ok "no problems found"
	else warn "$probs problem(s) need attention"; return 1; fi
}

cmd_clean() {
	need_root
	sz=$(du -sk "$SCRAPS_CACHE/pkg" 2>/dev/null | cut -f1)
	msg "Cache holds $(human $(( ${sz:-0} * 1024 )))"
	confirm "Delete cached packages?" || return 1
	rm -f "$SCRAPS_CACHE"/pkg/*.spz
	rm -rf "$SCRAPS_CACHE"/build/*
	ok "cache cleared"
}

cmd_hold() {
	need_root; scraps_init_db
	for p in "$@"; do
		is_installed "$p" || die "$p is not installed"
		: >"$SCRAPS_DB/hold/$p"; ok "$p held back from upgrades"
	done
}

cmd_unhold() {
	need_root
	for p in "$@"; do rm -f "$SCRAPS_DB/hold/$p"; ok "$p no longer held"; done
}

cmd_mark() {
	need_root
	p=${1:-}; r=${2:-}
	case "$r" in explicit|dep) ;; *) die "usage: scraps mark <pkg> explicit|dep" ;; esac
	is_installed "$p" || die "$p is not installed"
	printf '%s\n' "$r" >"$SCRAPS_DB/local/$p/REASON"
	ok "$p marked as $r"
}

# -------------------------------------------------------- snapshots / rollback

cmd_snapshot() {
	need_root; scraps_init_db
	nm=${1:-manual-$(txid)}
	d="$SCRAPS_DB/snapshots/$nm"
	mkdir -p "$d"
	: >"$d/packages"
	for p in "$SCRAPS_DB"/local/*/; do
		[ -d "$p" ] || continue
		n=$(basename "$p")
		printf '%s %s %s\n' "$n" "$(installed_version "$n")" \
			"$(cat "$p/REASON" 2>/dev/null)" >>"$d/packages"
	done
	printf 'id = %s\nkind = manual\ncreated = %s\n' "$nm" "$(date '+%Y-%m-%d %H:%M:%S')" \
		>"$d/TXINFO"
	ok "snapshot '$nm' saved ($(wc -l <"$d/packages" | tr -d ' ') packages)"
}

cmd_rollback() {
	need_root
	id=${1:-}
	if [ -z "$id" ]; then
		id=$(ls -1d "$SCRAPS_DB"/snapshots/*/ 2>/dev/null | sort | tail -1)
		[ -n "$id" ] || die "no transactions to roll back"
		id=$(basename "$id")
	fi
	d="$SCRAPS_DB/snapshots/$id"
	[ -d "$d" ] || die "no such transaction: $id"

	printf '\n  %sTransaction %s%s\n' "$C_B" "$id" "$C_R"
	[ -f "$d/TXINFO" ] && sed 's/^/    /' "$d/TXINFO"
	if [ -f "$d/packages" ]; then
		printf '\n  %sPackages it touched:%s\n' "$C_B" "$C_R"
		sed 's/^/    /' "$d/packages"
	fi
	na=$(wc -l <"$d/added" 2>/dev/null | tr -d ' ' || echo 0)
	nr=$(find "$d/replaced" -type f 2>/dev/null | wc -l | tr -d ' ')
	ndb=0
	[ -f "$d/db-new" ] && ndb=$(wc -l <"$d/db-new" | tr -d ' ')
	nre=0
	[ -d "$d/db" ] && nre=$(ls -1 "$d/db" 2>/dev/null | wc -l | tr -d ' ')
	printf '\n  rolling back will delete %s new file(s) and restore %s replaced file(s)\n' \
		"$na" "$nr"
	printf '  %s database entry(s) will be removed, %s restored to their previous version\n\n' \
		"$ndb" "$nre"
	confirm "Roll back transaction $id?" || { msg "aborted"; return 1; }

	if [ -f "$d/added" ]; then
		while read -r f; do
			[ -n "$f" ] || continue
			rm -f "$SCRAPS_ROOT$f" 2>/dev/null || :
		done <"$d/added"
		# A file's directory can be empty once it and its siblings are gone,
		# and nothing above removed those - a rollback deleted every file a
		# transaction added but left every directory it created behind,
		# forever, empty. The immediate parent is not the only one that can
		# end up empty - .../locale/de/LC_MESSAGES emptying out does not by
		# itself make .../locale/de disappear too - so every ancestor up to
		# SCRAPS_ROOT is a candidate, not just the one directly holding the
		# file. Longest path first so a child directory is always tried
		# before the parent that contained it: by the time a parent is
		# reached here it is already empty if it is going to become empty at
		# all. rmdir is a no-op on anything still occupied, so this can never
		# remove a directory something else still needs.
		while read -r f; do
			[ -n "$f" ] || continue
			p=$(dirname "$SCRAPS_ROOT$f")
			while :; do
				case "$p" in "$SCRAPS_ROOT"|/|.) break ;; esac
				printf '%s\n' "$p"
				np=$(dirname "$p")
				[ "$np" = "$p" ] && break
				p=$np
			done
		done <"$d/added" | sort -u | awk '{ print length, $0 }' | sort -rn | cut -d' ' -f2- | \
		while read -r dd; do
			[ -n "$dd" ] && rmdir "$dd" 2>/dev/null || :
		done
	fi
	if [ -d "$d/replaced" ]; then
		( cd "$d/replaced" && find . -type f -o -type l ) | while read -r rf; do
			t=$(printf '%s' "$rf" | sed 's|^\.||')
			mkdir -p "$(dirname "$SCRAPS_ROOT$t")"
			cp -a "$d/replaced/$rf" "$SCRAPS_ROOT$t"
		done
	fi
	# Put the database back to what it described before the transaction.
	#
	# This is what "rollback restores files but not the database" used to
	# mean: every file went back, and then local/ still said the new version
	# was installed - so `scraps list` showed a package whose files had just
	# been deleted, `verify` reported every one of them missing, and the
	# resolver counted it as satisfying dependencies it no longer could.
	# db-new lists packages this transaction created an entry for (delete
	# them); db/ holds the entry each replaced package had before it (put
	# those back verbatim).
	if [ -f "$d/db-new" ]; then
		while read -r n; do
			[ -n "$n" ] || continue
			safe_component "$n" || continue
			rm -rf "$SCRAPS_DB/local/$n"
			info "database entry for $n removed"
		done <"$d/db-new"
	fi
	if [ -d "$d/db" ]; then
		for e in "$d/db"/*; do
			[ -d "$e" ] || continue
			n=$(basename "$e")
			safe_component "$n" || continue
			rm -rf "$SCRAPS_DB/local/$n"
			cp -a "$e" "$SCRAPS_DB/local/$n"
			info "database entry for $n restored to $(installed_version "$n")"
		done
	fi
	# Anything left half-written by the transaction that died is not an
	# install, whatever its directory looks like.
	scraps_partials | while read -r stale; do rm -rf "$stale"; done
	scraps_log "rollback $id"
	HOOK_KERNEL=1; NOMOD=0; run_hooks
	ok "rolled back $id"
}

cmd_log() {
	[ -f "$SCRAPS_LOG" ] || { info "no log yet"; return 0; }
	printf '\n'
	tail -n "${1:-40}" "$SCRAPS_LOG" | sed 's/^/  /'
	printf '\n'
	printf '  %stransactions on disk:%s\n' "$C_B" "$C_R"
	for d in "$SCRAPS_DB"/snapshots/*/; do
		[ -d "$d" ] || continue
		printf '    %s %s\n' "$(basename "$d")" \
			"$(meta "$d/TXINFO" command 2>/dev/null || echo '-')"
	done
	printf '\n'
}

# -------------------------------------------------------------------- dispatch

SCRAPS_CMDLINE="scraps $*"

# Global flags (-y, -s, -nomod, --root=...) may come before the subcommand,
# after it, or mixed into its own arguments - "scraps -y ins vim" and
# "doas scraps --noconfirm ins vim" are both a completely ordinary way to type
# this, the same as every other package manager. Only requiring them after
# the subcommand meant a flag typed first was read as the subcommand itself
# ("unknown command: -y") and fell through to the help screen instead of
# actually running anything. The first token that is not one of these flags
# becomes the subcommand; everything else is passed through unchanged.
cmd=""
args=""
for a in "$@"; do
	case "$a" in
	-s|--source)  SOURCE=1 ;;
	-b|--binary)  SOURCE=0 ;;
	-r|--raw)     RAW=1 ;;
	-nomod|--nomod|--no-modify) NOMOD=1 ;;
	-y|--yes|--noconfirm)
		# Answer every prompt with yes: the install confirmation, the removal
		# confirmation, and the "no binary, compile?" warning. Exported so the
		# source-build path, which re-enters scraps and scraps-build, inherits it.
		SCRAPS_YES=1; export SCRAPS_YES ;;
	--force)      SCRAPS_FORCE=1 ;;
	--root=*)     SCRAPS_ROOT=${a#--root=} ;;
	*)
		if [ -z "$cmd" ]; then cmd=$a; else args="$args $a"; fi
		;;
	esac
done

# --root= is parsed above, but SCRAPS_DB/SCRAPS_CACHE/SCRAPS_LOG were already
# derived from SCRAPS_ROOT when libscraps.sh was sourced - at which point
# SCRAPS_ROOT was still "/". libscraps' own comment explains why these three
# follow the root ("what is installed there, what is cached there") and warns
# that getting it wrong records every install into the caller's own
# /var/lib/scraps instead of the target's. That was true for the environment
# form, `SCRAPS_ROOT=$TARGET scraps add ...`, which is set before this script
# starts - and untrue for the --root= flag, which lands far too late to affect
# them. `scraps --root=/mnt add foo` therefore unpacked the payload into /mnt
# correctly and then wrote the database entry to the *host*, leaving the target
# with a complete filesystem and an empty package database, and the host with a
# record of a package it does not have.
#
# Re-derived here, after the flag is known, and only for the values the caller
# did not set explicitly - an explicit SCRAPS_DB= in the environment still wins,
# which is what scraps-strap and the installer rely on.
if [ "${SCRAPS_ROOT%/}" != "" ]; then
	[ -n "$_env_db" ]    || SCRAPS_DB="${SCRAPS_ROOT%/}/var/lib/scraps"
	[ -n "$_env_cache" ] || SCRAPS_CACHE="${SCRAPS_ROOT%/}/var/cache/scraps"
	[ -n "$_env_log" ]   || SCRAPS_LOG="${SCRAPS_ROOT%/}/var/log/scraps.log"
fi

# shellcheck disable=SC2086
set -- $args

case "$cmd" in
# "ins" and "del" were what these were called before add and rem - add and
# rem are what every example and every page of documentation says now, but
# the old names still work as plain aliases rather than stopping with an
# error, the same way any other command keeps an old spelling working
# instead of demanding the new one be typed from memory.
ins|install|i)      cmd_ins "$@" ;;
reins|reinstall)    cmd_reins "$@" ;;
commit)             cmd_commit "$@" ;;
del|remove|rm)
	case "${1:-}" in
	repo) shift; cmd_delrepo "$@" ;;
	*)    [ -n "${1:-}" ] || die "usage: scraps del <package>..."
	      do_remove 0 0 "$@" ;;
	esac ;;
del+deps)
	[ -n "${1:-}" ] || die "usage: scraps del+deps <package>..."
	do_remove 1 0 "$@" ;;
del+junk)
	[ -n "${1:-}" ] || die "usage: scraps del+junk <package>..."
	do_remove 0 1 "$@" ;;
del+deps+junk|del+junk+deps)
	[ -n "${1:-}" ] || die "usage: scraps del+deps+junk <package>..."
	do_remove 1 1 "$@" ;;
autoremove)         cmd_autoremove ;;
get)                cmd_get "$@" ;;
offline)            cmd_offline "$@" ;;
fetch|sync)         cmd_fetch "$@" ;;
update|upgrade|up)  cmd_update "$@" ;;
system|sys)         cmd_system "$@" ;;
# add is what installing is called now. "scraps add repo <url>" still works
# because that is what it used to mean and scripts have it.
add|a)
	case "${1:-}" in
	repo) shift; cmd_addrepo "$@" ;;
	*)    cmd_ins "$@" ;;
	esac ;;
# The repository verbs read as what they do: scraps repo add <url>.
repo)
	_rv=${1:-}; shift 2>/dev/null || :
	case "$_rv" in
	add)          cmd_addrepo "$@" ;;
	del|rm|remove) cmd_delrepo "$@" ;;
	list|ls|"")   cmd_listrepos ;;
	*)            die "usage: scraps repo add <url> | scraps repo del <name> | scraps repo list" ;;
	esac ;;
# rem takes the flags rather than needing a different verb per combination:
#   rem <pkg>        the package
#   rem -a <pkg>     and everything it left behind
#   rem -d <pkg>     and the dependencies nothing else needs
#   rem -a -d <pkg>  both
#   rem -f <pkg>     even if something still depends on it
rem|remove-pkg)
	_rd=0; _rj=0
	while :; do
		case "${1:-}" in
		-a|--all)   _rj=1; shift ;;
		-d|--deps)  _rd=1; shift ;;
		-f|--force) SCRAPS_FORCE=1; shift ;;
		-ad|-da)    _rj=1; _rd=1; shift ;;
		*) break ;;
		esac
	done
	[ -n "${1:-}" ] || die "usage: scraps rem [-a] [-d] [-f] <package>..."
	do_remove "$_rd" "$_rj" "$@" ;;
mirror)             cmd_mirror "$@" ;;
list|ls)
	case "${1:-}" in
	repos|repo) cmd_listrepos ;;
	*)          cmd_list "$@" ;;
	esac ;;
index|avail)        cmd_index "$@" ;;
search|find|s)      cmd_search "$@" ;;
info|show)          cmd_info "$@" ;;
files)              cmd_files "$@" ;;
owns|owner)         cmd_owns "$@" ;;
deps)               cmd_deps "$@" ;;
rdeps)              cmd_rdeps "$@" ;;
why)                cmd_why "$@" ;;
orphans)            cmd_orphans ;;
stats)              cmd_stats ;;
verify|check)       cmd_verify "$@" ;;
doctor)             cmd_doctor ;;
clean)              cmd_clean ;;
hold)               cmd_hold "$@" ;;
unhold)             cmd_unhold "$@" ;;
mark)               cmd_mark "$@" ;;
snapshot|snap)      cmd_snapshot "$@" ;;
rollback|undo)      cmd_rollback "$@" ;;
log|history)        cmd_log "$@" ;;
build|mkpkg)        exec $(scraps_build_path) "$@" ;;
version|--version|-V) printf 'scraps %s (format %s) - ScrapLinux\n' "$SCRAPS_VERSION" "$SCRAPS_FORMAT" ;;
help|--help|-h|"")  usage ;;
*)                  err "unknown command: $cmd"; printf '\n'; usage; exit 1 ;;
esac
