diff --git a/README.md b/README.md index 4b9477c..9dffb62 100644 --- a/README.md +++ b/README.md @@ -147,6 +147,18 @@ replacements only the pacman transaction (`-Sup`) shows, packages dnf installs t others, packages a Tumbleweed `dist-upgrade` removes. With no row but such work, the list says "0 to review, but ... reports pending changes" and `` still runs the upgrade. +### Line editing and history + +When stdin is a terminal, the prompt is read with bash's readline and its defaults: Left/Right +move the cursor, Home/End or Ctrl-A/Ctrl-E jump to the ends, Backspace/Delete edit, and Up/Down +walk the lines entered in this session. Down past the newest entry brings back what you had typed +before pressing Up; Up at the oldest entry, and Down when already back on the new line, ring the +terminal bell. Empty lines and the `y/N` answer of `Nx` are not recorded. The history lives in +memory only and is never read from or written to a file (`HISTFILE` is ignored), so every run +starts with an empty one. As in bash, readline draws the prompt and the line on stderr and honours +your `~/.inputrc`. Piped or redirected stdin keeps the plain line reader: every line is echoed +after the prompt, and end of input aborts (exit 2). + ### Dry run `--dry-run` prints every mutating command as `+ ` instead of running it: @@ -265,7 +277,8 @@ signal during the exit handler, the holds lock kept while a restore failed, jour zypper configuration replayed only when no other run holds their lock, the pacman pre-check / database install / `-Su` sequence and stale throwaway databases, statefile locking and merge, the `mkdir` lock fallback (EPERM owner alive, no busy loop), an inaccessible state directory, the -sudo re-exec of the file itself and its loop guard). +sudo re-exec of the file itself and its loop guard), and the prompt on a real pty (`script`): +cursor editing, Up/Down history recall, the stashed line, the bell at both ends, no history file. About 30 seconds. ### End-to-end tests (`tests/e2e/`) @@ -288,7 +301,8 @@ excluding `su-dummy-b` (with and without a pre-existing hold/lock/IgnorePkg/excl `su-dummy-c`, which must survive byte for byte), trusting re-including an excluded dependency, full upgrade, `Nx` (refused for su-dummy-b, which su-dummy-a needs; su-dummy-a taking its dependency su-dummy-b along, shown in the prompt, and the unrelated orphan surviving), -purging everything, `q`, end of input, `--dry-run`, the read-only commands as an unprivileged +purging everything, `q`, end of input, a session on a pty where Up recalls the previous line +(skipped where the image has no `script`), `--dry-run`, the read-only commands as an unprivileged user, plus per backend: pacman never touching the real sync database outside the upgrade and installing exactly the reviewed database at `` (same hash, a package published after the review is not installed), apt diff --git a/docs/DESIGN.md b/docs/DESIGN.md index 17592a9..fee82b4 100644 --- a/docs/DESIGN.md +++ b/docs/DESIGN.md @@ -114,6 +114,18 @@ prints an error, runs nothing of that line and re-prompts. EOF on stdin aborts w upgrading (exit 2) so a piped run can never upgrade by accident unless it sends an empty line. When purges leave no row at all, the session ends ("Nothing left to upgrade.", exit 0). +Line input. When stdin is a terminal every line is read with bash's readline, +`IFS= read -e -r -p PROMPT`, with readline's defaults and nothing more: cursor movement and editing +(Left/Right, Home/End, Ctrl-A/E, Backspace/Delete) and Up/Down over an in-memory history of this +session's command lines. A line with a non-blank character is added with `history -s` (empty +lines, the proceed command, are not); Down past the newest entry restores the line typed before Up +(readline's saved line); Up at the oldest entry and Down on the new line ring the bell. The `Nx` +`y/N` answer is read the same way and never added. Nothing persists: `HISTFILE` is unset at +startup and `history -r`/`-w`/`-a` are never used (a non-interactive bash neither loads nor saves +history by itself). readline draws the prompt and the line on stderr, as bash does. Any other +stdin keeps the plain `IFS= read -r` with the prompt on stdout and each line echoed after it, so +piped runs and their logs are unchanged. + Flags shown after a row: `[held]` = the package manager will not upgrade it on its own: on apt a hold (`apt-mark hold`) or a package apt keeps back (the "kept back" block of `apt-get -s --with-new-pkgs upgrade`), on pacman an `IgnorePkg` (`[ignored]` in `pacman -Qu`). diff --git a/skeptical-updater b/skeptical-updater index df62932..2e447bf 100755 --- a/skeptical-updater +++ b/skeptical-updater @@ -19,6 +19,9 @@ fi set -euo pipefail export LC_ALL=C DEBIAN_FRONTEND=noninteractive +# On a terminal the prompt uses readline with a history of this session's lines only: in memory, +# never read from or written to a file (no HISTFILE; `history -r`/`-w`/`-a` are never used). +unset HISTFILE readonly SU_VERSION=0.1.0 readonly SU_PROG=skeptical-updater @@ -2185,14 +2188,21 @@ eof_abort() { exit 2 } -read_line() { # read_line -> $REPLY; EOF aborts (exit 2) +# read_line PROMPT [history] -> $REPLY; EOF aborts (exit 2). On a terminal the line is read with +# readline (bash's defaults: arrows, Home/End, ^A/^E, ...; the prompt is drawn on stderr) and, with +# "history", a non-empty line joins this session's in-memory history that Up/Down walk. Otherwise +# a plain read, and the input is echoed after the prompt so logs stay readable. +read_line() { REPLY= - if ! IFS= read -r REPLY; then - [[ -n $REPLY ]] || eof_abort + if [[ -t 0 ]]; then + IFS= read -e -r -p "$1" REPLY || [[ -n $REPLY ]] || eof_abort + if [[ ${2:-} == history && $REPLY == *[![:space:]]* ]]; then history -s -- "$REPLY"; fi + return 0 fi + printf '%s' "$1" 2>/dev/null || true + IFS= read -r REPLY || [[ -n $REPLY ]] || eof_abort REPLY=${REPLY%$'\r'} - # echo the input when it does not come from a terminal, so logs stay readable - [[ -t 0 ]] || say "$REPLY" + say "$REPLY" } num_row() { # num_row N -> $REPLY = row index of a listed row, rc 1 if N is not listed @@ -2250,8 +2260,7 @@ session_purge() { fi prompt="Purge $name now?" ((${#others[@]} == 0)) || prompt+=" This also removes: $(join_by ', ' "${others[@]}")" - printf '%s [y/N] ' "$prompt" 2>/dev/null || true - read_line + read_line "$prompt [y/N] " case $REPLY in y | Y | yes | YES | Yes) ;; *) @@ -2415,8 +2424,7 @@ cmd_interactive() { say '' say "$SU_PROMPT_HELP" while ((!SESSION_PROCEED)); do - printf '> ' 2>/dev/null || true - read_line + read_line '> ' history session_line "$REPLY" done for ((i = 0; i < ${#R_NAME[@]}; i++)); do diff --git a/tests/e2e/helpers.bash b/tests/e2e/helpers.bash index 65ab7ff..d27e63d 100644 --- a/tests/e2e/helpers.bash +++ b/tests/e2e/helpers.bash @@ -207,6 +207,23 @@ pacman_unpublish() { repo-add --quiet su-dummy.db.tar.gz ./su-dummy-a-2.0-1-any.pkg.tar.zst ./su-dummy-b-2.0-1-any.pkg.tar.zst ./su-dummy-c-2.0-1-any.pkg.tar.zst) } +# pty_session INPUT ARGS...: run skeptical-updater ARGS on a pseudo-terminal (script(1) of +# util-linux) with INPUT typed in one go (readline consumes it as it reads: no timing involved); +# sets status, output and lines like `run` (without the pty's CRs; NO_COLOR as stdout is a terminal) +# and PTY_RAW, the raw transcript. Skips the test when the image has no script(1). +# shellcheck disable=SC2034 # status/lines are read by bats-assert +pty_session() { + local input=$1 cmd + shift + command -v script >/dev/null 2>&1 || skip "script(1) (util-linux) not available in this image: no pty to drive" + PTY_RAW=$BATS_TEST_TMPDIR/pty.raw + printf -v cmd '%q ' env TERM=dumb NO_COLOR=1 skeptical-updater "$@" + status=0 + printf '%s' "$input" | timeout 120 script -qfec "$cmd" /dev/null >"$PTY_RAW" 2>&1 || status=$? + output=$(tr -d '\r' <"$PTY_RAW") + mapfile -t lines <<<"$output" +} + # session_start ARGS...: run skeptical-updater ARGS in the background with a FIFO as stdin and wait # for its prompt; sets SESSION_PID and SESSION_FD (write the input to it; fd 3 belongs to bats) session_start() { diff --git a/tests/e2e/skeptical-updater.bats b/tests/e2e/skeptical-updater.bats index d19c79f..db04358 100644 --- a/tests/e2e/skeptical-updater.bats +++ b/tests/e2e/skeptical-updater.bats @@ -219,6 +219,14 @@ zypper_verb() { case $(. /etc/os-release && printf '%s' "$ID") in opensuse-tumbl [[ ! -e $STATE_FILE ]] } +@test "on a terminal, Up recalls the previous line (readline, history of this session only)" { + pty_session $'1?\r\e[A\rq\r' --no-refresh + assert_success + [[ $(grep -c '^su-dummy-a: ' <<<"$output") -eq 2 ]] || fail "1? should have run twice (Up + Enter)" + assert_line 'Quit: nothing was upgraded.' + [[ $(versions) == "a=1.0 b=1.0 c=1.0" ]] +} + @test "--dry-run: enter prints the native upgrade command and changes nothing" { before=$(holds_snapshot) run skeptical-updater --dry-run <<<'' diff --git a/tests/unit/lineedit.bats b/tests/unit/lineedit.bats new file mode 100644 index 0000000..43f7df2 --- /dev/null +++ b/tests/unit/lineedit.bats @@ -0,0 +1,91 @@ +#!/usr/bin/env bats +# SPDX-License-Identifier: GPL-2.0-or-later +# Copyright (C) 2026 Bryan Joshua Pedini + +# Line editing and the per-session history of the prompt when stdin is a terminal: the applet runs +# on a real pty (script(1)) and gets the keys as escape sequences - Up \e[A, Down \e[B, Right \e[C, +# Left \e[D, Enter \r - typed in one go. apt backend, fixture rows [1] su-dummy-a [2] su-dummy-b +# [3] su-dummy-c. The piped (non-TTY) prompt is covered by interactive.bats. + +load test_helper + +setup() { common_setup; } + +UP=$'\e[A' DOWN=$'\e[B' LEFT=$'\e[D' CR=$'\r' +DESC_A='su-dummy-a: dummy package su-dummy-a for skeptical-updater tests' + +@test "pty: Up recalls the previous line, Enter runs it again" { + pty_skup "1?${CR}${UP}${CR}q${CR}" --no-refresh + assert_success + [[ $(count_lines "$DESC_A") -eq 2 ]] || fail "1? should have run twice" + assert_line 'Quit: nothing was upgraded.' + [[ $(pty_bells) -eq 0 ]] || fail "unexpected bell" +} + +@test "pty: Up at the oldest entry rings the bell and stays there" { + pty_skup "1?${CR}${UP}${UP}${CR}q${CR}" --no-refresh + assert_success + [[ $(pty_bells) -ge 1 ]] || fail "no BEL in the pty output" + [[ $(count_lines "$DESC_A") -eq 2 ]] || fail "the oldest entry (1?) should have run again" +} + +@test "pty: a line typed before Up is stashed and comes back with Down (readline's saved line)" { + pty_skup "1?${CR}3-${UP}${DOWN}${CR}${CR}" --no-refresh + assert_success + assert_line 'excluded from this run: su-dummy-c' + [[ $(count_lines "$DESC_A") -eq 1 ]] || fail "the recalled 1? must not have run" + assert_line 'Excluded from this run: su-dummy-c' + log_has 'apt-mark hold su-dummy-c' + [[ $(pty_bells) -eq 0 ]] || fail "unexpected bell" +} + +@test "pty: Down on the new line rings the bell, also once the stashed line is back" { + pty_skup "1?${CR}${DOWN}q${CR}" --no-refresh + assert_success + [[ $(pty_bells) -ge 1 ]] || fail "no BEL for Down on the new line" + assert_line 'Quit: nothing was upgraded.' + + pty_skup "1?${CR}3-${UP}${DOWN}${DOWN}${CR}q${CR}" --no-refresh + assert_success + [[ $(pty_bells) -ge 1 ]] || fail "no BEL for Down on the restored line" + assert_line 'excluded from this run: su-dummy-c' +} + +@test "pty: Left moves the cursor: 2? Left Left 1 Enter is the command 12?" { + pty_skup "2?${LEFT}${LEFT}1${CR}q${CR}" --no-refresh + assert_success + assert_output --partial 'no package [12] in the list (l to redraw)' + refute_output --partial 'su-dummy-b: dummy package' +} + +@test "pty: empty lines and the y/N answer stay out of the history" { + # history: 3x only - Up shows 3x, a second Up rings the bell (y was not added); 3x again is + # rejected because [3] was purged + pty_skup "3x${CR}y${CR}${UP}${UP}${CR}q${CR}" --no-refresh + assert_success + assert_line 'purged: su-dummy-c' + assert_output --partial 'no package [3] in the list (l to redraw)' + [[ $(pty_bells) -ge 1 ]] || fail "y must not be in the history" + log_has 'apt-get -y purge su-dummy-c' +} + +@test "pty: the history is never written to or read from a file (HISTFILE is ignored)" { + export HOME=$BATS_TEST_TMPDIR/home TMPDIR=$BATS_TEST_TMPDIR/tmp + mkdir -p "$HOME" "$TMPDIR" + export HISTFILE=$HOME/.bash_history HISTCONTROL=ignoredups HISTSIZE=0 + pty_skup "1?${CR}${UP}${CR}${UP}${CR}${CR}" --no-refresh + assert_success + [[ $(count_lines "$DESC_A") -eq 3 ]] || fail "the history did not work (inherited HISTSIZE/HISTCONTROL?)" + assert_line 'Upgrade finished.' + run find "$HOME" "$TMPDIR" -mindepth 1 + assert_success + assert_output '' +} + +@test "pty: end of input (^D on an empty line) still aborts with exit 2" { + pty_skup "1-${CR}"$'\x04' --no-refresh + assert_failure 2 + assert_line 'excluded from this run: su-dummy-a' + assert_output --partial 'end of input: aborted, nothing was upgraded' + log_lacks '^apt-get -y' +} diff --git a/tests/unit/test_helper.bash b/tests/unit/test_helper.bash index 92e2ed4..8d01203 100644 --- a/tests/unit/test_helper.bash +++ b/tests/unit/test_helper.bash @@ -50,6 +50,28 @@ use_fakes() { # skup ARGS...: run the applet (use as `run skup ...`, stdin is passed through) skup() { env PATH="$TEST_PATH" "$SU_BIN" "$@"; } +# pty_skup INPUT ARGS...: run the applet on a pseudo-terminal (script(1), util-linux/bsdutils) with +# INPUT typed in one go - readline consumes it as it reads, so no timing is involved; end INPUT with +# a line that ends the session (q or an empty line). Sets status, output and lines like `run` +# (output without the pty's CRs; NO_COLOR, as stdout is a terminal); the raw transcript stays in +# $PTY_RAW. Skips without script(1). +# shellcheck disable=SC2034 # status/lines are read by bats-assert +pty_skup() { + local input=$1 cmd + shift + command -v script >/dev/null 2>&1 || skip "script(1) (util-linux/bsdutils) not available: no pty to drive" + PTY_RAW=$BATS_TEST_TMPDIR/pty.raw + printf -v cmd '%q ' env PATH="$TEST_PATH" TERM=dumb NO_COLOR=1 "$SU_BIN" "$@" + status=0 + printf '%s' "$input" | timeout 60 script -qfec "$cmd" /dev/null >"$PTY_RAW" 2>&1 || status=$? + output=$(tr -d '\r' <"$PTY_RAW") + mapfile -t lines <<<"$output" +} +# pty_bells: number of BEL bytes (readline's bell) in the raw transcript of the last pty_skup +pty_bells() { tr -cd '\a' <"$PTY_RAW" | wc -c; } +# count_lines LINE: number of output lines that are exactly LINE +count_lines() { grep -cxF -- "$1" <<<"$output" || true; } + # shellcheck disable=SC2034 # used by the .bats files HEADER='# skeptical-updater trusted packages. Columns: backend package reason added(UTC ISO-8601)' # shellcheck disable=SC2034