feat(prompt): readline line editing with per-session history
When stdin is a terminal the prompt is read with bash's readline, `IFS= read -e -r -p '> ' REPLY`, so the arrow keys no longer print escape garbage: Left/Right, Home/End, Ctrl-A/E, Backspace/Delete edit the line, and Up/Down walk the lines entered in this session (`history -s` for every line with a non-blank character; empty lines are not recorded). Down past the newest entry restores the line typed before Up, and Up at the oldest entry or Down on the new line ring the bell, as readline does by default. The Nx y/N answer is read the same way and never recorded. Nothing persists: HISTFILE is unset at startup and the history is never read or written. Verified on bash 4.3.48 and 5.2.15 that `read -e` and `history -s` share one history list in a non-interactive shell, that inherited HISTSIZE/HISTCONTROL/HISTIGNORE have no effect there, and that Ctrl-C at the prompt restores the terminal. readline draws the prompt on stderr, as bash does. Piped stdin keeps the plain `read -r` exactly, including the echo of each line and EOF => exit 2. New unit tests (tests/unit/lineedit.bats) drive a real pty with script(1): history recall, the bell at both ends, the stashed line, Left-arrow editing, the y/N answer kept out of the history, no history file under HOME/TMPDIR, ^D => exit 2. The E2E suite gets a pty smoke test (Up recalls 1?), skipped where the image has no script(1).
This commit is contained in:
1 parent
71d7f87aa3
commit
296c77e319
7 files changed
+183
-11
No files matched your search
@@ -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 `<enter>` 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 `+ <command>` 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 `<enter>` (same hash, a package published after the
|
||||
review is not installed), apt
|
||||
|
||||
@@ -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`).
|
||||
|
||||
+17
-9
@@ -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
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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 <<<''
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user