feat: public dotfiles tier — no credential, no identity, one installer
Fresh history. This is the repo a throwaway VM clones anonymously: it brings a
machine to a working baseline and carries nothing that makes it mine.
56 files. 50 land in $HOME, 3 are chezmoi metadata, 2 are repo documentation,
1 is the manifest, and a 15-file test harness stays behind in .tests/.
What did not travel, and why:
encrypted_private_bws-token.age a real credential; age is dropped entirely
.chezmoidata/bws.toml env-var -> secret-id map; belongs with the
tier that can use it
SECRETS.md documentation of the rules, not config
finish-setup.sh.tmpl superseded by dotup
nvim/init.lua.backup dead file
dot_claude/**, dot_codex/**, 120 files of agent config, private tier
dot_pi/**
De-identified rather than dropped:
.gitconfig [user], the GitHub ssh rewrite and both Gitea host rewrites are
identity, not configuration. They move behind an [include] of
~/.config/git/config.local, which the private tier writes. Git
treats a missing include as a no-op, so a public-only machine
reads the file and stops.
.zshrc the two gitea aliases carried a personal domain and a LAN IP.
They move behind a guarded source of ~/.config/zsh/local.zsh,
the sibling of the secrets.zsh seam phase 2 established.
nvim a commented-out LM Studio endpoint naming a LAN address.
ghostty a stale auto-generated header naming an absolute home directory.
Newly captured, never tracked before: ~/.zshenv, ~/.config/gh/config.yml. The
former sourced ~/.cargo/env unguarded, so every zsh on a machine without rustup
printed an error -- the same shape as the unguarded oh-my-zsh source phase 2
fixed. It is guarded now.
.chezmoiexternal.toml grows from one entry to six. oh-my-zsh, powerlevel10k,
zsh-autosuggestions, zsh-ai and tpm were hand-installed and declared nowhere,
which is why `chezmoi init --apply` on a clean box produced a .zshrc that broke
the shell it configures. The theme and both plugins nest under
.oh-my-zsh/custom/, which is what $ZSH_CUSTOM resolves to.
dotup gains an install engine. It resolves each selected package to a channel
(apt, brew, npm, uv, snap, deb, flatpak, tarball, script, builtin) through one
function every consumer reads, probes apt-cache before batching so a name apt
does not know moves to brew instead of failing all thirty, and retries
individually if a batch still fails -- which earned its keep on the first real
container run, where mermaid-cli's puppeteer dependency failed and the other
twelve npm packages installed anyway. --unattended computes safe defaults fresh
from the manifest rather than inheriting a state file, and refuses private and
invasive rows outright even when a stale state file ticks them.
The manifest gains @spec, a second directive kind alongside @needs, carrying the
argument a channel needs but a package name cannot supply -- the scoped npm
name, the flatpak app id, the .deb source. The TSV stays five columns wide.
Three bugs the container runs found, all fixed here:
* `apt install nodejs` gives you node WITHOUT npm on Ubuntu, so all thirteen
npm packages failed on a fresh box. The manifest asks apt for both names.
* A tool installed a moment ago is not on this process's PATH -- uv lands in
~/.local/bin, npm -g honours the ~/.npmrc prefix, linuxbrew is outside a
non-login PATH. Resolved by looking in the places we just wrote to, never by
exporting a modified PATH.
* `A || { B && C; }` is one || list, so when `command -v sudo` failed the list
failed and `set -e` killed dotup at load. On a non-root machine with no
sudo it died before printing anything. There is a regression test.
.zshenv and .p10k.zsh are marked private_. Both are shell code the login shell
executes and both applied at 664, group-writable. Third occurrence of the class
of bug phase 1 found on .pi/agent/auth.json and phase 2 found on .zshrc; the
first one found on purpose rather than by accident.
Verification: 81 assertions, 81/81 on this box and in ubuntu:24.04, ubuntu:22.04
and debian:12. The installer is driven against a directory of fake package
managers that record what they were asked to do and install nothing, so the
engine is exercised end to end without a package landing on the test machine.
`gitleaks detect` over the full history and the working tree: no leaks found,
with no allowlist and no .gitleaks.toml.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Executable
+893
@@ -0,0 +1,893 @@
|
||||
#!/bin/sh
|
||||
# dotup — the front door. One command from a bare machine to a finished one.
|
||||
#
|
||||
# It is a picker and an installer, not a picker next to an installer. Tick boxes
|
||||
# on groups AND on individual packages; one toggle rule everywhere: expand the
|
||||
# row to its leaves, and if every leaf is on turn them all off, otherwise turn
|
||||
# them all on. That single rule covers a group row, a package row, and a bulk
|
||||
# toggle over a filtered set.
|
||||
#
|
||||
# dotup the picker, then install what you ticked
|
||||
# dotup --unattended no UI: safe defaults, never prompts, never private
|
||||
# dotup --print resolve and print every command, install nothing
|
||||
#
|
||||
# POSIX sh on purpose: this runs on a bare box before anything is installed,
|
||||
# and macOS still ships bash 3.2 (no associative arrays).
|
||||
|
||||
set -eu
|
||||
|
||||
SELF=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)/$(basename -- "$0")
|
||||
HERE=$(dirname -- "$SELF")
|
||||
STATE=${DOTUP_STATE:-${XDG_CONFIG_HOME:-$HOME/.config}/dotfiles}
|
||||
SEL=$STATE/selected
|
||||
EXP=$STATE/expanded
|
||||
|
||||
# The manifest is data, not a script, so it does not live in bin/. Checked in
|
||||
# the development layout first so the repo's own test suite and a checkout both
|
||||
# work without setting anything.
|
||||
if [ -n "${DOTUP_MANIFEST:-}" ]; then MANIFEST=$DOTUP_MANIFEST
|
||||
elif [ -f "$HERE/packages.tsv" ]; then MANIFEST=$HERE/packages.tsv
|
||||
else MANIFEST=${XDG_DATA_HOME:-$HOME/.local/share}/dotup/packages.tsv
|
||||
fi
|
||||
|
||||
[ -n "${DISPLAY:-}${WAYLAND_DISPLAY:-}" ] && HAS_DISPLAY=1 || HAS_DISPLAY=0
|
||||
case $(uname -s) in Darwin) PLAT=brew ;; *) PLAT=apt ;; esac
|
||||
|
||||
UNATTENDED=0
|
||||
DRYRUN=0
|
||||
ASSUME_YES=0
|
||||
|
||||
mkdir -p "$STATE"
|
||||
[ -f "$SEL" ] || : > "$SEL"
|
||||
[ -f "$EXP" ] || : > "$EXP"
|
||||
|
||||
R=''; DIM=''; B=''; GRN=''; YEL=''; RED=''
|
||||
if [ -t 1 ]; then
|
||||
R=$(printf '\033[0m'); DIM=$(printf '\033[2m'); B=$(printf '\033[1m')
|
||||
GRN=$(printf '\033[32m'); YEL=$(printf '\033[33m'); RED=$(printf '\033[31m')
|
||||
fi
|
||||
say() { printf '%s\n' "$*" >&2; }
|
||||
warn() { printf '%s!%s %s\n' "$YEL" "$R" "$*" >&2; }
|
||||
err() { printf '%s✗%s %s\n' "$RED" "$R" "$*" >&2; }
|
||||
head_() { printf '\n%s%s%s\n' "$B" "$*" "$R" >&2; }
|
||||
|
||||
# ---------------------------------------------------------------- leaves ----
|
||||
# Expand a row key to the package keys it covers.
|
||||
# g:agents -> agents/codex agents/pi ...
|
||||
# p:agents/pi -> agents/pi
|
||||
leaves() {
|
||||
for key in "$@"; do
|
||||
case $key in
|
||||
g:*) grp=${key#g:}
|
||||
awk -F'\t' -v g="$grp" '!/^[#@]/ && NF>=3 && $1==g {print $1"/"$2}' "$MANIFEST" ;;
|
||||
p:*) printf '%s\n' "${key#p:}" ;;
|
||||
esac
|
||||
done
|
||||
}
|
||||
|
||||
# ------------------------------------------------------------------ deps ----
|
||||
# Dependencies are @needs directives, not a sixth column: the TSV stays five
|
||||
# wide and greppable, and a package with no dependencies costs nothing to read.
|
||||
#
|
||||
# @needs <group/pkg> <dep> [dep ...] dep is group/pkg or a whole group
|
||||
#
|
||||
# There is no "requires" state to display. Ticking a box ticks what it needs;
|
||||
# unticking one unticks what needed it. The counts on screen move as it happens,
|
||||
# so the closure is visible rather than described.
|
||||
|
||||
# stdin: keys -> stdout: those keys plus everything they need, transitively.
|
||||
expand_deps() {
|
||||
work=$(sort -u); prev=
|
||||
while [ "$work" != "$prev" ]; do
|
||||
prev=$work
|
||||
add=$(printf '%s\n' "$work" | while read -r k; do
|
||||
[ -n "$k" ] || continue
|
||||
awk -F'\t' -v k="$k" '$1=="@needs" && $2==k {print $3}' "$MANIFEST"
|
||||
done | tr ' ' '\n' | while read -r d; do
|
||||
[ -n "$d" ] || continue
|
||||
case $d in
|
||||
*/*) printf '%s\n' "$d" ;;
|
||||
*) awk -F'\t' -v g="$d" '!/^[#@]/ && NF>=3 && $1==g {print $1"/"$2}' "$MANIFEST" ;;
|
||||
esac
|
||||
done)
|
||||
work=$(printf '%s\n%s\n' "$work" "$add" | grep . | sort -u)
|
||||
done
|
||||
printf '%s\n' "$work"
|
||||
}
|
||||
|
||||
# stdin: keys -> stdout: those keys plus everything that needs them.
|
||||
expand_rdeps() {
|
||||
work=$(sort -u); prev=
|
||||
while [ "$work" != "$prev" ]; do
|
||||
prev=$work
|
||||
add=$(printf '%s\n' "$work" | while read -r k; do
|
||||
[ -n "$k" ] || continue
|
||||
awk -F'\t' -v k="$k" -v g="${k%%/*}" '$1=="@needs"{
|
||||
n=split($3, d, " ")
|
||||
for(i=1;i<=n;i++) if (d[i]==k || d[i]==g) print $2 }' "$MANIFEST"
|
||||
done)
|
||||
work=$(printf '%s\n%s\n' "$work" "$add" | grep . | sort -u)
|
||||
done
|
||||
printf '%s\n' "$work"
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------- toggle ----
|
||||
# All on -> all off. Anything else -> all on.
|
||||
cmd_toggle() {
|
||||
want=$(leaves "$@" | sort -u)
|
||||
[ -n "$want" ] || return 0
|
||||
all_on=1
|
||||
for k in $want; do grep -qxF "$k" "$SEL" || { all_on=0; break; }; done
|
||||
# Decide direction on what you touched, then widen along the dependency
|
||||
# edges: switching on pulls in what it needs, switching off drops what
|
||||
# needed it. Either way the ticks never describe a broken machine.
|
||||
if [ "$all_on" -eq 1 ]; then want=$(printf '%s\n' "$want" | expand_rdeps)
|
||||
else want=$(printf '%s\n' "$want" | expand_deps); fi
|
||||
tmp=$STATE/.sel.$$
|
||||
grep -vxF -f - "$SEL" > "$tmp" <<-EOF || :
|
||||
$want
|
||||
EOF
|
||||
if [ "$all_on" -eq 0 ]; then printf '%s\n' "$want" >> "$tmp"; fi
|
||||
sort -u "$tmp" > "$SEL" && rm -f "$tmp"
|
||||
}
|
||||
|
||||
cmd_expand() {
|
||||
for key in "$@"; do
|
||||
case $key in g:*) g=${key#g:} ;; p:*) g=${key#p:}; g=${g%%/*} ;; *) continue ;; esac
|
||||
tmp=$STATE/.exp.$$
|
||||
if grep -qxF "$g" "$EXP"; then grep -vxF "$g" "$EXP" > "$tmp" || :
|
||||
else cp "$EXP" "$tmp"; printf '%s\n' "$g" >> "$tmp"; fi
|
||||
mv "$tmp" "$EXP"
|
||||
done
|
||||
}
|
||||
|
||||
cmd_expand_all() {
|
||||
if [ -s "$EXP" ]; then : > "$EXP"
|
||||
else awk -F'\t' '!/^[#@]/ && NF>=3 {print $1}' "$MANIFEST" | awk '!seen[$0]++' > "$EXP"; fi
|
||||
}
|
||||
|
||||
# --------------------------------------------------------------- presets ----
|
||||
cmd_preset() {
|
||||
case ${1:-defaults} in
|
||||
none) : > "$SEL" ;;
|
||||
safe) awk -F'\t' '!/^[#@]/ && NF>=3 && $3=="safe" {print $1"/"$2}' "$MANIFEST" > "$SEL" ;;
|
||||
defaults)
|
||||
awk -F'\t' -v d="$HAS_DISPLAY" \
|
||||
'!/^[#@]/ && NF>=3 && ($3=="safe" || ($3=="gui" && d==1)) {print $1"/"$2}' \
|
||||
"$MANIFEST" > "$SEL" ;;
|
||||
esac
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------- render ----
|
||||
cmd_render() {
|
||||
awk -F'\t' -v selfile="$SEL" -v expfile="$EXP" -v plat="$PLAT" -v disp="$HAS_DISPLAY" '
|
||||
function sev(f){ return f=="invasive"?3 : f=="private"?2 : f=="gui"?1 : 0 }
|
||||
function lbl(s){ return s==3?"invasive" : s==2?"private" : s==1?"gui" : "safe" }
|
||||
function col(s){ return s==3?RED : s==2?MAG : s==1?CYA : GRN }
|
||||
# Linux is apt-then-brew, not apt-instead-of-brew: all three machines run
|
||||
# linuxbrew, and omp/herdr/lazygit exist only as taps.
|
||||
# The fallback runs one way only. Linux is apt-then-brew because all three
|
||||
# machines run linuxbrew and omp/herdr/lazygit exist only as taps. There is
|
||||
# no apt on a Mac, so a bare `-` in the brew column means unavailable — the
|
||||
# other direction would offer to `apt install davfs2` on macOS.
|
||||
function src(a,b, v,p){ if (plat=="brew") { v=b; p="brew" }
|
||||
else { v=a; p="apt"; if (v=="-") { v=b; p="brew" } }
|
||||
if (v=="-") return "unavailable"
|
||||
if (substr(v,1,1)=="-") return substr(v,2)
|
||||
return p" "v }
|
||||
function cut(s,n){ return length(s)>n ? substr(s,1,n-1) "\342\200\246" : s }
|
||||
BEGIN{
|
||||
R="\033[0m"; DIM="\033[2m"; B="\033[1m"
|
||||
GRN="\033[32m"; YEL="\033[33m"; RED="\033[31m"; CYA="\033[36m"; MAG="\033[35m"
|
||||
while((getline l < selfile) > 0) sel[l]=1
|
||||
while((getline l < expfile) > 0) expd[l]=1
|
||||
}
|
||||
# @group<TAB>note — a group whose members fail the safe test for several
|
||||
# different reasons needs its own line. Deriving it from the first child
|
||||
# would show "opens port 22" on a group that also mounts setuid helpers.
|
||||
$1=="@needs" || $1=="@spec" { next }
|
||||
/^@/ { gnote[substr($1,2)] = $2; next }
|
||||
/^#/ || NF<3 { next }
|
||||
{
|
||||
g=$1; p=$2; f=$3; key=g"/"p
|
||||
n++; G[n]=g; P[n]=p; F[n]=f; A[n]=$4; Bc[n]=$5; NT[n]=$6
|
||||
if (!(g in seen)) { seen[g]=1; order[++ng]=g }
|
||||
tot[g]++
|
||||
if (key in sel) { selc[g]++; on[n]=1 }
|
||||
if (sev(f) > worst[g]) worst[g]=sev(f)
|
||||
if (f=="invasive" || f=="private") why[g]=($6!="" && why[g]=="") ? $6 : why[g]
|
||||
}
|
||||
END{
|
||||
for (i=1; i<=ng; i++) {
|
||||
g=order[i]; s=worst[g]; c=selc[g]+0; t=tot[g]
|
||||
mark = (c==t) ? "x" : (c>0 ? "~" : " ")
|
||||
mc = (c==t) ? GRN : (c>0 ? YEL : DIM)
|
||||
arrow = (g in expd) ? "\342\226\276" : "\342\226\270"
|
||||
reason = (g in gnote) ? gnote[g] : why[g]
|
||||
note = (s>=2 && reason!="") ? " " col(s) reason R : ""
|
||||
printf "%s %s[%s]%s %s%-13s%s %s%5s%s %s%-8s%s%s\t%s\n",
|
||||
arrow, mc, mark, R, B, g, R, DIM, c"/"t, R, col(s), lbl(s), R, cut(note,44), "g:" g
|
||||
if (!(g in expd)) continue
|
||||
for (j=1; j<=n; j++) {
|
||||
if (G[j]!=g) continue
|
||||
m = (j in on) ? "x" : " "
|
||||
mc = (j in on) ? GRN : DIM
|
||||
d = src(A[j], Bc[j])
|
||||
if (NT[j]!="") d = d " \302\267 " NT[j]
|
||||
printf " %s[%s]%s %-18s %s%s%s\t%s\n",
|
||||
mc, m, R, P[j], DIM, cut(d,46), R, "p:" g "/" P[j]
|
||||
}
|
||||
}
|
||||
}' "$MANIFEST"
|
||||
}
|
||||
|
||||
# --------------------------------------------------------------- explain ----
|
||||
cmd_explain() {
|
||||
key=${1:-}
|
||||
awk -F'\t' -v key="$key" -v selfile="$SEL" -v plat="$PLAT" '
|
||||
function src(a,b, v,p){ if (plat=="brew") { v=b; p="brew" }
|
||||
else { v=a; p="apt"; if (v=="-") { v=b; p="brew" } }
|
||||
if (v=="-") return "not available"
|
||||
if (substr(v,1,1)=="-") return substr(v,2)
|
||||
return p " install " v }
|
||||
BEGIN{
|
||||
R="\033[0m"; DIM="\033[2m"; B="\033[1m"; RED="\033[31m"; GRN="\033[32m"
|
||||
while((getline l < selfile) > 0) sel[l]=1
|
||||
split(key, kk, ":"); kind=kk[1]; rest=substr(key, index(key,":")+1)
|
||||
if (kind=="g") { wantg=rest } else { split(rest, pp, "/"); wantg=pp[1]; wantp=pp[2] }
|
||||
}
|
||||
# A key may carry several @needs lines; keep them all, not just the last.
|
||||
$1=="@needs" { if ($2==rest) needs = needs (needs==""?"":" ") $3; next }
|
||||
$1=="@spec" { if ($2==rest) spec=$3; next }
|
||||
/^@/ { if (substr($1,2)==wantg) gnote=$2; next }
|
||||
/^#/ || NF<3 { next }
|
||||
$1==wantg {
|
||||
if (wantp=="" ) {
|
||||
t++; if (($1"/"$2) in sel) c++
|
||||
list = list sprintf(" %s %s\n", (($1"/"$2) in sel)?GRN "\342\234\223" R:DIM "\342\227\246" R, $2)
|
||||
if ($6!="" && flagnote=="") flagnote=$6
|
||||
f=$3
|
||||
} else if ($2==wantp) {
|
||||
printf "%s%s%s\n\n", B, $2, R
|
||||
printf "%sgroup%s %s\n", DIM, R, $1
|
||||
printf "%srisk%s %s\n", DIM, R, $3
|
||||
printf "%sapt%s %s\n", DIM, R, ($4=="-"?"not available":$4)
|
||||
printf "%sbrew%s %s\n", DIM, R, ($5=="-"?"not available":$5)
|
||||
printf "%sinstall%s %s\n", DIM, R, src($4,$5)
|
||||
if ($6!="") printf "\n%s\n", $6
|
||||
done=1
|
||||
}
|
||||
}
|
||||
END{
|
||||
if (done) {
|
||||
if (spec!="") printf "\n%sspec%s %s\n", DIM, R, spec
|
||||
if (needs!="") printf "\n%sneeds%s %s%s\n", DIM, R, needs, DIM " (ticked automatically)" R
|
||||
printf "\n%sstate%s %s\n", DIM, R, (key_in_sel()) ? GRN "selected" R : DIM "not selected" R
|
||||
exit
|
||||
}
|
||||
printf "%s%s%s %s%d of %d selected%s\n\n", B, wantg, R, DIM, c, t, R
|
||||
printf "%s\n", list
|
||||
if (f=="invasive") printf "%sinvasive%s %s\n", RED, R, (gnote!="" ? gnote : flagnote)
|
||||
if (f=="private") printf "%s\n", (gnote!="" ? gnote : "one password, typed after the install finishes")
|
||||
}
|
||||
function key_in_sel(){ return (rest in sel) }
|
||||
' "$MANIFEST"
|
||||
}
|
||||
|
||||
# ------------------------------------------------------------- resolution ----
|
||||
# One place decides, for a selected package on this platform, what actually
|
||||
# installs it. Everything downstream — the plan, the installer, the dry run —
|
||||
# reads this and nothing else, so they cannot disagree.
|
||||
#
|
||||
# Output: <channel><TAB><argument>
|
||||
# apt|brew argument is the package name
|
||||
# npm|uv|snap|deb|flatpak argument is the @spec, defaulting to the pkg name
|
||||
# tarball|script|xcode|builtin bespoke by nature; dispatched on the key
|
||||
# unavailable neither column offers it here
|
||||
resolve() {
|
||||
awk -F'\t' -v key="$1" -v plat="$PLAT" '
|
||||
$1=="@spec" { if ($2==key) spec=$3; next }
|
||||
/^[#@]/ || NF<3 { next }
|
||||
($1"/"$2)==key { pkg=$2; apt=$4; brw=$5; found=1 }
|
||||
END{
|
||||
if (!found) { print "missing\t"; exit }
|
||||
# apt-then-brew, not apt-instead-of-brew: omp, herdr and lazygit have no
|
||||
# apt package at all and install perfectly well from linuxbrew. The
|
||||
# fallback is one-directional on purpose — there is no apt on a Mac, so
|
||||
# a bare `-` in the brew column is the end of the road, not a reason to
|
||||
# go looking in a column that names Debian packages.
|
||||
if (plat=="brew") { v=brw; p="brew" }
|
||||
else { v=apt; p="apt"; if (v=="-") { v=brw; p="brew" } }
|
||||
if (v=="-") { print "unavailable\t"; exit }
|
||||
if (substr(v,1,1)=="-") { print substr(v,2) "\t" (spec!="" ? spec : pkg); exit }
|
||||
print p "\t" v
|
||||
}' "$MANIFEST"
|
||||
}
|
||||
|
||||
# Every selected key, minus the private rows — those are not packages.
|
||||
selected_packages() {
|
||||
awk -F'\t' -v selfile="$SEL" '
|
||||
BEGIN{ while((getline l < selfile)>0) sel[l]=1 }
|
||||
/^[#@]/ || NF<3 { next }
|
||||
$3=="private" { next }
|
||||
($1"/"$2) in sel { print $1"/"$2 }' "$MANIFEST"
|
||||
}
|
||||
|
||||
selected_private() {
|
||||
awk -F'\t' -v selfile="$SEL" '
|
||||
BEGIN{ while((getline l < selfile)>0) sel[l]=1 }
|
||||
/^[#@]/ || NF<3 { next }
|
||||
$3=="private" && (($1"/"$2) in sel) { print $1"/"$2 }' "$MANIFEST"
|
||||
}
|
||||
|
||||
# Build <channel><TAB><arg><TAB><key> for everything selected.
|
||||
plan_table() {
|
||||
selected_packages | while read -r k; do
|
||||
[ -n "$k" ] || continue
|
||||
printf '%s\t%s\n' "$(resolve "$k")" "$k"
|
||||
done
|
||||
}
|
||||
|
||||
cmd_plan() {
|
||||
head_ "plan"
|
||||
plan_table | sort | awk -F'\t' '
|
||||
BEGIN{ R="\033[0m"; B="\033[1m"; DIM="\033[2m"; YEL="\033[33m" }
|
||||
{ ch=$1; arg=$2; key=$3
|
||||
if (ch=="unavailable" || ch=="missing") { bad = bad " " key "\n"; nb++; next }
|
||||
line[ch] = line[ch] " " sprintf("%-28s %s", key, arg) "\n"; n++ }
|
||||
END{
|
||||
for (c in line) printf " %s%s%s\n%s", B, c, R, line[c]
|
||||
printf "\n %d packages\n", n
|
||||
if (nb) printf "\n %sno source on this platform (%d):%s\n%s", YEL, nb, R, bad
|
||||
}'
|
||||
}
|
||||
|
||||
# --------------------------------------------------------------- installer ---
|
||||
# Nothing here is clever. It batches what can be batched, refuses what it cannot
|
||||
# reach, and never lets one bad package sink the other thirty.
|
||||
# Written as an `if`, not `A || { B && C; }`: that form is one || list, so when
|
||||
# `command -v sudo` fails the whole list fails and `set -e` exits the script at
|
||||
# load. On a non-root machine with no sudo, dotup died before printing anything.
|
||||
SUDO=
|
||||
if [ "$(id -u)" != 0 ] && command -v sudo >/dev/null 2>&1; then SUDO=sudo; fi
|
||||
FAILED=$STATE/.failed.$$
|
||||
APT_UPDATED=0
|
||||
|
||||
have() { find_tool "$1" >/dev/null 2>&1; }
|
||||
|
||||
# A tool installed a moment ago is not on this process's PATH: the astral
|
||||
# installer drops uv in ~/.local/bin, npm -g honours the ~/.npmrc prefix, the go
|
||||
# tarball lands in /usr/local/go, and linuxbrew lives outside a non-login PATH.
|
||||
# The fix is to look in the places we just wrote to -- NOT to export a modified
|
||||
# PATH. A tool that refuses to shadow your fzf has no business rewriting PATH
|
||||
# for its own convenience either, and a child process's PATH would be a lie the
|
||||
# moment dotup exits.
|
||||
find_tool() {
|
||||
command -v "$1" 2>/dev/null && return 0
|
||||
for ft_c in "$HOME/.local/bin/$1" "$HOME/.npm-global/bin/$1" \
|
||||
"/usr/local/bin/$1" "/usr/local/go/bin/$1" \
|
||||
"/home/linuxbrew/.linuxbrew/bin/$1" "/opt/homebrew/bin/$1"; do
|
||||
[ -x "$ft_c" ] && { printf '%s\n' "$ft_c"; return 0; }
|
||||
done
|
||||
return 1
|
||||
}
|
||||
# Collapse a space-separated list to canonical form; an all-blank list becomes
|
||||
# empty, so a channel with nothing in it prints no header.
|
||||
norm() { printf '%s\n' "$*" | tr ' ' '\n' | grep . | tr '\n' ' ' | sed 's/ $//'; }
|
||||
|
||||
run() {
|
||||
if [ "$DRYRUN" -eq 1 ]; then printf ' + %s\n' "$*"; return 0; fi
|
||||
printf '%s + %s%s\n' "$DIM" "$*" "$R" >&2
|
||||
"$@"
|
||||
}
|
||||
run_sh() {
|
||||
if [ "$DRYRUN" -eq 1 ]; then printf ' + %s\n' "$1"; return 0; fi
|
||||
printf '%s + %s%s\n' "$DIM" "$1" "$R" >&2
|
||||
sh -c "$1"
|
||||
}
|
||||
note_fail() { printf '%s\t%s\n' "$1" "$2" >> "$FAILED"; err "$1: $2"; }
|
||||
|
||||
apt_update_once() {
|
||||
[ "$APT_UPDATED" -eq 0 ] || return 0
|
||||
APT_UPDATED=1
|
||||
run_sh "${SUDO:+$SUDO }apt-get update -qq" || warn "apt-get update failed; continuing with stale lists"
|
||||
}
|
||||
|
||||
# apt refuses the whole batch when one name is unknown, so ask first. A name apt
|
||||
# does not know is not a dead end: if the brew column offers it, it moves there.
|
||||
# That is what "apt-then-brew" has to mean in practice, and it is the difference
|
||||
# between 34 packages installed and 0.
|
||||
apt_known() { apt-cache show "$1" >/dev/null 2>&1; }
|
||||
|
||||
install_apt() {
|
||||
pkgs=$(norm "$1")
|
||||
[ -n "$pkgs" ] || return 0
|
||||
head_ "apt"
|
||||
apt_update_once
|
||||
keep=; moved=; unknown=
|
||||
for p in $pkgs; do
|
||||
if [ "$DRYRUN" -eq 1 ] || ! have apt-cache || apt_known "$p"; then keep="$keep $p"
|
||||
else unknown="$unknown $p"; fi
|
||||
done
|
||||
for p in $unknown; do
|
||||
k=$(awk -F'\t' -v p="$p" '!/^[#@]/ && NF>=3 && index(" "$4" "," "p" ") {print $1"/"$2; exit}' "$MANIFEST")
|
||||
b=$(awk -F'\t' -v p="$p" '!/^[#@]/ && NF>=3 && index(" "$4" "," "p" ") {print $5; exit}' "$MANIFEST")
|
||||
case $b in
|
||||
-|-*) note_fail "${k:-$p}" "apt does not know '$p' and there is no brew fallback" ;;
|
||||
*) warn "apt does not know '$p' — falling back to brew '$b'"; moved="$moved $b" ;;
|
||||
esac
|
||||
done
|
||||
if [ -n "$keep" ]; then
|
||||
# shellcheck disable=SC2086
|
||||
if ! run_sh "${SUDO:+$SUDO }DEBIAN_FRONTEND=noninteractive apt-get install -y$(printf ' %s' $keep)"; then
|
||||
warn "batch install failed; retrying one at a time so one bad package does not sink the rest"
|
||||
for p in $keep; do
|
||||
run_sh "${SUDO:+$SUDO }DEBIAN_FRONTEND=noninteractive apt-get install -y $p" \
|
||||
|| note_fail "$p" "apt install failed"
|
||||
done
|
||||
fi
|
||||
fi
|
||||
BREW_EXTRA=$moved
|
||||
}
|
||||
|
||||
install_brew() {
|
||||
pkgs=$(norm "$1")
|
||||
[ -n "$pkgs" ] || return 0
|
||||
head_ "brew"
|
||||
BREW=$(find_tool brew || echo brew)
|
||||
if ! have brew && [ "$DRYRUN" -eq 0 ]; then
|
||||
# Installing a second package manager is exactly the kind of thing the
|
||||
# `invasive` flag exists to refuse doing on your behalf. Say what to run.
|
||||
warn "brew is not installed; skipping:$pkgs"
|
||||
warn " install it first: /bin/bash -c \"\$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)\""
|
||||
for p in $pkgs; do note_fail "$p" "brew missing"; done
|
||||
return 0
|
||||
fi
|
||||
for p in $pkgs; do run_sh "$BREW install $p" || note_fail "$p" "brew install failed"; done
|
||||
}
|
||||
|
||||
install_npm() {
|
||||
specs=$(norm "$1")
|
||||
[ -n "$specs" ] || return 0
|
||||
head_ "npm"
|
||||
NPM=$(find_tool npm || echo npm)
|
||||
if ! have npm && [ "$DRYRUN" -eq 0 ]; then
|
||||
# Ubuntu's `nodejs` package ships node WITHOUT npm; the manifest asks
|
||||
# apt for both. If this fires anyway, node itself did not land.
|
||||
for p in $specs; do note_fail "$p" "npm missing — core/node did not install"; done
|
||||
return 0
|
||||
fi
|
||||
# shellcheck disable=SC2086
|
||||
run_sh "$NPM install -g$(printf ' %s' $specs)" || {
|
||||
warn "batch npm install failed; retrying one at a time"
|
||||
for p in $specs; do run_sh "$NPM install -g $p" || note_fail "$p" "npm install failed"; done
|
||||
}
|
||||
}
|
||||
|
||||
install_uv() {
|
||||
tools=$(norm "$1")
|
||||
[ -n "$tools" ] || return 0
|
||||
head_ "uv"
|
||||
UV=$(find_tool uv || echo uv)
|
||||
if ! have uv && [ "$DRYRUN" -eq 0 ]; then
|
||||
for t in $tools; do note_fail "$t" "uv missing — core/uv did not install"; done
|
||||
return 0
|
||||
fi
|
||||
for t in $tools; do run_sh "$UV tool install $t" || note_fail "$t" "uv tool install failed"; done
|
||||
}
|
||||
|
||||
install_snap() {
|
||||
names=$(norm "$1")
|
||||
[ -n "$names" ] || return 0
|
||||
head_ "snap"
|
||||
if ! have snap && [ "$DRYRUN" -eq 0 ]; then
|
||||
for n in $names; do note_fail "$n" "snapd is not present on this machine"; done
|
||||
return 0
|
||||
fi
|
||||
for n in $names; do run_sh "${SUDO:+$SUDO }snap install $n" || note_fail "$n" "snap install failed"; done
|
||||
}
|
||||
|
||||
install_flatpak() {
|
||||
ids=$(norm "$1")
|
||||
[ -n "$ids" ] || return 0
|
||||
head_ "flatpak"
|
||||
if ! have flatpak && [ "$DRYRUN" -eq 0 ]; then
|
||||
for i in $ids; do note_fail "$i" "flatpak is not present on this machine"; done
|
||||
return 0
|
||||
fi
|
||||
for i in $ids; do
|
||||
run_sh "flatpak install -y --noninteractive flathub $i" || note_fail "$i" "flatpak install failed"
|
||||
done
|
||||
}
|
||||
|
||||
# Two forms, because two real packages need two different things:
|
||||
# https://…/x.deb a stable vendor URL (chrome)
|
||||
# gh:<owner>/<repo>:<asset substring> latest release asset (ghostty)
|
||||
install_deb() {
|
||||
srcs=$(norm "$1")
|
||||
[ -n "$srcs" ] || return 0
|
||||
head_ "deb"
|
||||
for s in $srcs; do
|
||||
url=$s
|
||||
case $s in
|
||||
gh:*)
|
||||
spec=${s#gh:}; repo=${spec%%:*}; match=${spec#*:}
|
||||
if [ "$DRYRUN" -eq 1 ]; then
|
||||
printf ' + resolve latest %s asset matching *%s*\n' "$repo" "$match"
|
||||
url="https://github.com/$repo/releases/latest/<asset matching *$match*>"
|
||||
else
|
||||
url=$(curl -fsSL "https://api.github.com/repos/$repo/releases/latest" 2>/dev/null \
|
||||
| awk -F'"' -v m="$match" '/browser_download_url/ && index($4,m) {print $4; exit}')
|
||||
[ -n "$url" ] || { note_fail "$repo" "no release asset matching *$match*"; continue; }
|
||||
fi ;;
|
||||
esac
|
||||
f=${TMPDIR:-/tmp}/dotup-$$.deb
|
||||
run_sh "curl -fsSL '$url' -o '$f'" || { note_fail "$url" "download failed"; continue; }
|
||||
run_sh "${SUDO:+$SUDO }apt-get install -y '$f'" || note_fail "$url" "dpkg install failed"
|
||||
run_sh "rm -f '$f'"
|
||||
done
|
||||
}
|
||||
|
||||
# Bespoke by nature: each of these needs arch detection, a destination outside
|
||||
# $HOME, and a symlink. A data column cannot express that honestly, so the
|
||||
# dispatch is on the key and the code says what it does.
|
||||
install_bespoke() {
|
||||
bsp=$(norm "$2")
|
||||
[ -n "$bsp" ] || return 0
|
||||
head_ "$1"
|
||||
for key in $bsp; do
|
||||
case $key in
|
||||
core/neovim)
|
||||
# The one place the channel is prescribed rather than "whatever the
|
||||
# package manager has": 24.04's apt candidate is 0.9.5 and LazyVim
|
||||
# needs 0.12 for the kitty graphics protocol.
|
||||
if have nvim && [ "$DRYRUN" -eq 0 ]; then
|
||||
v=$(nvim --version 2>/dev/null | awk 'NR==1{print $2}' | tr -d 'v')
|
||||
case $v in 0.1[2-9]*|0.[2-9]*|[1-9]*) say " nvim $v already above 0.12 — leaving it"; continue ;; esac
|
||||
fi
|
||||
case $(uname -m) in
|
||||
x86_64|amd64) a=x86_64 ;;
|
||||
aarch64|arm64) a=arm64 ;;
|
||||
*) note_fail "$key" "no neovim tarball for $(uname -m)"; continue ;;
|
||||
esac
|
||||
b=https://github.com/neovim/neovim/releases/latest/download
|
||||
run_sh "curl -fsSL '$b/nvim-linux-$a.tar.gz' -o /tmp/nvim.tgz || curl -fsSL '$b/nvim-linux64.tar.gz' -o /tmp/nvim.tgz" \
|
||||
|| { note_fail "$key" "tarball download failed"; continue; }
|
||||
run_sh "${SUDO:+$SUDO }rm -rf /opt/nvim && ${SUDO:+$SUDO }mkdir -p /opt/nvim && ${SUDO:+$SUDO }tar -xzf /tmp/nvim.tgz -C /opt/nvim --strip-components=1" \
|
||||
|| { note_fail "$key" "tarball extract failed"; continue; }
|
||||
run_sh "${SUDO:+$SUDO }ln -sf /opt/nvim/bin/nvim /usr/local/bin/nvim"
|
||||
run_sh "rm -f /tmp/nvim.tgz" ;;
|
||||
core/go)
|
||||
if have go && [ "$DRYRUN" -eq 0 ]; then say " go already present — leaving it"; continue; fi
|
||||
case $(uname -s) in Darwin) o=darwin ;; *) o=linux ;; esac
|
||||
case $(uname -m) in x86_64|amd64) a=amd64 ;; aarch64|arm64) a=arm64 ;;
|
||||
*) note_fail "$key" "no go tarball for $(uname -m)"; continue ;; esac
|
||||
if [ "$DRYRUN" -eq 1 ]; then v='go1.X.Y'
|
||||
else v=$(curl -fsSL 'https://go.dev/VERSION?m=text' 2>/dev/null | head -1); fi
|
||||
[ -n "$v" ] || { note_fail "$key" "could not resolve the current go version"; continue; }
|
||||
run_sh "curl -fsSL 'https://go.dev/dl/$v.$o-$a.tar.gz' -o /tmp/go.tgz" \
|
||||
|| { note_fail "$key" "tarball download failed"; continue; }
|
||||
run_sh "${SUDO:+$SUDO }rm -rf /usr/local/go && ${SUDO:+$SUDO }tar -xzf /tmp/go.tgz -C /usr/local" \
|
||||
|| { note_fail "$key" "tarball extract failed"; continue; }
|
||||
run_sh "rm -f /tmp/go.tgz" ;;
|
||||
core/chezmoi)
|
||||
# Circular by nature: dotup arrives *via* chezmoi. Present already
|
||||
# in every case that matters; here for the one where it is not.
|
||||
if have chezmoi && [ "$DRYRUN" -eq 0 ]; then say " chezmoi already present — leaving it"; continue; fi
|
||||
run_sh "sh -c \"\$(curl -fsLS get.chezmoi.io)\" -- -b \"\$HOME/.local/bin\"" \
|
||||
|| note_fail "$key" "installer failed" ;;
|
||||
core/uv)
|
||||
if have uv && [ "$DRYRUN" -eq 0 ]; then say " uv already present — leaving it"; continue; fi
|
||||
run_sh "curl -LsSf https://astral.sh/uv/install.sh | sh" || note_fail "$key" "installer failed" ;;
|
||||
core/build-tools)
|
||||
# macOS only — the Linux side is build-essential through apt.
|
||||
if [ "$DRYRUN" -eq 0 ] && xcode-select -p >/dev/null 2>&1; then
|
||||
say " Xcode command line tools already installed"; continue
|
||||
fi
|
||||
run_sh "xcode-select --install" || note_fail "$key" "xcode-select --install failed" ;;
|
||||
core/zsh|networking/openssh-server)
|
||||
say " $key: built into macOS, nothing to install" ;;
|
||||
*) note_fail "$key" "no handler for channel '$1'" ;;
|
||||
esac
|
||||
done
|
||||
}
|
||||
|
||||
cmd_install() {
|
||||
: > "$FAILED"
|
||||
tbl=$STATE/.plan.$$
|
||||
plan_table > "$tbl"
|
||||
|
||||
# Two filters, and they are the reason this is safe to run unattended.
|
||||
# `private` is never a package: it needs a password nobody is there to type.
|
||||
# `invasive` is never installed by a run with nobody at the keyboard, even
|
||||
# if a stale state file says otherwise — the boundary holds because of what
|
||||
# this refuses, not because of what it was asked.
|
||||
if [ "$UNATTENDED" -eq 1 ]; then
|
||||
inv=$(awk -F'\t' -v selfile="$SEL" '
|
||||
BEGIN{ while((getline l < selfile)>0) sel[l]=1 }
|
||||
!/^[#@]/ && NF>=3 && $3=="invasive" && (($1"/"$2) in sel) {print $1"/"$2}' "$MANIFEST")
|
||||
if [ -n "$inv" ]; then
|
||||
warn "unattended: refusing invasive packages$(printf ' %s' $inv)"
|
||||
for k in $inv; do
|
||||
awk -F'\t' -v k="$k" '$3!=k' "$tbl" > "$tbl.f"; mv "$tbl.f" "$tbl"
|
||||
done
|
||||
fi
|
||||
fi
|
||||
|
||||
col() { awk -F'\t' -v c="$1" '$1==c {print $2}' "$tbl" | sort -u | tr '\n' ' '; }
|
||||
keys() { awk -F'\t' -v c="$1" '$1==c {print $3}' "$tbl" | sort -u | tr '\n' ' '; }
|
||||
|
||||
bad=$(awk -F'\t' '$1=="unavailable" || $1=="missing" {print $3}' "$tbl" | tr '\n' ' ')
|
||||
[ -z "$bad" ] || warn "no source on this platform:$bad"
|
||||
|
||||
# Order is a fixed pipeline, not a topological sort, because the real
|
||||
# manifest has exactly two ordering constraints and both are channel-level:
|
||||
# npm needs node (apt/brew), and uv tools need uv (script).
|
||||
BREW_EXTRA=
|
||||
install_apt "$(col apt)"
|
||||
install_brew "$(col brew) $BREW_EXTRA"
|
||||
install_bespoke script "$(keys script)"
|
||||
install_bespoke tarball "$(keys tarball)"
|
||||
install_bespoke builtin "$(keys builtin)"
|
||||
install_bespoke xcode "$(keys xcode)"
|
||||
install_snap "$(col snap)"
|
||||
install_deb "$(col deb)"
|
||||
install_flatpak "$(col flatpak)"
|
||||
install_npm "$(col npm)"
|
||||
install_uv "$(col uv)"
|
||||
|
||||
rm -f "$tbl"
|
||||
if [ -s "$FAILED" ]; then
|
||||
head_ "did not install"
|
||||
while IFS=" " read -r what why; do printf ' %s%-32s%s %s\n' "$RED" "$what" "$R" "$why" >&2; done < "$FAILED"
|
||||
n=$(grep -c . "$FAILED")
|
||||
rm -f "$FAILED"
|
||||
printf '\n%s%d package(s) did not install.%s Everything else did.\n' "$YEL" "$n" "$R" >&2
|
||||
return 1
|
||||
fi
|
||||
rm -f "$FAILED"
|
||||
head_ "done"
|
||||
return 0
|
||||
}
|
||||
|
||||
# ----------------------------------------------------------------- private ---
|
||||
# The tick does not do the work, it schedules it. git, chezmoi and bws have to
|
||||
# exist before either row can act, and a password typed at picker time would sit
|
||||
# in memory for the ten minutes of package downloads in between. So it happens
|
||||
# here, immediately before it is used, and never at all without a human.
|
||||
PRIV_SRC=${DOTUP_PRIVATE_SRC:-${XDG_DATA_HOME:-$HOME/.local/share}/dotfiles-private}
|
||||
BWS_TOKEN=${DOTUP_BWS_TOKEN:-${XDG_CONFIG_HOME:-$HOME/.config}/bitwarden/bws-token}
|
||||
|
||||
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://<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"
|
||||
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
|
||||
run chezmoi init --apply --source "$PRIV_SRC" "$p_repo" \
|
||||
|| err "private repo init failed"
|
||||
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<y) exit 1 }
|
||||
exit 0 }'
|
||||
}
|
||||
|
||||
fzf_version() { fzf --version 2>/dev/null | awk '{print $1}'; }
|
||||
|
||||
install_fzf() {
|
||||
case $(uname -s) in Darwin) os=darwin ;; *) os=linux ;; esac
|
||||
case $(uname -m) in
|
||||
x86_64|amd64) arch=amd64 ;;
|
||||
aarch64|arm64) arch=arm64 ;;
|
||||
armv7l) arch=armv7 ;;
|
||||
*) echo "dotup: unsupported arch $(uname -m); install fzf yourself" >&2; return 1 ;;
|
||||
esac
|
||||
# The release tag gained a leading v between 0.53.0 and 0.55.0. Anything we
|
||||
# would pin today is above that; the fallback keeps an older pin working.
|
||||
base=https://github.com/junegunn/fzf/releases/download
|
||||
mkdir -p "$FZF_CACHE"
|
||||
tmp=$FZF_CACHE/.fzf.$$
|
||||
echo "dotup: fetching fzf $FZF_PIN ($os/$arch) for its own use" >&2
|
||||
if curl -sfL "$base/v$FZF_PIN/fzf-$FZF_PIN-${os}_${arch}.tar.gz" | tar xz -O fzf > "$tmp" 2>/dev/null && [ -s "$tmp" ]; then :
|
||||
elif curl -sfL "$base/$FZF_PIN/fzf-$FZF_PIN-${os}_${arch}.tar.gz" | tar xz -O fzf > "$tmp" 2>/dev/null && [ -s "$tmp" ]; then :
|
||||
else rm -f "$tmp"; echo "dotup: could not fetch fzf $FZF_PIN" >&2; return 1; fi
|
||||
chmod +x "$tmp" && mv "$tmp" "$FZF_CACHE/fzf"
|
||||
}
|
||||
|
||||
# Resolution order, cheapest first. The machine's own fzf wins when it clears
|
||||
# the floor — nothing is replaced merely for being old.
|
||||
ensure_fzf() {
|
||||
if [ -x "$FZF_CACHE/fzf" ] && ver_ge "$("$FZF_CACHE/fzf" --version 2>/dev/null | awk '{print $1}')" "$FZF_FLOOR"; then
|
||||
FZF=$FZF_CACHE/fzf; return 0
|
||||
fi
|
||||
if command -v fzf >/dev/null 2>&1; then
|
||||
cur=$(fzf_version)
|
||||
if [ -n "$cur" ] && ver_ge "$cur" "$FZF_FLOOR"; then
|
||||
FZF=$(command -v fzf); return 0
|
||||
fi
|
||||
echo "dotup: system fzf $cur is below the verified floor $FZF_FLOOR" >&2
|
||||
fi
|
||||
install_fzf || return 1
|
||||
FZF=$FZF_CACHE/fzf
|
||||
}
|
||||
|
||||
# ------------------------------------------------------------------- pick ----
|
||||
cmd_pick() {
|
||||
ensure_fzf || { echo "dotup: no usable fzf; use the numbered prompt" >&2; return 2; }
|
||||
[ -s "$SEL" ] || cmd_preset defaults
|
||||
# --exact is a safety property, not a preference. ^t toggles every row the
|
||||
# filter is showing, so the filter must mean exactly what it looks like.
|
||||
# Fuzzy-matching "nvidia" also matches docker, tailscale and desktop.
|
||||
cmd_render | "$FZF" --ansi --exact --no-sort --cycle --multi --layout=reverse --height=100% \
|
||||
--delimiter='\t' --with-nth=1 --pointer='>' --marker=' ' \
|
||||
--info=inline --border=none \
|
||||
--header=$'space tick tab open ^t tick all shown ^a defaults ^x none ^o open all enter install\n' \
|
||||
--preview "$SELF explain {2}" --preview-window='right,46%,wrap,border-left' \
|
||||
--bind "space:execute-silent($SELF toggle {2})+reload($SELF render)" \
|
||||
--bind "tab:execute-silent($SELF expand {2})+reload($SELF render)" \
|
||||
--bind "ctrl-t:select-all+execute-silent($SELF toggle {+2})+clear-selection+reload($SELF render)" \
|
||||
--bind "ctrl-a:execute-silent($SELF preset defaults)+reload($SELF render)" \
|
||||
--bind "ctrl-x:execute-silent($SELF preset none)+reload($SELF render)" \
|
||||
--bind "ctrl-o:execute-silent($SELF expand-all)+reload($SELF render)" \
|
||||
--bind 'enter:accept' > /dev/null || return 1
|
||||
}
|
||||
|
||||
confirm() {
|
||||
[ "$ASSUME_YES" -eq 0 ] || return 0
|
||||
[ -t 0 ] || return 0
|
||||
printf '\n install? [y/N] ' >&2
|
||||
a=''; IFS= read -r a || a=''
|
||||
case $a in y|Y|yes|YES) return 0 ;; *) say " nothing installed."; return 1 ;; esac
|
||||
}
|
||||
|
||||
cmd_run() {
|
||||
if [ "$UNATTENDED" -eq 1 ]; then
|
||||
# Deterministic by construction: computed fresh from the manifest, never
|
||||
# inherited from whatever a previous interactive run left in the state
|
||||
# file. "What a VM or CI run gets" has to mean the same thing twice.
|
||||
cmd_preset defaults
|
||||
cmd_plan
|
||||
else
|
||||
cmd_pick || return $?
|
||||
cmd_plan
|
||||
confirm || return 0
|
||||
fi
|
||||
rc=0
|
||||
cmd_install || rc=$?
|
||||
cmd_private || :
|
||||
return $rc
|
||||
}
|
||||
|
||||
# ------------------------------------------------------------------ usage ----
|
||||
usage() {
|
||||
cat >&2 <<-EOF
|
||||
usage: dotup [--unattended] [--print] [--yes]
|
||||
|
||||
(no flags) the picker, then install what you ticked
|
||||
--unattended no UI: safe defaults, never prompts, never private
|
||||
--print, -n resolve everything and print the commands, install nothing
|
||||
--yes, -y skip the confirmation after the picker
|
||||
|
||||
plumbing, called by the fzf bindings:
|
||||
render toggle expand expand-all preset explain plan preflight
|
||||
EOF
|
||||
}
|
||||
|
||||
CMD=
|
||||
while [ $# -gt 0 ]; do
|
||||
case $1 in
|
||||
--unattended) UNATTENDED=1; ASSUME_YES=1 ;;
|
||||
--print|-n) DRYRUN=1 ;;
|
||||
--yes|-y) ASSUME_YES=1 ;;
|
||||
-h|--help) usage; exit 0 ;;
|
||||
--*) err "unknown flag: $1"; usage; exit 2 ;;
|
||||
*) CMD=$1; shift; break ;;
|
||||
esac
|
||||
shift
|
||||
done
|
||||
|
||||
case ${CMD:-run} in
|
||||
run) cmd_run ;;
|
||||
pick) cmd_pick && cmd_plan ;;
|
||||
install) cmd_install ;;
|
||||
private) cmd_private ;;
|
||||
render) cmd_render ;;
|
||||
toggle) cmd_toggle "$@" ;;
|
||||
expand) cmd_expand "$@" ;;
|
||||
expand-all) cmd_expand_all ;;
|
||||
preset) cmd_preset "${1:-defaults}" ;;
|
||||
explain) cmd_explain "${1:-}" ;;
|
||||
plan) cmd_plan ;;
|
||||
resolve) resolve "${1:-}" ;; # test hook
|
||||
preflight) ensure_fzf && echo "using $FZF ($("$FZF" --version | awk '{print $1}'), floor $FZF_FLOOR)" ;;
|
||||
fzf-path) ensure_fzf >/dev/null 2>&1 && echo "$FZF" ;; # test hook
|
||||
vercmp) ver_ge "$1" "$2" ;; # test hook
|
||||
*) usage; exit 2 ;;
|
||||
esac
|
||||
Reference in New Issue
Block a user