From 1ea6b492eb0d5322bc538865eab3d7e91407eb8f Mon Sep 17 00:00:00 2001 From: bcherb2 Date: Fri, 21 Aug 2026 22:34:22 -0400 Subject: [PATCH] =?UTF-8?q?fix:=20dotup=20=E2=80=94=20installer,=20picker?= =?UTF-8?q?=20and=20private-tier=20correctness=20pass?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Installer: - channel order is a dependency graph: `script` now runs before `brew`, so core/brew exists by the time lazygit, omp and herdr are attempted. Those three have no apt package at all and failed on every fresh Linux box. - core/brew and core/node are real script/tarball rows now. apt's nodejs is 18.19 while four npm rows declare node>=20; npm only warns on a failed engines check, so the install "succeeded" and `pi` then died at parse time. - apt_known asks `apt-cache policy` for an installation candidate rather than `apt-cache show` for existence: docker-ce exists in the cache with `Candidate: (none)` and exits 0, which kept it in the batch and made apt refuse all thirty packages at once. - failures are reported by manifest key, not by install argument, so the name in the summary is one you can type at the picker or pass to `dotup explain`. - flatpak installs the tool, adds the flathub remote, and uses --user, which is the only scope that works without a session bus. - gh: deb assets fill %a and %v, because a distro-targeted deb names the release as well as the arch (ghostty-ubuntu). - ensure_bws downloads into a `mktemp -d` instead of fixed /tmp paths. - the endpoint password prompt says so out loud when stty cannot turn echo off, instead of silently leaving the password in the scrollback. - cmd_toggle writes tmp-then-rename, matching cmd_expand. Picker: - DU-C1: comments had been inserted between the continued lines of the fzf invocation. The `\`-newline is stripped first, so the comment's own newline terminated the command — fzf ran with two binds and the lines below it ran as a command named `--bind`, so `dotup pick` returned 1 having drawn a dead picker. The comment now sits above the pipeline. - the header is built with a plain variable: `$'...'` is a bashism and /bin/sh on Ubuntu is dash, the one platform this is written for. - the /dev/tty probe runs BEFORE the defaults preset, so a run in a pipe no longer overwrites the selection with 43 rows before failing to draw. - a completed pick is recorded in $STATE/picked, so "install nothing" survives the next run and a prior `dotup plan` no longer counts as having chosen. - $SEL must be readable and writable before anything draws: every bind is execute-silent, which throws its child's status away. - ensure_fzf resolves system-first, cache-second, as the README promises. - ^t clears the query, so rows pulled in along @needs are on screen. --- dot_local/bin/executable_dotup | 304 +++++++++++++++++++++++++---- dot_local/share/dotup/packages.tsv | 12 +- 2 files changed, 276 insertions(+), 40 deletions(-) diff --git a/dot_local/bin/executable_dotup b/dot_local/bin/executable_dotup index 52e3f2d..621ef0a 100755 --- a/dot_local/bin/executable_dotup +++ b/dot_local/bin/executable_dotup @@ -21,6 +21,9 @@ HERE=$(dirname -- "$SELF") STATE=${DOTUP_STATE:-${XDG_CONFIG_HOME:-$HOME/.config}/dotfiles} SEL=$STATE/selected 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 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 @@ -128,7 +131,11 @@ cmd_toggle() { $want EOF if [ "$all_on" -eq 0 ]; then printf '%s\n' "$want" >> "$tmp"; fi - sort -u "$tmp" > "$SEL" && rm -f "$tmp" + # `sort -u "$tmp" > "$SEL"` truncated the real file before sort produced a + # single byte, so a SIGTERM or a full disk in that window lost the whole + # selection. cmd_expand twelve lines down already writes tmp-then-rename; + # this now matches it. + sort -u "$tmp" > "$tmp.s" && mv "$tmp.s" "$SEL" && rm -f "$tmp" } cmd_expand() { @@ -443,28 +450,36 @@ ensure_bws() { # deliberately; the checksum below is what makes that safe. v=$BWS_PIN b=https://github.com/bitwarden/sdk-sm/releases/download/bws-v$v + # These were fixed paths -- /tmp/bws.zip, /tmp/bws.sums, /tmp/bws.d -- in a + # world-writable directory. Two concurrent runs overwrote each other, and on + # a shared machine anyone could pre-create those names as symlinks and + # redirect the write. The deb channel a few functions down already used + # $TMPDIR; this now does better, with a private directory it owns and + # removes. mktemp -d is 700 by definition, so the download and the unpacked + # binary are unreadable to everyone else while they sit there. + bws_tmp=$(mktemp -d "${TMPDIR:-/tmp}/dotup-bws.XXXXXX") || { + err "could not create a temporary directory for bws"; return 1; } say " fetching bws $v" - curl -fsSL "$b/bws-$t-$v.zip" -o /tmp/bws.zip 2>/dev/null \ - || { err "bws download failed"; return 1; } + curl -fsSL "$b/bws-$t-$v.zip" -o "$bws_tmp/bws.zip" 2>/dev/null \ + || { err "bws download failed"; rm -rf "$bws_tmp"; return 1; } # A binary about to hold the key to every other credential is worth verifying. - if have sha256sum && curl -fsSL "$b/bws-sha256-checksums-$v.txt" -o /tmp/bws.sums 2>/dev/null; then - want=$(awk -v f="bws-$t-$v.zip" '$2==f || $2=="*"f {print $1}' /tmp/bws.sums | head -1) - got=$(sha256sum /tmp/bws.zip | awk '{print $1}') + if have sha256sum && curl -fsSL "$b/bws-sha256-checksums-$v.txt" -o "$bws_tmp/sums" 2>/dev/null; then + want=$(awk -v f="bws-$t-$v.zip" '$2==f || $2=="*"f {print $1}' "$bws_tmp/sums" | head -1) + got=$(sha256sum "$bws_tmp/bws.zip" | awk '{print $1}') if [ -n "$want" ] && [ "$want" != "$got" ]; then err "bws checksum mismatch — refusing to install" - rm -f /tmp/bws.zip /tmp/bws.sums; return 1 + rm -rf "$bws_tmp"; return 1 fi [ -n "$want" ] && say " bws checksum verified" - rm -f /tmp/bws.sums else warn " bws checksums unavailable — installing unverified" fi - mkdir -p "$HOME/.local/bin" /tmp/bws.d - unzip -oq /tmp/bws.zip -d /tmp/bws.d 2>/dev/null \ - || { err "bws extract failed"; rm -rf /tmp/bws.zip /tmp/bws.d; return 1; } - install -m 755 "$(find /tmp/bws.d -type f -name bws | head -1)" "$HOME/.local/bin/bws" \ - || { err "bws install failed"; rm -rf /tmp/bws.zip /tmp/bws.d; return 1; } - rm -rf /tmp/bws.zip /tmp/bws.d + mkdir -p "$HOME/.local/bin" "$bws_tmp/d" + unzip -oq "$bws_tmp/bws.zip" -d "$bws_tmp/d" 2>/dev/null \ + || { err "bws extract failed"; rm -rf "$bws_tmp"; return 1; } + install -m 755 "$(find "$bws_tmp/d" -type f -name bws | head -1)" "$HOME/.local/bin/bws" \ + || { err "bws install failed"; rm -rf "$bws_tmp"; return 1; } + rm -rf "$bws_tmp" say " bws $v installed to ~/.local/bin" } @@ -480,6 +495,21 @@ run_sh() { } note_fail() { printf '%s\t%s\n' "$1" "$2" >> "$FAILED"; err "$1: $2"; } +# A failure has to be reported by a name the reader can act on. The channel +# installers only ever hold the install ARGUMENT, so the summary said +# `can1357/tap/omp`, `bw` and `md.obsidian.Obsidian` -- none of which can be +# typed at the picker, passed to `dotup explain`, or found in the manifest. +# The plan table already carries channel, argument and key side by side; this +# reads the key back out of it. Falls back to the argument when there is no +# table (a package moved from apt to brew is not in the brew column). +PLAN_TBL= +keyof() { + kf= + [ -n "$PLAN_TBL" ] && [ -f "$PLAN_TBL" ] && kf=$(awk -F'\t' -v c="$1" -v a="$2" ' + $1==c && index(" "$2" ", " "a" ") { print $3; exit }' "$PLAN_TBL") + printf '%s\n' "${kf:-$2}" +} + apt_update_once() { [ "$APT_UPDATED" -eq 0 ] || return 0 APT_UPDATED=1 @@ -490,7 +520,16 @@ apt_update_once() { # does not know is not a dead end: if the brew column offers it, it moves there. # That is what "apt-then-brew" has to mean in practice, and it is the difference # between 34 packages installed and 0. -apt_known() { apt-cache show "$1" >/dev/null 2>&1; } +# `apt-cache show` is not an existence test. A name that exists only because +# something else Conflicts/Replaces it -- docker-ce on a stock Ubuntu is the +# live example -- prints nothing and exits 0, so the batch kept it, apt refused +# the whole batch with "has no installation candidate", and the brew column was +# never consulted. Ask for the installation candidate instead, which is the +# thing `apt-get install` will actually go looking for. +apt_known() { + apt-cache policy "$1" 2>/dev/null \ + | awk '/^ Candidate:/ { ok = ($2 != "(none)") } END { exit !ok }' +} install_apt() { pkgs=$(norm "$1") @@ -533,10 +572,10 @@ install_brew() { # `invasive` flag exists to refuse doing on your behalf. Say what to run. warn "brew is not installed; skipping:$pkgs" warn " install it first: /bin/bash -c \"\$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)\"" - for p in $pkgs; do note_fail "$p" "brew missing"; done + for p in $pkgs; do note_fail "$(keyof brew "$p")" "brew missing"; done return 0 fi - for p in $pkgs; do run_sh "$BREW install $p" || note_fail "$p" "brew install failed"; done + for p in $pkgs; do run_sh "$BREW install $p" || note_fail "$(keyof brew "$p")" "brew install failed"; done } install_npm() { @@ -547,13 +586,13 @@ install_npm() { if ! have npm && [ "$DRYRUN" -eq 0 ]; then # Ubuntu's `nodejs` package ships node WITHOUT npm; the manifest asks # apt for both. If this fires anyway, node itself did not land. - for p in $specs; do note_fail "$p" "npm missing — core/node did not install"; done + for p in $specs; do note_fail "$(keyof npm "$p")" "npm missing — core/node did not install"; done return 0 fi # shellcheck disable=SC2086 run_sh "$NPM install -g$(printf ' %s' $specs)" || { warn "batch npm install failed; retrying one at a time" - for p in $specs; do run_sh "$NPM install -g $p" || note_fail "$p" "npm install failed"; done + for p in $specs; do run_sh "$NPM install -g $p" || note_fail "$(keyof npm "$p")" "npm install failed"; done } } @@ -563,33 +602,59 @@ install_uv() { head_ "uv" UV=$(find_tool uv || echo uv) if ! have uv && [ "$DRYRUN" -eq 0 ]; then - for t in $tools; do note_fail "$t" "uv missing — core/uv did not install"; done + for t in $tools; do note_fail "$(keyof uv "$t")" "uv missing — core/uv did not install"; done return 0 fi - for t in $tools; do run_sh "$UV tool install $t" || note_fail "$t" "uv tool install failed"; done + for t in $tools; do run_sh "$UV tool install $t" || note_fail "$(keyof uv "$t")" "uv tool install failed"; done } install_snap() { names=$(norm "$1") [ -n "$names" ] || return 0 head_ "snap" + SNAP=$(find_tool snap || echo snap) if ! have snap && [ "$DRYRUN" -eq 0 ]; then - for n in $names; do note_fail "$n" "snapd is not present on this machine"; done + for n in $names; do note_fail "$(keyof snap "$n")" "snapd is not present on this machine"; done return 0 fi - for n in $names; do run_sh "${SUDO:+$SUDO }snap install $n" || note_fail "$n" "snap install failed"; done + for n in $names; do run_sh "${SUDO:+$SUDO }$SNAP install $n" || note_fail "$(keyof snap "$n")" "snap install failed"; done } +# Three things were missing, and all three are needed before a single flatpak +# can install on a stock Ubuntu box: +# +# the tool Ubuntu ships snap, not flatpak. `flatpak` is an ordinary, +# uninvasive apt package -- refusing to install it is not the +# same call as refusing to install a second package manager +# that rewrites /usr/local, so install it. +# the remote Ubuntu configures no remotes at all, so the old command died +# with `error: No remote refs found for 'flathub'` even where +# flatpak WAS present. Nothing in the manifest can express this; +# it belongs here, once, beside the install. +# --user the system-wide scope needs polkit on a session bus, and a +# headless or freshly-booted box has none: `flatpak remote-add` +# answers `error: Unable to connect to system bus`. --user needs +# nothing, installs into ~/.local/share/flatpak, and is where a +# single-user desktop wants these anyway. install_flatpak() { ids=$(norm "$1") [ -n "$ids" ] || return 0 head_ "flatpak" if ! have flatpak && [ "$DRYRUN" -eq 0 ]; then - for i in $ids; do note_fail "$i" "flatpak is not present on this machine"; done + apt_update_once + run_sh "${SUDO:+$SUDO }DEBIAN_FRONTEND=noninteractive apt-get install -y flatpak" \ + || warn "could not install flatpak" + fi + if ! have flatpak && [ "$DRYRUN" -eq 0 ]; then + for i in $ids; do note_fail "$(keyof flatpak "$i")" "flatpak is not present and could not be installed"; done return 0 fi + FLATPAK=$(find_tool flatpak || echo flatpak) + run_sh "$FLATPAK --user remote-add --if-not-exists flathub https://dl.flathub.org/repo/flathub.flatpakrepo" \ + || warn "could not add the flathub remote" for i in $ids; do - run_sh "flatpak install -y --noninteractive flathub $i" || note_fail "$i" "flatpak install failed" + run_sh "$FLATPAK install -y --noninteractive --user flathub $i" \ + || note_fail "$(keyof flatpak "$i")" "flatpak install failed" done } @@ -605,18 +670,28 @@ install_deb() { case $s in gh:*) spec=${s#gh:}; repo=${spec%%:*}; match=${spec#*:} + # A distro-targeted deb names the release as well as the arch: + # ghostty-ubuntu publishes ghostty_1.3.1-0.ppa2_amd64_24.04.deb, so + # the literal `_amd64.deb` the manifest used to carry matched no + # asset that has ever existed and apps/ghostty could never install. + # The manifest now writes `_%a_%v.deb` and the two placeholders are + # filled in here, which is the only place the machine is known. + deb_arch=$(dpkg --print-architecture 2>/dev/null || uname -m) + deb_rel=$( . /etc/os-release 2>/dev/null; printf '%s' "${VERSION_ID:-}" ) + match=$(printf '%s' "$match" | sed -e "s/%a/$deb_arch/g" -e "s/%v/$deb_rel/g") if [ "$DRYRUN" -eq 1 ]; then printf ' + resolve latest %s asset matching *%s*\n' "$repo" "$match" url="https://github.com/$repo/releases/latest/" else url=$(curl -fsSL "https://api.github.com/repos/$repo/releases/latest" 2>/dev/null \ | awk -F'"' -v m="$match" '/browser_download_url/ && index($4,m) {print $4; exit}') - [ -n "$url" ] || { note_fail "$repo" "no release asset matching *$match*"; continue; } + [ -n "$url" ] || { note_fail "$(keyof deb "$s")" "no release asset matching *$match*"; continue; } fi ;; esac f=${TMPDIR:-/tmp}/dotup-$$.deb - run_sh "curl -fsSL '$url' -o '$f'" || { note_fail "$url" "download failed"; continue; } - run_sh "${SUDO:+$SUDO }apt-get install -y '$f'" || note_fail "$url" "dpkg install failed" + 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" run_sh "rm -f '$f'" done } @@ -650,6 +725,37 @@ install_bespoke() { || { 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" ;; + 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 + # npm rows in this manifest declare node>=20 and agents/pi declares + # node>=22.19. npm only WARNS about a failed engines check, so + # `npm install -g` exited 0, dotup recorded a success, and `pi` + # then died on an import attribute the 18 parser cannot read. An + # install that cannot run is not an install. + if have node && [ "$DRYRUN" -eq 0 ]; then + v=$(node --version 2>/dev/null | tr -d 'v'); maj=${v%%.*} + case $maj in ''|*[!0-9]*) maj=0 ;; esac + if [ "$maj" -ge 20 ]; then say " node $v already above 20 — leaving it"; continue; fi + fi + case $(uname -s) in Darwin) o=darwin ;; *) o=linux ;; esac + case $(uname -m) in x86_64|amd64) a=x64 ;; aarch64|arm64) a=arm64 ;; + *) note_fail "$key" "no node tarball for $(uname -m)"; continue ;; esac + # index.tab names its own columns, so the LTS column is found rather + # than counted -- nodejs.org has added columns before. + if [ "$DRYRUN" -eq 1 ]; then v='vXX.Y.Z' + else v=$(curl -fsSL 'https://nodejs.org/download/release/index.tab' 2>/dev/null \ + | 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" \ + || { 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" ;; 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 @@ -669,6 +775,42 @@ install_bespoke() { if have chezmoi && [ "$DRYRUN" -eq 0 ]; then say " chezmoi already present — leaving it"; continue; fi run_sh "sh -c \"\$(curl -fsLS get.chezmoi.io)\" -- -b \"\$HOME/.local/bin\"" \ || note_fail "$key" "installer failed" ;; + core/brew) + # Homebrew runs on Linux, and this manifest has always assumed so: + # `find_tool` probes /home/linuxbrew/.linuxbrew/bin, and the note on + # agents/omp reads "pulls linuxbrew on linux". Three safe rows -- + # lazygit, omp and herdr -- have no apt package at all and resolve + # here. Nothing ever installed brew itself, so `^a` promised three + # packages that failed on every fresh Linux box. + # + # On Linux this is much less invasive than the macOS install it + # tends to be judged by: everything lands under + # /home/linuxbrew/.linuxbrew and /usr/local is left alone. + if have brew && [ "$DRYRUN" -eq 0 ]; then + say " brew already present — leaving it"; continue + fi + # Homebrew refuses to run as root by its own design. Say which of + # the two problems it is rather than letting its installer explain. + if [ "$DRYRUN" -eq 0 ] && [ "$(id -u)" -eq 0 ]; then + note_fail "$key" "Homebrew refuses to install as root — run dotup as your own user" + continue + fi + # The installer's own documented prerequisites. git is already here, + # since the public tier cannot apply without it; the rest are not. + apt_update_once + run_sh "${SUDO:+$SUDO }DEBIAN_FRONTEND=noninteractive apt-get install -y build-essential procps curl file git" \ + || warn "could not install Homebrew's build prerequisites" + run_sh "NONINTERACTIVE=1 /bin/bash -c \"\$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)\"" \ + || { note_fail "$key" "the Homebrew installer failed"; continue; } + # install_brew resolves through find_tool, which probes the linuxbrew + # prefix by absolute path, so nothing here needs PATH edited for the + # remainder of this run. + # Only meaningful after something actually ran. Under --print nothing + # was installed, so this reported "brew still not found after + # installing it" for a run that never claimed to install anything -- + # a failure invented by the dry run itself. + [ "$DRYRUN" -eq 1 ] || have brew \ + || note_fail "$key" "brew still not found after installing it" ;; core/uv) if have uv && [ "$DRYRUN" -eq 0 ]; then say " uv already present — leaving it"; continue; fi run_sh "curl -LsSf https://astral.sh/uv/install.sh | sh" || note_fail "$key" "installer failed" ;; @@ -689,6 +831,7 @@ cmd_install() { : > "$FAILED" tbl=$STATE/.plan.$$ plan_table > "$tbl" + PLAN_TBL=$tbl # Two filters, and they are the reason this is safe to run unattended. # `private` is never a package: it needs a password nobody is there to type. @@ -716,10 +859,15 @@ cmd_install() { # Order is a fixed pipeline, not a topological sort, because the real # manifest has exactly two ordering constraints and both are channel-level: # npm needs node (apt/brew), and uv tools need uv (script). + # Order is a dependency graph, not a preference. apt first because most + # things come from it; `script` BEFORE `brew`, because core/brew is a script + # row and the three brew-only packages cannot install until it has run; + # npm after apt and tarball because node has to exist first; uv after script + # for the same reason. BREW_EXTRA= install_apt "$(col apt)" - install_brew "$(col brew) $BREW_EXTRA" install_bespoke script "$(keys script)" + install_brew "$(col brew) $BREW_EXTRA" install_bespoke tarball "$(keys tarball)" install_bespoke builtin "$(keys builtin)" install_bespoke xcode "$(keys xcode)" @@ -858,8 +1006,19 @@ cmd_private() { IFS= read -r p_in || : [ -z "$p_in" ] || P_USER=$p_in + # Both stty calls were `|| :`, which meant that on a box where stty is + # missing or fails, the endpoint password was typed in the clear and + # left in the scrollback -- silently, at the one prompt where that + # matters most. Say so instead. Still not fatal: someone on a console + # with no stty may genuinely want to continue, but they get to know. + if stty -echo 2>/dev/null; then p_echo=off; else + p_echo=on + warn "this terminal will not turn off echo — the password WILL be visible" + fi printf ' Password: ' >&2 - stty -echo 2>/dev/null || :; IFS= read -r P_PW || :; stty echo 2>/dev/null || :; printf '\n' >&2 + IFS= read -r P_PW || : + [ "$p_echo" = off ] && { stty echo 2>/dev/null || :; } + printf '\n' >&2 # curl -K - reads its config, credentials included, from stdin rather than # the command line, so nothing reaches `ps`. The status is captured @@ -1026,9 +1185,11 @@ install_fzf() { # Resolution order, cheapest first. The machine's own fzf wins when it clears # the floor — nothing is replaced merely for being old. ensure_fzf() { - if [ -x "$FZF_CACHE/fzf" ] && ver_ge "$("$FZF_CACHE/fzf" --version 2>/dev/null | awk '{print $1}')" "$FZF_FLOOR"; then - FZF=$FZF_CACHE/fzf; return 0 - fi + # System first, cache second. The README promises "your own fzf wins + # whenever it clears the floor", and cache-first quietly broke that: once + # dotup had fetched its copy, a system fzf installed later never won again, + # however new it was. The cost of getting this right is one `fzf --version` + # fork per run. if command -v fzf >/dev/null 2>&1; then cur=$(fzf_version) if [ -n "$cur" ] && ver_ge "$cur" "$FZF_FLOOR"; then @@ -1036,29 +1197,94 @@ ensure_fzf() { fi echo "dotup: system fzf $cur is below the verified floor $FZF_FLOOR" >&2 fi + if [ -x "$FZF_CACHE/fzf" ] && ver_ge "$("$FZF_CACHE/fzf" --version 2>/dev/null | awk '{print $1}')" "$FZF_FLOOR"; then + FZF=$FZF_CACHE/fzf; return 0 + fi install_fzf || return 1 FZF=$FZF_CACHE/fzf } # ------------------------------------------------------------------- pick ---- cmd_pick() { - ensure_fzf || { echo "dotup: no usable fzf; use the numbered prompt" >&2; return 2; } - [ -s "$SEL" ] || cmd_preset defaults + # fzf opens /dev/tty itself, so `[ -t 0 ]` asks the wrong question -- a run + # with a pipe on stdin but a terminal attached is fine, and a run with + # neither is not. Ask the question fzf will ask. + # + # It has to come BEFORE the defaults preset below, and that ordering is the + # actual bug: a run in a pipe or a CI job printed fzf's raw + # `failed to open /dev/tty` and exited 1, but had already overwritten the + # selection with 43 default rows. A picker that never drew anything must + # not change what a later install does. + if ! (exec 3/dev/null; then + err "no terminal — the picker cannot draw." + say " For a machine with nobody at the keyboard: dotup --unattended" + return 2 + fi + ensure_fzf || { + echo "dotup: no usable fzf, and none could be fetched." >&2 + echo "dotup: install fzf (apt install fzf, brew install fzf) and re-run," >&2 + echo "dotup: or pick without the UI: dotup preset defaults && dotup install" >&2 + return 2 + } + # Every binding is `execute-silent`, which throws its child's exit status + # away. So a state file that cannot be read or written makes the picker + # LOOK alive -- rows draw, the cursor moves -- while every tick is silently + # discarded. Mode 000 is what a `sudo dotup` leaves behind, which is how + # anyone actually meets this. Find out before drawing anything. + if [ ! -r "$SEL" ] || [ ! -w "$SEL" ] || [ ! -w "$STATE" ]; then + err "$SEL is not readable and writable — every tick would be silently lost." + say " fix its ownership, or delete it, and re-run." + return 2 + fi + # Seed the defaults only on a machine that has never finished a pick. + # + # This used to read `[ -s "$SEL" ] || cmd_preset defaults`, which cannot + # tell an empty selection apart from an absent one -- so `^x` then enter, + # a deliberate "install nothing", was silently replaced by the defaults on + # the very next run. Emptiness is the wrong signal. + # + # Existence is the wrong signal too, and that is a subtler trap: the file is + # created at load by ANY invocation, so a `dotup plan` before the first + # `dotup` would make it exist and the picker would then open with nothing + # ticked on a brand new machine. (Found exactly that way.) + # + # The right signal is "a pick has completed", which only this function + # knows. It is written after fzf ACCEPTS, so a ^c leaves nothing behind -- + # abandoning the picker is not a decision. + [ -f "$PICKED" ] || cmd_preset defaults # --exact is a safety property, not a preference. ^t toggles every row the # filter is showing, so the filter must mean exactly what it looks like. # Fuzzy-matching "nvidia" also matches docker, tailscale and desktop. + # $'...' is a bashism. This script is #!/bin/sh and Ubuntu's /bin/sh is + # dash, which does not implement it -- so the header rendered as a literal + # `$space tick ... enter install\n`, leading dollar and trailing backslash-n + # included, on the one platform this is written for. It looked right in + # every test because the test host's shell was not dash. Build the newline + # with a plain variable, which every POSIX shell agrees about. + 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. cmd_render | "$FZF" --ansi --exact --no-sort --cycle --multi --layout=reverse --height=100% \ --delimiter='\t' --with-nth=1 --pointer='>' --marker=' ' \ --info=inline --border=none \ - --header=$'space tick tab open ^t tick all shown ^a defaults ^x none ^o open all enter install\n' \ + --header="$hdr" \ --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+reload($SELF render)" \ + --bind "ctrl-t:select-all+execute-silent($SELF toggle {+2})+clear-selection+clear-query+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)" \ --bind 'enter:accept' > /dev/null || return 1 + # Only on accept. See the note above cmd_preset: this is the record that a + # human has been through the picker and meant what the file now says. + : > "$PICKED" } confirm() { @@ -1094,7 +1320,9 @@ usage() { (no flags) the picker, then install what you ticked --unattended no UI: safe defaults, never prompts, never private - --print, -n resolve everything and print the commands, install nothing + --print, -n resolve everything and print the commands, install nothing. + Still opens the picker and still asks to confirm; combine with + --unattended for a dry run with no UI at all. --yes, -y skip the confirmation after the picker private ONLY the private tier: prompt for the endpoint and fetch. diff --git a/dot_local/share/dotup/packages.tsv b/dot_local/share/dotup/packages.tsv index ea34030..a5b87a3 100644 --- a/dot_local/share/dotup/packages.tsv +++ b/dot_local/share/dotup/packages.tsv @@ -30,6 +30,13 @@ @needs agents/pi-plugins agents/pi @needs agents/specify-cli core/uv @needs core/mermaid-cli core/node +# The three rows with no apt package at all. They resolved to brew and +# nothing installed brew, so `^a` ticked them and they failed on every +# fresh Linux box. On Linux brew lives entirely under +# /home/linuxbrew/.linuxbrew, which find_tool already probes. +@needs core/lazygit core/brew +@needs agents/omp core/brew +@needs agents/herdr core/brew @needs core/neovim core/imagemagick core/mermaid-cli @needs private/bws-secrets core/unzip # @@ -43,7 +50,7 @@ @spec core/bitwarden-cli bw @spec apps/obsidian md.obsidian.Obsidian @spec apps/chrome https://dl.google.com/linux/direct/google-chrome-stable_current_amd64.deb -@spec apps/ghostty gh:mkasberg/ghostty-ubuntu:_amd64.deb +@spec apps/ghostty gh:mkasberg/ghostty-ubuntu:_%a_%v.deb core neovim safe -tarball neovim apt ships 0.9.5 — tarball to /opt/nvim core ripgrep safe ripgrep ripgrep binary is rg core fd safe fd-find fd binary is fdfind on ubuntu @@ -58,7 +65,8 @@ core gh safe gh gh core chezmoi safe -script chezmoi bootstrap cannot come from the manifest core go safe -tarball go /usr/local/go on linux core uv safe -script uv astral installer to ~/.local/bin -core node safe nodejs npm node apt's nodejs ships node WITHOUT npm — both names needed +core brew safe -script -builtin linuxbrew — the only source for lazygit, omp and herdr +core node safe -tarball node apt ships 18.19 and the npm rows below need 20+ — tarball to /opt/node core imagemagick safe imagemagick imagemagick required by the nvim markdown render path core mermaid-cli safe -npm -npm mmdc — nvim renders mermaid fences with it core btop safe btop btop