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} STATE=${DOTUP_STATE:-${XDG_CONFIG_HOME:-$HOME/.config}/dotfiles}
SEL=$STATE/selected SEL=$STATE/selected
EXP=$STATE/expanded 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 manifest is data, not a script, so it does not live in bin/. Checked in
# the development layout first so the repo's own test suite and a checkout both # the development layout first so the repo's own test suite and a checkout both
@@ -128,7 +131,11 @@ cmd_toggle() {
$want $want
EOF EOF
if [ "$all_on" -eq 0 ]; then printf '%s\n' "$want" >> "$tmp"; fi 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() { cmd_expand() {
@@ -443,28 +450,36 @@ ensure_bws() {
# deliberately; the checksum below is what makes that safe. # deliberately; the checksum below is what makes that safe.
v=$BWS_PIN v=$BWS_PIN
b=https://github.com/bitwarden/sdk-sm/releases/download/bws-v$v 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" say " fetching bws $v"
curl -fsSL "$b/bws-$t-$v.zip" -o /tmp/bws.zip 2>/dev/null \ curl -fsSL "$b/bws-$t-$v.zip" -o "$bws_tmp/bws.zip" 2>/dev/null \
|| { err "bws download failed"; return 1; } || { err "bws download failed"; rm -rf "$bws_tmp"; return 1; }
# A binary about to hold the key to every other credential is worth verifying. # 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 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}' /tmp/bws.sums | head -1) want=$(awk -v f="bws-$t-$v.zip" '$2==f || $2=="*"f {print $1}' "$bws_tmp/sums" | head -1)
got=$(sha256sum /tmp/bws.zip | awk '{print $1}') got=$(sha256sum "$bws_tmp/bws.zip" | awk '{print $1}')
if [ -n "$want" ] && [ "$want" != "$got" ]; then if [ -n "$want" ] && [ "$want" != "$got" ]; then
err "bws checksum mismatch — refusing to install" err "bws checksum mismatch — refusing to install"
rm -f /tmp/bws.zip /tmp/bws.sums; return 1 rm -rf "$bws_tmp"; return 1
fi fi
[ -n "$want" ] && say " bws checksum verified" [ -n "$want" ] && say " bws checksum verified"
rm -f /tmp/bws.sums
else else
warn " bws checksums unavailable — installing unverified" warn " bws checksums unavailable — installing unverified"
fi fi
mkdir -p "$HOME/.local/bin" /tmp/bws.d mkdir -p "$HOME/.local/bin" "$bws_tmp/d"
unzip -oq /tmp/bws.zip -d /tmp/bws.d 2>/dev/null \ unzip -oq "$bws_tmp/bws.zip" -d "$bws_tmp/d" 2>/dev/null \
|| { err "bws extract failed"; rm -rf /tmp/bws.zip /tmp/bws.d; return 1; } || { err "bws extract failed"; rm -rf "$bws_tmp"; return 1; }
install -m 755 "$(find /tmp/bws.d -type f -name bws | head -1)" "$HOME/.local/bin/bws" \ install -m 755 "$(find "$bws_tmp/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; } || { err "bws install failed"; rm -rf "$bws_tmp"; return 1; }
rm -rf /tmp/bws.zip /tmp/bws.d rm -rf "$bws_tmp"
say " bws $v installed to ~/.local/bin" 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"; } 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_update_once() {
[ "$APT_UPDATED" -eq 0 ] || return 0 [ "$APT_UPDATED" -eq 0 ] || return 0
APT_UPDATED=1 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. # 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 # That is what "apt-then-brew" has to mean in practice, and it is the difference
# between 34 packages installed and 0. # 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() { install_apt() {
pkgs=$(norm "$1") pkgs=$(norm "$1")
@@ -533,10 +572,10 @@ install_brew() {
# `invasive` flag exists to refuse doing on your behalf. Say what to run. # `invasive` flag exists to refuse doing on your behalf. Say what to run.
warn "brew is not installed; skipping:$pkgs" 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)\"" 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 return 0
fi 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() { install_npm() {
@@ -547,13 +586,13 @@ install_npm() {
if ! have npm && [ "$DRYRUN" -eq 0 ]; then if ! have npm && [ "$DRYRUN" -eq 0 ]; then
# Ubuntu's `nodejs` package ships node WITHOUT npm; the manifest asks # Ubuntu's `nodejs` package ships node WITHOUT npm; the manifest asks
# apt for both. If this fires anyway, node itself did not land. # 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 return 0
fi fi
# shellcheck disable=SC2086 # shellcheck disable=SC2086
run_sh "$NPM install -g$(printf ' %s' $specs)" || { run_sh "$NPM install -g$(printf ' %s' $specs)" || {
warn "batch npm install failed; retrying one at a time" 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" head_ "uv"
UV=$(find_tool uv || echo uv) UV=$(find_tool uv || echo uv)
if ! have uv && [ "$DRYRUN" -eq 0 ]; then 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 return 0
fi 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() { install_snap() {
names=$(norm "$1") names=$(norm "$1")
[ -n "$names" ] || return 0 [ -n "$names" ] || return 0
head_ "snap" head_ "snap"
SNAP=$(find_tool snap || echo snap)
if ! have snap && [ "$DRYRUN" -eq 0 ]; then 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 return 0
fi 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() { install_flatpak() {
ids=$(norm "$1") ids=$(norm "$1")
[ -n "$ids" ] || return 0 [ -n "$ids" ] || return 0
head_ "flatpak" head_ "flatpak"
if ! have flatpak && [ "$DRYRUN" -eq 0 ]; then 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 return 0
fi 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 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 done
} }
@@ -605,18 +670,28 @@ install_deb() {
case $s in case $s in
gh:*) gh:*)
spec=${s#gh:}; repo=${spec%%:*}; match=${spec#*:} 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 if [ "$DRYRUN" -eq 1 ]; then
printf ' + resolve latest %s asset matching *%s*\n' "$repo" "$match" printf ' + resolve latest %s asset matching *%s*\n' "$repo" "$match"
url="https://github.com/$repo/releases/latest/<asset matching *$match*>" url="https://github.com/$repo/releases/latest/<asset matching *$match*>"
else else
url=$(curl -fsSL "https://api.github.com/repos/$repo/releases/latest" 2>/dev/null \ 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}') | 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 ;; fi ;;
esac esac
f=${TMPDIR:-/tmp}/dotup-$$.deb f=${TMPDIR:-/tmp}/dotup-$$.deb
run_sh "curl -fsSL '$url' -o '$f'" || { note_fail "$url" "download failed"; continue; } apt_update_once
run_sh "${SUDO:+$SUDO }apt-get install -y '$f'" || note_fail "$url" "dpkg install failed" 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'" run_sh "rm -f '$f'"
done done
} }
@@ -650,6 +725,37 @@ install_bespoke() {
|| { note_fail "$key" "tarball extract failed"; continue; } || { note_fail "$key" "tarball extract failed"; continue; }
run_sh "${SUDO:+$SUDO }ln -sf /opt/nvim/bin/nvim /usr/local/bin/nvim" run_sh "${SUDO:+$SUDO }ln -sf /opt/nvim/bin/nvim /usr/local/bin/nvim"
run_sh "rm -f /tmp/nvim.tgz" ;; 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) core/go)
if have go && [ "$DRYRUN" -eq 0 ]; then say " go already present — leaving it"; continue; fi if have go && [ "$DRYRUN" -eq 0 ]; then say " go already present — leaving it"; continue; fi
case $(uname -s) in Darwin) o=darwin ;; *) o=linux ;; esac case $(uname -s) in Darwin) o=darwin ;; *) o=linux ;; esac
@@ -669,6 +775,42 @@ install_bespoke() {
if have chezmoi && [ "$DRYRUN" -eq 0 ]; then say " chezmoi already present — leaving it"; continue; fi 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\"" \ run_sh "sh -c \"\$(curl -fsLS get.chezmoi.io)\" -- -b \"\$HOME/.local/bin\"" \
|| note_fail "$key" "installer failed" ;; || 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) core/uv)
if have uv && [ "$DRYRUN" -eq 0 ]; then say " uv already present — leaving it"; continue; fi 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" ;; run_sh "curl -LsSf https://astral.sh/uv/install.sh | sh" || note_fail "$key" "installer failed" ;;
@@ -689,6 +831,7 @@ cmd_install() {
: > "$FAILED" : > "$FAILED"
tbl=$STATE/.plan.$$ tbl=$STATE/.plan.$$
plan_table > "$tbl" plan_table > "$tbl"
PLAN_TBL=$tbl
# Two filters, and they are the reason this is safe to run unattended. # 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. # `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 # Order is a fixed pipeline, not a topological sort, because the real
# manifest has exactly two ordering constraints and both are channel-level: # manifest has exactly two ordering constraints and both are channel-level:
# npm needs node (apt/brew), and uv tools need uv (script). # 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= BREW_EXTRA=
install_apt "$(col apt)" install_apt "$(col apt)"
install_brew "$(col brew) $BREW_EXTRA"
install_bespoke script "$(keys script)" install_bespoke script "$(keys script)"
install_brew "$(col brew) $BREW_EXTRA"
install_bespoke tarball "$(keys tarball)" install_bespoke tarball "$(keys tarball)"
install_bespoke builtin "$(keys builtin)" install_bespoke builtin "$(keys builtin)"
install_bespoke xcode "$(keys xcode)" install_bespoke xcode "$(keys xcode)"
@@ -858,8 +1006,19 @@ cmd_private() {
IFS= read -r p_in || : IFS= read -r p_in || :
[ -z "$p_in" ] || P_USER=$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 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 # curl -K - reads its config, credentials included, from stdin rather than
# the command line, so nothing reaches `ps`. The status is captured # 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 # Resolution order, cheapest first. The machine's own fzf wins when it clears
# the floor — nothing is replaced merely for being old. # the floor — nothing is replaced merely for being old.
ensure_fzf() { ensure_fzf() {
if [ -x "$FZF_CACHE/fzf" ] && ver_ge "$("$FZF_CACHE/fzf" --version 2>/dev/null | awk '{print $1}')" "$FZF_FLOOR"; then # System first, cache second. The README promises "your own fzf wins
FZF=$FZF_CACHE/fzf; return 0 # whenever it clears the floor", and cache-first quietly broke that: once
fi # 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 if command -v fzf >/dev/null 2>&1; then
cur=$(fzf_version) cur=$(fzf_version)
if [ -n "$cur" ] && ver_ge "$cur" "$FZF_FLOOR"; then if [ -n "$cur" ] && ver_ge "$cur" "$FZF_FLOOR"; then
@@ -1036,29 +1197,94 @@ ensure_fzf() {
fi fi
echo "dotup: system fzf $cur is below the verified floor $FZF_FLOOR" >&2 echo "dotup: system fzf $cur is below the verified floor $FZF_FLOOR" >&2
fi 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 install_fzf || return 1
FZF=$FZF_CACHE/fzf FZF=$FZF_CACHE/fzf
} }
# ------------------------------------------------------------------- pick ---- # ------------------------------------------------------------------- pick ----
cmd_pick() { cmd_pick() {
ensure_fzf || { echo "dotup: no usable fzf; use the numbered prompt" >&2; return 2; } # fzf opens /dev/tty itself, so `[ -t 0 ]` asks the wrong question -- a run
[ -s "$SEL" ] || cmd_preset defaults # 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 # --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. # filter is showing, so the filter must mean exactly what it looks like.
# Fuzzy-matching "nvidia" also matches docker, tailscale and desktop. # 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% \ cmd_render | "$FZF" --ansi --exact --no-sort --cycle --multi --layout=reverse --height=100% \
--delimiter='\t' --with-nth=1 --pointer='>' --marker=' ' \ --delimiter='\t' --with-nth=1 --pointer='>' --marker=' ' \
--info=inline --border=none \ --info=inline --border=none \
--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' \ --preview "$SELF explain {2}" --preview-window='right,46%,wrap,border-left' \
--bind "space:execute-silent($SELF toggle {2})+reload($SELF render)" \ --bind "space:execute-silent($SELF toggle {2})+reload($SELF render)" \
--bind "tab:execute-silent($SELF expand {2})+reload($SELF render)" \ --bind "tab:execute-silent($SELF expand {2})+reload($SELF render)" \
--bind "ctrl-t:select-all+execute-silent($SELF toggle {+2})+clear-selection+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-a:execute-silent($SELF preset defaults)+reload($SELF render)" \
--bind "ctrl-x:execute-silent($SELF preset none)+reload($SELF render)" \ --bind "ctrl-x:execute-silent($SELF preset none)+reload($SELF render)" \
--bind "ctrl-o:execute-silent($SELF expand-all)+reload($SELF render)" \ --bind "ctrl-o:execute-silent($SELF expand-all)+reload($SELF render)" \
--bind 'enter:accept' > /dev/null || return 1 --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() { confirm() {
@@ -1094,7 +1320,9 @@ usage() {
(no flags) the picker, then install what you ticked (no flags) the picker, then install what you ticked
--unattended no UI: safe defaults, never prompts, never private --unattended no UI: safe defaults, never prompts, never private
--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 --yes, -y skip the confirmation after the picker
private ONLY the private tier: prompt for the endpoint and fetch. 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/pi-plugins agents/pi
@needs agents/specify-cli core/uv @needs agents/specify-cli core/uv
@needs core/mermaid-cli core/node @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 core/neovim core/imagemagick core/mermaid-cli
@needs private/bws-secrets core/unzip @needs private/bws-secrets core/unzip
# #
@@ -43,7 +50,7 @@
@spec core/bitwarden-cli bw @spec core/bitwarden-cli bw
@spec apps/obsidian md.obsidian.Obsidian @spec apps/obsidian md.obsidian.Obsidian
@spec apps/chrome https://dl.google.com/linux/direct/google-chrome-stable_current_amd64.deb @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 neovim safe -tarball neovim apt ships 0.9.5 — tarball to /opt/nvim
core ripgrep safe ripgrep ripgrep binary is rg core ripgrep safe ripgrep ripgrep binary is rg
core fd safe fd-find fd binary is fdfind on ubuntu 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 chezmoi safe -script chezmoi bootstrap cannot come from the manifest
core go safe -tarball go /usr/local/go on linux core go safe -tarball go /usr/local/go on linux
core uv safe -script uv astral installer to ~/.local/bin 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 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 mermaid-cli safe -npm -npm mmdc — nvim renders mermaid fences with it
core btop safe btop btop core btop safe btop btop
Can't render this file because it contains an unexpected character in line 17 and column 49.