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:
+266
-38
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user