4043787a58
- DU-H1: flags are parsed wherever they sit, so `install --unattended` and `--unattended install` are the same run; any unknown flag, word, or subcommand exits 2 to stderr before a package manager is touched. - DU-H2: every download lands in one private mktemp -d (mode 700) workdir per run, is checked non-empty before sudo tar sees it, and an EXIT/INT/TERM trap cleans up. No fixed /tmp paths remain. - BUG-1: ^t is now toggle-shown — it ticks only the rows the active filter is showing, and @needs expansion stops at the first invasive row, so an invasive package can never be ticked off-screen. - BUG-2: ^t journals what it added, so a second ^t over the same shown set unticks exactly that set; the bind no longer clears the query. - lab: the type verb polls fzf's reported query to a deadline instead of a fixed sleep; marks_settled retries within its deadline. Suite 256/0 host, 214/0 docker (ubuntu:24.04), mutations 24/24 killed (six new mutants re-introduce each bug and all die), lab 6/6 green.
1526 lines
70 KiB
Bash
Executable File
1526 lines
70 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
|
|
# Written when the picker is ACCEPTED, never merely opened. Its presence is the
|
|
# difference between "chose nothing" and "has not chosen yet".
|
|
PICKED=$STATE/picked
|
|
# The journal of the last ^t: the rows it was given, then '=', then the keys it
|
|
# actually added. One keystroke's worth, not state -- any other keystroke moves
|
|
# the shown set out from under it, the next ^t sees the mismatch and falls back
|
|
# to the ordinary toggle rule. See cmd_toggle_shown.
|
|
TICK=$STATE/ticked
|
|
|
|
# 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: package keys -> stdout: the ones the manifest does NOT flag invasive.
|
|
drop_invasive() {
|
|
awk -F'\t' 'NR==FNR { if (!/^[#@]/ && NF>=3 && $3=="invasive") inv[$1"/"$2]=1; next }
|
|
NF && !($0 in inv)' "$MANIFEST" -
|
|
}
|
|
|
|
# stdin: keys -> stdout: those keys plus everything they need, transitively.
|
|
#
|
|
# `expand_deps invasive-stop` stops the walk AT an invasive dependency instead
|
|
# of walking through it: neither that row nor anything reachable only behind it
|
|
# is pulled in. ^t is the only caller -- see cmd_toggle_shown.
|
|
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)
|
|
[ "${1:-}" != invasive-stop ] || add=$(printf '%s\n' "$add" | drop_invasive)
|
|
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"` truncated the real file before sort produced a
|
|
# single byte, so a SIGTERM or a full disk in that window lost the whole
|
|
# selection. cmd_expand twelve lines down already writes tmp-then-rename;
|
|
# this now matches it.
|
|
sort -u "$tmp" > "$tmp.s" && mv "$tmp.s" "$SEL" && rm -f "$tmp"
|
|
}
|
|
|
|
# ------------------------------------------------------ toggle every shown ---
|
|
# ^t, and only ^t. Deliberately NOT cmd_toggle over the visible rows; the two
|
|
# differences are both safety properties rather than taste.
|
|
#
|
|
# 1. A bulk keystroke must not tick a row you cannot see. cmd_toggle widens
|
|
# along @needs, and over a filter those edges reach off screen: `nvidia`
|
|
# shows three gpu rows, gpu/container-toolkit @needs docker, and one ^t used
|
|
# to switch on three invasive daemon packages that were never drawn. So the
|
|
# forward walk here STOPS at an invasive dependency. A row that is ON SCREEN
|
|
# may still be invasive and is still ticked -- you are looking at it, and
|
|
# that is the whole difference. An invasive need left unticked is not lost:
|
|
# `explain` and the plan already report an unmet @needs.
|
|
# 2. ^t is its own undo. The keys it actually moved are journalled in $TICK, so
|
|
# a second press over the same rows takes back exactly those and nothing
|
|
# else. The reverse @needs closure cannot do that job -- nothing needs the
|
|
# gpu rows, so it would never let go of what the forward press pulled in.
|
|
# Any other keystroke changes the shown set, the journal stops matching, and
|
|
# ^t falls back to cmd_toggle's plain all-on/all-off rule.
|
|
cmd_toggle_shown() {
|
|
shown=$(leaves "$@" | sort -u)
|
|
[ -n "$shown" ] || return 0
|
|
all_on=1
|
|
for k in $shown; do grep -qxF "$k" "$SEL" || { all_on=0; break; }; done
|
|
tmp=$STATE/.sel.$$
|
|
if [ "$all_on" -eq 1 ]; then
|
|
if [ -f "$TICK" ] && [ "$shown" = "$(sed '/^=$/,$d' "$TICK")" ]; then
|
|
go=$(sed '1,/^=$/d' "$TICK")
|
|
else
|
|
go=$(printf '%s\n' "$shown" | expand_rdeps)
|
|
fi
|
|
rm -f "$TICK"
|
|
# An empty journal means the forward press added nothing, so there is
|
|
# nothing to hand back. It must never reach the grep below: an empty
|
|
# pattern list matches every line, and -v would erase the selection.
|
|
[ -n "$go" ] || return 0
|
|
grep -vxF -f - "$SEL" > "$tmp" <<-EOF || :
|
|
$go
|
|
EOF
|
|
sort -u "$tmp" > "$tmp.s"
|
|
else
|
|
go=$(printf '%s\n' "$shown" | expand_deps invasive-stop)
|
|
add=$(printf '%s\n' "$go" | grep -vxF -f "$SEL" || :)
|
|
{ cat "$SEL"; printf '%s\n' "$go"; } | grep . | sort -u > "$tmp.s" || :
|
|
{ printf '%s\n=\n' "$shown"; [ -z "$add" ] || printf '%s\n' "$add"; } > "$TICK"
|
|
fi
|
|
mv "$tmp.s" "$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
|
|
|
|
# One private directory per run, for everything this script downloads.
|
|
#
|
|
# The three tarball handlers wrote /tmp/nvim.tgz, /tmp/node.tgz and /tmp/go.tgz
|
|
# -- fixed names in a world-writable directory -- and then unpacked them with
|
|
# `sudo tar`. Anyone with an account on the box could pre-create those names as
|
|
# symlinks, or swap the file in the window between the download and the extract,
|
|
# and have tar write their content anywhere as root. The deb channel's
|
|
# ${TMPDIR:-/tmp}/dotup-$$.deb was only slightly better: a pid is a small number
|
|
# and it is reused.
|
|
#
|
|
# mktemp -d is 700 by definition; the chmod says so out loud rather than trusting
|
|
# every mktemp on every platform to agree. Created on first use, so a run that
|
|
# downloads nothing leaves nothing behind, and removed on the way out either way.
|
|
# Sets $WORKDIR rather than printing it, and the callers read the variable. A
|
|
# `wd=$(workdir)` would run the whole thing in a SUBSHELL: every caller would
|
|
# get a directory of its own, the parent's $WORKDIR would stay empty, and the
|
|
# trap below would have nothing to remove. Written that way first, and the
|
|
# suite's "does not outlive the run" assertion is what said so.
|
|
WORKDIR=
|
|
workdir() {
|
|
[ -z "$WORKDIR" ] || return 0
|
|
WORKDIR=$(mktemp -d "${TMPDIR:-/tmp}/dotup.XXXXXX") || {
|
|
err "could not create a private working directory"; return 1; }
|
|
chmod 700 "$WORKDIR" || { err "could not lock down $WORKDIR"; return 1; }
|
|
}
|
|
# Named, not inlined into the trap, because cmd_private installs a trap of its
|
|
# own further down and has to be able to call this one as well.
|
|
dotup_cleanup() { [ -z "${WORKDIR:-}" ] || rm -rf "$WORKDIR"; WORKDIR=; }
|
|
trap dotup_cleanup EXIT INT TERM
|
|
|
|
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/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.
|
|
# chezmoi is how dotup ARRIVES, so "it must already be here" is the natural
|
|
# assumption -- and it is wrong often enough to have broken a real install.
|
|
#
|
|
# get.chezmoi.io installs to ./bin RELATIVE TO THE CWD when -b is not given,
|
|
# which is what the README's one-liner does. Run it from $HOME and the binary
|
|
# lands in ~/bin; run it from /workspace, as anyone in a container does, and it
|
|
# lands in /workspace/bin. Neither is on PATH, and the private tier then failed
|
|
# with `chezmoi: not found` AFTER writing the bws token -- half-configured, at
|
|
# the very last step, having already spent the password.
|
|
#
|
|
# So resolve it the way every other tool here is resolved, and install it if it
|
|
# genuinely is not present. CHEZMOI holds the resolved path; nothing calls the
|
|
# bare name.
|
|
CHEZMOI=chezmoi
|
|
ensure_chezmoi() {
|
|
if CHEZMOI=$(find_tool chezmoi); then return 0; fi
|
|
say " chezmoi is not on PATH or in the usual places — installing it"
|
|
sh -c "$(curl -fsLS get.chezmoi.io)" -- -b "$HOME/.local/bin" >/dev/null 2>&1 \
|
|
|| { err "the chezmoi installer failed"; return 1; }
|
|
CHEZMOI=$(find_tool chezmoi) || { err "chezmoi still not found after installing it"; return 1; }
|
|
say " chezmoi installed to ~/.local/bin"
|
|
}
|
|
|
|
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
|
|
# These were fixed paths -- /tmp/bws.zip, /tmp/bws.sums, /tmp/bws.d -- in a
|
|
# world-writable directory. Two concurrent runs overwrote each other, and on
|
|
# a shared machine anyone could pre-create those names as symlinks and
|
|
# redirect the write. The deb channel a few functions down already used
|
|
# $TMPDIR; this now does better, with a private directory it owns and
|
|
# removes. mktemp -d is 700 by definition, so the download and the unpacked
|
|
# binary are unreadable to everyone else while they sit there.
|
|
bws_tmp=$(mktemp -d "${TMPDIR:-/tmp}/dotup-bws.XXXXXX") || {
|
|
err "could not create a temporary directory for bws"; return 1; }
|
|
say " fetching bws $v"
|
|
curl -fsSL "$b/bws-$t-$v.zip" -o "$bws_tmp/bws.zip" 2>/dev/null \
|
|
|| { err "bws download failed"; rm -rf "$bws_tmp"; return 1; }
|
|
# A binary about to hold the key to every other credential is worth verifying.
|
|
if have sha256sum && curl -fsSL "$b/bws-sha256-checksums-$v.txt" -o "$bws_tmp/sums" 2>/dev/null; then
|
|
want=$(awk -v f="bws-$t-$v.zip" '$2==f || $2=="*"f {print $1}' "$bws_tmp/sums" | head -1)
|
|
got=$(sha256sum "$bws_tmp/bws.zip" | awk '{print $1}')
|
|
if [ -n "$want" ] && [ "$want" != "$got" ]; then
|
|
err "bws checksum mismatch — refusing to install"
|
|
rm -rf "$bws_tmp"; return 1
|
|
fi
|
|
[ -n "$want" ] && say " bws checksum verified"
|
|
else
|
|
warn " bws checksums unavailable — installing unverified"
|
|
fi
|
|
mkdir -p "$HOME/.local/bin" "$bws_tmp/d"
|
|
unzip -oq "$bws_tmp/bws.zip" -d "$bws_tmp/d" 2>/dev/null \
|
|
|| { err "bws extract failed"; rm -rf "$bws_tmp"; return 1; }
|
|
install -m 755 "$(find "$bws_tmp/d" -type f -name bws | head -1)" "$HOME/.local/bin/bws" \
|
|
|| { err "bws install failed"; rm -rf "$bws_tmp"; return 1; }
|
|
rm -rf "$bws_tmp"
|
|
say " bws $v installed to ~/.local/bin"
|
|
}
|
|
|
|
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"; }
|
|
|
|
# A failure has to be reported by a name the reader can act on. The channel
|
|
# installers only ever hold the install ARGUMENT, so the summary said
|
|
# `can1357/tap/omp`, `bw` and `md.obsidian.Obsidian` -- none of which can be
|
|
# typed at the picker, passed to `dotup explain`, or found in the manifest.
|
|
# The plan table already carries channel, argument and key side by side; this
|
|
# reads the key back out of it. Falls back to the argument when there is no
|
|
# table (a package moved from apt to brew is not in the brew column).
|
|
PLAN_TBL=
|
|
keyof() {
|
|
kf=
|
|
[ -n "$PLAN_TBL" ] && [ -f "$PLAN_TBL" ] && kf=$(awk -F'\t' -v c="$1" -v a="$2" '
|
|
$1==c && index(" "$2" ", " "a" ") { print $3; exit }' "$PLAN_TBL")
|
|
printf '%s\n' "${kf:-$2}"
|
|
}
|
|
|
|
apt_update_once() {
|
|
[ "$APT_UPDATED" -eq 0 ] || return 0
|
|
APT_UPDATED=1
|
|
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-cache show` is not an existence test. A name that exists only because
|
|
# something else Conflicts/Replaces it -- docker-ce on a stock Ubuntu is the
|
|
# live example -- prints nothing and exits 0, so the batch kept it, apt refused
|
|
# the whole batch with "has no installation candidate", and the brew column was
|
|
# never consulted. Ask for the installation candidate instead, which is the
|
|
# thing `apt-get install` will actually go looking for.
|
|
apt_known() {
|
|
apt-cache policy "$1" 2>/dev/null \
|
|
| awk '/^ Candidate:/ { ok = ($2 != "(none)") } END { exit !ok }'
|
|
}
|
|
|
|
install_apt() {
|
|
pkgs=$(norm "$1")
|
|
[ -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 "$(keyof brew "$p")" "brew missing"; done
|
|
return 0
|
|
fi
|
|
for p in $pkgs; do run_sh "$BREW install $p" || note_fail "$(keyof brew "$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 "$(keyof npm "$p")" "npm missing — core/node did not install"; done
|
|
return 0
|
|
fi
|
|
# shellcheck disable=SC2086
|
|
run_sh "$NPM install -g$(printf ' %s' $specs)" || {
|
|
warn "batch npm install failed; retrying one at a time"
|
|
for p in $specs; do run_sh "$NPM install -g $p" || note_fail "$(keyof npm "$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 "$(keyof uv "$t")" "uv missing — core/uv did not install"; done
|
|
return 0
|
|
fi
|
|
for t in $tools; do run_sh "$UV tool install $t" || note_fail "$(keyof uv "$t")" "uv tool install failed"; done
|
|
}
|
|
|
|
install_snap() {
|
|
names=$(norm "$1")
|
|
[ -n "$names" ] || return 0
|
|
head_ "snap"
|
|
SNAP=$(find_tool snap || echo snap)
|
|
if ! have snap && [ "$DRYRUN" -eq 0 ]; then
|
|
for n in $names; do note_fail "$(keyof snap "$n")" "snapd is not present on this machine"; done
|
|
return 0
|
|
fi
|
|
for n in $names; do run_sh "${SUDO:+$SUDO }$SNAP install $n" || note_fail "$(keyof snap "$n")" "snap install failed"; done
|
|
}
|
|
|
|
# Three things were missing, and all three are needed before a single flatpak
|
|
# can install on a stock Ubuntu box:
|
|
#
|
|
# the tool Ubuntu ships snap, not flatpak. `flatpak` is an ordinary,
|
|
# uninvasive apt package -- refusing to install it is not the
|
|
# same call as refusing to install a second package manager
|
|
# that rewrites /usr/local, so install it.
|
|
# the remote Ubuntu configures no remotes at all, so the old command died
|
|
# with `error: No remote refs found for 'flathub'` even where
|
|
# flatpak WAS present. Nothing in the manifest can express this;
|
|
# it belongs here, once, beside the install.
|
|
# --user the system-wide scope needs polkit on a session bus, and a
|
|
# headless or freshly-booted box has none: `flatpak remote-add`
|
|
# answers `error: Unable to connect to system bus`. --user needs
|
|
# nothing, installs into ~/.local/share/flatpak, and is where a
|
|
# single-user desktop wants these anyway.
|
|
install_flatpak() {
|
|
ids=$(norm "$1")
|
|
[ -n "$ids" ] || return 0
|
|
head_ "flatpak"
|
|
if ! have flatpak && [ "$DRYRUN" -eq 0 ]; then
|
|
apt_update_once
|
|
run_sh "${SUDO:+$SUDO }DEBIAN_FRONTEND=noninteractive apt-get install -y flatpak" \
|
|
|| warn "could not install flatpak"
|
|
fi
|
|
if ! have flatpak && [ "$DRYRUN" -eq 0 ]; then
|
|
for i in $ids; do note_fail "$(keyof flatpak "$i")" "flatpak is not present and could not be installed"; done
|
|
return 0
|
|
fi
|
|
FLATPAK=$(find_tool flatpak || echo flatpak)
|
|
run_sh "$FLATPAK --user remote-add --if-not-exists flathub https://dl.flathub.org/repo/flathub.flatpakrepo" \
|
|
|| warn "could not add the flathub remote"
|
|
for i in $ids; do
|
|
run_sh "$FLATPAK install -y --noninteractive --user flathub $i" \
|
|
|| note_fail "$(keyof flatpak "$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"
|
|
deb_n=0
|
|
for s in $srcs; do
|
|
url=$s
|
|
case $s in
|
|
gh:*)
|
|
spec=${s#gh:}; repo=${spec%%:*}; match=${spec#*:}
|
|
# A distro-targeted deb names the release as well as the arch:
|
|
# ghostty-ubuntu publishes ghostty_1.3.1-0.ppa2_amd64_24.04.deb, so
|
|
# the literal `_amd64.deb` the manifest used to carry matched no
|
|
# asset that has ever existed and apps/ghostty could never install.
|
|
# The manifest now writes `_%a_%v.deb` and the two placeholders are
|
|
# filled in here, which is the only place the machine is known.
|
|
deb_arch=$(dpkg --print-architecture 2>/dev/null || uname -m)
|
|
deb_rel=$( . /etc/os-release 2>/dev/null; printf '%s' "${VERSION_ID:-}" )
|
|
match=$(printf '%s' "$match" | sed -e "s/%a/$deb_arch/g" -e "s/%v/$deb_rel/g")
|
|
if [ "$DRYRUN" -eq 1 ]; then
|
|
printf ' + resolve latest %s asset matching *%s*\n' "$repo" "$match"
|
|
url="https://github.com/$repo/releases/latest/<asset matching *$match*>"
|
|
else
|
|
url=$(curl -fsSL "https://api.github.com/repos/$repo/releases/latest" 2>/dev/null \
|
|
| awk -F'"' -v m="$match" '/browser_download_url/ && index($4,m) {print $4; exit}')
|
|
[ -n "$url" ] || { note_fail "$(keyof deb "$s")" "no release asset matching *$match*"; continue; }
|
|
fi ;;
|
|
esac
|
|
workdir || { note_fail "$(keyof deb "$s")" "no private working directory"; continue; }
|
|
deb_n=$((deb_n + 1))
|
|
f=$WORKDIR/pkg-$deb_n.deb
|
|
apt_update_once
|
|
run_sh "curl -fsSL '$url' -o '$f'" || { note_fail "$(keyof deb "$s")" "download failed"; continue; }
|
|
run_sh "${SUDO:+$SUDO }apt-get install -y '$f'" || note_fail "$(keyof deb "$s")" "dpkg install failed"
|
|
run_sh "rm -f '$f'"
|
|
done
|
|
}
|
|
|
|
# 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
|
|
workdir || { note_fail "$key" "no private working directory"; continue; }
|
|
b=https://github.com/neovim/neovim/releases/latest/download
|
|
# The `[ -s ]` is part of the download, not a step after it: nothing
|
|
# may reach `sudo tar` that was not verified to be here and non-empty
|
|
# first. A zero-byte file is what a proxy error page truncated to
|
|
# nothing looks like, and tar's complaint about it is not a sentence
|
|
# anyone can act on.
|
|
run_sh "{ curl -fsSL '$b/nvim-linux-$a.tar.gz' -o '$WORKDIR/nvim.tgz' || curl -fsSL '$b/nvim-linux64.tar.gz' -o '$WORKDIR/nvim.tgz'; } && [ -s '$WORKDIR/nvim.tgz' ]" \
|
|
|| { note_fail "$key" "tarball download failed or arrived empty"; continue; }
|
|
run_sh "${SUDO:+$SUDO }rm -rf /opt/nvim && ${SUDO:+$SUDO }mkdir -p /opt/nvim && ${SUDO:+$SUDO }tar -xzf '$WORKDIR/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" ;;
|
|
core/node)
|
|
# Same call as core/neovim, for the same reason and with better
|
|
# evidence. 24.04's apt candidate is node 18.19.1; three of the four
|
|
# npm rows in this manifest declare node>=20 and agents/pi declares
|
|
# node>=22.19. npm only WARNS about a failed engines check, so
|
|
# `npm install -g` exited 0, dotup recorded a success, and `pi`
|
|
# then died on an import attribute the 18 parser cannot read. An
|
|
# install that cannot run is not an install.
|
|
if have node && [ "$DRYRUN" -eq 0 ]; then
|
|
v=$(node --version 2>/dev/null | tr -d 'v'); maj=${v%%.*}
|
|
case $maj in ''|*[!0-9]*) maj=0 ;; esac
|
|
if [ "$maj" -ge 20 ]; then say " node $v already above 20 — leaving it"; continue; fi
|
|
fi
|
|
case $(uname -s) in Darwin) o=darwin ;; *) o=linux ;; esac
|
|
case $(uname -m) in x86_64|amd64) a=x64 ;; aarch64|arm64) a=arm64 ;;
|
|
*) note_fail "$key" "no node tarball for $(uname -m)"; continue ;; esac
|
|
# index.tab names its own columns, so the LTS column is found rather
|
|
# than counted -- nodejs.org has added columns before.
|
|
if [ "$DRYRUN" -eq 1 ]; then v='vXX.Y.Z'
|
|
else v=$(curl -fsSL 'https://nodejs.org/download/release/index.tab' 2>/dev/null \
|
|
| awk -F'\t' 'NR==1{for(i=1;i<=NF;i++) if($i=="lts") c=i; next}
|
|
c && $c!="-" {print $1; exit}'); fi
|
|
[ -n "$v" ] || { note_fail "$key" "could not resolve the current node LTS"; continue; }
|
|
workdir || { note_fail "$key" "no private working directory"; continue; }
|
|
run_sh "curl -fsSL 'https://nodejs.org/dist/$v/node-$v-$o-$a.tar.gz' -o '$WORKDIR/node.tgz' && [ -s '$WORKDIR/node.tgz' ]" \
|
|
|| { note_fail "$key" "tarball download failed or arrived empty"; continue; }
|
|
run_sh "${SUDO:+$SUDO }rm -rf /opt/node && ${SUDO:+$SUDO }mkdir -p /opt/node && ${SUDO:+$SUDO }tar -xzf '$WORKDIR/node.tgz' -C /opt/node --strip-components=1" \
|
|
|| { note_fail "$key" "tarball extract failed"; continue; }
|
|
run_sh "${SUDO:+$SUDO }ln -sf /opt/node/bin/node /usr/local/bin/node"
|
|
run_sh "${SUDO:+$SUDO }ln -sf /opt/node/bin/npm /usr/local/bin/npm"
|
|
run_sh "${SUDO:+$SUDO }ln -sf /opt/node/bin/npx /usr/local/bin/npx" ;;
|
|
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; }
|
|
workdir || { note_fail "$key" "no private working directory"; continue; }
|
|
run_sh "curl -fsSL 'https://go.dev/dl/$v.$o-$a.tar.gz' -o '$WORKDIR/go.tgz' && [ -s '$WORKDIR/go.tgz' ]" \
|
|
|| { note_fail "$key" "tarball download failed or arrived empty"; continue; }
|
|
run_sh "${SUDO:+$SUDO }rm -rf /usr/local/go && ${SUDO:+$SUDO }tar -xzf '$WORKDIR/go.tgz' -C /usr/local" \
|
|
|| { note_fail "$key" "tarball extract failed"; continue; } ;;
|
|
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/brew)
|
|
# Homebrew runs on Linux, and this manifest has always assumed so:
|
|
# `find_tool` probes /home/linuxbrew/.linuxbrew/bin, and the note on
|
|
# agents/omp reads "pulls linuxbrew on linux". Three safe rows --
|
|
# lazygit, omp and herdr -- have no apt package at all and resolve
|
|
# here. Nothing ever installed brew itself, so `^a` promised three
|
|
# packages that failed on every fresh Linux box.
|
|
#
|
|
# On Linux this is much less invasive than the macOS install it
|
|
# tends to be judged by: everything lands under
|
|
# /home/linuxbrew/.linuxbrew and /usr/local is left alone.
|
|
if have brew && [ "$DRYRUN" -eq 0 ]; then
|
|
say " brew already present — leaving it"; continue
|
|
fi
|
|
# Homebrew refuses to run as root by its own design. Say which of
|
|
# the two problems it is rather than letting its installer explain.
|
|
if [ "$DRYRUN" -eq 0 ] && [ "$(id -u)" -eq 0 ]; then
|
|
note_fail "$key" "Homebrew refuses to install as root — run dotup as your own user"
|
|
continue
|
|
fi
|
|
# The installer's own documented prerequisites. git is already here,
|
|
# since the public tier cannot apply without it; the rest are not.
|
|
apt_update_once
|
|
run_sh "${SUDO:+$SUDO }DEBIAN_FRONTEND=noninteractive apt-get install -y build-essential procps curl file git" \
|
|
|| warn "could not install Homebrew's build prerequisites"
|
|
run_sh "NONINTERACTIVE=1 /bin/bash -c \"\$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)\"" \
|
|
|| { note_fail "$key" "the Homebrew installer failed"; continue; }
|
|
# install_brew resolves through find_tool, which probes the linuxbrew
|
|
# prefix by absolute path, so nothing here needs PATH edited for the
|
|
# remainder of this run.
|
|
# Only meaningful after something actually ran. Under --print nothing
|
|
# was installed, so this reported "brew still not found after
|
|
# installing it" for a run that never claimed to install anything --
|
|
# a failure invented by the dry run itself.
|
|
[ "$DRYRUN" -eq 1 ] || have brew \
|
|
|| note_fail "$key" "brew still not found after installing it" ;;
|
|
core/uv)
|
|
if have uv && [ "$DRYRUN" -eq 0 ]; then say " uv already present — leaving it"; continue; fi
|
|
run_sh "curl -LsSf https://astral.sh/uv/install.sh | sh" || note_fail "$key" "installer failed" ;;
|
|
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"
|
|
PLAN_TBL=$tbl
|
|
|
|
# Two filters, and they are the reason this is safe to run unattended.
|
|
# `private` is never a package: it needs a password nobody is there to type.
|
|
# `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).
|
|
# Order is a dependency graph, not a preference. apt first because most
|
|
# things come from it; `script` BEFORE `brew`, because core/brew is a script
|
|
# row and the three brew-only packages cannot install until it has run;
|
|
# npm after apt and tarball because node has to exist first; uv after script
|
|
# for the same reason.
|
|
BREW_EXTRA=
|
|
install_apt "$(col apt)"
|
|
install_bespoke script "$(keys script)"
|
|
install_brew "$(col brew) $BREW_EXTRA"
|
|
install_bespoke tarball "$(keys tarball)"
|
|
install_bespoke builtin "$(keys builtin)"
|
|
install_bespoke xcode "$(keys xcode)"
|
|
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
|
|
|
|
# Resolve chezmoi BEFORE asking for anything. It is needed only for the repo
|
|
# row, but finding out it is missing afterwards means the password has already
|
|
# been typed and spent, the bws token is already on disk, and the machine is
|
|
# left half-configured at the last step. Fail before the prompt or not at all.
|
|
if [ "$want_repo" -eq 1 ]; then
|
|
ensure_chezmoi || { err "the private repo cannot be cloned without chezmoi"; return 1; }
|
|
fi
|
|
|
|
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=''; p_in=''
|
|
# This REPLACES the load-time trap rather than adding to it, so it has to do
|
|
# that trap's job too: without the dotup_cleanup call the run's private
|
|
# working directory outlived the run whenever the private tier was reached.
|
|
# shellcheck disable=SC2064
|
|
trap 'unset P_URL P_USER P_PW P_BLOB p_repo p_tok p_cred 2>/dev/null || :; dotup_cleanup' EXIT INT TERM
|
|
|
|
# Ask, and keep asking.
|
|
#
|
|
# These three questions come at the very END of a run, after every package is
|
|
# installed, because a password typed at picker time would sit in memory
|
|
# through ten minutes of downloads. That ordering is right, and it is exactly
|
|
# what made one mistyped character so expensive: any non-200 used to be fatal,
|
|
# so a typo meant re-running the entire install to get back to this prompt.
|
|
# Nothing about a wrong password justifies reinstalling a compiler.
|
|
#
|
|
# URL and username persist across attempts and blank keeps them, because the
|
|
# password is the thing you get wrong, and retyping an address you already
|
|
# pasted correctly is its own source of error.
|
|
p_try=0
|
|
while :; do
|
|
p_try=$((p_try + 1))
|
|
if [ "$p_try" -gt 5 ]; then
|
|
err "five failed attempts — stopping rather than looping."
|
|
say " Nothing else needs redoing. Re-run only this step: dotup private"
|
|
return 1
|
|
fi
|
|
|
|
if [ -n "$P_URL" ]; then printf ' Bootstrap URL [%s]: ' "$P_URL" >&2
|
|
else printf ' Bootstrap URL: ' >&2; fi
|
|
IFS= read -r p_in || :
|
|
case $p_in in
|
|
q|Q) say " public-only machine. Nothing was asked for."; return 0 ;;
|
|
'') [ -n "$P_URL" ] || { say " public-only machine. Nothing was asked for."; return 0; } ;;
|
|
*) P_URL=$p_in ;;
|
|
esac
|
|
# 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, and the 404
|
|
# would be reported below as a credential problem.
|
|
P_URL=${P_URL%/}
|
|
P_URL=${P_URL%/bootstrap.env}
|
|
|
|
if [ -n "$P_USER" ]; then printf ' Username [%s]: ' "$P_USER" >&2
|
|
else printf ' Username: ' >&2; fi
|
|
IFS= read -r p_in || :
|
|
[ -z "$p_in" ] || P_USER=$p_in
|
|
|
|
# Both stty calls were `|| :`, which meant that on a box where stty is
|
|
# missing or fails, the endpoint password was typed in the clear and
|
|
# left in the scrollback -- silently, at the one prompt where that
|
|
# matters most. Say so instead. Still not fatal: someone on a console
|
|
# with no stty may genuinely want to continue, but they get to know.
|
|
if stty -echo 2>/dev/null; then p_echo=off; else
|
|
p_echo=on
|
|
warn "this terminal will not turn off echo — the password WILL be visible"
|
|
fi
|
|
printf ' Password: ' >&2
|
|
IFS= read -r P_PW || :
|
|
[ "$p_echo" = off ] && { stty echo 2>/dev/null || :; }
|
|
printf '\n' >&2
|
|
|
|
# curl -K - reads its config, credentials included, from stdin rather than
|
|
# the command line, so nothing reaches `ps`. The status is captured
|
|
# alongside the body instead of relying on --fail, because "it failed" is
|
|
# not a useful thing to say here: 401, 404 and an unreachable host each
|
|
# need a different next move, and the old single message blamed the
|
|
# credentials for all three. The body is used only when the code is 200,
|
|
# so an error page is still never parsed as a blob.
|
|
p_resp=$(printf 'user = "%s:%s"\nsilent\nwrite-out = "\\n%%{http_code}"\n' "$P_USER" "$P_PW" \
|
|
| curl -K - --connect-timeout 15 -m 120 "$P_URL/bootstrap.env" 2>/dev/null) || :
|
|
p_code=$(printf '%s\n' "$p_resp" | tail -n1)
|
|
P_BLOB=$(printf '%s\n' "$p_resp" | sed '$d')
|
|
p_resp=''
|
|
P_PW=''
|
|
|
|
case $p_code in
|
|
200) break ;;
|
|
401) err "wrong username or password." ;;
|
|
404) err "reached the host, but no bootstrap.env is there — check the route in the URL." ;;
|
|
000) err "could not reach that address (DNS, TLS, or the host is down)." ;;
|
|
*) err "the endpoint answered HTTP $p_code." ;;
|
|
esac
|
|
P_BLOB=''
|
|
say " Try again, or type q at the URL prompt to stay public-only."
|
|
done
|
|
unset P_PW p_in p_code p_try 2>/dev/null || :
|
|
|
|
# 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() {
|
|
# System first, cache second. The README promises "your own fzf wins
|
|
# whenever it clears the floor", and cache-first quietly broke that: once
|
|
# dotup had fetched its copy, a system fzf installed later never won again,
|
|
# however new it was. The cost of getting this right is one `fzf --version`
|
|
# fork per run.
|
|
if command -v fzf >/dev/null 2>&1; then
|
|
cur=$(fzf_version)
|
|
if [ -n "$cur" ] && ver_ge "$cur" "$FZF_FLOOR"; then
|
|
FZF=$(command -v fzf); return 0
|
|
fi
|
|
echo "dotup: system fzf $cur is below the verified floor $FZF_FLOOR" >&2
|
|
fi
|
|
if [ -x "$FZF_CACHE/fzf" ] && ver_ge "$("$FZF_CACHE/fzf" --version 2>/dev/null | awk '{print $1}')" "$FZF_FLOOR"; then
|
|
FZF=$FZF_CACHE/fzf; return 0
|
|
fi
|
|
install_fzf || return 1
|
|
FZF=$FZF_CACHE/fzf
|
|
}
|
|
|
|
# ------------------------------------------------------------------- pick ----
|
|
cmd_pick() {
|
|
# fzf opens /dev/tty itself, so `[ -t 0 ]` asks the wrong question -- a run
|
|
# with a pipe on stdin but a terminal attached is fine, and a run with
|
|
# neither is not. Ask the question fzf will ask.
|
|
#
|
|
# It has to come BEFORE the defaults preset below, and that ordering is the
|
|
# actual bug: a run in a pipe or a CI job printed fzf's raw
|
|
# `failed to open /dev/tty` and exited 1, but had already overwritten the
|
|
# selection with 43 default rows. A picker that never drew anything must
|
|
# not change what a later install does.
|
|
if ! (exec 3</dev/tty) 2>/dev/null; then
|
|
err "no terminal — the picker cannot draw."
|
|
say " For a machine with nobody at the keyboard: dotup --unattended"
|
|
return 2
|
|
fi
|
|
ensure_fzf || {
|
|
echo "dotup: no usable fzf, and none could be fetched." >&2
|
|
echo "dotup: install fzf (apt install fzf, brew install fzf) and re-run," >&2
|
|
echo "dotup: or pick without the UI: dotup preset defaults && dotup install" >&2
|
|
return 2
|
|
}
|
|
# Every binding is `execute-silent`, which throws its child's exit status
|
|
# away. So a state file that cannot be read or written makes the picker
|
|
# LOOK alive -- rows draw, the cursor moves -- while every tick is silently
|
|
# discarded. Mode 000 is what a `sudo dotup` leaves behind, which is how
|
|
# anyone actually meets this. Find out before drawing anything.
|
|
if [ ! -r "$SEL" ] || [ ! -w "$SEL" ] || [ ! -w "$STATE" ]; then
|
|
err "$SEL is not readable and writable — every tick would be silently lost."
|
|
say " fix its ownership, or delete it, and re-run."
|
|
return 2
|
|
fi
|
|
# Seed the defaults only on a machine that has never finished a pick.
|
|
#
|
|
# This used to read `[ -s "$SEL" ] || cmd_preset defaults`, which cannot
|
|
# tell an empty selection apart from an absent one -- so `^x` then enter,
|
|
# a deliberate "install nothing", was silently replaced by the defaults on
|
|
# the very next run. Emptiness is the wrong signal.
|
|
#
|
|
# Existence is the wrong signal too, and that is a subtler trap: the file is
|
|
# created at load by ANY invocation, so a `dotup plan` before the first
|
|
# `dotup` would make it exist and the picker would then open with nothing
|
|
# ticked on a brand new machine. (Found exactly that way.)
|
|
#
|
|
# The right signal is "a pick has completed", which only this function
|
|
# knows. It is written after fzf ACCEPTS, so a ^c leaves nothing behind --
|
|
# abandoning the picker is not a decision.
|
|
[ -f "$PICKED" ] || cmd_preset defaults
|
|
# --exact is a safety property, not a preference. ^t toggles every row the
|
|
# filter is showing, so the filter must mean exactly what it looks like.
|
|
# Fuzzy-matching "nvidia" also matches docker, tailscale and desktop.
|
|
# $'...' is a bashism. This script is #!/bin/sh and Ubuntu's /bin/sh is
|
|
# dash, which does not implement it -- so the header rendered as a literal
|
|
# `$space tick ... enter install\n`, leading dollar and trailing backslash-n
|
|
# included, on the one platform this is written for. It looked right in
|
|
# every test because the test host's shell was not dash. Build the newline
|
|
# with a plain variable, which every POSIX shell agrees about.
|
|
nl='
|
|
'
|
|
hdr="space tick tab open ^t tick all shown ^a defaults ^x none ^o open all enter install$nl"
|
|
# ^t is `toggle-shown`, not `toggle`, and the difference is the two bugs
|
|
# this bind used to have. Full reasoning above cmd_toggle_shown; the
|
|
# semantics, stated once:
|
|
#
|
|
# ^t ticks every row the filter is showing, plus what those rows @needs
|
|
# as far as the first invasive dependency -- an invasive row is ticked
|
|
# only by ticking the visible row itself. Pressing ^t again over the same
|
|
# rows unticks exactly the keys the first press added, so ^t ^t is a
|
|
# no-op. Over rows that were already all on, ^t is the ordinary all-off
|
|
# with the reverse @needs closure.
|
|
#
|
|
# `clear-query` used to hang off the end of this bind, because the toggle
|
|
# reached rows the query was hiding and the rule here is "never silently".
|
|
# Nothing is hidden any more, and the query has to survive the keystroke
|
|
# for the second press to be the undo of the first -- clearing it made ^t
|
|
# ^t mean "tick the three rows I filtered for, then tick the manifest".
|
|
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="$hdr" \
|
|
--preview "$SELF explain {2}" --preview-window='right,46%,wrap,border-left' \
|
|
--bind "space:execute-silent($SELF toggle {2})+reload($SELF render)" \
|
|
--bind "tab:execute-silent($SELF expand {2})+reload($SELF render)" \
|
|
--bind "ctrl-t:select-all+execute-silent($SELF toggle-shown {+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
|
|
# Only on accept. See the note above cmd_preset: this is the record that a
|
|
# human has been through the picker and meant what the file now says.
|
|
: > "$PICKED"
|
|
}
|
|
|
|
confirm() {
|
|
[ "$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] [command [args]]
|
|
|
|
Flags may come before the command, after it, or both --
|
|
\`dotup install --unattended\` and \`dotup --unattended install\` are the
|
|
same run. Anything not listed here is an error, never a shrug.
|
|
|
|
(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.
|
|
Still opens the picker and still asks to confirm; combine with
|
|
--unattended for a dry run with no UI at all.
|
|
--yes, -y skip the confirmation after the picker
|
|
|
|
private ONLY the private tier: prompt for the endpoint and fetch.
|
|
Everything already installed is left alone, so this is the
|
|
way back in after a mistyped password -- no reinstall.
|
|
|
|
plumbing, called by the fzf bindings:
|
|
render toggle toggle-shown expand expand-all preset explain plan preflight
|
|
EOF
|
|
}
|
|
|
|
# Flags are read wherever they sit: before the subcommand, after it, or both.
|
|
#
|
|
# The old loop stopped at the first bare word and left the rest of the command
|
|
# line sitting in "$@", where nothing ever looked at it again. So `dotup install
|
|
# --unattended` -- the order half the people who type this type it in -- ran a
|
|
# full ATTENDED install: the flag was accepted by the parser's silence and then
|
|
# dropped on the floor. That is how a machine meant to get the safe defaults got
|
|
# every invasive row in the manifest instead.
|
|
#
|
|
# Two rules, and the second matters as much as the first: every argument is
|
|
# parsed wherever it appears, and anything unrecognised -- a flag or a word --
|
|
# stops the run HERE, before a package manager has been asked for anything. A
|
|
# flag we do not understand is not a flag we are entitled to ignore.
|
|
CMD=
|
|
endopts=0
|
|
n=$#; i=0
|
|
while [ "$i" -lt "$n" ]; do
|
|
i=$((i + 1)); a=$1; shift
|
|
if [ "$endopts" -eq 0 ]; then
|
|
case $a in
|
|
--) endopts=1; continue ;;
|
|
--unattended) UNATTENDED=1; ASSUME_YES=1; continue ;;
|
|
--print|-n) DRYRUN=1; continue ;;
|
|
--yes|-y) ASSUME_YES=1; continue ;;
|
|
-h|--help) usage; exit 0 ;;
|
|
-?*) err "unknown flag: $a"; usage; exit 2 ;;
|
|
esac
|
|
fi
|
|
# The first bare word is the subcommand; every later one is an operand,
|
|
# rotated to the back of "$@" so the dispatch below reads them in the order
|
|
# they were typed with the flags taken out from between them.
|
|
if [ -z "$CMD" ]; then CMD=$a; else set -- "$@" "$a"; fi
|
|
done
|
|
|
|
# What each subcommand accepts. -1 is "as many as you like". An unknown
|
|
# subcommand, or one word more than a subcommand can use, is the same class of
|
|
# mistake as an unknown flag and gets the same answer: say so, and stop.
|
|
# This list and the dispatch below must name the same commands.
|
|
amin=0; amax=0
|
|
case ${CMD:-run} in
|
|
run|pick|install|private|render|expand-all|plan|preflight|fzf-path) ;;
|
|
toggle|toggle-shown|expand) amax=-1 ;;
|
|
preset|explain|resolve) amax=1 ;;
|
|
vercmp) amin=2; amax=2 ;;
|
|
*) err "unknown command: $CMD"; usage; exit 2 ;;
|
|
esac
|
|
[ "$#" -ge "$amin" ] || { err "${CMD:-run} needs $amin argument(s), got $#"; usage; exit 2; }
|
|
[ "$amax" -lt 0 ] || [ "$#" -le "$amax" ] || {
|
|
err "${CMD:-run} takes at most $amax argument(s), got $#:$(printf ' %s' "$@")"
|
|
usage; exit 2; }
|
|
|
|
case ${CMD:-run} in
|
|
run) cmd_run ;;
|
|
pick) cmd_pick && cmd_plan ;;
|
|
install) cmd_install ;;
|
|
private) cmd_private ;;
|
|
render) cmd_render ;;
|
|
toggle) cmd_toggle "$@" ;;
|
|
toggle-shown) cmd_toggle_shown "$@" ;;
|
|
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
|