SPS · log · files · refs · tags · releases · people · languages · GitHub · commit
2ca9f8c
Add pkconf, the missing half of the configuration workflow
Parents
Stat
bin/pkconf | 414 +++++++++++++++++++++++++++++++++++++++++++++++++++ bin/sget | 4 +- man/pkconf.1 | 139 +++++++++++++++++ tests/test_pkconf.sh | 153 +++++++++++++++++++ 4 files changed, 708 insertions(+), 2 deletions(-)
Diff
diff --git a/bin/pkconf b/bin/pkconf
new file mode 100755
index 0000000..4b706c9
--- /dev/null
+++ b/bin/pkconf
@@ -0,0 +1,414 @@
+#!/bin/sh
+# Reconcile protected configuration files with the packaged defaults an
+# upgrade left beside them.
+#
+# An upgrade never overwrites a protected file the administrator changed. It
+# keeps the local file and writes the packaged default next to it as
+# ".sps-new" (or ".sps-new.VERSION-RELEASE"). "sget config-status" reports
+# that situation and deliberately does nothing about it. pkconf is the step
+# that acts on it, the way CRUX has rejmerge and Gentoo has etc-update.
+#
+# Accepting a packaged default is a rename: pkin already recorded the new
+# package's digest for that path, so once the default is in place the file
+# matches its record again and pkcheck reports it clean. Keeping the local
+# file only removes the sibling; the path stays locally modified, which is
+# the honest state.
+
+sps_prog=pkconf
+sps_bin_dir=$(CDPATH= cd -P "$(dirname "$0")" 2>/dev/null && pwd) || exit 1
+if [ -z "${SPS_LIBDIR+x}" ]; then
+ if [ -r "$sps_bin_dir/../lib/common.sh" ]; then
+ SPS_LIBDIR=$sps_bin_dir/../lib
+ else
+ SPS_LIBDIR=$sps_bin_dir/../lib/sps
+ fi
+fi
+if ! . "$SPS_LIBDIR/common.sh"; then
+ printf '%s\n' "pkconf: cannot load $SPS_LIBDIR/common.sh" >&2
+ exit 1
+fi
+sps_load_config || exit $?
+
+sps_tab=$(printf '\tX')
+sps_tab=${sps_tab%X}
+
+usage()
+{
+ printf '%s\n' 'usage: pkconf [--backup] [PACKAGE]'
+ printf '%s\n' ' pkconf --list [--raw] [PACKAGE]'
+ printf '%s\n' ' pkconf --diff PATH'
+ printf '%s\n' ' pkconf --accept [--backup] PATH...'
+ printf '%s\n' ' pkconf --keep PATH...'
+ printf '%s\n' ' pkconf --accept-all [--backup] [PACKAGE]'
+ printf '%s\n' ' pkconf --keep-all [PACKAGE]'
+}
+
+mode=review
+raw=0
+backup=0
+scope=
+nargs=0
+paths=
+
+while [ "$#" -gt 0 ]; do
+ case $1 in
+ --list) mode=list; shift ;;
+ --diff) mode=diff; shift ;;
+ --accept) mode=accept; shift ;;
+ --keep) mode=keep; shift ;;
+ --accept-all) mode=accept-all; shift ;;
+ --keep-all) mode=keep-all; shift ;;
+ --raw) raw=1; shift ;;
+ --backup) backup=1; shift ;;
+ -h|--help) usage; exit 0 ;;
+ --) shift
+ while [ "$#" -gt 0 ]; do
+ paths="$paths$1$sps_tab"; nargs=$((nargs + 1)); scope=$1; shift
+ done
+ break ;;
+ -*) usage >&2; exit "$SPS_EX_USAGE" ;;
+ *) paths="$paths$1$sps_tab"; nargs=$((nargs + 1)); scope=$1; shift ;;
+ esac
+done
+
+case $mode in
+ diff|accept|keep)
+ [ "$nargs" -ge 1 ] ||
+ sps_die "$SPS_EX_USAGE" "--${mode} needs at least one path"
+ [ "$mode" != diff ] || [ "$nargs" -eq 1 ] ||
+ sps_die "$SPS_EX_USAGE" "--diff takes exactly one path"
+ ;;
+ *)
+ [ "$nargs" -le 1 ] ||
+ sps_die "$SPS_EX_USAGE" "pkconf takes at most one package name"
+ if [ -n "$scope" ]; then
+ sps_validate_package_name "$scope" ||
+ sps_die "$SPS_EX_USAGE" "invalid package name '$scope'"
+ fi
+ ;;
+esac
+
+[ -d "$SPS_DB/installed" ] && [ ! -L "$SPS_DB/installed" ] ||
+ sps_die "$SPS_EX_DATABASE" "installed database is missing or is not a real directory"
+
+[ "${SPS_PRESERVE:-etc}" != none ] ||
+ sps_die "$SPS_EX_USAGE" "SPS_PRESERVE is 'none'; no configuration files are protected"
+
+work=$(sps_mktemp_dir "${TMPDIR:-/tmp}" pkconf) ||
+ sps_die "$SPS_EX_PERMISSION" "cannot create work directory"
+trap 'status=$?; rm -rf "$work"; exit "$status"' 0
+trap 'exit 130' 1 2 3 15
+
+# Turn a user-supplied path into the package-relative path SPS records. The
+# ".sps-new" and ".sps-new.VERSION-RELEASE" spellings both resolve to the base
+# path so "pkconf --accept /etc/foo.conf.sps-new" does what it looks like.
+normalize_path()
+{
+ np_in=$1
+ case "$SPS_ROOT" in
+ /) case $np_in in /*) np_out=${np_in#/} ;; *) np_out=$np_in ;; esac ;;
+ *) case $np_in in
+ "$SPS_ROOT"/*) np_out=${np_in#"$SPS_ROOT"/} ;;
+ /*) np_out=${np_in#/} ;;
+ *) np_out=$np_in ;;
+ esac ;;
+ esac
+ np_out=${np_out%/}
+ case $np_out in
+ *.sps-new) np_out=${np_out%.sps-new} ;;
+ *.sps-new.*) np_out=${np_out%.sps-new.*} ;;
+ esac
+ [ -n "$np_out" ] || return 1
+ printf '%s\n' "$np_out"
+}
+
+# Collect one row per pending packaged default:
+# package <TAB> relative-path <TAB> state <TAB> default-file
+# state is "modified" when the live file differs from its recorded digest and
+# "clean" when it matches (a stale sibling the admin already reconciled).
+: >"$work/rows"
+for record in "$SPS_DB/installed"/*; do
+ [ -d "$record" ] && [ ! -L "$record" ] || continue
+ name=${record##*/}
+ case $name in .|..) continue ;; esac
+ [ -f "$record/hashes" ] && [ ! -L "$record/hashes" ] || continue
+ case $mode in
+ list|review|accept-all|keep-all)
+ if [ -n "$scope" ] && [ "$name" != "$scope" ]; then
+ continue
+ fi
+ ;;
+ esac
+ while IFS="$sps_tab" read -r algo expected relpath || [ -n "$relpath" ]; do
+ [ -n "$relpath" ] || continue
+ sps_is_preserved_path "$relpath" || continue
+ defaults=$(sps_config_new_defaults "$relpath")
+ [ -n "$defaults" ] || continue
+ target=$(sps_root_path "/$relpath")
+ state=missing
+ if [ -f "$target" ] && [ ! -L "$target" ]; then
+ live=$(sps_sha256 "$target" 2>/dev/null) || live=
+ set -- $live
+ live=$1
+ if [ -n "$live" ] && [ "$live" = "$expected" ]; then
+ state=clean
+ else
+ state=modified
+ fi
+ fi
+ printf '%s\n' "$defaults" | while IFS= read -r default; do
+ [ -n "$default" ] || continue
+ printf '%s\t%s\t%s\t%s\n' "$name" "$relpath" "$state" "$default"
+ done >>"$work/rows"
+ done <"$record/hashes"
+done
+LC_ALL=C sort -u "$work/rows" -o "$work/rows"
+
+# Restrict to explicitly named paths for --diff, --accept and --keep.
+if [ "$mode" = diff ] || [ "$mode" = accept ] || [ "$mode" = keep ]; then
+ : >"$work/wanted"
+ saved_ifs=$IFS
+ IFS=$sps_tab
+ for arg in $paths; do
+ [ -n "$arg" ] || continue
+ want=$(normalize_path "$arg") ||
+ sps_die "$SPS_EX_USAGE" "invalid path '$arg'"
+ printf '%s\n' "$want" >>"$work/wanted"
+ done
+ IFS=$saved_ifs
+ awk -F"$sps_tab" 'NR == FNR { want[$0] = 1; next } ($2 in want)' \
+ "$work/wanted" "$work/rows" >"$work/rows.sel"
+ mv -f "$work/rows.sel" "$work/rows"
+ while IFS= read -r want; do
+ if ! awk -F"$sps_tab" -v p="$want" '$2 == p { found = 1 }
+ END { exit !found }' "$work/rows"; then
+ sps_die "$SPS_EX_NOTFOUND" \
+ "no pending packaged default for /$want"
+ fi
+ done <"$work/wanted"
+fi
+
+if [ ! -s "$work/rows" ]; then
+ if [ "$raw" -eq 0 ]; then
+ printf '%s\n' 'No pending packaged configuration defaults.'
+ fi
+ exit 0
+fi
+
+show_diff()
+{
+ sd_target=$1
+ sd_default=$2
+ if ! sps_require_cmd diff 2>/dev/null; then
+ sps_warn "diff is not available; showing the packaged default instead"
+ cat "$sd_default"
+ return 0
+ fi
+ if [ -f "$sd_target" ] && [ ! -L "$sd_target" ]; then
+ # Non-zero status just means the files differ, which is the point.
+ diff -u "$sd_target" "$sd_default" || :
+ else
+ sps_warn "/${sd_target#${SPS_ROOT%/}} is missing; showing the packaged default"
+ cat "$sd_default"
+ fi
+}
+
+# Replace the live file with the packaged default. cp then rename keeps the
+# target a complete file at every instant, and preserves the mode the package
+# asked for rather than whatever the local copy drifted to.
+accept_default()
+{
+ ad_target=$1
+ ad_default=$2
+ ad_parent=${ad_target%/*}
+ [ -n "$ad_parent" ] || ad_parent=/
+ if [ "$backup" -eq 1 ] && [ -f "$ad_target" ] && [ ! -L "$ad_target" ]; then
+ if ! cp -p "$ad_target" "$ad_target.sps-bak"; then
+ sps_warn "cannot write backup for /${ad_target#${SPS_ROOT%/}}"
+ return 1
+ fi
+ fi
+ ad_tmp=$(mktemp "$ad_parent/.sps-conf.XXXXXX") || {
+ sps_warn "cannot stage /${ad_target#${SPS_ROOT%/}}"
+ return 1
+ }
+ if ! cp -p "$ad_default" "$ad_tmp"; then
+ rm -f "$ad_tmp"
+ sps_warn "cannot stage /${ad_target#${SPS_ROOT%/}}"
+ return 1
+ fi
+ if sps_is_root; then
+ chown 0:0 "$ad_tmp" 2>/dev/null || :
+ fi
+ if ! mv -f "$ad_tmp" "$ad_target"; then
+ rm -f "$ad_tmp"
+ sps_warn "cannot install packaged default for /${ad_target#${SPS_ROOT%/}}"
+ return 1
+ fi
+ rm -f "$ad_default"
+ return 0
+}
+
+keep_local()
+{
+ kl_default=$1
+ rm -f "$kl_default" || {
+ sps_warn "cannot remove ${kl_default#${SPS_ROOT%/}}"
+ return 1
+ }
+ return 0
+}
+
+record_history()
+{
+ rh_action=$1
+ rh_path=$2
+ rh_date=$(date '+%Y-%m-%d' 2>/dev/null || printf '%s' unknown)
+ printf '%s config %s /%s\n' "$rh_date" "$rh_action" "$rh_path" \
+ >>"$SPS_DB/history" 2>/dev/null || :
+}
+
+accepted=0
+kept=0
+skipped=0
+
+case $mode in
+list)
+ if [ "$raw" -eq 1 ]; then
+ cat "$work/rows"
+ else
+ while IFS="$sps_tab" read -r name relpath state default || [ -n "$relpath" ]; do
+ [ -n "$relpath" ] || continue
+ case $state in
+ modified) note='local file differs from the packaged default' ;;
+ clean) note='local file already matches its record' ;;
+ *) note='local file is missing' ;;
+ esac
+ printf '%-20s /%s\n' "$name" "$relpath"
+ printf ' %s\n' "$note"
+ printf ' default: %s\n' "${default#${SPS_ROOT%/}}"
+ done <"$work/rows"
+ printf '\n%s pending packaged default(s)\n' "$(wc -l <"$work/rows" | tr -d ' ')"
+ fi
+ ;;
+diff)
+ while IFS="$sps_tab" read -r name relpath state default || [ -n "$relpath" ]; do
+ [ -n "$relpath" ] || continue
+ show_diff "$(sps_root_path "/$relpath")" "$default"
+ done <"$work/rows"
+ ;;
+accept|accept-all)
+ while IFS="$sps_tab" read -r name relpath state default || [ -n "$relpath" ]; do
+ [ -n "$relpath" ] || continue
+ if accept_default "$(sps_root_path "/$relpath")" "$default"; then
+ accepted=$((accepted + 1))
+ record_history accept "$relpath"
+ printf '%s: installed packaged default for /%s\n' "$sps_prog" "$relpath"
+ fi
+ done <"$work/rows"
+ ;;
+keep|keep-all)
+ while IFS="$sps_tab" read -r name relpath state default || [ -n "$relpath" ]; do
+ [ -n "$relpath" ] || continue
+ if keep_local "$default"; then
+ kept=$((kept + 1))
+ record_history keep "$relpath"
+ printf '%s: kept local /%s\n' "$sps_prog" "$relpath"
+ fi
+ done <"$work/rows"
+ ;;
+review)
+ if [ ! -t 0 ]; then
+ sps_die "$SPS_EX_USAGE" \
+ "pkconf review needs a terminal; use --list, --accept-all, or --keep-all"
+ fi
+ total=$(wc -l <"$work/rows" | tr -d ' ')
+ index=0
+ # Read the rows from a dedicated descriptor so the prompt can still read
+ # the terminal on standard input.
+ exec 3<"$work/rows"
+ while IFS="$sps_tab" read -r name relpath state default <&3 || [ -n "$relpath" ]; do
+ [ -n "$relpath" ] || continue
+ index=$((index + 1))
+ target=$(sps_root_path "/$relpath")
+ printf '\n[%s/%s] /%s\n' "$index" "$total" "$relpath"
+ printf ' package: %s\n' "$name"
+ printf ' default: %s\n' "${default#${SPS_ROOT%/}}"
+ case $state in
+ modified) printf ' the local file differs from the packaged default\n' ;;
+ clean) printf ' the local file already matches its recorded digest\n' ;;
+ *) printf ' the local file is missing\n' ;;
+ esac
+ while :; do
+ printf ' [d]iff [n]ew (use packaged) [k]eep local [e]dit [s]kip [q]uit: '
+ if ! read -r answer; then
+ printf '\n'
+ answer=q
+ fi
+ case $answer in
+ d|diff)
+ show_diff "$target" "$default"
+ ;;
+ n|new)
+ if accept_default "$target" "$default"; then
+ accepted=$((accepted + 1))
+ record_history accept "$relpath"
+ printf ' installed the packaged default\n'
+ fi
+ break
+ ;;
+ k|keep)
+ if keep_local "$default"; then
+ kept=$((kept + 1))
+ record_history keep "$relpath"
+ printf ' kept the local file and removed the packaged default\n'
+ fi
+ break
+ ;;
+ e|edit)
+ editor=${VISUAL:-${EDITOR:-vi}}
+ if ! command -v "${editor%% *}" >/dev/null 2>&1; then
+ sps_warn "editor '$editor' not found; set EDITOR"
+ continue
+ fi
+ # Edit a copy so an abandoned edit cannot damage either
+ # the live file or the packaged default.
+ merge=$work/merge
+ if [ -f "$target" ] && [ ! -L "$target" ]; then
+ cp -p "$target" "$merge" || continue
+ else
+ cp -p "$default" "$merge" || continue
+ fi
+ $editor "$merge" || { sps_warn 'editor exited nonzero'; continue; }
+ if cp -p "$merge" "$target"; then
+ rm -f "$default" "$merge"
+ accepted=$((accepted + 1))
+ record_history merge "$relpath"
+ printf ' wrote the merged file and removed the packaged default\n'
+ break
+ fi
+ sps_warn "cannot write /$relpath"
+ ;;
+ s|skip|'')
+ skipped=$((skipped + 1))
+ break
+ ;;
+ q|quit)
+ exec 3<&-
+ printf '\n%s: %s accepted, %s kept, %s skipped\n' \
+ "$sps_prog" "$accepted" "$kept" "$skipped"
+ exit 0
+ ;;
+ *)
+ printf ' answer d, n, k, e, s, or q\n'
+ ;;
+ esac
+ done
+ done
+ exec 3<&-
+ printf '\n%s: %s accepted, %s kept, %s skipped\n' \
+ "$sps_prog" "$accepted" "$kept" "$skipped"
+ ;;
+esac
+
+exit 0
diff --git a/bin/sget b/bin/sget
index 00a23e3..b5ca444 100755
--- a/bin/sget
+++ b/bin/sget
@@ -725,7 +725,7 @@ sget_install()
printf '%s\n' 'All requested packages are already installed.'
fi
if [ "${sget_plan:-0}" -eq 0 ] && sps_config_review_pending; then
- sget_note "run 'sget config-status' to review protected configuration changes from this update"
+ sget_note "run 'sget config-status' to review protected configuration changes from this update, or 'pkconf' to reconcile them"
fi
sget_cleanup_install
trap - 0 1 2 3 15
@@ -866,7 +866,7 @@ sget_remove()
done <"$sget_remove_work/order"
rm -rf "$sget_remove_work"
if [ "$sget_remove_plan" -eq 0 ] && sps_config_review_pending; then
- sget_note "run 'sget config-status' to review protected configuration changes"
+ sget_note "run 'sget config-status' to review protected configuration changes, or 'pkconf' to reconcile them"
fi
sps_lock_release
trap - 0 1 2 3 15
diff --git a/man/pkconf.1 b/man/pkconf.1
new file mode 100644
index 0000000..e9eb42b
--- /dev/null
+++ b/man/pkconf.1
@@ -0,0 +1,139 @@
+.TH PKCONF 1 "2026-09-22" "SPS 1.0" "SPS Commands"
+.SH NAME
+pkconf \- reconcile protected configuration files with packaged defaults
+.SH SYNOPSIS
+.nf
+pkconf [--backup] [PACKAGE]
+pkconf --list [--raw] [PACKAGE]
+pkconf --diff PATH
+pkconf --accept [--backup] PATH...
+pkconf --keep PATH...
+pkconf --accept-all [--backup] [PACKAGE]
+pkconf --keep-all [PACKAGE]
+.fi
+.SH DESCRIPTION
+An upgrade never overwrites a protected configuration file that the
+administrator has changed. Instead
+.BR pkin (1)
+keeps the local file and writes the new packaged default beside it as
+.I .sps-new
+(or
+.IR .sps-new.VERSION-RELEASE ).
+.PP
+.BR "sget config-status" (1)
+reports that situation and is deliberately read-only.
+.B pkconf
+is the command that acts on it, the way
+.BR rejmerge (8)
+does for CRUX and
+.BR etc-update (1)
+does for Gentoo.
+.PP
+With no options
+.B pkconf
+walks every pending packaged default and prompts for each one. An optional
+.I PACKAGE
+limits the review to the files owned by that package.
+.PP
+Accepting a packaged default installs it over the local file and removes the
+sibling. Because
+.BR pkin (1)
+already recorded the new package's digest for that path, the file then matches
+its record again and
+.BR pkcheck (1)
+reports the package clean. Keeping the local file only removes the sibling; the
+path stays locally modified, which remains the honest state.
+.SH OPTIONS
+.TP
+.B --list
+List pending packaged defaults and exit without changing anything.
+.TP
+.B --raw
+With
+.BR --list ,
+print sorted tab-separated records: package, package-relative path,
+.I clean
+or
+.IR modified ,
+and the packaged default's absolute path.
+.TP
+.B --diff " PATH"
+Print
+.B diff -u
+between the live local file and the packaged default. The plain path, the
+.I .sps-new
+sibling, and the versioned spelling all select the same file.
+.TP
+.B --accept " PATH..."
+Install the packaged default for each named path.
+.TP
+.B --keep " PATH..."
+Discard the packaged default for each named path and keep the local file.
+.TP
+.B --accept-all
+Install every pending packaged default without prompting.
+.TP
+.B --keep-all
+Discard every pending packaged default without prompting.
+.TP
+.B --backup
+Before replacing a local file, copy it to
+.IR PATH .sps-bak .
+.SH INTERACTIVE KEYS
+.TP
+.B d
+Show a unified diff of the local file against the packaged default.
+.TP
+.B n
+Install the packaged default.
+.TP
+.B k
+Keep the local file and remove the packaged default.
+.TP
+.B e
+Open a scratch copy in
+.B $VISUAL
+or
+.BR $EDITOR ,
+then write the result over the local file and remove the packaged default. The
+edit happens on a copy, so abandoning it damages neither file.
+.TP
+.B s
+Skip this file and leave both copies in place.
+.TP
+.B q
+Stop reviewing and report what was done.
+.SH ENVIRONMENT
+.TP
+.B SPS_PRESERVE
+Comma-separated list of package-relative path prefixes that are protected. The
+default is
+.BR etc .
+When it is
+.BR none ,
+.B pkconf
+exits with a usage error because nothing is protected.
+.TP
+.B SPS_ROOT
+Alternate filesystem root. Paths given on the command line may be written with
+or without that prefix.
+.SH FILES
+.TP
+.I $SPS_DB/installed/NAME/hashes
+The recorded digest for every regular file a package owns. A protected path
+whose live file no longer matches this digest is the locally modified case.
+.TP
+.I $SPS_DB/history
+Each accept, keep, and merge is appended here.
+.SH EXIT STATUS
+Returns 0 on success, 2 for usage errors or when
+.B SPS_PRESERVE
+is
+.BR none ,
+3 when a named path has no pending packaged default, and 11 for database
+failures.
+.SH SEE ALSO
+.BR sget (1),
+.BR pkin (1),
+.BR pkcheck (1),
+.BR sps.conf (5)
diff --git a/tests/test_pkconf.sh b/tests/test_pkconf.sh
new file mode 100755
index 0000000..d054dc3
--- /dev/null
+++ b/tests/test_pkconf.sh
@@ -0,0 +1,153 @@
+#!/bin/sh
+set -eu
+
+case $0 in */*) test_dir=${0%/*} ;; *) test_dir=. ;; esac
+project_dir=$(CDPATH= cd "$test_dir/.." 2>/dev/null && pwd) || exit 1
+fail() { printf 'test_pkconf: %s\n' "$*" >&2; exit 1; }
+
+tmp=$(mktemp -d "${TMPDIR:-/tmp}/sps-pkconf.XXXXXX") || fail 'mktemp failed'
+trap 'rm -rf "$tmp"' 0 HUP INT TERM
+root=$tmp/root
+db=$tmp/db
+mkdir -p "$root" "$tmp/cache" "$tmp/build" "$tmp/pkg" "$tmp/recipe"
+
+run()
+{
+ SPS_ROOT=$root SPS_DB=$db SPS_CACHE=$tmp/cache SPS_BUILD=$tmp/build \
+ SPS_CONFIG=/dev/null SPS_REPOS_CONFIG=/dev/null SPS_PRESERVE=etc \
+ SPS_LIBDIR=$project_dir/lib "$@"
+}
+
+make_pkg()
+{
+ mp_version=$1 mp_text=$2
+ cat >"$tmp/recipe/recipe" <<EOF_RECIPE
+name confpkg
+version $mp_version
+release 1
+arch any
+description pkconf fixture
+install mkdir -p "\$PKG/etc"
+install printf '%s\n' '$mp_text' > "\$PKG/etc/confpkg.conf"
+EOF_RECIPE
+ rm -f "$tmp/pkg/artifact"
+ run "$project_dir/bin/mkpkg" --artifact-file "$tmp/pkg/artifact" \
+ --compression none --output "$tmp/pkg" "$tmp/recipe/recipe" >/dev/null 2>&1
+ sed -n 1p "$tmp/pkg/artifact"
+}
+
+# Stage the situation pkconf exists for: a locally edited protected file plus
+# an upgrade whose packaged default also changed.
+stage()
+{
+ rm -rf "$root" "$db" "$tmp/pkg"
+ mkdir -p "$root" "$tmp/pkg"
+ artifact=$(make_pkg 1.0 default-1)
+ run "$project_dir/bin/pkin" "$artifact" >/dev/null 2>&1
+ printf '%s\n' local-admin-value >"$root/etc/confpkg.conf"
+ artifact=$(make_pkg 2.0 default-2)
+ run "$project_dir/bin/pkin" "$artifact" >/dev/null 2>&1
+ [ -f "$root/etc/confpkg.conf.sps-new" ] ||
+ fail 'fixture did not produce a .sps-new sibling'
+}
+
+# A clean system has nothing to reconcile.
+mkdir -p "$db/installed"
+: >"$db/owners"
+: >"$db/world"
+out=$(run "$project_dir/bin/pkconf" --list) ||
+ fail 'pkconf --list failed on a clean system'
+case $out in
+ *'No pending packaged configuration defaults.'*) ;;
+ *) fail "pkconf --list on a clean system said: $out" ;;
+esac
+
+# --list reports the pending default.
+stage
+out=$(run "$project_dir/bin/pkconf" --list) || fail 'pkconf --list failed'
+case $out in
+ *'/etc/confpkg.conf'*) ;;
+ *) fail "pkconf --list did not report the pending file: $out" ;;
+esac
+raw=$(run "$project_dir/bin/pkconf" --list --raw) || fail 'pkconf --list --raw failed'
+case $raw in
+ *confpkg*etc/confpkg.conf*modified*) ;;
+ *) fail "pkconf --list --raw is wrong: $raw" ;;
+esac
+
+# --diff compares the live file against the packaged default.
+out=$(run "$project_dir/bin/pkconf" --diff /etc/confpkg.conf) ||
+ fail 'pkconf --diff failed'
+case $out in
+ *local-admin-value*default-2*) ;;
+ *) fail "pkconf --diff output is wrong: $out" ;;
+esac
+# The .sps-new spelling must resolve to the same base path.
+run "$project_dir/bin/pkconf" --diff /etc/confpkg.conf.sps-new >/dev/null ||
+ fail 'pkconf --diff did not accept the .sps-new spelling'
+
+# --keep drops the packaged default and leaves the local file alone.
+stage
+run "$project_dir/bin/pkconf" --keep /etc/confpkg.conf >/dev/null ||
+ fail 'pkconf --keep failed'
+[ "$(cat "$root/etc/confpkg.conf")" = local-admin-value ] ||
+ fail 'pkconf --keep changed the local file'
+[ ! -e "$root/etc/confpkg.conf.sps-new" ] ||
+ fail 'pkconf --keep left the packaged default behind'
+
+# --accept installs the packaged default. Afterwards the file must match the
+# digest pkin recorded, so pkcheck sees a clean package again.
+stage
+run "$project_dir/bin/pkconf" --accept /etc/confpkg.conf >/dev/null ||
+ fail 'pkconf --accept failed'
+[ "$(cat "$root/etc/confpkg.conf")" = default-2 ] ||
+ fail 'pkconf --accept did not install the packaged default'
+[ ! -e "$root/etc/confpkg.conf.sps-new" ] ||
+ fail 'pkconf --accept left the packaged default behind'
+run "$project_dir/bin/pkcheck" confpkg >/dev/null ||
+ fail 'pkcheck is unhappy after pkconf --accept'
+out=$(run "$project_dir/bin/sget" config-status) || fail 'config-status failed'
+case $out in
+ *'no protected configuration changes'*) ;;
+ *) fail "config-status is not clean after --accept: $out" ;;
+esac
+
+# --backup keeps the replaced local file.
+stage
+run "$project_dir/bin/pkconf" --accept --backup /etc/confpkg.conf >/dev/null ||
+ fail 'pkconf --accept --backup failed'
+[ "$(cat "$root/etc/confpkg.conf.sps-bak")" = local-admin-value ] ||
+ fail 'pkconf --backup did not preserve the local file'
+
+# --accept-all and --keep-all operate without naming paths.
+stage
+run "$project_dir/bin/pkconf" --accept-all >/dev/null ||
+ fail 'pkconf --accept-all failed'
+[ "$(cat "$root/etc/confpkg.conf")" = default-2 ] ||
+ fail 'pkconf --accept-all did not install the packaged default'
+
+stage
+run "$project_dir/bin/pkconf" --keep-all confpkg >/dev/null ||
+ fail 'pkconf --keep-all failed'
+[ "$(cat "$root/etc/confpkg.conf")" = local-admin-value ] ||
+ fail 'pkconf --keep-all changed the local file'
+
+# A path with no pending default is an error rather than a silent success.
+stage
+if run "$project_dir/bin/pkconf" --accept /etc/nosuch.conf >/dev/null 2>&1; then
+ fail 'pkconf --accept accepted a path with no pending default'
+fi
+
+# Interactive review refuses to run without a terminal instead of guessing.
+stage
+if run "$project_dir/bin/pkconf" </dev/null >/dev/null 2>&1; then
+ fail 'pkconf review should refuse a non-interactive stdin'
+fi
+
+# The operations are recorded in the history log.
+stage
+run "$project_dir/bin/pkconf" --accept /etc/confpkg.conf >/dev/null
+grep -q 'config accept /etc/confpkg.conf' "$db/history" ||
+ fail 'pkconf did not record the accept in history'
+
+printf '%s\n' 'pkconf tests passed'