Splux Linux

source-based Linux using SPS

SPS · log · files · refs · tags · releases · people · languages · GitHub · commit

2ca9f8c


Add pkconf, the missing half of the configuration workflow

Date
Commit2ca9f8cb35104aed80cc04bb4d363eb917fbe50c
GitHubmirror

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'