diff --git a/.tests/fakebin/_curl b/.tests/fakebin/_curl index 930b52c..9905ef4 100644 --- a/.tests/fakebin/_curl +++ b/.tests/fakebin/_curl @@ -20,6 +20,14 @@ done cfg= [ "$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; } # A tiny installer script, printed the way get.chezmoi.io and astral.sh print diff --git a/.tests/lab/scenarios/20-picker.sh b/.tests/lab/scenarios/20-picker.sh index 5d149de..09066e4 100644 --- a/.tests/lab/scenarios/20-picker.sh +++ b/.tests/lab/scenarios/20-picker.sh @@ -26,12 +26,13 @@ # silent no-op) were fixed, and the assertions below now hold the FIXED # behaviour -- they fail again if it regresses. # -# What is left is DEFERRED, not unknown, and there is no expected-fail -# mechanism here to hide it behind: BUG-1/2, in `promise 3`, three assertions. -# ^t over an --exact filter widens along @needs and ticks invasive rows that -# are not on screen, and ^t ^t is not its own undo because the reverse edges -# do not retract what the forward ones pulled in. Until those are fixed this -# scenario exits 3. +# BUG-1/2, in `promise 3`, were the last three left: ^t over an --exact filter +# widened along @needs and ticked invasive rows that were not on screen, and +# ^t ^t was not its own undo because the reverse edges do not retract what the +# forward ones pulled in. Both are fixed -- ^t is `dotup toggle-shown`, whose +# forward walk stops at an invasive dependency and whose second press replays +# 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 FAILS=0 @@ -72,6 +73,7 @@ cat > /tmp/api.sh <<'SH' # api.sh total totalCount # api.sh pos cursor position, 0-based # api.sh cur the key under the cursor +# api.sh query the query fzf has actually READ off the keyboard p=$1; a=$2 j=$(curl -s --max-time 5 "localhost:$p/?limit=500") || 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 ;; 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- ;; +# 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" ;; esac SH @@ -166,7 +171,8 @@ proc api {what} { # 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. 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} { pump set a [api marks] @@ -177,15 +183,21 @@ proc marks_settled {} { # Cross-check the parse against fzf's own matchCount. A reader that # quietly returns nothing becomes a fifteen-minute hang somewhere # 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 c [api count] - if {[string is integer -strict $c] && $n != $c} { - bail "marks_settled: parsed $n rows but fzf reports $c matches -- the --listen reader is out of step with fzf's JSON" - } - return $a + if {![string is integer -strict $c] || $n == $c} { return $a } + set last "parsed $n rows but fzf reports $c matches -- the --listen reader is out of step with fzf's JSON" } + after 100 } - bail "marks_settled: the picker never held still for 8s" + bail "marks_settled: $last (15s)" } # 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]] after 200 } - type { foreach ch [split [lindex $rest 1] ""] { send -- $ch; after 80 } - after 500; pump; set ::lastmarks [marks_settled] } + type { + # 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] } wait { wait_change [lindex $rest 0] } scr { wait_screen [lindex $rest 0] [lindex $rest 1] } diff --git a/.tests/mutations/17-nvim-path-typo.mut b/.tests/mutations/17-nvim-path-typo.mut index 74dc4ec..1966913 100644 --- a/.tests/mutations/17-nvim-path-typo.mut +++ b/.tests/mutations/17-nvim-path-typo.mut @@ -3,7 +3,7 @@ # kills: the tarball is extracted where the symlink points # run: unit <<=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' # `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`. @@ -453,9 +504,14 @@ hasnt "…including a listening ssh port" "openssh-server" "$out" has "…but still installs the safe defaults" "ripgrep" "$out" # 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. -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 -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" \ "$([ "$a" = "$b" ] && echo same || echo different)" # `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" # 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. -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 [ -x "$bd/$t" ] && { ln -sf "$bd/$t" "$MIN/$t"; break; } done @@ -577,6 +635,56 @@ newbox; pick core/ripgrep docker/docker-ce box install >/dev/null 2>&1 || true 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' # 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. @@ -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 "…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' # 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 diff --git a/README.md b/README.md index 723fe5e..0c293c8 100644 --- a/README.md +++ b/README.md @@ -120,8 +120,11 @@ safer than something inside it. ticking `xrdp` ticks the `desktop` group it is useless without, and unticking `core/node` drops `codex`, `pi`, `pi-plugins`, `mermaid-cli` and then `neovim` — 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 -just pulled in are on screen rather than hidden behind your query. +counts move on the same keystroke. `^t` is the one place the closure is +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 the default collapsed screen every group row counts as "shown", so a single diff --git a/dot_local/bin/executable_dotup b/dot_local/bin/executable_dotup index 621ef0a..26819ed 100755 --- a/dot_local/bin/executable_dotup +++ b/dot_local/bin/executable_dotup @@ -24,6 +24,11 @@ EXP=$STATE/expanded # Written when the picker is ACCEPTED, never merely opened. Its presence is the # difference between "chose nothing" and "has not chosen yet". 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 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, # 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. +# +# `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() { work=$(sort -u); prev= while [ "$work" != "$prev" ]; do @@ -93,6 +108,7 @@ expand_deps() { *) awk -F'\t' -v g="$d" '!/^[#@]/ && NF>=3 && $1==g {print $1"/"$2}' "$MANIFEST" ;; esac done) + [ "${1:-}" != invasive-stop ] || add=$(printf '%s\n' "$add" | drop_invasive) work=$(printf '%s\n%s\n' "$work" "$add" | grep . | sort -u) done printf '%s\n' "$work" @@ -138,6 +154,54 @@ cmd_toggle() { 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() { for key in "$@"; do 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.$$ 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; } # A tool installed a moment ago is not on this process's PATH: the astral @@ -665,6 +759,7 @@ install_deb() { srcs=$(norm "$1") [ -n "$srcs" ] || return 0 head_ "deb" + deb_n=0 for s in $srcs; do url=$s case $s in @@ -688,7 +783,9 @@ install_deb() { [ -n "$url" ] || { note_fail "$(keyof deb "$s")" "no release asset matching *$match*"; continue; } fi ;; 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 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" @@ -718,13 +815,18 @@ install_bespoke() { aarch64|arm64) a=arm64 ;; *) note_fail "$key" "no neovim tarball for $(uname -m)"; continue ;; esac + workdir || { note_fail "$key" "no private working directory"; continue; } 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" \ - || { 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" \ + # The `[ -s ]` is part of the download, not a step after it: nothing + # may reach `sudo tar` that was not verified to be here and non-empty + # 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; } - run_sh "${SUDO:+$SUDO }ln -sf /opt/nvim/bin/nvim /usr/local/bin/nvim" - run_sh "rm -f /tmp/nvim.tgz" ;; + run_sh "${SUDO:+$SUDO }ln -sf /opt/nvim/bin/nvim /usr/local/bin/nvim" ;; core/node) # 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 @@ -748,14 +850,14 @@ install_bespoke() { | awk -F'\t' 'NR==1{for(i=1;i<=NF;i++) if($i=="lts") c=i; next} c && $c!="-" {print $1; exit}'); fi [ -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" \ - || { note_fail "$key" "tarball download failed"; continue; } - 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" \ + workdir || { note_fail "$key" "no private working directory"; continue; } + run_sh "curl -fsSL 'https://nodejs.org/dist/$v/node-$v-$o-$a.tar.gz' -o '$WORKDIR/node.tgz' && [ -s '$WORKDIR/node.tgz' ]" \ + || { 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; } 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/npx /usr/local/bin/npx" - run_sh "rm -f /tmp/node.tgz" ;; + run_sh "${SUDO:+$SUDO }ln -sf /opt/node/bin/npx /usr/local/bin/npx" ;; core/go) 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 @@ -764,11 +866,11 @@ install_bespoke() { 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 [ -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" \ - || { note_fail "$key" "tarball download failed"; continue; } - run_sh "${SUDO:+$SUDO }rm -rf /usr/local/go && ${SUDO:+$SUDO }tar -xzf /tmp/go.tgz -C /usr/local" \ - || { note_fail "$key" "tarball extract failed"; continue; } - run_sh "rm -f /tmp/go.tgz" ;; + workdir || { note_fail "$key" "no private working directory"; continue; } + run_sh "curl -fsSL 'https://go.dev/dl/$v.$o-$a.tar.gz' -o '$WORKDIR/go.tgz' && [ -s '$WORKDIR/go.tgz' ]" \ + || { note_fail "$key" "tarball download failed or arrived empty"; continue; } + run_sh "${SUDO:+$SUDO }rm -rf /usr/local/go && ${SUDO:+$SUDO }tar -xzf '$WORKDIR/go.tgz' -C /usr/local" \ + || { note_fail "$key" "tarball extract failed"; continue; } ;; core/chezmoi) # Circular by nature: dotup arrives *via* chezmoi. Present already # 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." # Read into variables: nothing reaches argv, so nothing reaches `ps`. 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 - 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. # @@ -1264,12 +1369,22 @@ cmd_pick() { 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 - # the filter shows, and the toggle widens along @needs -- so typing - # `nvidia` and pressing ^t also ticked docker-ce, docker-buildx and - # docker-compose, all of them `invasive`, none of them on screen. The rule - # this repo states is "never silently", not "never": dropping the query - # puts what just happened in front of you. + # ^t is `toggle-shown`, not `toggle`, and the difference is the two bugs + # this bind used to have. Full reasoning above cmd_toggle_shown; the + # semantics, stated once: + # + # ^t ticks every row the filter is showing, plus what those rows @needs + # 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% \ --delimiter='\t' --with-nth=1 --pointer='>' --marker=' ' \ --info=inline --border=none \ @@ -1277,7 +1392,7 @@ cmd_pick() { --preview "$SELF explain {2}" --preview-window='right,46%,wrap,border-left' \ --bind "space:execute-silent($SELF toggle {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-x:execute-silent($SELF preset none)+reload($SELF render)" \ --bind "ctrl-o:execute-silent($SELF expand-all)+reload($SELF render)" \ @@ -1316,7 +1431,11 @@ cmd_run() { # ------------------------------------------------------------------ usage ---- usage() { 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 --unattended no UI: safe defaults, never prompts, never private @@ -1330,23 +1449,61 @@ usage() { way back in after a mistyped password -- no reinstall. 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 } +# 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= -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 +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; } + case ${CMD:-run} in run) cmd_run ;; pick) cmd_pick && cmd_plan ;; @@ -1354,6 +1511,7 @@ install) cmd_install ;; private) cmd_private ;; render) cmd_render ;; toggle) cmd_toggle "$@" ;; +toggle-shown) cmd_toggle_shown "$@" ;; expand) cmd_expand "$@" ;; expand-all) cmd_expand_all ;; preset) cmd_preset "${1:-defaults}" ;;