fix: dotup — installer, picker and private-tier correctness pass

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.
This commit is contained in:
bcherb2
2026-08-21 22:34:22 -04:00
parent 94d6acadb3
commit 1ea6b492eb
2 changed files with 276 additions and 40 deletions
+266 -38
View File
@@ -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/<asset matching *$match*>"
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/tty) 2>/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.
+10 -2
View File
@@ -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
Can't render this file because it contains an unexpected character in line 17 and column 49.