Files
dotfiles-public/dot_local/bin/executable_dotup
T
bcherb2 2afdfd093b fix: accept either bootstrap URL shape, in dotup and in the harness
Three files each appended /bootstrap.env to a value whose shape nobody had
pinned down, and they did not agree:

  - the rotation scripts print the FULL file URL and say to store THAT in
    Bitwarden, so that is what gets pasted
  - dotup asked for the directory and appended /bootstrap.env itself
  - .tests/e2e.sh independently duplicated dotup's assumption

Pasting the saved value made the request .../bootstrap.env/bootstrap.env. The
404 tripped curl --fail and surfaced as "endpoint refused the credentials. The
machine stays public-only" -- blaming the password for a URL shape, which is
about the most expensive wrong error message this path could produce.

dotup and the wrapper now both strip a trailing slash and a trailing
/bootstrap.env before building the request, so either form works and the
existing Bitwarden entry needs no editing.

Four assertions cover all four shapes -- directory and file URL, each with and
without a trailing slash. They eval the normalisation lifted straight out of
dotup rather than a copy, and that binding was mutation-tested: deleting the
line from dotup makes them fail with exactly the production symptom,
https://h/r/bootstrap.env/bootstrap.env. 101 -> 105.

The second instance, in e2e.sh, was found only by running the harness with the
file URL instead of the directory. Every earlier run passed because it was fed
the shape that happened to be in a variable, not the shape a person has in a
password manager. Both shapes now run end to end and pass.

The normalisation exists in two places because the harness cannot source
dotup's internals. That is the same duplication that caused this, and only
dotup's copy is covered by the suite.
2026-08-17 22:52:11 -04:00

1055 lines
44 KiB
Bash
Executable File

#!/bin/sh
# dotup — the front door. One command from a bare machine to a finished one.
#
# It is a picker and an installer, not a picker next to an installer. Tick boxes
# on groups AND on individual packages; one toggle rule everywhere: expand the
# row to its leaves, and if every leaf is on turn them all off, otherwise turn
# them all on. That single rule covers a group row, a package row, and a bulk
# toggle over a filtered set.
#
# dotup the picker, then install what you ticked
# dotup --unattended no UI: safe defaults, never prompts, never private
# dotup --print resolve and print every command, install nothing
#
# POSIX sh on purpose: this runs on a bare box before anything is installed,
# and macOS still ships bash 3.2 (no associative arrays).
set -eu
SELF=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)/$(basename -- "$0")
HERE=$(dirname -- "$SELF")
STATE=${DOTUP_STATE:-${XDG_CONFIG_HOME:-$HOME/.config}/dotfiles}
SEL=$STATE/selected
EXP=$STATE/expanded
# 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
# work without setting anything.
if [ -n "${DOTUP_MANIFEST:-}" ]; then MANIFEST=$DOTUP_MANIFEST
elif [ -f "$HERE/packages.tsv" ]; then MANIFEST=$HERE/packages.tsv
else MANIFEST=${XDG_DATA_HOME:-$HOME/.local/share}/dotup/packages.tsv
fi
[ -n "${DISPLAY:-}${WAYLAND_DISPLAY:-}" ] && HAS_DISPLAY=1 || HAS_DISPLAY=0
case $(uname -s) in Darwin) PLAT=brew ;; *) PLAT=apt ;; esac
UNATTENDED=0
DRYRUN=0
ASSUME_YES=0
mkdir -p "$STATE"
[ -f "$SEL" ] || : > "$SEL"
[ -f "$EXP" ] || : > "$EXP"
R=''; DIM=''; B=''; GRN=''; YEL=''; RED=''
if [ -t 1 ]; then
R=$(printf '\033[0m'); DIM=$(printf '\033[2m'); B=$(printf '\033[1m')
GRN=$(printf '\033[32m'); YEL=$(printf '\033[33m'); RED=$(printf '\033[31m')
fi
say() { printf '%s\n' "$*" >&2; }
warn() { printf '%s!%s %s\n' "$YEL" "$R" "$*" >&2; }
err() { printf '%s✗%s %s\n' "$RED" "$R" "$*" >&2; }
head_() { printf '\n%s%s%s\n' "$B" "$*" "$R" >&2; }
# ---------------------------------------------------------------- leaves ----
# Expand a row key to the package keys it covers.
# g:agents -> agents/codex agents/pi ...
# p:agents/pi -> agents/pi
leaves() {
for key in "$@"; do
case $key in
g:*) grp=${key#g:}
awk -F'\t' -v g="$grp" '!/^[#@]/ && NF>=3 && $1==g {print $1"/"$2}' "$MANIFEST" ;;
p:*) printf '%s\n' "${key#p:}" ;;
esac
done
}
# ------------------------------------------------------------------ deps ----
# Dependencies are @needs directives, not a sixth column: the TSV stays five
# wide and greppable, and a package with no dependencies costs nothing to read.
#
# @needs <group/pkg> <dep> [dep ...] dep is group/pkg or a whole group
#
# There is no "requires" state to display. Ticking a box ticks what it needs;
# unticking one unticks what needed it. The counts on screen move as it happens,
# so the closure is visible rather than described.
# stdin: keys -> stdout: those keys plus everything they need, transitively.
expand_deps() {
work=$(sort -u); prev=
while [ "$work" != "$prev" ]; do
prev=$work
add=$(printf '%s\n' "$work" | while read -r k; do
[ -n "$k" ] || continue
awk -F'\t' -v k="$k" '$1=="@needs" && $2==k {print $3}' "$MANIFEST"
done | tr ' ' '\n' | while read -r d; do
[ -n "$d" ] || continue
case $d in
*/*) printf '%s\n' "$d" ;;
*) awk -F'\t' -v g="$d" '!/^[#@]/ && NF>=3 && $1==g {print $1"/"$2}' "$MANIFEST" ;;
esac
done)
work=$(printf '%s\n%s\n' "$work" "$add" | grep . | sort -u)
done
printf '%s\n' "$work"
}
# stdin: keys -> stdout: those keys plus everything that needs them.
expand_rdeps() {
work=$(sort -u); prev=
while [ "$work" != "$prev" ]; do
prev=$work
add=$(printf '%s\n' "$work" | while read -r k; do
[ -n "$k" ] || continue
awk -F'\t' -v k="$k" -v g="${k%%/*}" '$1=="@needs"{
n=split($3, d, " ")
for(i=1;i<=n;i++) if (d[i]==k || d[i]==g) print $2 }' "$MANIFEST"
done)
work=$(printf '%s\n%s\n' "$work" "$add" | grep . | sort -u)
done
printf '%s\n' "$work"
}
# ---------------------------------------------------------------- toggle ----
# All on -> all off. Anything else -> all on.
cmd_toggle() {
want=$(leaves "$@" | sort -u)
[ -n "$want" ] || return 0
all_on=1
for k in $want; do grep -qxF "$k" "$SEL" || { all_on=0; break; }; done
# Decide direction on what you touched, then widen along the dependency
# edges: switching on pulls in what it needs, switching off drops what
# needed it. Either way the ticks never describe a broken machine.
if [ "$all_on" -eq 1 ]; then want=$(printf '%s\n' "$want" | expand_rdeps)
else want=$(printf '%s\n' "$want" | expand_deps); fi
tmp=$STATE/.sel.$$
grep -vxF -f - "$SEL" > "$tmp" <<-EOF || :
$want
EOF
if [ "$all_on" -eq 0 ]; then printf '%s\n' "$want" >> "$tmp"; fi
sort -u "$tmp" > "$SEL" && rm -f "$tmp"
}
cmd_expand() {
for key in "$@"; do
case $key in g:*) g=${key#g:} ;; p:*) g=${key#p:}; g=${g%%/*} ;; *) continue ;; esac
tmp=$STATE/.exp.$$
if grep -qxF "$g" "$EXP"; then grep -vxF "$g" "$EXP" > "$tmp" || :
else cp "$EXP" "$tmp"; printf '%s\n' "$g" >> "$tmp"; fi
mv "$tmp" "$EXP"
done
}
cmd_expand_all() {
if [ -s "$EXP" ]; then : > "$EXP"
else awk -F'\t' '!/^[#@]/ && NF>=3 {print $1}' "$MANIFEST" | awk '!seen[$0]++' > "$EXP"; fi
}
# --------------------------------------------------------------- presets ----
cmd_preset() {
case ${1:-defaults} in
none) : > "$SEL" ;;
safe) awk -F'\t' '!/^[#@]/ && NF>=3 && $3=="safe" {print $1"/"$2}' "$MANIFEST" > "$SEL" ;;
defaults)
awk -F'\t' -v d="$HAS_DISPLAY" \
'!/^[#@]/ && NF>=3 && ($3=="safe" || ($3=="gui" && d==1)) {print $1"/"$2}' \
"$MANIFEST" > "$SEL" ;;
esac
}
# ---------------------------------------------------------------- render ----
cmd_render() {
awk -F'\t' -v selfile="$SEL" -v expfile="$EXP" -v plat="$PLAT" -v disp="$HAS_DISPLAY" '
function sev(f){ return f=="invasive"?3 : f=="private"?2 : f=="gui"?1 : 0 }
function lbl(s){ return s==3?"invasive" : s==2?"private" : s==1?"gui" : "safe" }
function col(s){ return s==3?RED : s==2?MAG : s==1?CYA : GRN }
# Linux is apt-then-brew, not apt-instead-of-brew: all three machines run
# linuxbrew, and omp/herdr/lazygit exist only as taps.
# The fallback runs one way only. Linux is apt-then-brew because all three
# machines run linuxbrew and omp/herdr/lazygit exist only as taps. There is
# no apt on a Mac, so a bare `-` in the brew column means unavailable — the
# other direction would offer to `apt install davfs2` on macOS.
function src(a,b, v,p){ if (plat=="brew") { v=b; p="brew" }
else { v=a; p="apt"; if (v=="-") { v=b; p="brew" } }
if (v=="-") return "unavailable"
if (substr(v,1,1)=="-") return substr(v,2)
return p" "v }
function cut(s,n){ return length(s)>n ? substr(s,1,n-1) "\342\200\246" : s }
BEGIN{
R="\033[0m"; DIM="\033[2m"; B="\033[1m"
GRN="\033[32m"; YEL="\033[33m"; RED="\033[31m"; CYA="\033[36m"; MAG="\033[35m"
while((getline l < selfile) > 0) sel[l]=1
while((getline l < expfile) > 0) expd[l]=1
}
# @group<TAB>note — a group whose members fail the safe test for several
# different reasons needs its own line. Deriving it from the first child
# would show "opens port 22" on a group that also mounts setuid helpers.
$1=="@needs" || $1=="@spec" { next }
/^@/ { gnote[substr($1,2)] = $2; next }
/^#/ || NF<3 { next }
{
g=$1; p=$2; f=$3; key=g"/"p
n++; G[n]=g; P[n]=p; F[n]=f; A[n]=$4; Bc[n]=$5; NT[n]=$6
if (!(g in seen)) { seen[g]=1; order[++ng]=g }
tot[g]++
if (key in sel) { selc[g]++; on[n]=1 }
if (sev(f) > worst[g]) worst[g]=sev(f)
if (f=="invasive" || f=="private") why[g]=($6!="" && why[g]=="") ? $6 : why[g]
}
END{
for (i=1; i<=ng; i++) {
g=order[i]; s=worst[g]; c=selc[g]+0; t=tot[g]
mark = (c==t) ? "x" : (c>0 ? "~" : " ")
mc = (c==t) ? GRN : (c>0 ? YEL : DIM)
arrow = (g in expd) ? "\342\226\276" : "\342\226\270"
reason = (g in gnote) ? gnote[g] : why[g]
note = (s>=2 && reason!="") ? " " col(s) reason R : ""
printf "%s %s[%s]%s %s%-13s%s %s%5s%s %s%-8s%s%s\t%s\n",
arrow, mc, mark, R, B, g, R, DIM, c"/"t, R, col(s), lbl(s), R, cut(note,44), "g:" g
if (!(g in expd)) continue
for (j=1; j<=n; j++) {
if (G[j]!=g) continue
m = (j in on) ? "x" : " "
mc = (j in on) ? GRN : DIM
d = src(A[j], Bc[j])
if (NT[j]!="") d = d " \302\267 " NT[j]
printf " %s[%s]%s %-18s %s%s%s\t%s\n",
mc, m, R, P[j], DIM, cut(d,46), R, "p:" g "/" P[j]
}
}
}' "$MANIFEST"
}
# --------------------------------------------------------------- explain ----
cmd_explain() {
key=${1:-}
awk -F'\t' -v key="$key" -v selfile="$SEL" -v plat="$PLAT" '
function src(a,b, v,p){ if (plat=="brew") { v=b; p="brew" }
else { v=a; p="apt"; if (v=="-") { v=b; p="brew" } }
if (v=="-") return "not available"
if (substr(v,1,1)=="-") return substr(v,2)
return p " install " v }
BEGIN{
R="\033[0m"; DIM="\033[2m"; B="\033[1m"; RED="\033[31m"; GRN="\033[32m"
while((getline l < selfile) > 0) sel[l]=1
split(key, kk, ":"); kind=kk[1]; rest=substr(key, index(key,":")+1)
if (kind=="g") { wantg=rest } else { split(rest, pp, "/"); wantg=pp[1]; wantp=pp[2] }
}
# A key may carry several @needs lines; keep them all, not just the last.
$1=="@needs" { if ($2==rest) needs = needs (needs==""?"":" ") $3; next }
$1=="@spec" { if ($2==rest) spec=$3; next }
/^@/ { if (substr($1,2)==wantg) gnote=$2; next }
/^#/ || NF<3 { next }
$1==wantg {
if (wantp=="" ) {
t++; if (($1"/"$2) in sel) c++
list = list sprintf(" %s %s\n", (($1"/"$2) in sel)?GRN "\342\234\223" R:DIM "\342\227\246" R, $2)
if ($6!="" && flagnote=="") flagnote=$6
f=$3
} else if ($2==wantp) {
printf "%s%s%s\n\n", B, $2, R
printf "%sgroup%s %s\n", DIM, R, $1
printf "%srisk%s %s\n", DIM, R, $3
printf "%sapt%s %s\n", DIM, R, ($4=="-"?"not available":$4)
printf "%sbrew%s %s\n", DIM, R, ($5=="-"?"not available":$5)
printf "%sinstall%s %s\n", DIM, R, src($4,$5)
if ($6!="") printf "\n%s\n", $6
done=1
}
}
END{
if (done) {
if (spec!="") printf "\n%sspec%s %s\n", DIM, R, spec
if (needs!="") printf "\n%sneeds%s %s%s\n", DIM, R, needs, DIM " (ticked automatically)" R
printf "\n%sstate%s %s\n", DIM, R, (key_in_sel()) ? GRN "selected" R : DIM "not selected" R
exit
}
printf "%s%s%s %s%d of %d selected%s\n\n", B, wantg, R, DIM, c, t, R
printf "%s\n", list
if (f=="invasive") printf "%sinvasive%s %s\n", RED, R, (gnote!="" ? gnote : flagnote)
if (f=="private") printf "%s\n", (gnote!="" ? gnote : "one password, typed after the install finishes")
}
function key_in_sel(){ return (rest in sel) }
' "$MANIFEST"
}
# ------------------------------------------------------------- resolution ----
# One place decides, for a selected package on this platform, what actually
# installs it. Everything downstream — the plan, the installer, the dry run —
# reads this and nothing else, so they cannot disagree.
#
# Output: <channel><TAB><argument>
# apt|brew argument is the package name
# npm|uv|snap|deb|flatpak argument is the @spec, defaulting to the pkg name
# tarball|script|xcode|builtin bespoke by nature; dispatched on the key
# unavailable neither column offers it here
resolve() {
awk -F'\t' -v key="$1" -v plat="$PLAT" '
$1=="@spec" { if ($2==key) spec=$3; next }
/^[#@]/ || NF<3 { next }
($1"/"$2)==key { pkg=$2; apt=$4; brw=$5; found=1 }
END{
if (!found) { print "missing\t"; exit }
# apt-then-brew, not apt-instead-of-brew: omp, herdr and lazygit have no
# apt package at all and install perfectly well from linuxbrew. The
# fallback is one-directional on purpose — there is no apt on a Mac, so
# a bare `-` in the brew column is the end of the road, not a reason to
# go looking in a column that names Debian packages.
if (plat=="brew") { v=brw; p="brew" }
else { v=apt; p="apt"; if (v=="-") { v=brw; p="brew" } }
if (v=="-") { print "unavailable\t"; exit }
if (substr(v,1,1)=="-") { print substr(v,2) "\t" (spec!="" ? spec : pkg); exit }
print p "\t" v
}' "$MANIFEST"
}
# Every selected key, minus the private rows — those are not packages.
selected_packages() {
awk -F'\t' -v selfile="$SEL" '
BEGIN{ while((getline l < selfile)>0) sel[l]=1 }
/^[#@]/ || NF<3 { next }
$3=="private" { next }
($1"/"$2) in sel { print $1"/"$2 }' "$MANIFEST"
}
selected_private() {
awk -F'\t' -v selfile="$SEL" '
BEGIN{ while((getline l < selfile)>0) sel[l]=1 }
/^[#@]/ || NF<3 { next }
$3=="private" && (($1"/"$2) in sel) { print $1"/"$2 }' "$MANIFEST"
}
# Build <channel><TAB><arg><TAB><key> for everything selected.
plan_table() {
selected_packages | while read -r k; do
[ -n "$k" ] || continue
printf '%s\t%s\n' "$(resolve "$k")" "$k"
done
}
cmd_plan() {
head_ "plan"
plan_table | sort | awk -F'\t' '
BEGIN{ R="\033[0m"; B="\033[1m"; DIM="\033[2m"; YEL="\033[33m" }
{ ch=$1; arg=$2; key=$3
if (ch=="unavailable" || ch=="missing") { bad = bad " " key "\n"; nb++; next }
line[ch] = line[ch] " " sprintf("%-28s %s", key, arg) "\n"; n++ }
END{
for (c in line) printf " %s%s%s\n%s", B, c, R, line[c]
printf "\n %d packages\n", n
if (nb) printf "\n %sno source on this platform (%d):%s\n%s", YEL, nb, R, bad
}'
}
# --------------------------------------------------------------- installer ---
# Nothing here is clever. It batches what can be batched, refuses what it cannot
# reach, and never lets one bad package sink the other thirty.
# Written as an `if`, not `A || { B && C; }`: that form is one || list, so when
# `command -v sudo` fails the whole list fails and `set -e` exits the script at
# load. On a non-root machine with no sudo, dotup died before printing anything.
SUDO=
if [ "$(id -u)" != 0 ] && command -v sudo >/dev/null 2>&1; then SUDO=sudo; fi
FAILED=$STATE/.failed.$$
APT_UPDATED=0
have() { find_tool "$1" >/dev/null 2>&1; }
# A tool installed a moment ago is not on this process's PATH: the astral
# installer drops uv in ~/.local/bin, npm -g honours the ~/.npmrc prefix, the go
# tarball lands in /usr/local/go, and linuxbrew lives outside a non-login PATH.
# The fix is to look in the places we just wrote to -- NOT to export a modified
# PATH. A tool that refuses to shadow your fzf has no business rewriting PATH
# for its own convenience either, and a child process's PATH would be a lie the
# moment dotup exits.
find_tool() {
command -v "$1" 2>/dev/null && return 0
for ft_c in "$HOME/.local/bin/$1" "$HOME/.npm-global/bin/$1" \
"/usr/local/bin/$1" "/usr/local/go/bin/$1" \
"/home/linuxbrew/.linuxbrew/bin/$1" "/opt/homebrew/bin/$1"; do
[ -x "$ft_c" ] && { printf '%s\n' "$ft_c"; return 0; }
done
return 1
}
# Collapse a space-separated list to canonical form; an all-blank list becomes
# empty, so a channel with nothing in it prints no header.
norm() { printf '%s\n' "$*" | tr ' ' '\n' | grep . | tr '\n' ' ' | sed 's/ $//'; }
# Strip userinfo from a URL for tracing: https://user:token@host/p -> https://host/p
# Used wherever a URL that may carry a credential is about to be printed.
redact_url() { printf '%s\n' "$1" | sed 's#://[^/@]*@#://<redacted>@#'; }
BWS_PIN=2.1.0
# The Bitwarden Secrets Manager CLI, installed by the PRIVATE tier only.
#
# It cannot be a manifest row. `selected_packages` drops every row flagged
# `private` -- "private is never a package" is the invariant that lets
# --unattended be safe -- so a private row can never install anything. And it
# must not be a `safe` row either: the defaults preset ticks every safe package,
# which would put a Bitwarden binary and a 12 MB GitHub download on every
# throwaway public VM, for a tool those machines have no credential to use.
#
# So it lives here, called from cmd_private beside the token it exists to read.
# Nothing installs bws unless something is about to hand it a token.
ensure_bws() {
have bws && { say " bws $(bws --version 2>/dev/null | awk '{print $2}') already present"; return 0; }
command -v unzip >/dev/null 2>&1 || { err "bws needs unzip; install it and re-run"; return 1; }
# musl, not gnu: the gnu build pins a glibc newer than some LTS images
# carry, and this has to work on whatever a fresh VM turns out to be.
case $(uname -s) in
Darwin) t=macos-universal ;;
*) case $(uname -m) in
x86_64|amd64) t=x86_64-unknown-linux-musl ;;
aarch64|arm64) t=aarch64-unknown-linux-musl ;;
*) err "no bws build for $(uname -m)"; return 1 ;;
esac ;;
esac
# Pinned, the same way fzf is, and for a better reason than caution.
#
# There is no way to resolve "the current bws" without authenticating.
# sdk-sm is a monorepo with per-component tags, so /releases/latest
# redirects to whatever shipped last — measured 2026-08-17, that is
# `python-v2.1.0`, not bws. And the unauthenticated releases LIST endpoint
# returns an empty array for this repo: an earlier version of this function
# resolved the version through it and worked on the dev box only because
# `gh` had authenticated that shell. In a bare container it returned nothing
# and the install failed with "could not resolve the current bws version".
#
# Release-download URLs need no auth at all, so pin and move on. Bump BWS_PIN
# deliberately; the checksum below is what makes that safe.
v=$BWS_PIN
b=https://github.com/bitwarden/sdk-sm/releases/download/bws-v$v
say " fetching bws $v"
curl -fsSL "$b/bws-$t-$v.zip" -o /tmp/bws.zip 2>/dev/null \
|| { err "bws download failed"; 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 [ -n "$want" ] && [ "$want" != "$got" ]; then
err "bws checksum mismatch — refusing to install"
rm -f /tmp/bws.zip /tmp/bws.sums; 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
say " bws $v installed to ~/.local/bin"
}
run() {
if [ "$DRYRUN" -eq 1 ]; then printf ' + %s\n' "$*"; return 0; fi
printf '%s + %s%s\n' "$DIM" "$*" "$R" >&2
"$@"
}
run_sh() {
if [ "$DRYRUN" -eq 1 ]; then printf ' + %s\n' "$1"; return 0; fi
printf '%s + %s%s\n' "$DIM" "$1" "$R" >&2
sh -c "$1"
}
note_fail() { printf '%s\t%s\n' "$1" "$2" >> "$FAILED"; err "$1: $2"; }
apt_update_once() {
[ "$APT_UPDATED" -eq 0 ] || return 0
APT_UPDATED=1
run_sh "${SUDO:+$SUDO }apt-get update -qq" || warn "apt-get update failed; continuing with stale lists"
}
# apt refuses the whole batch when one name is unknown, so ask first. A name apt
# 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; }
install_apt() {
pkgs=$(norm "$1")
[ -n "$pkgs" ] || return 0
head_ "apt"
apt_update_once
keep=; moved=; unknown=
for p in $pkgs; do
if [ "$DRYRUN" -eq 1 ] || ! have apt-cache || apt_known "$p"; then keep="$keep $p"
else unknown="$unknown $p"; fi
done
for p in $unknown; do
k=$(awk -F'\t' -v p="$p" '!/^[#@]/ && NF>=3 && index(" "$4" "," "p" ") {print $1"/"$2; exit}' "$MANIFEST")
b=$(awk -F'\t' -v p="$p" '!/^[#@]/ && NF>=3 && index(" "$4" "," "p" ") {print $5; exit}' "$MANIFEST")
case $b in
-|-*) note_fail "${k:-$p}" "apt does not know '$p' and there is no brew fallback" ;;
*) warn "apt does not know '$p' — falling back to brew '$b'"; moved="$moved $b" ;;
esac
done
if [ -n "$keep" ]; then
# shellcheck disable=SC2086
if ! run_sh "${SUDO:+$SUDO }DEBIAN_FRONTEND=noninteractive apt-get install -y$(printf ' %s' $keep)"; then
warn "batch install failed; retrying one at a time so one bad package does not sink the rest"
for p in $keep; do
run_sh "${SUDO:+$SUDO }DEBIAN_FRONTEND=noninteractive apt-get install -y $p" \
|| note_fail "$p" "apt install failed"
done
fi
fi
BREW_EXTRA=$moved
}
install_brew() {
pkgs=$(norm "$1")
[ -n "$pkgs" ] || return 0
head_ "brew"
BREW=$(find_tool brew || echo brew)
if ! have brew && [ "$DRYRUN" -eq 0 ]; then
# Installing a second package manager is exactly the kind of thing the
# `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
return 0
fi
for p in $pkgs; do run_sh "$BREW install $p" || note_fail "$p" "brew install failed"; done
}
install_npm() {
specs=$(norm "$1")
[ -n "$specs" ] || return 0
head_ "npm"
NPM=$(find_tool npm || echo 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
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
}
}
install_uv() {
tools=$(norm "$1")
[ -n "$tools" ] || return 0
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
return 0
fi
for t in $tools; do run_sh "$UV tool install $t" || note_fail "$t" "uv tool install failed"; done
}
install_snap() {
names=$(norm "$1")
[ -n "$names" ] || return 0
head_ "snap"
if ! have snap && [ "$DRYRUN" -eq 0 ]; then
for n in $names; do note_fail "$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
}
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
return 0
fi
for i in $ids; do
run_sh "flatpak install -y --noninteractive flathub $i" || note_fail "$i" "flatpak install failed"
done
}
# Two forms, because two real packages need two different things:
# https://…/x.deb a stable vendor URL (chrome)
# gh:<owner>/<repo>:<asset substring> latest release asset (ghostty)
install_deb() {
srcs=$(norm "$1")
[ -n "$srcs" ] || return 0
head_ "deb"
for s in $srcs; do
url=$s
case $s in
gh:*)
spec=${s#gh:}; repo=${spec%%:*}; match=${spec#*:}
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; }
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"
run_sh "rm -f '$f'"
done
}
# Bespoke by nature: each of these needs arch detection, a destination outside
# $HOME, and a symlink. A data column cannot express that honestly, so the
# dispatch is on the key and the code says what it does.
install_bespoke() {
bsp=$(norm "$2")
[ -n "$bsp" ] || return 0
head_ "$1"
for key in $bsp; do
case $key in
core/neovim)
# The one place the channel is prescribed rather than "whatever the
# package manager has": 24.04's apt candidate is 0.9.5 and LazyVim
# needs 0.12 for the kitty graphics protocol.
if have nvim && [ "$DRYRUN" -eq 0 ]; then
v=$(nvim --version 2>/dev/null | awk 'NR==1{print $2}' | tr -d 'v')
case $v in 0.1[2-9]*|0.[2-9]*|[1-9]*) say " nvim $v already above 0.12 — leaving it"; continue ;; esac
fi
case $(uname -m) in
x86_64|amd64) a=x86_64 ;;
aarch64|arm64) a=arm64 ;;
*) note_fail "$key" "no neovim tarball for $(uname -m)"; continue ;;
esac
b=https://github.com/neovim/neovim/releases/latest/download
run_sh "curl -fsSL '$b/nvim-linux-$a.tar.gz' -o /tmp/nvim.tgz || curl -fsSL '$b/nvim-linux64.tar.gz' -o /tmp/nvim.tgz" \
|| { note_fail "$key" "tarball download failed"; continue; }
run_sh "${SUDO:+$SUDO }rm -rf /opt/nvim && ${SUDO:+$SUDO }mkdir -p /opt/nvim && ${SUDO:+$SUDO }tar -xzf /tmp/nvim.tgz -C /opt/nvim --strip-components=1" \
|| { 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/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
case $(uname -m) in x86_64|amd64) a=amd64 ;; aarch64|arm64) a=arm64 ;;
*) note_fail "$key" "no go tarball for $(uname -m)"; continue ;; esac
if [ "$DRYRUN" -eq 1 ]; then v='go1.X.Y'
else v=$(curl -fsSL 'https://go.dev/VERSION?m=text' 2>/dev/null | head -1); fi
[ -n "$v" ] || { note_fail "$key" "could not resolve the current go version"; continue; }
run_sh "curl -fsSL 'https://go.dev/dl/$v.$o-$a.tar.gz' -o /tmp/go.tgz" \
|| { note_fail "$key" "tarball download failed"; continue; }
run_sh "${SUDO:+$SUDO }rm -rf /usr/local/go && ${SUDO:+$SUDO }tar -xzf /tmp/go.tgz -C /usr/local" \
|| { note_fail "$key" "tarball extract failed"; continue; }
run_sh "rm -f /tmp/go.tgz" ;;
core/chezmoi)
# Circular by nature: dotup arrives *via* chezmoi. Present already
# in every case that matters; here for the one where it is not.
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/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" ;;
core/build-tools)
# macOS only — the Linux side is build-essential through apt.
if [ "$DRYRUN" -eq 0 ] && xcode-select -p >/dev/null 2>&1; then
say " Xcode command line tools already installed"; continue
fi
run_sh "xcode-select --install" || note_fail "$key" "xcode-select --install failed" ;;
core/zsh|networking/openssh-server)
say " $key: built into macOS, nothing to install" ;;
*) note_fail "$key" "no handler for channel '$1'" ;;
esac
done
}
cmd_install() {
: > "$FAILED"
tbl=$STATE/.plan.$$
plan_table > "$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.
# `invasive` is never installed by a run with nobody at the keyboard, even
# if a stale state file says otherwise — the boundary holds because of what
# this refuses, not because of what it was asked.
if [ "$UNATTENDED" -eq 1 ]; then
inv=$(awk -F'\t' -v selfile="$SEL" '
BEGIN{ while((getline l < selfile)>0) sel[l]=1 }
!/^[#@]/ && NF>=3 && $3=="invasive" && (($1"/"$2) in sel) {print $1"/"$2}' "$MANIFEST")
if [ -n "$inv" ]; then
warn "unattended: refusing invasive packages$(printf ' %s' $inv)"
for k in $inv; do
awk -F'\t' -v k="$k" '$3!=k' "$tbl" > "$tbl.f"; mv "$tbl.f" "$tbl"
done
fi
fi
col() { awk -F'\t' -v c="$1" '$1==c {print $2}' "$tbl" | sort -u | tr '\n' ' '; }
keys() { awk -F'\t' -v c="$1" '$1==c {print $3}' "$tbl" | sort -u | tr '\n' ' '; }
bad=$(awk -F'\t' '$1=="unavailable" || $1=="missing" {print $3}' "$tbl" | tr '\n' ' ')
[ -z "$bad" ] || warn "no source on this platform:$bad"
# 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).
BREW_EXTRA=
install_apt "$(col apt)"
install_brew "$(col brew) $BREW_EXTRA"
install_bespoke script "$(keys script)"
install_bespoke tarball "$(keys tarball)"
install_bespoke builtin "$(keys builtin)"
install_bespoke xcode "$(keys xcode)"
install_snap "$(col snap)"
install_deb "$(col deb)"
install_flatpak "$(col flatpak)"
install_npm "$(col npm)"
install_uv "$(col uv)"
rm -f "$tbl"
if [ -s "$FAILED" ]; then
head_ "did not install"
while IFS=" " read -r what why; do printf ' %s%-32s%s %s\n' "$RED" "$what" "$R" "$why" >&2; done < "$FAILED"
n=$(grep -c . "$FAILED")
rm -f "$FAILED"
printf '\n%s%d package(s) did not install.%s Everything else did.\n' "$YEL" "$n" "$R" >&2
return 1
fi
rm -f "$FAILED"
head_ "done"
return 0
}
# ----------------------------------------------------------------- private ---
# The tick does not do the work, it schedules it. git, chezmoi and bws have to
# exist before either row can act, and a password typed at picker time would sit
# in memory for the ten minutes of package downloads in between. So it happens
# here, immediately before it is used, and never at all without a human.
PRIV_SRC=${DOTUP_PRIVATE_SRC:-${XDG_DATA_HOME:-$HOME/.local/share}/dotfiles-private}
BWS_TOKEN=${DOTUP_BWS_TOKEN:-${XDG_CONFIG_HOME:-$HOME/.config}/bitwarden/bws-token}
# The private tier gets its OWN config file, and this is load-bearing.
#
# chezmoi renders .chezmoi.toml.tmpl to the config path, and without -c that is
# ~/.config/chezmoi/chezmoi.toml for BOTH tiers. The private template asks seven
# [data] questions once (promptStringOnce) -- name, email, signing key, four
# gitea addresses. Re-running the PUBLIC installer afterwards rewrites that same
# file, and since the public tier's rendered config carries no [data] block, the
# seven answers are simply gone.
#
# The failure is silent, which is what makes it worth a separate file rather
# than a warning: the private templates degrade politely when their data is
# missing -- config.local emits a comment telling you to re-run init instead of
# failing the apply -- so the first symptom is `git commit` not knowing who you
# are, days later, with nothing connecting it to the install you ran.
PRIV_CFG=${DOTUP_PRIVATE_CFG:-${XDG_CONFIG_HOME:-$HOME/.config}/chezmoi/private.toml}
# git's credential store for the private remote.
#
# The clone URL the endpoint hands back carries the token inline, and handing
# that to `chezmoi init` puts a live credential in two durable places: the
# init process's argv, which ANY account on the box can read out of
# /proc/<pid>/cmdline for as long as the clone runs, and the resulting
# .git/config, which keeps it until the tree is deleted. Neither is fixed by
# not printing it -- the earlier redaction work only covered the trace line.
#
# Splitting the credential out of the URL fixes both: git reads the secret from
# this file at 600, and the remote it records is clean.
PRIV_CRED=${DOTUP_PRIVATE_CRED:-$STATE/private-credentials}
cmd_private() {
rows=$(selected_private)
[ -n "$rows" ] || return 0
# The §1 boundary is structural: it holds because there is nobody to type a
# password, not because of a policy this is obeying.
if [ "$UNATTENDED" -eq 1 ]; then
warn "unattended: the private tier needs a password nobody is here to type — skipped"
return 0
fi
[ "$DRYRUN" -eq 0 ] || { head_ "private"; printf ' + prompt for URL, username, password (interactive only)\n'; return 0; }
[ -t 0 ] || { warn "no terminal: the private tier needs a password — skipped"; return 0; }
want_repo=0; want_bws=0
case $rows in *private/private-repo*) want_repo=1 ;; esac
case $rows in *private/bws-secrets*) want_bws=1 ;; esac
# A satisfied row is silent. You only see the prompt for something absent.
[ ! -d "$PRIV_SRC" ] || { say " private repo already present at $PRIV_SRC"; want_repo=0; }
[ ! -r "$BWS_TOKEN" ] || { say " bws token already present"; want_bws=0; }
[ "$want_repo" -eq 1 ] || [ "$want_bws" -eq 1 ] || return 0
head_ "private tier"
say " The address is in no repository. Leave it blank to stay public-only."
# Read into variables: nothing reaches argv, so nothing reaches `ps`.
P_URL=''; P_USER=''; P_PW=''
printf ' Bootstrap URL: ' >&2; IFS= read -r P_URL || :
[ -n "$P_URL" ] || { say " public-only machine. Nothing was asked for."; return 0; }
# Accept the directory OR the full file URL, because both are in circulation:
# the rotation scripts print the file form and say to store THAT in Bitwarden,
# so pasting what you saved is the likely case. Without this, the request
# becomes .../bootstrap.env/bootstrap.env, the 404 trips curl --fail, and the
# error below blames the credentials for what is actually a URL shape.
P_URL=${P_URL%/}
P_URL=${P_URL%/bootstrap.env}
printf ' Username: ' >&2; IFS= read -r P_USER || :
printf ' Password: ' >&2
stty -echo 2>/dev/null || :; IFS= read -r P_PW || :; stty echo 2>/dev/null || :; printf '\n' >&2
# shellcheck disable=SC2064
trap 'unset P_URL P_USER P_PW P_BLOB p_repo p_tok p_cred 2>/dev/null || :' EXIT INT TERM
# curl -K - reads its config, credentials included, from stdin rather than
# the command line. --fail matters too: without it a 401 body is parsed as
# if it were the blob.
P_BLOB=$(printf 'user = "%s:%s"\nsilent\nfail\n' "$P_USER" "$P_PW" \
| curl -K - "${P_URL%/}/bootstrap.env" 2>/dev/null) || {
err "endpoint refused the credentials. The machine stays public-only."
unset P_PW; return 1; }
unset P_PW # spent. only the fetched credentials exist now.
# Contract with the endpoint (phase 4 writes the file this parses):
# two KEY=VALUE lines, no quoting, no shell
# PRIVATE_REPO_URL=https://<user>:<read-only-token>@host/path/dotfiles-private.git
# BWS_ACCESS_TOKEN=<machine account token, scoped to one project>
p_repo=$(printf '%s\n' "$P_BLOB" | sed -n 's/^PRIVATE_REPO_URL=//p' | head -1)
p_tok=$(printf '%s\n' "$P_BLOB" | sed -n 's/^BWS_ACCESS_TOKEN=//p' | head -1)
unset P_BLOB
if [ "$want_bws" -eq 1 ]; then
if [ -n "$p_tok" ]; then
mkdir -p "$(dirname "$BWS_TOKEN")"
( umask 077; printf '%s\n' "$p_tok" > "$BWS_TOKEN" )
chmod 600 "$BWS_TOKEN"
say " bws token written, mode 600"
ensure_bws
else err "the blob carried no BWS_ACCESS_TOKEN"; fi
fi
unset p_tok
if [ "$want_repo" -eq 1 ]; then
if [ -n "$p_repo" ]; then
# Split the credential out of the URL before anything executes.
# p_cred is the bare scheme://user:token@host that
# git-credential-store wants -- host only, no path. p_clean is the
# same URL with the userinfo removed. A remote with no userinfo
# (ssh, or an unauthenticated https URL) leaves p_cred empty and
# p_clean identical to what arrived, so it is used unchanged.
p_cred=$(printf '%s\n' "$p_repo" | sed -n 's|^\(https\{0,1\}://[^@/]*@[^/]*\).*|\1|p')
p_clean=$(printf '%s\n' "$p_repo" | sed -e 's|^\(https\{0,1\}://\)[^@/]*@|\1|')
if [ -n "$p_cred" ]; then
mkdir -p "$(dirname "$PRIV_CRED")"
( umask 077; printf '%s\n' "$p_cred" > "$PRIV_CRED" )
chmod 600 "$PRIV_CRED"
# GIT_CONFIG_* rather than `git -c`: the helper string names
# the credential file, and while that path is not itself a
# secret, argv is the wrong channel for anything about it.
# The value is single-quoted because git runs a helper
# containing spaces through a shell -- an unquoted $HOME with
# a space in it would split into two arguments.
GIT_CONFIG_COUNT=1
GIT_CONFIG_KEY_0=credential.helper
GIT_CONFIG_VALUE_0="store --file='$PRIV_CRED'"
export GIT_CONFIG_COUNT GIT_CONFIG_KEY_0 GIT_CONFIG_VALUE_0
fi
# NOT `run`. It echoes its whole argv to stderr, and even a clean
# URL is worth tracing deliberately rather than by accident.
# redact_url stays on the trace as a second line of defence: if
# the split above ever fails to match, the credential still does
# not reach terminal scrollback, `dotup 2>log`, or an agent
# transcript capturing the run.
mkdir -p "$(dirname "$PRIV_CFG")"
if [ "$DRYRUN" -eq 1 ]; then
printf ' + chezmoi init --apply --source %s -c %s %s\n' \
"$PRIV_SRC" "$PRIV_CFG" "$(redact_url "$p_clean")"
else
printf '%s + chezmoi init --apply --source %s -c %s %s%s\n' \
"$DIM" "$PRIV_SRC" "$PRIV_CFG" "$(redact_url "$p_clean")" "$R" >&2
chezmoi init --apply --source "$PRIV_SRC" -c "$PRIV_CFG" "$p_clean" \
|| err "private repo init failed"
fi
# The exported vars above only cover this process. Record the same
# helper in the clone's own config so a later `chezmoi update`
# still authenticates -- the token stays in PRIV_CRED, and
# .git/config learns only where to look for it.
if [ -n "$p_cred" ] && [ -d "$PRIV_SRC/.git" ]; then
git -C "$PRIV_SRC" config credential.helper \
"store --file='$PRIV_CRED'" 2>/dev/null || :
fi
unset GIT_CONFIG_COUNT GIT_CONFIG_KEY_0 GIT_CONFIG_VALUE_0
# The clone lands with the caller's umask, which on a stock Ubuntu
# is 022 -- world-readable. This tree holds ssh config, accepted
# keys and machine identity. On a shared or multi-user box that is
# readable by anyone with an account.
[ ! -d "$PRIV_SRC" ] || chmod -R go-rwx "$PRIV_SRC" 2>/dev/null || :
else err "the blob carried no PRIVATE_REPO_URL"; fi
fi
unset p_repo p_cred p_clean
trap - EXIT INT TERM
}
# --------------------------------------------------------------- preflight ---
# fzf cannot come from the manifest: the picker needs it to draw the list that
# installs it. Same bootstrap problem chezmoi has. So it is fetched here, before
# any UI exists.
#
# FLOOR is exactly where verification stops, not a guess. Every release from
# 0.29 up parses every option and binding the picker uses, but cursor-on-reload
# — the property that makes ticking a list bearable — can only be measured from
# 0.44.1, where fzf's --listen API began reporting state. Below that it is
# unverifiable rather than known-broken, so we decline to rely on it.
#
# The binary is dotup's own, not yours. It lands in a cache directory and is
# invoked by absolute path — PATH is never touched. Dropping a newer fzf into
# ~/.local/bin would shadow the distro's copy for Ctrl-R, the oh-my-zsh plugin
# and every other script, which is precisely the kind of silent change the
# `invasive` flag exists to refuse. Not the repo either: a vendored binary means
# either megabytes in git or a .gitignore rule, and the public repo stays clean.
FZF_FLOOR=0.44.0
FZF_PIN=0.74.2
FZF_CACHE=${XDG_CACHE_HOME:-$HOME/.cache}/dotup
FZF=
# $1 >= $2, dotted numeric. Not `sort -V`: BSD sort on older macOS lacks it.
ver_ge() {
awk -v a="$1" -v b="$2" 'BEGIN{
na=split(a,A,"."); nb=split(b,B,".")
for(i=1;i<=3;i++){ x=(i<=na)?A[i]+0:0; y=(i<=nb)?B[i]+0:0
if(x>y) exit 0; if(x<y) exit 1 }
exit 0 }'
}
fzf_version() { fzf --version 2>/dev/null | awk '{print $1}'; }
install_fzf() {
case $(uname -s) in Darwin) os=darwin ;; *) os=linux ;; esac
case $(uname -m) in
x86_64|amd64) arch=amd64 ;;
aarch64|arm64) arch=arm64 ;;
armv7l) arch=armv7 ;;
*) echo "dotup: unsupported arch $(uname -m); install fzf yourself" >&2; return 1 ;;
esac
# The release tag gained a leading v between 0.53.0 and 0.55.0. Anything we
# would pin today is above that; the fallback keeps an older pin working.
base=https://github.com/junegunn/fzf/releases/download
mkdir -p "$FZF_CACHE"
tmp=$FZF_CACHE/.fzf.$$
echo "dotup: fetching fzf $FZF_PIN ($os/$arch) for its own use" >&2
if curl -sfL "$base/v$FZF_PIN/fzf-$FZF_PIN-${os}_${arch}.tar.gz" | tar xz -O fzf > "$tmp" 2>/dev/null && [ -s "$tmp" ]; then :
elif curl -sfL "$base/$FZF_PIN/fzf-$FZF_PIN-${os}_${arch}.tar.gz" | tar xz -O fzf > "$tmp" 2>/dev/null && [ -s "$tmp" ]; then :
else rm -f "$tmp"; echo "dotup: could not fetch fzf $FZF_PIN" >&2; return 1; fi
chmod +x "$tmp" && mv "$tmp" "$FZF_CACHE/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
if command -v fzf >/dev/null 2>&1; then
cur=$(fzf_version)
if [ -n "$cur" ] && ver_ge "$cur" "$FZF_FLOOR"; then
FZF=$(command -v fzf); return 0
fi
echo "dotup: system fzf $cur is below the verified floor $FZF_FLOOR" >&2
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
# --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.
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' \
--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-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
}
confirm() {
[ "$ASSUME_YES" -eq 0 ] || return 0
[ -t 0 ] || return 0
printf '\n install? [y/N] ' >&2
a=''; IFS= read -r a || a=''
case $a in y|Y|yes|YES) return 0 ;; *) say " nothing installed."; return 1 ;; esac
}
cmd_run() {
if [ "$UNATTENDED" -eq 1 ]; then
# Deterministic by construction: computed fresh from the manifest, never
# inherited from whatever a previous interactive run left in the state
# file. "What a VM or CI run gets" has to mean the same thing twice.
cmd_preset defaults
cmd_plan
else
cmd_pick || return $?
cmd_plan
confirm || return 0
fi
rc=0
cmd_install || rc=$?
cmd_private || :
return $rc
}
# ------------------------------------------------------------------ usage ----
usage() {
cat >&2 <<-EOF
usage: dotup [--unattended] [--print] [--yes]
(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
--yes, -y skip the confirmation after the picker
plumbing, called by the fzf bindings:
render toggle expand expand-all preset explain plan preflight
EOF
}
CMD=
while [ $# -gt 0 ]; do
case $1 in
--unattended) UNATTENDED=1; ASSUME_YES=1 ;;
--print|-n) DRYRUN=1 ;;
--yes|-y) ASSUME_YES=1 ;;
-h|--help) usage; exit 0 ;;
--*) err "unknown flag: $1"; usage; exit 2 ;;
*) CMD=$1; shift; break ;;
esac
shift
done
case ${CMD:-run} in
run) cmd_run ;;
pick) cmd_pick && cmd_plan ;;
install) cmd_install ;;
private) cmd_private ;;
render) cmd_render ;;
toggle) cmd_toggle "$@" ;;
expand) cmd_expand "$@" ;;
expand-all) cmd_expand_all ;;
preset) cmd_preset "${1:-defaults}" ;;
explain) cmd_explain "${1:-}" ;;
plan) cmd_plan ;;
resolve) resolve "${1:-}" ;; # test hook
preflight) ensure_fzf && echo "using $FZF ($("$FZF" --version | awk '{print $1}'), floor $FZF_FLOOR)" ;;
fzf-path) ensure_fzf >/dev/null 2>&1 && echo "$FZF" ;; # test hook
vercmp) ver_ge "$1" "$2" ;; # test hook
*) usage; exit 2 ;;
esac