fix: close the four known installer bugs (DU-H1, DU-H2, BUG-1, BUG-2)

- DU-H1: flags are parsed wherever they sit, so `install --unattended`
  and `--unattended install` are the same run; any unknown flag, word,
  or subcommand exits 2 to stderr before a package manager is touched.
- DU-H2: every download lands in one private mktemp -d (mode 700)
  workdir per run, is checked non-empty before sudo tar sees it, and an
  EXIT/INT/TERM trap cleans up. No fixed /tmp paths remain.
- BUG-1: ^t is now toggle-shown — it ticks only the rows the active
  filter is showing, and @needs expansion stops at the first invasive
  row, so an invasive package can never be ticked off-screen.
- BUG-2: ^t journals what it added, so a second ^t over the same shown
  set unticks exactly that set; the bind no longer clears the query.
- lab: the type verb polls fzf's reported query to a deadline instead
  of a fixed sleep; marks_settled retries within its deadline.

Suite 256/0 host, 214/0 docker (ubuntu:24.04), mutations 24/24 killed
(six new mutants re-introduce each bug and all die), lab 6/6 green.
This commit is contained in:
bcherb2
2026-08-22 13:25:21 -04:00
parent 084fb7a730
commit 4043787a58
13 changed files with 537 additions and 58 deletions
+8
View File
@@ -20,6 +20,14 @@ done
cfg= cfg=
[ "$readcfg" = 1 ] && cfg=$(cat) # always drain: the writer is a pipe [ "$readcfg" = 1 ] && cfg=$(cat) # always drain: the writer is a pipe
# Where a download lands is itself under test (DU-H2: the tarballs used to go to
# /tmp/nvim.tgz and be unpacked by `sudo tar`). The directory is private to the
# run and removed before it exits, so its mode can only be observed from in
# here. Recorded beside the argv line _fake has already written.
[ -n "$out" ] && printf 'curl-outdir %s %s\n' "$(dirname "$out")" \
"$(stat -c %a "$(dirname "$out")" 2>/dev/null || echo unknown)" \
>> "${DOTUP_TEST_LOG:?DOTUP_TEST_LOG unset}"
emit() { if [ -n "$out" ]; then cat > "$out"; else cat; fi; } emit() { if [ -n "$out" ]; then cat > "$out"; else cat; fi; }
# A tiny installer script, printed the way get.chezmoi.io and astral.sh print # A tiny installer script, printed the way get.chezmoi.io and astral.sh print
+43 -14
View File
@@ -26,12 +26,13 @@
# silent no-op) were fixed, and the assertions below now hold the FIXED # silent no-op) were fixed, and the assertions below now hold the FIXED
# behaviour -- they fail again if it regresses. # behaviour -- they fail again if it regresses.
# #
# What is left is DEFERRED, not unknown, and there is no expected-fail # BUG-1/2, in `promise 3`, were the last three left: ^t over an --exact filter
# mechanism here to hide it behind: BUG-1/2, in `promise 3`, three assertions. # widened along @needs and ticked invasive rows that were not on screen, and
# ^t over an --exact filter widens along @needs and ticks invasive rows that # ^t ^t was not its own undo because the reverse edges do not retract what the
# are not on screen, and ^t ^t is not its own undo because the reverse edges # forward ones pulled in. Both are fixed -- ^t is `dotup toggle-shown`, whose
# do not retract what the forward ones pulled in. Until those are fixed this # forward walk stops at an invasive dependency and whose second press replays
# scenario exits 3. # a journal of the first -- and those three assertions now hold the FIXED
# behaviour too. The scenario exits 0, and every remaining NOTE is a NOTE.
set -u set -u
FAILS=0 FAILS=0
@@ -72,6 +73,7 @@ cat > /tmp/api.sh <<'SH'
# api.sh <port> total totalCount # api.sh <port> total totalCount
# api.sh <port> pos cursor position, 0-based # api.sh <port> pos cursor position, 0-based
# api.sh <port> cur the key under the cursor # api.sh <port> cur the key under the cursor
# api.sh <port> query the query fzf has actually READ off the keyboard
p=$1; a=$2 p=$1; a=$2
j=$(curl -s --max-time 5 "localhost:$p/?limit=500") || exit 1 j=$(curl -s --max-time 5 "localhost:$p/?limit=500") || exit 1
[ -n "$j" ] || exit 1 [ -n "$j" ] || exit 1
@@ -92,6 +94,9 @@ count) printf '%s' "$j" | grep -o '"matchCount":[0-9]*' | cut -d: -f2 ;;
total) printf '%s' "$j" | grep -o '"totalCount":[0-9]*' | cut -d: -f2 ;; total) printf '%s' "$j" | grep -o '"totalCount":[0-9]*' | cut -d: -f2 ;;
pos) printf '%s' "$j" | grep -o '"position":[0-9]*' | cut -d: -f2 ;; pos) printf '%s' "$j" | grep -o '"position":[0-9]*' | cut -d: -f2 ;;
cur) printf '%s' "$j" | sed 's/.*"current":{//; s/},"matches".*//' | grep -o '\\t[pg]:[^"]*' | cut -c3- ;; cur) printf '%s' "$j" | sed 's/.*"current":{//; s/},"matches".*//' | grep -o '\\t[pg]:[^"]*' | cut -c3- ;;
# Not `sed 's/.*"query":"//'`: .* is greedy and would anchor on a later
# occurrence of the word inside a row's own text.
query) printf '%s' "$j" | grep -o '"query":"[^"]*"' | head -1 | cut -d'"' -f4 ;;
raw) printf '%s\n' "$j" ;; raw) printf '%s\n' "$j" ;;
esac esac
SH SH
@@ -166,7 +171,8 @@ proc api {what} {
# it sees is off by one keystroke for the rest of the session. Two reads that # it sees is off by one keystroke for the rest of the session. Two reads that
# agree are a settled screen. This cost two false failures to learn. # agree are a settled screen. This cost two false failures to learn.
proc marks_settled {} { proc marks_settled {} {
set deadline [expr {[clock milliseconds] + 8000}] set deadline [expr {[clock milliseconds] + 15000}]
set last "the picker never held still"
while {[clock milliseconds] < $deadline} { while {[clock milliseconds] < $deadline} {
pump pump
set a [api marks] set a [api marks]
@@ -177,15 +183,21 @@ proc marks_settled {} {
# Cross-check the parse against fzf's own matchCount. A reader that # Cross-check the parse against fzf's own matchCount. A reader that
# quietly returns nothing becomes a fifteen-minute hang somewhere # quietly returns nothing becomes a fifteen-minute hang somewhere
# else; this turns it into one named failure, here. # else; this turns it into one named failure, here.
#
# RETRY rather than bail: the two reads above and this count are
# three separate HTTP round trips, so a reload landing between them
# disagrees for one sample and agrees on the next. Bailing on the
# first disagreement made a redraw look like a broken reader. It is
# still a named failure -- just at the deadline, with the last
# disagreement as the reason.
set n [llength [split $a "\n"]] set n [llength [split $a "\n"]]
set c [api count] set c [api count]
if {[string is integer -strict $c] && $n != $c} { if {![string is integer -strict $c] || $n == $c} { return $a }
bail "marks_settled: parsed $n rows but fzf reports $c matches -- the --listen reader is out of step with fzf's JSON" set last "parsed $n rows but fzf reports $c matches -- the --listen reader is out of step with fzf's JSON"
} }
return $a after 100
} }
} bail "marks_settled: $last (15s)"
bail "marks_settled: the picker never held still for 8s"
} }
# The same settling, but tolerant: used BEFORE a keystroke, where the picker # The same settling, but tolerant: used BEFORE a keystroke, where the picker
@@ -305,8 +317,25 @@ while {[gets $fh line] >= 0} {
send -- [subst -nocommands -novariables [lindex $rest 1]] send -- [subst -nocommands -novariables [lindex $rest 1]]
after 200 after 200
} }
type { foreach ch [split [lindex $rest 1] ""] { send -- $ch; after 80 } type {
after 500; pump; set ::lastmarks [marks_settled] } # fzf reads the keyboard asynchronously, so a fixed sleep after the
# last character samples whatever it happens to have consumed by
# then. That is how `nvidia` was once measured as `n` -- 59 rows
# matching instead of three, and a filtered session that was not
# filtered. Wait for fzf to REPORT the whole query, then for the
# rows it produced to hold still.
set want [lindex $rest 1]
foreach ch [split $want ""] { send -- $ch; after 80 }
set deadline [expr {[clock milliseconds] + 15000}]
set got ""
while {[clock milliseconds] < $deadline} {
pump
set got [api query]
if {$got eq $want} { break }
after 100
}
if {$got ne $want} { bail "type $want: fzf's query still reads '$got' 15s after the last key" }
pump; set ::lastmarks [marks_settled] }
at { goto [lindex $rest 0] } at { goto [lindex $rest 0] }
wait { wait_change [lindex $rest 0] } wait { wait_change [lindex $rest 0] }
scr { wait_screen [lindex $rest 0] [lindex $rest 1] } scr { wait_screen [lindex $rest 0] [lindex $rest 1] }
+2 -2
View File
@@ -3,7 +3,7 @@
# kills: the tarball is extracted where the symlink points # kills: the tarball is extracted where the symlink points
# run: unit # run: unit
<<<OLD <<<OLD
run_sh "${SUDO:+$SUDO }rm -rf /opt/nvim && ${SUDO:+$SUDO }mkdir -p /opt/nvim && ${SUDO:+$SUDO }tar -xzf /tmp/nvim.tgz -C /opt/nvim --strip-components=1" \ run_sh "${SUDO:+$SUDO }rm -rf /opt/nvim && ${SUDO:+$SUDO }mkdir -p /opt/nvim && ${SUDO:+$SUDO }tar -xzf '$WORKDIR/nvim.tgz' -C /opt/nvim --strip-components=1" \
<<<NEW <<<NEW
run_sh "${SUDO:+$SUDO }rm -rf /opt/nvim && ${SUDO:+$SUDO }mkdir -p /opt/nvim && ${SUDO:+$SUDO }tar -xzf /tmp/nvim.tgz -C /opt/nvim-TYPO --strip-components=1" \ run_sh "${SUDO:+$SUDO }rm -rf /opt/nvim && ${SUDO:+$SUDO }mkdir -p /opt/nvim && ${SUDO:+$SUDO }tar -xzf '$WORKDIR/nvim.tgz' -C /opt/nvim-TYPO --strip-components=1" \
<<<END <<<END
@@ -0,0 +1,56 @@
# what: the parser stops at the subcommand again, so flags after it are dropped
# why: DU-H1 as it shipped: `dotup install --unattended` ran a FULL ATTENDED install and said nothing, which is how invasive packages reached a machine that had asked for the safe defaults
# kills: install --unattended refuses invasive too
# run: unit
<<<OLD
CMD=
endopts=0
n=$#; i=0
while [ "$i" -lt "$n" ]; do
i=$((i + 1)); a=$1; shift
if [ "$endopts" -eq 0 ]; then
case $a in
--) endopts=1; continue ;;
--unattended) UNATTENDED=1; ASSUME_YES=1; continue ;;
--print|-n) DRYRUN=1; continue ;;
--yes|-y) ASSUME_YES=1; continue ;;
-h|--help) usage; exit 0 ;;
-?*) err "unknown flag: $a"; usage; exit 2 ;;
esac
fi
# The first bare word is the subcommand; every later one is an operand,
# rotated to the back of "$@" so the dispatch below reads them in the order
# they were typed with the flags taken out from between them.
if [ -z "$CMD" ]; then CMD=$a; else set -- "$@" "$a"; fi
done
# What each subcommand accepts. -1 is "as many as you like". An unknown
# subcommand, or one word more than a subcommand can use, is the same class of
# mistake as an unknown flag and gets the same answer: say so, and stop.
# This list and the dispatch below must name the same commands.
amin=0; amax=0
case ${CMD:-run} in
run|pick|install|private|render|expand-all|plan|preflight|fzf-path) ;;
toggle|toggle-shown|expand) amax=-1 ;;
preset|explain|resolve) amax=1 ;;
vercmp) amin=2; amax=2 ;;
*) err "unknown command: $CMD"; usage; exit 2 ;;
esac
[ "$#" -ge "$amin" ] || { err "${CMD:-run} needs $amin argument(s), got $#"; usage; exit 2; }
[ "$amax" -lt 0 ] || [ "$#" -le "$amax" ] || {
err "${CMD:-run} takes at most $amax argument(s), got $#:$(printf ' %s' "$@")"
usage; exit 2; }
<<<NEW
CMD=
while [ $# -gt 0 ]; do
case $1 in
--unattended) UNATTENDED=1; ASSUME_YES=1 ;;
--print|-n) DRYRUN=1 ;;
--yes|-y) ASSUME_YES=1 ;;
-h|--help) usage; exit 0 ;;
--*) err "unknown flag: $1"; usage; exit 2 ;;
*) CMD=$1; shift; break ;;
esac
shift
done
<<<END
@@ -0,0 +1,9 @@
# what: an unrecognised flag is skipped instead of stopping the run
# why: the other half of DU-H1 -- a parser that shrugs at `--unatended` installs a different set of packages than the operator asked for, with no way to tell
# kills: an unknown flag after the subcommand is refused
# run: unit
<<<OLD
-?*) err "unknown flag: $a"; usage; exit 2 ;;
<<<NEW
-?*) continue ;;
<<<END
@@ -0,0 +1,13 @@
# what: the neovim tarball is downloaded to and extracted from /tmp/nvim.tgz again
# why: DU-H2: a fixed name in a world-writable directory, unpacked by `sudo tar` -- anyone on the box can pre-create it as a symlink or swap it between the two commands and have tar write their content as root
# kills: no download is written to a fixed /tmp path
# run: unit
<<<OLD
run_sh "{ curl -fsSL '$b/nvim-linux-$a.tar.gz' -o '$WORKDIR/nvim.tgz' || curl -fsSL '$b/nvim-linux64.tar.gz' -o '$WORKDIR/nvim.tgz'; } && [ -s '$WORKDIR/nvim.tgz' ]" \
|| { note_fail "$key" "tarball download failed or arrived empty"; continue; }
run_sh "${SUDO:+$SUDO }rm -rf /opt/nvim && ${SUDO:+$SUDO }mkdir -p /opt/nvim && ${SUDO:+$SUDO }tar -xzf '$WORKDIR/nvim.tgz' -C /opt/nvim --strip-components=1" \
<<<NEW
run_sh "curl -fsSL '$b/nvim-linux-$a.tar.gz' -o /tmp/nvim.tgz || curl -fsSL '$b/nvim-linux64.tar.gz' -o /tmp/nvim.tgz" \
|| { note_fail "$key" "tarball download failed"; continue; }
run_sh "${SUDO:+$SUDO }rm -rf /opt/nvim && ${SUDO:+$SUDO }mkdir -p /opt/nvim && ${SUDO:+$SUDO }tar -xzf /tmp/nvim.tgz -C /opt/nvim --strip-components=1" \
<<<END
@@ -0,0 +1,9 @@
# what: the EXIT trap that removes the run's private download directory is gone
# why: the directory holds whatever was fetched on the way through; leaving one behind per run turns a fix for a race into a slow leak of downloaded payloads under /tmp
# kills: …and which does not outlive the run
# run: unit
<<<OLD
trap dotup_cleanup EXIT INT TERM
<<<NEW
:
<<<END
@@ -0,0 +1,9 @@
# what: ^t's dependency walk no longer stops at an invasive row (BUG-1, reintroduced)
# why: filtering for `nvidia` and pressing ^t ticks the three docker rows off screen — a root-equivalent daemon group nobody looked at
# kills: …and the invasive group it @needs stays off
# run: unit
<<<OLD
go=$(printf '%s\n' "$shown" | expand_deps invasive-stop)
<<<NEW
go=$(printf '%s\n' "$shown" | expand_deps)
<<<END
@@ -0,0 +1,13 @@
# what: ^t's second press goes back to the reverse @needs closure instead of the journal (BUG-2, reintroduced)
# why: the reverse edges do not retract what the forward ones pulled in, so ^t ^t leaves the dependencies ticked and is not an undo
# kills: …and ^t ^t hands that back too, node included
# run: unit
<<<OLD
if [ -f "$TICK" ] && [ "$shown" = "$(sed '/^=$/,$d' "$TICK")" ]; then
go=$(sed '1,/^=$/d' "$TICK")
else
go=$(printf '%s\n' "$shown" | expand_rdeps)
fi
<<<NEW
go=$(printf '%s\n' "$shown" | expand_rdeps)
<<<END
+1 -1
View File
@@ -30,7 +30,7 @@ set -eu
# "gzip: not found" -- which is the seal working, and is how it was found. # "gzip: not found" -- which is the seal working, and is how it was found.
REAL='sh awk grep sed sort cut tr head tail id uname mkdir cp mv rm cat REAL='sh awk grep sed sort cut tr head tail id uname mkdir cp mv rm cat
dirname basename chmod mktemp find install sha256sum stty tar gzip ln dirname basename chmod mktemp find install sha256sum stty tar gzip ln
touch true false env expr wc' touch true false env expr wc stat'
# Deliberately NOT here by default, each for a reason a test depends on: # Deliberately NOT here by default, each for a reason a test depends on:
# sudo absent means SUDO='' and apt-get is invoked directly, which is the # sudo absent means SUDO='' and apt-get is invoked directly, which is the
# only way FAKE_FAIL=apt-get can reach the installer at all. # only way FAKE_FAIL=apt-get can reach the installer at all.
+175 -3
View File
@@ -162,6 +162,57 @@ is "bws-secrets stays a non-package" "-" \
is "no manifest row installs bws" "0" \ is "no manifest row installs bws" "0" \
"$(awk -F'\t' '!/^[#@]/ && NF>=3 && $2=="bws" {c++} END{print c+0}' "$M")" "$(awk -F'\t' '!/^[#@]/ && NF>=3 && $2=="bws" {c++} END{print c+0}' "$M")"
printf '\n\033[1m^t — the bulk toggle over the shown rows\033[0m\n'
# ^t is `toggle-shown`, not `toggle`, and these are the two bugs that made it a
# separate command. Pure state-file logic, so it is checked here rather than in
# the lab: the picker only ever hands it the keys of the matched rows.
#
# BUG-1: the walk widened past the filter. Typing `nvidia` shows exactly three
# gpu rows; gpu/container-toolkit @needs docker, and all three docker rows are
# invasive — "the docker group is root-equivalent". One keystroke ticked them
# off screen.
reset; cp state/selected state/before
sh $D toggle-shown p:gpu/nvidia-driver p:gpu/cuda-toolkit p:gpu/container-toolkit >/dev/null
is "^t over 'nvidia' ticks exactly the three rows on screen" \
"gpu/container-toolkit
gpu/cuda-toolkit
gpu/nvidia-driver" \
"$(grep -vxF -f state/before state/selected | sort || true)"
is "…and the invasive group it @needs stays off" "0/3" "$(grp docker)"
# BUG-2: and the same keystroke twice is a no-op. The reverse @needs closure
# cannot do this — nothing needs the gpu rows — so the journal is what undoes it.
sh $D toggle-shown p:gpu/nvidia-driver p:gpu/cuda-toolkit p:gpu/container-toolkit >/dev/null
sort -u state/before > state/before.s; sort -u state/selected > state/after.s
is "^t ^t returns the selection exactly as it was" "" \
"$(diff state/before.s state/after.s 2>&1 || :)"
# The guard is on `invasive`, not on "anything off screen": a safe dependency is
# still pulled in, and the undo still gives it back.
reset; sh $D preset none >/dev/null; cp state/selected state/before
sh $D toggle-shown p:agents/codex >/dev/null
is "^t still pulls in a SAFE @needs" "on" "$(on core/node)"
sh $D toggle-shown p:agents/codex >/dev/null
sort -u state/before > state/before.s; sort -u state/selected > state/after.s
is "…and ^t ^t hands that back too, node included" "" \
"$(diff state/before.s state/after.s 2>&1 || :)"
# One row, both commands, so the difference is not hidden behind a bulk case.
reset; sh $D preset none >/dev/null; sh $D toggle-shown p:gpu/container-toolkit >/dev/null
is "^t never auto-ticks an invasive dependency" "0/3" "$(grp docker)"
is "…but does tick the visible row it was handed" "on" "$(on gpu/container-toolkit)"
reset; sh $D preset none >/dev/null; sh $D toggle p:gpu/container-toolkit >/dev/null
is "space on that row is unchanged — you asked for that one" "3/3" "$(grp docker)"
# With no journal to undo, ^t is cmd_toggle's plain all-on/all-off rule.
reset; sh $D toggle-shown g:media >/dev/null
is "^t on an already-full group turns it off" "0/3" "$(grp media)"
reset; sh $D toggle-shown p:core/node >/dev/null
is "…and that all-off still drops what needed it" "off" "$(on agents/codex)"
is "the picker binds ^t to toggle-shown, never the plain toggle" "1" \
"$(grep -c 'ctrl-t:select-all+execute-silent($SELF toggle-shown ' $D)"
# clear-query used to hang off that bind. It has to be gone, not merely
# unused: it drops the filter, so the second ^t is over the whole manifest and
# the undo becomes "tick everything".
is "…and does not drop the query out from under the undo" "0" \
"$(grep -c -- '+clear-query' $D || true)"
printf '\n\033[1mcredentials never reach a log\033[0m\n' printf '\n\033[1mcredentials never reach a log\033[0m\n'
# `run` echoes its whole argv to stderr. The private init's argv ends in # `run` echoes its whole argv to stderr. The private init's argv ends in
# https://user:TOKEN@host, so it must not go through `run`. # https://user:TOKEN@host, so it must not go through `run`.
@@ -453,9 +504,14 @@ hasnt "…including a listening ssh port" "openssh-server" "$out"
has "…but still installs the safe defaults" "ripgrep" "$out" has "…but still installs the safe defaults" "ripgrep" "$out"
# Determinism: the same command twice, on the same machine, means the same # Determinism: the same command twice, on the same machine, means the same
# thing. A state file left by an interactive run must not change it. # thing. A state file left by an interactive run must not change it.
a=$(box --unattended --print 2>/dev/null || true) # Normalised on the one thing that is MEANT to differ between two runs: every
# run gets its own mktemp'd download directory now (DU-H2), and the printed
# commands name it. What must not differ is the selection, which is what this
# assertion has always been about.
strip_wd() { sed 's|/dotup\.[^/]*/|/dotup.XXXXXX/|g'; }
a=$(box --unattended --print 2>/dev/null | strip_wd || true)
box preset none >/dev/null box preset none >/dev/null
b=$(box --unattended --print 2>/dev/null || true) b=$(box --unattended --print 2>/dev/null | strip_wd || true)
is "unattended is computed, not inherited" "same" \ is "unattended is computed, not inherited" "same" \
"$([ "$a" = "$b" ] && echo same || echo different)" "$([ "$a" = "$b" ] && echo same || echo different)"
# `set -e` would kill the subshell at the failing command, so the status is # `set -e` would kill the subshell at the failing command, so the status is
@@ -479,7 +535,9 @@ MIN=${TMPDIR:-/tmp}/dotup-minbin.$$
rm -rf "$MIN"; mkdir -p "$MIN" rm -rf "$MIN"; mkdir -p "$MIN"
# Resolved by looking, not by `command -v`: an interactive shell can report an # Resolved by looking, not by `command -v`: an interactive shell can report an
# alias or a bare name for these, and a symlink to a bare name is a loop. # alias or a bare name for these, and a symlink to a bare name is a loop.
for t in awk grep sed sort cut tr id uname mkdir cp mv rm cat head sh dirname basename; do # mktemp and chmod earn their place the same way the rest do: dotup gives every
# run its own private download directory (DU-H2), and it makes it here.
for t in awk grep sed sort cut tr id uname mkdir cp mv rm cat head sh dirname basename mktemp chmod; do
for bd in /usr/bin /bin /usr/local/bin; do for bd in /usr/bin /bin /usr/local/bin; do
[ -x "$bd/$t" ] && { ln -sf "$bd/$t" "$MIN/$t"; break; } [ -x "$bd/$t" ] && { ln -sf "$bd/$t" "$MIN/$t"; break; }
done done
@@ -577,6 +635,56 @@ newbox; pick core/ripgrep docker/docker-ce
box install >/dev/null 2>&1 || true box install >/dev/null 2>&1 || true
has "with a human present the same tick does install" "docker-ce" "$(cat "$LOG")" has "with a human present the same tick does install" "docker-ce" "$(cat "$LOG")"
printf '\n\033[1mflags are read wherever they sit — DU-H1\033[0m\n'
# The production incident. The old parser stopped at the first bare word and
# left everything behind it in "$@", where nothing ever looked at it again --
# so `dotup install --unattended`, which is how half the people who type this
# type it, ran a FULL ATTENDED install and put every ticked invasive package on
# the machine. Silence is the worst possible answer to a flag: the operator has
# no way to tell the run they asked for from the run they got.
#
# These are the same assertions the flags-first section above makes, with the
# words the other way round. Both orders are one command line.
newbox; BOXPATH=$SEALSUDO
pick core/ripgrep docker/docker-ce networking/openssh-server
out=$(box install --unattended 2>&1 | plain || true)
log=$(cat "$LOG")
has "install --unattended refuses invasive too" "refusing invasive packages" "$out"
hasnt "…so the daemon never reaches a package manager" "docker-ce" "$log"
hasnt "…nor does the listening ssh port" "openssh-server" "$log"
has "…and the safe package still installs" "ripgrep" "$log"
# --print is the flag whose absence is most expensive to discover afterwards.
newbox; BOXPATH=$SEALSUDO; pick core/ripgrep
out=$(box install --print 2>&1 | plain || true)
is "install --print installs nothing" "" \
"$(grep -E ' (install|remove|upgrade) ' "$LOG" || true)"
has "…while still printing what it would" "apt-get install -y" "$out"
# A flag on each side is one command line, not two halves of one.
newbox; BOXPATH=$SEALSUDO; pick core/ripgrep docker/docker-ce
out=$(box --print install --unattended 2>&1 | plain || true)
has "flags on both sides are all parsed" "refusing invasive packages" "$out"
is "…and the run still changed nothing" "" \
"$(grep -E ' (install|remove|upgrade) ' "$LOG" || true)"
# The other half of DU-H1, and the half that keeps the fix honest: a flag or a
# word this script does not recognise must STOP the run. A typo that installs
# the wrong set of packages is worse than one that installs nothing.
newbox; BOXPATH=$SEALSUDO; pick core/ripgrep
is "an unknown flag after the subcommand is refused" "2" "$(boxrc install --nonsense)"
is "…an unknown word after it too" "2" "$(boxrc install extra)"
is "…and an unknown subcommand" "2" "$(boxrc instal)"
is "…and a short flag nobody defined" "2" "$(boxrc install -Q)"
is "a refused command line installs nothing at all" "" "$(cat "$LOG")"
has "…and names the argument it did not understand" "unknown flag: --nonsense" \
"$(box install --nonsense 2>&1 | plain || true)"
has "…or the command, when that is what was wrong" "unknown command: instal" \
"$(box instal 2>&1 | plain || true)"
# Operands must survive the same parser: these are how the fzf bindings call
# back into dotup, and a picker whose every tick errored would be worse still.
is "a subcommand's own operands still reach it" "not selected" \
"$(box explain p:media/sox 2>&1 | plain | sed -n 's/^state *//p')"
is "…several of them, in order" "0" \
"$(boxrc toggle p:media/sox p:media/ffmpeg)"
printf '\n\033[1ma package that fails is reported, and the run says so\033[0m\n' printf '\n\033[1ma package that fails is reported, and the run says so\033[0m\n'
# Reachable only because the fakes can be made to fail. Every assertion here # Reachable only because the fakes can be made to fail. Every assertion here
# survived deleting the code it is about, for want of a way to make brew lose. # survived deleting the code it is about, for want of a way to make brew lose.
@@ -679,6 +787,70 @@ is "the tarball is extracted where the symlink points" "$xt" "$xl"
is "…into the directory that was cleared and recreated" "$xt $xt" "$xr" is "…into the directory that was cleared and recreated" "$xt $xt" "$xr"
is "…and that directory is /opt/nvim" "/opt/nvim" "$xt" is "…and that directory is /opt/nvim" "/opt/nvim" "$xt"
printf '\n\033[1mdownloads land in a private directory, never a fixed /tmp path — DU-H2\033[0m\n'
# The tarball handlers wrote /tmp/nvim.tgz, /tmp/node.tgz and /tmp/go.tgz and
# then unpacked them with `sudo tar`. Any account on the box could pre-create
# those names as symlinks, or swap the file in the window between the download
# and the extract, and have tar write their content anywhere, as root.
#
# Static half first, because it covers handlers no test drives -- go and node
# both write outside $HOME and have no business running here at all.
is "no download is written to a fixed /tmp path" "" \
"$(grep -nE -- "-o +'?/tmp/|-o +'?.\{TMPDIR:-/tmp\}/" $D || true)"
is "…and nothing is extracted from one" "" \
"$(grep -nE -- "tar [^|]*-x[a-z]*f +'?/tmp/" $D || true)"
is "the working directory comes from mktemp -d" "1" \
"$(grep -c 'mktemp -d "\${TMPDIR:-/tmp}/dotup\.' $D)"
is "…and its mode is stated rather than assumed" "1" \
"$(grep -c 'chmod 700 "\$WORKDIR"' $D)"
is "…and a trap removes it however the run ends" "1" \
"$(grep -c '^trap dotup_cleanup EXIT INT TERM' $D)"
# cmd_private REPLACES that trap with one of its own. If it does not do the
# cleanup too, every run that reaches the private tier leaks its directory.
is "…including the trap the private tier installs" "1" \
"$(grep -c "dotup_cleanup. EXIT INT TERM" $D)"
# Behavioural half. The directory is gone by the time the run exits, so the
# fake curl records it and its mode from the inside (see fakebin/_curl).
newbox; BOXPATH=$SEALSUDO; pick apps/chrome
box install >/dev/null 2>&1 || true
wdline=$(grep '^curl-outdir ' "$LOG" | head -1 || true)
is "control: the run recorded where it downloaded to" "yes" \
"$([ -n "$wdline" ] && echo yes || echo no)"
is "the deb lands in a directory made for this run" "yes" \
"$(case ${wdline:-} in *" $SB/tmp/dotup."*) echo yes ;; *) echo "no [$wdline]" ;; esac)"
is "…which nobody else can read into" "700" \
"$(printf '%s\n' "$wdline" | awk '{print $3}')"
is "…and which does not outlive the run" "" \
"$(ls "$SB/tmp" 2>/dev/null || true)"
# Predictability was the whole bug, so two runs must not agree on the name.
# Same box, so the only thing that can differ is what mktemp chose.
one=$(printf '%s\n' "$wdline" | awk '{print $2}')
: > "$LOG"
box install >/dev/null 2>&1 || true
two=$(grep '^curl-outdir ' "$LOG" | head -1 | awk '{print $2}' || true)
is "control: the second run named one as well" "yes" \
"$([ -n "$two" ] && echo yes || echo no)"
is "no two runs choose the same directory" "different" \
"$([ "$one" = "$two" ] && echo same || echo different)"
# The neovim handler, read back out of dotup's own dry run: what it downloads
# and what it hands to `sudo tar` must be the same file, and that file must not
# be under a name anyone could have written down in advance.
newbox; pick core/neovim
dry=$(box --print install 2>&1 | plain || true)
dl=$(printf '%s\n' "$dry" | sed -n "s/.*-o '\([^']*nvim\.tgz\)'.*/\1/p" | head -1)
ex=$(printf '%s\n' "$dry" | sed -n "s/.*tar -xzf '\([^']*\)' -C .*/\1/p" | head -1)
is "control: the dry run names a download path" "yes" \
"$([ -n "$dl" ] && echo yes || echo no)"
is "the tarball is extracted from the file just downloaded" "$dl" "$ex"
is "…out of this run's own directory, not /tmp/nvim.tgz" "yes" \
"$(case $dl in "$SB/tmp/dotup."*/nvim.tgz) echo yes ;; *) echo "no [$dl]" ;; esac)"
# Nothing may reach `sudo tar` that was not checked to be there and non-empty:
# a truncated proxy error page is a zero-byte file, and tar's complaint about
# one is not a sentence anyone can act on.
has "…and only if it arrived non-empty" "[ -s '$dl' ]" "$dry"
printf '\n\033[1mthe picker fetches its own fzf, from the real release URL\033[0m\n' printf '\n\033[1mthe picker fetches its own fzf, from the real release URL\033[0m\n'
# The stand-in for GitHub answers ONE path -- the release download URL -- and # The stand-in for GitHub answers ONE path -- the release download URL -- and
# builds the tarball from the version named in it. So a preflight that reports # builds the tarball from the version named in it. So a preflight that reports
+5 -2
View File
@@ -120,8 +120,11 @@ safer than something inside it.
ticking `xrdp` ticks the `desktop` group it is useless without, and unticking ticking `xrdp` ticks the `desktop` group it is useless without, and unticking
`core/node` drops `codex`, `pi`, `pi-plugins`, `mermaid-cli` and then `neovim` `core/node` drops `codex`, `pi`, `pi-plugins`, `mermaid-cli` and then `neovim`
— six rows including `node` itself. The closure is shown, not described: the — six rows including `node` itself. The closure is shown, not described: the
counts move on the same keystroke, and `^t` clears the filter so the rows it counts move on the same keystroke. `^t` is the one place the closure is
just pulled in are on screen rather than hidden behind your query. clipped: it follows `@needs` only as far as the first `invasive` row, so a bulk
toggle over a filter can never tick a root-equivalent daemon you were not
looking at — filter for `nvidia` and the docker group stays off. Press `^t`
again over the same rows and it hands back exactly what it took.
`^o` is a toggle, not "open all": press it again and everything closes. And on `^o` is a toggle, not "open all": press it again and everything closes. And on
the default collapsed screen every group row counts as "shown", so a single the default collapsed screen every group row counts as "shown", so a single
+192 -34
View File
@@ -24,6 +24,11 @@ EXP=$STATE/expanded
# Written when the picker is ACCEPTED, never merely opened. Its presence is the # Written when the picker is ACCEPTED, never merely opened. Its presence is the
# difference between "chose nothing" and "has not chosen yet". # difference between "chose nothing" and "has not chosen yet".
PICKED=$STATE/picked PICKED=$STATE/picked
# The journal of the last ^t: the rows it was given, then '=', then the keys it
# actually added. One keystroke's worth, not state -- any other keystroke moves
# the shown set out from under it, the next ^t sees the mismatch and falls back
# to the ordinary toggle rule. See cmd_toggle_shown.
TICK=$STATE/ticked
# The manifest is data, not a script, so it does not live in bin/. Checked in # The manifest is data, not a script, so it does not live in bin/. Checked in
# the development layout first so the repo's own test suite and a checkout both # the development layout first so the repo's own test suite and a checkout both
@@ -78,7 +83,17 @@ leaves() {
# unticking one unticks what needed it. The counts on screen move as it happens, # unticking one unticks what needed it. The counts on screen move as it happens,
# so the closure is visible rather than described. # so the closure is visible rather than described.
# stdin: package keys -> stdout: the ones the manifest does NOT flag invasive.
drop_invasive() {
awk -F'\t' 'NR==FNR { if (!/^[#@]/ && NF>=3 && $3=="invasive") inv[$1"/"$2]=1; next }
NF && !($0 in inv)' "$MANIFEST" -
}
# stdin: keys -> stdout: those keys plus everything they need, transitively. # stdin: keys -> stdout: those keys plus everything they need, transitively.
#
# `expand_deps invasive-stop` stops the walk AT an invasive dependency instead
# of walking through it: neither that row nor anything reachable only behind it
# is pulled in. ^t is the only caller -- see cmd_toggle_shown.
expand_deps() { expand_deps() {
work=$(sort -u); prev= work=$(sort -u); prev=
while [ "$work" != "$prev" ]; do while [ "$work" != "$prev" ]; do
@@ -93,6 +108,7 @@ expand_deps() {
*) awk -F'\t' -v g="$d" '!/^[#@]/ && NF>=3 && $1==g {print $1"/"$2}' "$MANIFEST" ;; *) awk -F'\t' -v g="$d" '!/^[#@]/ && NF>=3 && $1==g {print $1"/"$2}' "$MANIFEST" ;;
esac esac
done) done)
[ "${1:-}" != invasive-stop ] || add=$(printf '%s\n' "$add" | drop_invasive)
work=$(printf '%s\n%s\n' "$work" "$add" | grep . | sort -u) work=$(printf '%s\n%s\n' "$work" "$add" | grep . | sort -u)
done done
printf '%s\n' "$work" printf '%s\n' "$work"
@@ -138,6 +154,54 @@ cmd_toggle() {
sort -u "$tmp" > "$tmp.s" && mv "$tmp.s" "$SEL" && rm -f "$tmp" sort -u "$tmp" > "$tmp.s" && mv "$tmp.s" "$SEL" && rm -f "$tmp"
} }
# ------------------------------------------------------ toggle every shown ---
# ^t, and only ^t. Deliberately NOT cmd_toggle over the visible rows; the two
# differences are both safety properties rather than taste.
#
# 1. A bulk keystroke must not tick a row you cannot see. cmd_toggle widens
# along @needs, and over a filter those edges reach off screen: `nvidia`
# shows three gpu rows, gpu/container-toolkit @needs docker, and one ^t used
# to switch on three invasive daemon packages that were never drawn. So the
# forward walk here STOPS at an invasive dependency. A row that is ON SCREEN
# may still be invasive and is still ticked -- you are looking at it, and
# that is the whole difference. An invasive need left unticked is not lost:
# `explain` and the plan already report an unmet @needs.
# 2. ^t is its own undo. The keys it actually moved are journalled in $TICK, so
# a second press over the same rows takes back exactly those and nothing
# else. The reverse @needs closure cannot do that job -- nothing needs the
# gpu rows, so it would never let go of what the forward press pulled in.
# Any other keystroke changes the shown set, the journal stops matching, and
# ^t falls back to cmd_toggle's plain all-on/all-off rule.
cmd_toggle_shown() {
shown=$(leaves "$@" | sort -u)
[ -n "$shown" ] || return 0
all_on=1
for k in $shown; do grep -qxF "$k" "$SEL" || { all_on=0; break; }; done
tmp=$STATE/.sel.$$
if [ "$all_on" -eq 1 ]; then
if [ -f "$TICK" ] && [ "$shown" = "$(sed '/^=$/,$d' "$TICK")" ]; then
go=$(sed '1,/^=$/d' "$TICK")
else
go=$(printf '%s\n' "$shown" | expand_rdeps)
fi
rm -f "$TICK"
# An empty journal means the forward press added nothing, so there is
# nothing to hand back. It must never reach the grep below: an empty
# pattern list matches every line, and -v would erase the selection.
[ -n "$go" ] || return 0
grep -vxF -f - "$SEL" > "$tmp" <<-EOF || :
$go
EOF
sort -u "$tmp" > "$tmp.s"
else
go=$(printf '%s\n' "$shown" | expand_deps invasive-stop)
add=$(printf '%s\n' "$go" | grep -vxF -f "$SEL" || :)
{ cat "$SEL"; printf '%s\n' "$go"; } | grep . | sort -u > "$tmp.s" || :
{ printf '%s\n=\n' "$shown"; [ -z "$add" ] || printf '%s\n' "$add"; } > "$TICK"
fi
mv "$tmp.s" "$SEL"; rm -f "$tmp"
}
cmd_expand() { cmd_expand() {
for key in "$@"; do for key in "$@"; do
case $key in g:*) g=${key#g:} ;; p:*) g=${key#p:}; g=${g%%/*} ;; *) continue ;; esac case $key in g:*) g=${key#g:} ;; p:*) g=${key#p:}; g=${g%%/*} ;; *) continue ;; esac
@@ -360,6 +424,36 @@ if [ "$(id -u)" != 0 ] && command -v sudo >/dev/null 2>&1; then SUDO=sudo; fi
FAILED=$STATE/.failed.$$ FAILED=$STATE/.failed.$$
APT_UPDATED=0 APT_UPDATED=0
# One private directory per run, for everything this script downloads.
#
# The three tarball handlers wrote /tmp/nvim.tgz, /tmp/node.tgz and /tmp/go.tgz
# -- fixed names in a world-writable directory -- and then unpacked them with
# `sudo tar`. Anyone with an account on the box could pre-create those names as
# symlinks, or swap the file in the window between the download and the extract,
# and have tar write their content anywhere as root. The deb channel's
# ${TMPDIR:-/tmp}/dotup-$$.deb was only slightly better: a pid is a small number
# and it is reused.
#
# mktemp -d is 700 by definition; the chmod says so out loud rather than trusting
# every mktemp on every platform to agree. Created on first use, so a run that
# downloads nothing leaves nothing behind, and removed on the way out either way.
# Sets $WORKDIR rather than printing it, and the callers read the variable. A
# `wd=$(workdir)` would run the whole thing in a SUBSHELL: every caller would
# get a directory of its own, the parent's $WORKDIR would stay empty, and the
# trap below would have nothing to remove. Written that way first, and the
# suite's "does not outlive the run" assertion is what said so.
WORKDIR=
workdir() {
[ -z "$WORKDIR" ] || return 0
WORKDIR=$(mktemp -d "${TMPDIR:-/tmp}/dotup.XXXXXX") || {
err "could not create a private working directory"; return 1; }
chmod 700 "$WORKDIR" || { err "could not lock down $WORKDIR"; return 1; }
}
# Named, not inlined into the trap, because cmd_private installs a trap of its
# own further down and has to be able to call this one as well.
dotup_cleanup() { [ -z "${WORKDIR:-}" ] || rm -rf "$WORKDIR"; WORKDIR=; }
trap dotup_cleanup EXIT INT TERM
have() { find_tool "$1" >/dev/null 2>&1; } have() { find_tool "$1" >/dev/null 2>&1; }
# A tool installed a moment ago is not on this process's PATH: the astral # A tool installed a moment ago is not on this process's PATH: the astral
@@ -665,6 +759,7 @@ install_deb() {
srcs=$(norm "$1") srcs=$(norm "$1")
[ -n "$srcs" ] || return 0 [ -n "$srcs" ] || return 0
head_ "deb" head_ "deb"
deb_n=0
for s in $srcs; do for s in $srcs; do
url=$s url=$s
case $s in case $s in
@@ -688,7 +783,9 @@ install_deb() {
[ -n "$url" ] || { note_fail "$(keyof deb "$s")" "no release asset matching *$match*"; continue; } [ -n "$url" ] || { note_fail "$(keyof deb "$s")" "no release asset matching *$match*"; continue; }
fi ;; fi ;;
esac esac
f=${TMPDIR:-/tmp}/dotup-$$.deb workdir || { note_fail "$(keyof deb "$s")" "no private working directory"; continue; }
deb_n=$((deb_n + 1))
f=$WORKDIR/pkg-$deb_n.deb
apt_update_once apt_update_once
run_sh "curl -fsSL '$url' -o '$f'" || { note_fail "$(keyof deb "$s")" "download failed"; continue; } run_sh "curl -fsSL '$url' -o '$f'" || { note_fail "$(keyof deb "$s")" "download failed"; continue; }
run_sh "${SUDO:+$SUDO }apt-get install -y '$f'" || note_fail "$(keyof deb "$s")" "dpkg install failed" run_sh "${SUDO:+$SUDO }apt-get install -y '$f'" || note_fail "$(keyof deb "$s")" "dpkg install failed"
@@ -718,13 +815,18 @@ install_bespoke() {
aarch64|arm64) a=arm64 ;; aarch64|arm64) a=arm64 ;;
*) note_fail "$key" "no neovim tarball for $(uname -m)"; continue ;; *) note_fail "$key" "no neovim tarball for $(uname -m)"; continue ;;
esac esac
workdir || { note_fail "$key" "no private working directory"; continue; }
b=https://github.com/neovim/neovim/releases/latest/download b=https://github.com/neovim/neovim/releases/latest/download
run_sh "curl -fsSL '$b/nvim-linux-$a.tar.gz' -o /tmp/nvim.tgz || curl -fsSL '$b/nvim-linux64.tar.gz' -o /tmp/nvim.tgz" \ # The `[ -s ]` is part of the download, not a step after it: nothing
|| { note_fail "$key" "tarball download failed"; continue; } # may reach `sudo tar` that was not verified to be here and non-empty
run_sh "${SUDO:+$SUDO }rm -rf /opt/nvim && ${SUDO:+$SUDO }mkdir -p /opt/nvim && ${SUDO:+$SUDO }tar -xzf /tmp/nvim.tgz -C /opt/nvim --strip-components=1" \ # first. A zero-byte file is what a proxy error page truncated to
# nothing looks like, and tar's complaint about it is not a sentence
# anyone can act on.
run_sh "{ curl -fsSL '$b/nvim-linux-$a.tar.gz' -o '$WORKDIR/nvim.tgz' || curl -fsSL '$b/nvim-linux64.tar.gz' -o '$WORKDIR/nvim.tgz'; } && [ -s '$WORKDIR/nvim.tgz' ]" \
|| { note_fail "$key" "tarball download failed or arrived empty"; continue; }
run_sh "${SUDO:+$SUDO }rm -rf /opt/nvim && ${SUDO:+$SUDO }mkdir -p /opt/nvim && ${SUDO:+$SUDO }tar -xzf '$WORKDIR/nvim.tgz' -C /opt/nvim --strip-components=1" \
|| { note_fail "$key" "tarball extract failed"; continue; } || { note_fail "$key" "tarball extract failed"; continue; }
run_sh "${SUDO:+$SUDO }ln -sf /opt/nvim/bin/nvim /usr/local/bin/nvim" run_sh "${SUDO:+$SUDO }ln -sf /opt/nvim/bin/nvim /usr/local/bin/nvim" ;;
run_sh "rm -f /tmp/nvim.tgz" ;;
core/node) core/node)
# Same call as core/neovim, for the same reason and with better # Same call as core/neovim, for the same reason and with better
# evidence. 24.04's apt candidate is node 18.19.1; three of the four # evidence. 24.04's apt candidate is node 18.19.1; three of the four
@@ -748,14 +850,14 @@ install_bespoke() {
| awk -F'\t' 'NR==1{for(i=1;i<=NF;i++) if($i=="lts") c=i; next} | awk -F'\t' 'NR==1{for(i=1;i<=NF;i++) if($i=="lts") c=i; next}
c && $c!="-" {print $1; exit}'); fi c && $c!="-" {print $1; exit}'); fi
[ -n "$v" ] || { note_fail "$key" "could not resolve the current node LTS"; continue; } [ -n "$v" ] || { note_fail "$key" "could not resolve the current node LTS"; continue; }
run_sh "curl -fsSL 'https://nodejs.org/dist/$v/node-$v-$o-$a.tar.gz' -o /tmp/node.tgz" \ workdir || { note_fail "$key" "no private working directory"; continue; }
|| { note_fail "$key" "tarball download failed"; continue; } run_sh "curl -fsSL 'https://nodejs.org/dist/$v/node-$v-$o-$a.tar.gz' -o '$WORKDIR/node.tgz' && [ -s '$WORKDIR/node.tgz' ]" \
run_sh "${SUDO:+$SUDO }rm -rf /opt/node && ${SUDO:+$SUDO }mkdir -p /opt/node && ${SUDO:+$SUDO }tar -xzf /tmp/node.tgz -C /opt/node --strip-components=1" \ || { note_fail "$key" "tarball download failed or arrived empty"; continue; }
run_sh "${SUDO:+$SUDO }rm -rf /opt/node && ${SUDO:+$SUDO }mkdir -p /opt/node && ${SUDO:+$SUDO }tar -xzf '$WORKDIR/node.tgz' -C /opt/node --strip-components=1" \
|| { note_fail "$key" "tarball extract failed"; continue; } || { note_fail "$key" "tarball extract failed"; continue; }
run_sh "${SUDO:+$SUDO }ln -sf /opt/node/bin/node /usr/local/bin/node" run_sh "${SUDO:+$SUDO }ln -sf /opt/node/bin/node /usr/local/bin/node"
run_sh "${SUDO:+$SUDO }ln -sf /opt/node/bin/npm /usr/local/bin/npm" run_sh "${SUDO:+$SUDO }ln -sf /opt/node/bin/npm /usr/local/bin/npm"
run_sh "${SUDO:+$SUDO }ln -sf /opt/node/bin/npx /usr/local/bin/npx" run_sh "${SUDO:+$SUDO }ln -sf /opt/node/bin/npx /usr/local/bin/npx" ;;
run_sh "rm -f /tmp/node.tgz" ;;
core/go) core/go)
if have go && [ "$DRYRUN" -eq 0 ]; then say " go already present — leaving it"; continue; fi if have go && [ "$DRYRUN" -eq 0 ]; then say " go already present — leaving it"; continue; fi
case $(uname -s) in Darwin) o=darwin ;; *) o=linux ;; esac case $(uname -s) in Darwin) o=darwin ;; *) o=linux ;; esac
@@ -764,11 +866,11 @@ install_bespoke() {
if [ "$DRYRUN" -eq 1 ]; then v='go1.X.Y' if [ "$DRYRUN" -eq 1 ]; then v='go1.X.Y'
else v=$(curl -fsSL 'https://go.dev/VERSION?m=text' 2>/dev/null | head -1); fi else v=$(curl -fsSL 'https://go.dev/VERSION?m=text' 2>/dev/null | head -1); fi
[ -n "$v" ] || { note_fail "$key" "could not resolve the current go version"; continue; } [ -n "$v" ] || { note_fail "$key" "could not resolve the current go version"; continue; }
run_sh "curl -fsSL 'https://go.dev/dl/$v.$o-$a.tar.gz' -o /tmp/go.tgz" \ workdir || { note_fail "$key" "no private working directory"; continue; }
|| { note_fail "$key" "tarball download failed"; continue; } run_sh "curl -fsSL 'https://go.dev/dl/$v.$o-$a.tar.gz' -o '$WORKDIR/go.tgz' && [ -s '$WORKDIR/go.tgz' ]" \
run_sh "${SUDO:+$SUDO }rm -rf /usr/local/go && ${SUDO:+$SUDO }tar -xzf /tmp/go.tgz -C /usr/local" \ || { note_fail "$key" "tarball download failed or arrived empty"; continue; }
|| { note_fail "$key" "tarball extract failed"; continue; } run_sh "${SUDO:+$SUDO }rm -rf /usr/local/go && ${SUDO:+$SUDO }tar -xzf '$WORKDIR/go.tgz' -C /usr/local" \
run_sh "rm -f /tmp/go.tgz" ;; || { note_fail "$key" "tarball extract failed"; continue; } ;;
core/chezmoi) core/chezmoi)
# Circular by nature: dotup arrives *via* chezmoi. Present already # Circular by nature: dotup arrives *via* chezmoi. Present already
# in every case that matters; here for the one where it is not. # in every case that matters; here for the one where it is not.
@@ -961,8 +1063,11 @@ cmd_private() {
say " The address is in no repository. Leave it blank to stay public-only." say " The address is in no repository. Leave it blank to stay public-only."
# Read into variables: nothing reaches argv, so nothing reaches `ps`. # Read into variables: nothing reaches argv, so nothing reaches `ps`.
P_URL=''; P_USER=''; P_PW=''; p_in='' P_URL=''; P_USER=''; P_PW=''; p_in=''
# This REPLACES the load-time trap rather than adding to it, so it has to do
# that trap's job too: without the dotup_cleanup call the run's private
# working directory outlived the run whenever the private tier was reached.
# shellcheck disable=SC2064 # shellcheck disable=SC2064
trap 'unset P_URL P_USER P_PW P_BLOB p_repo p_tok p_cred 2>/dev/null || :' EXIT INT TERM trap 'unset P_URL P_USER P_PW P_BLOB p_repo p_tok p_cred 2>/dev/null || :; dotup_cleanup' EXIT INT TERM
# Ask, and keep asking. # Ask, and keep asking.
# #
@@ -1264,12 +1369,22 @@ cmd_pick() {
nl=' nl='
' '
hdr="space tick tab open ^t tick all shown ^a defaults ^x none ^o open all enter install$nl" hdr="space tick tab open ^t tick all shown ^a defaults ^x none ^o open all enter install$nl"
# clear-query in the ^t bind below is not cosmetic. ^t toggles every row # ^t is `toggle-shown`, not `toggle`, and the difference is the two bugs
# the filter shows, and the toggle widens along @needs -- so typing # this bind used to have. Full reasoning above cmd_toggle_shown; the
# `nvidia` and pressing ^t also ticked docker-ce, docker-buildx and # semantics, stated once:
# docker-compose, all of them `invasive`, none of them on screen. The rule #
# this repo states is "never silently", not "never": dropping the query # ^t ticks every row the filter is showing, plus what those rows @needs
# puts what just happened in front of you. # as far as the first invasive dependency -- an invasive row is ticked
# only by ticking the visible row itself. Pressing ^t again over the same
# rows unticks exactly the keys the first press added, so ^t ^t is a
# no-op. Over rows that were already all on, ^t is the ordinary all-off
# with the reverse @needs closure.
#
# `clear-query` used to hang off the end of this bind, because the toggle
# reached rows the query was hiding and the rule here is "never silently".
# Nothing is hidden any more, and the query has to survive the keystroke
# for the second press to be the undo of the first -- clearing it made ^t
# ^t mean "tick the three rows I filtered for, then tick the manifest".
cmd_render | "$FZF" --ansi --exact --no-sort --cycle --multi --layout=reverse --height=100% \ cmd_render | "$FZF" --ansi --exact --no-sort --cycle --multi --layout=reverse --height=100% \
--delimiter='\t' --with-nth=1 --pointer='>' --marker=' ' \ --delimiter='\t' --with-nth=1 --pointer='>' --marker=' ' \
--info=inline --border=none \ --info=inline --border=none \
@@ -1277,7 +1392,7 @@ cmd_pick() {
--preview "$SELF explain {2}" --preview-window='right,46%,wrap,border-left' \ --preview "$SELF explain {2}" --preview-window='right,46%,wrap,border-left' \
--bind "space:execute-silent($SELF toggle {2})+reload($SELF render)" \ --bind "space:execute-silent($SELF toggle {2})+reload($SELF render)" \
--bind "tab:execute-silent($SELF expand {2})+reload($SELF render)" \ --bind "tab:execute-silent($SELF expand {2})+reload($SELF render)" \
--bind "ctrl-t:select-all+execute-silent($SELF toggle {+2})+clear-selection+clear-query+reload($SELF render)" \ --bind "ctrl-t:select-all+execute-silent($SELF toggle-shown {+2})+clear-selection+reload($SELF render)" \
--bind "ctrl-a:execute-silent($SELF preset defaults)+reload($SELF render)" \ --bind "ctrl-a:execute-silent($SELF preset defaults)+reload($SELF render)" \
--bind "ctrl-x:execute-silent($SELF preset none)+reload($SELF render)" \ --bind "ctrl-x:execute-silent($SELF preset none)+reload($SELF render)" \
--bind "ctrl-o:execute-silent($SELF expand-all)+reload($SELF render)" \ --bind "ctrl-o:execute-silent($SELF expand-all)+reload($SELF render)" \
@@ -1316,7 +1431,11 @@ cmd_run() {
# ------------------------------------------------------------------ usage ---- # ------------------------------------------------------------------ usage ----
usage() { usage() {
cat >&2 <<-EOF cat >&2 <<-EOF
usage: dotup [--unattended] [--print] [--yes] usage: dotup [--unattended] [--print] [--yes] [command [args]]
Flags may come before the command, after it, or both --
\`dotup install --unattended\` and \`dotup --unattended install\` are the
same run. Anything not listed here is an error, never a shrug.
(no flags) the picker, then install what you ticked (no flags) the picker, then install what you ticked
--unattended no UI: safe defaults, never prompts, never private --unattended no UI: safe defaults, never prompts, never private
@@ -1330,23 +1449,61 @@ usage() {
way back in after a mistyped password -- no reinstall. way back in after a mistyped password -- no reinstall.
plumbing, called by the fzf bindings: plumbing, called by the fzf bindings:
render toggle expand expand-all preset explain plan preflight render toggle toggle-shown expand expand-all preset explain plan preflight
EOF EOF
} }
# Flags are read wherever they sit: before the subcommand, after it, or both.
#
# The old loop stopped at the first bare word and left the rest of the command
# line sitting in "$@", where nothing ever looked at it again. So `dotup install
# --unattended` -- the order half the people who type this type it in -- ran a
# full ATTENDED install: the flag was accepted by the parser's silence and then
# dropped on the floor. That is how a machine meant to get the safe defaults got
# every invasive row in the manifest instead.
#
# Two rules, and the second matters as much as the first: every argument is
# parsed wherever it appears, and anything unrecognised -- a flag or a word --
# stops the run HERE, before a package manager has been asked for anything. A
# flag we do not understand is not a flag we are entitled to ignore.
CMD= CMD=
while [ $# -gt 0 ]; do endopts=0
case $1 in n=$#; i=0
--unattended) UNATTENDED=1; ASSUME_YES=1 ;; while [ "$i" -lt "$n" ]; do
--print|-n) DRYRUN=1 ;; i=$((i + 1)); a=$1; shift
--yes|-y) ASSUME_YES=1 ;; if [ "$endopts" -eq 0 ]; then
case $a in
--) endopts=1; continue ;;
--unattended) UNATTENDED=1; ASSUME_YES=1; continue ;;
--print|-n) DRYRUN=1; continue ;;
--yes|-y) ASSUME_YES=1; continue ;;
-h|--help) usage; exit 0 ;; -h|--help) usage; exit 0 ;;
--*) err "unknown flag: $1"; usage; exit 2 ;; -?*) err "unknown flag: $a"; usage; exit 2 ;;
*) CMD=$1; shift; break ;;
esac esac
shift fi
# The first bare word is the subcommand; every later one is an operand,
# rotated to the back of "$@" so the dispatch below reads them in the order
# they were typed with the flags taken out from between them.
if [ -z "$CMD" ]; then CMD=$a; else set -- "$@" "$a"; fi
done done
# What each subcommand accepts. -1 is "as many as you like". An unknown
# subcommand, or one word more than a subcommand can use, is the same class of
# mistake as an unknown flag and gets the same answer: say so, and stop.
# This list and the dispatch below must name the same commands.
amin=0; amax=0
case ${CMD:-run} in
run|pick|install|private|render|expand-all|plan|preflight|fzf-path) ;;
toggle|toggle-shown|expand) amax=-1 ;;
preset|explain|resolve) amax=1 ;;
vercmp) amin=2; amax=2 ;;
*) err "unknown command: $CMD"; usage; exit 2 ;;
esac
[ "$#" -ge "$amin" ] || { err "${CMD:-run} needs $amin argument(s), got $#"; usage; exit 2; }
[ "$amax" -lt 0 ] || [ "$#" -le "$amax" ] || {
err "${CMD:-run} takes at most $amax argument(s), got $#:$(printf ' %s' "$@")"
usage; exit 2; }
case ${CMD:-run} in case ${CMD:-run} in
run) cmd_run ;; run) cmd_run ;;
pick) cmd_pick && cmd_plan ;; pick) cmd_pick && cmd_plan ;;
@@ -1354,6 +1511,7 @@ install) cmd_install ;;
private) cmd_private ;; private) cmd_private ;;
render) cmd_render ;; render) cmd_render ;;
toggle) cmd_toggle "$@" ;; toggle) cmd_toggle "$@" ;;
toggle-shown) cmd_toggle_shown "$@" ;;
expand) cmd_expand "$@" ;; expand) cmd_expand "$@" ;;
expand-all) cmd_expand_all ;; expand-all) cmd_expand_all ;;
preset) cmd_preset "${1:-defaults}" ;; preset) cmd_preset "${1:-defaults}" ;;