#!/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 [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 } # @groupnote — 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: # 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 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#://[^/@]*@#://@#'; } # 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 # sdk-sm is a monorepo with per-component tags, so the `latest` release is # usually a python SDK rather than bws. Filter by tag prefix. v=$(curl -fsSL 'https://api.github.com/repos/bitwarden/sdk-sm/releases?per_page=40' 2>/dev/null \ | sed -n 's/.*"tag_name": *"bws-v\([0-9.]*\)".*/\1/p' | head -1) [ -n "$v" ] || { err "could not resolve the current bws version"; return 1; } 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:/: 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/" 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} 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; } 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 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://:@host/path/dotfiles-private.git # BWS_ACCESS_TOKEN= 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 # NOT `run`. It echoes its whole argv to stderr, and this argv ends # in https://user:TOKEN@host/... -- which would put a live git # credential into terminal scrollback, any `dotup 2>log`, and any # agent or CI transcript capturing the run. Trace a redacted form # and execute quietly. 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_repo")" else printf '%s + chezmoi init --apply --source %s -c %s %s%s\n' \ "$DIM" "$PRIV_SRC" "$PRIV_CFG" "$(redact_url "$p_repo")" "$R" >&2 chezmoi init --apply --source "$PRIV_SRC" -c "$PRIV_CFG" "$p_repo" \ || err "private repo init failed" fi # 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, and its .git/config stores the # credential-bearing remote URL in plain text. 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 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/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