#!/bin/bash # What a real user actually gets. # # The picker's ^a is `preset defaults`, and Enter is `install`. This scenario is # that keystroke pair with nobody at the keyboard: tick the defaults, resolve # every ticked row through dotup's own `resolve`, run dotup's own installer, and # then ask the machine -- not the log -- whether each package is there. # # The log is not evidence. `apt-get install -y` prints a great deal and still # leaves you without the package; a batch that fails silently retries one at a # time and the second failure scrolls past the first. So every row is verified # against the filesystem or the package database afterwards, and anything that # did not land is RE-RUN ON ITS OWN through dotup, which does three things at # once: it gets the exact command dotup would use, the exact exit code, and the # exact first line of the error, with no other package's output interleaved. # # The re-run also settles the question a single pass cannot: a package that # fails in the batch and installs on its own was a flake or a batch fault, not # an unavailable package. Those come back as FLAKE-RECOVERED and are reported # separately, so a transient 503 from a mirror is never filed as a bug. # # Output: a tab-separated table on stdout, one row per selected package. # STATUS KEY CHANNEL ARG ISORC ERROR set -u fail() { echo "FAIL: $*"; exit 1; } ok() { echo " ok $*"; } STATE=$HOME/.config/dotfiles SEL=$STATE/selected DOTUP=$HOME/.local/bin/dotup LOG=/tmp/dotup-lab mkdir -p "$LOG" # ------------------------------------------------------------------ preamble - echo "-- preamble --" # ISSUE-1: the documented one-liner used to die on a stock box, because chezmoi's # six git-repo externals need git and the image has none. Try it UNAIDED first -- # if the repo now supplies git itself, installing it here would hide that and # hide any future regression. Only fall back to the hand-install, loudly, if the # documented command still cannot stand on its own. if sh -c "$(curl -fsLS get.chezmoi.io)" -- init --apply "$PUB_URL" >"$LOG/init.log" 2>&1; then if grep -q 'installing git' "$LOG/init.log"; then ok "ISSUE-1 fixed upstream: the repo installed git itself" else ok "chezmoi init --apply succeeded (git was already satisfied)" fi else echo " NOTE ISSUE-1 still open: init --apply failed on a box with no git" sudo apt-get update -qq >/dev/null 2>&1 sudo apt-get install -y -qq git >/dev/null 2>&1 || fail "could not install git" echo " NOTE installed git by hand and retried -- see ISSUE-1" sh -c "$(curl -fsLS get.chezmoi.io)" -- init --apply "$PUB_URL" >"$LOG/init.log" 2>&1 \ || { tail -20 "$LOG/init.log"; fail "chezmoi init --apply"; } fi [ -x "$DOTUP" ] || fail "no dotup at $DOTUP" ok "public tier applied; dotup at $DOTUP" # The tree under test moves while this suite is being written, so pin which # build produced the table below. Two runs that disagree are only interesting # if they ran the same code. echo " dotup sha256 $(sha256sum "$DOTUP" | cut -c1-16) manifest sha256 $(sha256sum "$HOME/.local/share/dotup/packages.tsv" | cut -c1-16)" # A user on a desktop has a display, and `preset defaults` ticks the gui rows # only when one is present -- so ^a on a headless box selects a DIFFERENT set. # Say which of the two we are testing instead of inheriting whatever the # container happens to be. export DISPLAY=:99 # --------------------------------------------------------------- selection -- echo "-- selection: ^a (preset defaults) --" "$DOTUP" preset defaults || fail "preset defaults" nsel=$(grep -c . "$SEL") nsafe=$(awk -F'\t' '!/^[#@]/ && NF>=3 && $3=="safe"' "$HOME/.local/share/dotup/packages.tsv" | wc -l) ngui=$(awk -F'\t' '!/^[#@]/ && NF>=3 && $3=="gui"' "$HOME/.local/share/dotup/packages.tsv" | wc -l) echo " manifest: safe=$nsafe gui=$ngui -> expected $((nsafe + ngui))" echo " selected: $nsel" [ "$nsel" -eq $((nsafe + ngui)) ] || fail "defaults preset did not tick safe+gui" ok "$nsel packages ticked" # -------------------------------------------------------------- resolution -- # dotup's own resolver, one key at a time, so the table below is what dotup # decided rather than what this scenario guessed. echo "-- resolution (dotup resolve) --" : > "$LOG/resolved.tsv" while read -r key; do [ -n "$key" ] || continue line=$("$DOTUP" resolve "$key" 2>/dev/null) IFS=$'\t' read -r ch arg <<<"$line" [ -n "${arg:-}" ] || arg="-" printf '%s\t%s\t%s\n' "$key" "${ch:-?}" "$arg" >> "$LOG/resolved.tsv" done < "$SEL" echo " channel spread:" awk -F'\t' '{c[$2]++} END{for(k in c) printf " %-12s %d\n", k, c[k]}' "$LOG/resolved.tsv" | sort "$DOTUP" plan >"$LOG/plan.log" 2>&1 || : "$DOTUP" --print --yes install >"$LOG/print.log" 2>&1 || : ok "plan and dry-run captured" # ----------------------------------------------------------------- install -- echo "-- install: dotup --yes install (this takes a while) --" t0=$(date +%s) "$DOTUP" --yes install >"$LOG/install.log" 2>&1 irc=$? t1=$(date +%s) echo " exit $irc after $((t1 - t0))s, $(wc -l <"$LOG/install.log") lines of log" echo "-- what dotup itself said did not install --" sed -n '/did not install/,$p' "$LOG/install.log" | sed 's/\x1b\[[0-9;]*m//g' | sed 's/^/ | /' # ------------------------------------------------------------ verification -- # Ask the machine, not the log. UV=$(command -v uv 2>/dev/null || echo "$HOME/.local/bin/uv") dpkg_ok() { [ "$(dpkg-query -W -f='${db:Status-Status}' "$1" 2>/dev/null)" = installed ]; } # The same search path dotup's own find_tool uses, and for the same reason: a # tool installed a moment ago is not on this process's PATH. Checking only PATH # and ~/.local/bin reported chezmoi missing when get.chezmoi.io had put it in # ~/bin -- a false failure against dotup for a fault in the check. have_bin() { local c command -v "$1" >/dev/null 2>&1 && return 0 for c in "$HOME/.local/bin/$1" "$HOME/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 "$c" ] && return 0 done return 1 } npm_ok() { local root root=$(npm root -g 2>/dev/null) || return 1 [ -n "$root" ] && [ -e "$root/$1" ] } verify() { local key=$1 ch=$2 arg=$3 n # The bespoke channels land somewhere only their handler knows about. case $key in core/neovim) [ -x /opt/nvim/bin/nvim ]; return $? ;; core/go) [ -x /usr/local/go/bin/go ]; return $? ;; core/chezmoi) have_bin chezmoi; return $? ;; core/uv) have_bin uv; return $? ;; apps/chrome) dpkg_ok google-chrome-stable; return $? ;; apps/ghostty) dpkg_ok ghostty; return $? ;; esac case $ch in apt) for n in $arg; do dpkg_ok "$n" || return 1; done; return 0 ;; brew) command -v brew >/dev/null 2>&1 || return 1 brew list --formula "$arg" >/dev/null 2>&1; return $? ;; npm) for n in $arg; do npm_ok "$n" || return 1; done; return 0 ;; uv) [ -x "$UV" ] || return 1 "$UV" tool list 2>/dev/null | grep -q "^$arg "; return $? ;; # Both of these are bounded on purpose. `snap list` does not fail fast when # snapd is installed but not running -- it blocks trying to reach a daemon # systemd never started -- and a verification step that can hang forever is # not a verification step. snap) command -v snap >/dev/null 2>&1 || return 1 timeout 20 snap list "$arg" >/dev/null 2>&1; return $? ;; flatpak) command -v flatpak >/dev/null 2>&1 || return 1 timeout 20 flatpak --user info "$arg" >/dev/null 2>&1 && return 0 timeout 20 flatpak info "$arg" >/dev/null 2>&1; return $? ;; *) return 1 ;; esac } # Re-run ONE key through dotup, with its own state directory so the selection is # exactly that key. Everything -- resolution, channel dispatch, the command # string, sudo -- is dotup's, so what comes back is dotup's behaviour for that # package in isolation and not this scenario's reconstruction of it. isolate() { local key=$1 d rc d=$(mktemp -d) printf '%s\n' "$key" >"$d/selected" : >"$d/expanded" DOTUP_STATE=$d "$DOTUP" --yes install >"$LOG/iso.$(printf '%s' "$key" | tr / _).log" 2>&1 rc=$? rm -rf "$d" return $rc } isolog() { printf '%s/iso.%s.log' "$LOG" "$(printf '%s' "$1" | tr / _)"; } # The first line that looks like a diagnosis, falling back to the last thing # said. apt says "E:", npm says "npm error", brew and snap just say it. firstline() { local f=$1 l l=$(sed 's/\x1b\[[0-9;]*m//g' "$f" 2>/dev/null \ | grep -m1 -aE 'E: |error|Error|ERROR|Unable to locate|not found|No such|Permission denied|refus|missing|unavailable|failed|Failed' ) [ -n "$l" ] || l=$(sed 's/\x1b\[[0-9;]*m//g' "$f" 2>/dev/null | grep -a . | tail -1) printf '%s' "$l" | sed 's/^[[:space:]+]*//' | cut -c1-150 } echo echo "-- verification and per-package isolation --" : > "$LOG/table.tsv" while IFS=$'\t' read -r key ch arg; do if verify "$key" "$ch" "$arg"; then printf 'OK\t%s\t%s\t%s\t-\t-\n' "$key" "$ch" "$arg" >> "$LOG/table.tsv" continue fi isolate "$key"; rc=$? if verify "$key" "$ch" "$arg"; then printf 'FLAKE-RECOVERED\t%s\t%s\t%s\t%s\t%s\n' \ "$key" "$ch" "$arg" "$rc" "$(firstline "$(isolog "$key")")" >> "$LOG/table.tsv" else printf 'FAIL\t%s\t%s\t%s\t%s\t%s\n' \ "$key" "$ch" "$arg" "$rc" "$(firstline "$(isolog "$key")")" >> "$LOG/table.tsv" fi done < "$LOG/resolved.tsv" # --------------------------------------------------------------- the table -- echo echo "===== RESULT TABLE =====" printf 'STATUS\tKEY\tCHANNEL\tARG\tISORC\tERROR\n' sort -k1,1r -k2,2 "$LOG/table.tsv" echo "===== END RESULT TABLE =====" echo echo "===== COUNTS =====" awk -F'\t' '{s[$1]++; if($1!="OK") c[$3]++} END{ for (k in s) printf "%-16s %d\n", k, s[k] printf "\nfailures by channel:\n" for (k in c) printf " %-10s %d\n", k, c[k] }' "$LOG/table.tsv" printf 'total%12s %d\n' "" "$(grep -c . "$LOG/table.tsv")" echo "===== END COUNTS =====" echo echo "===== RUNTIME SMOKE =====" # Installed is not the same as usable, and the difference is not visible in any # install log. apt's nodejs on 24.04 is 18.19.1; every npm row in this manifest # declares node>=20. npm 9 only WARNS about a failed engine check, so the # install exits 0 and leaves a tool that cannot start. A table that stopped at # "the files are on disk" would score that as a success. smoke() { local b=$1 p="" c out rc first shift p=$(command -v "$b" 2>/dev/null) || p="" if [ -z "$p" ]; then for c in "$HOME/.local/bin/$b" "$HOME/bin/$b" "$HOME/.npm-global/bin/$b" \ "/usr/local/bin/$b" "/usr/local/go/bin/$b"; do [ -x "$c" ] && { p=$c; break; } done fi [ -n "$p" ] || { printf 'ABSENT\t%s\t-\t-\n' "$b"; return; } out=$("$p" "$@" 2>&1); rc=$? first=$(printf '%s' "$out" | sed 's/\x1b\[[0-9;]*m//g' | grep -a . | head -1 | cut -c1-110) if [ "$rc" -eq 0 ]; then printf 'RUNS\t%s\t%s\t%s\n' "$b" "$rc" "$first" else printf 'BROKEN\t%s\t%s\t%s\n' "$b" "$rc" "$first"; fi } printf 'STATUS\tBINARY\tRC\tFIRSTLINE\n' smoke node --version smoke npm --version smoke nvim --version smoke go version smoke uv --version smoke chezmoi --version smoke rg --version smoke fdfind --version smoke batcat --version smoke eza --version smoke gh --version smoke mmdc --version smoke codex --version smoke pi --version smoke specify --help smoke btop --version smoke lazygit --version smoke bw --version echo "===== END RUNTIME SMOKE =====" echo echo "===== ISOLATION DETAIL (non-OK rows) =====" awk -F'\t' '$1!="OK"{print $2}' "$LOG/table.tsv" | while read -r key; do echo "--- $key ---" sed 's/\x1b\[[0-9;]*m//g' "$(isolog "$key")" 2>/dev/null | grep -a . | tail -14 | sed 's/^/ /' done echo "===== END ISOLATION DETAIL =====" echo echo "===== DOTUP PLAN =====" sed 's/\x1b\[[0-9;]*m//g' "$LOG/plan.log" | sed 's/^/ /' echo "===== END DOTUP PLAN =====" echo echo "===== DRY RUN (dotup --print install) =====" sed 's/\x1b\[[0-9;]*m//g' "$LOG/print.log" | sed 's/^/ /' echo "===== END DRY RUN =====" # A dry run reports what WOULD happen. It must never invent a failure that # exists only because nothing ran. core/brew probed `have brew` after an # install step that --print had merely printed, so a perfectly clean plan # reported "brew still not found after installing it". # # This assertion has to live in a container. On a developer box `find_tool` # probes /home/linuxbrew/.linuxbrew/bin by ABSOLUTE path and finds a real brew, # so `have brew` succeeds and the bug cannot reproduce -- the same assertion in # the fast suite passed with the guard deliberately removed. invented=$(sed 's/\x1b\[[0-9;]*m//g' "$LOG/print.log" \ | sed -n '/did not install/,$p' | grep -E '^[[:space:]]+[a-z]+/[a-z]' || true) if [ -n "$invented" ]; then printf '%s\n' "$invented" | sed 's/^/ | /' fail "the dry run reported failures for packages it never tried to install" fi ok "a dry run invents no failures" nfail=$(awk -F'\t' '$1=="FAIL"' "$LOG/table.tsv" | wc -l) echo echo "MANIFEST-INSTALL: $nsel selected, $nfail did not install" exit 0