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:
@@ -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
|
||||
|
||||
@@ -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 <port> total totalCount
|
||||
# api.sh <port> pos cursor position, 0-based
|
||||
# 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
|
||||
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] }
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
# kills: the tarball is extracted where the symlink points
|
||||
# run: unit
|
||||
<<<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
|
||||
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
|
||||
|
||||
@@ -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
@@ -30,7 +30,7 @@ set -eu
|
||||
# "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
|
||||
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:
|
||||
# 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.
|
||||
|
||||
+175
-3
@@ -162,6 +162,57 @@ is "bws-secrets stays a non-package" "-" \
|
||||
is "no manifest row installs bws" "0" \
|
||||
"$(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'
|
||||
# `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
|
||||
|
||||
@@ -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
|
||||
|
||||
+194
-36
@@ -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}" ;;
|
||||
|
||||
Reference in New Issue
Block a user