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:
bcherb2
2026-08-17 00:11:52 -04:00
commit b487b0e855
71 changed files with 7824 additions and 0 deletions
+893
View File
@@ -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
+167
View File
@@ -0,0 +1,167 @@
#!/usr/bin/env bash
# sysjournal — shared system-knowledge journal for the Obsidian vault.
#
# One tool, used by every coding agent (Claude Code, Codex, Pi), so host/
# environment changes get recorded in ONE consistent, greppable place with
# the frontmatter schema that "Tech/Infrastructure/System Log MOC.md" indexes
# via Dataview (date, machine, type, status, tags, review-by).
#
# Journal HOST/ENVIRONMENT changes — system config, services, networking,
# VMs, drivers, build toolchains, host-wiring of an app. NOT ordinary work
# inside a code repo. See `sysjournal help`.
#
# Portable bash (invoked by agents in varied environments), not zsh.
set -euo pipefail
VAULT="${SYSJOURNAL_VAULT:-$HOME/Documents/Obsidian25}"
SUBDIR="${SYSJOURNAL_SUBDIR:-Tech/Infrastructure}"
INFRA="$VAULT/$SUBDIR"
die() { printf 'sysjournal: %s\n' "$*" >&2; exit 1; }
ensure_dir() {
[ -d "$VAULT" ] || die "vault not found: $VAULT (set SYSJOURNAL_VAULT)"
mkdir -p "$INFRA"
}
usage() {
cat <<'EOF'
sysjournal — record & recall host/environment changes in the Obsidian vault.
USAGE
sysjournal search <keywords...> Recall: grep the journal first (case-insensitive)
sysjournal new "<Title>" [options] Scaffold a new note, print its path
sysjournal list [N] List the N most-recent notes (default 20)
sysjournal path Print the infrastructure folder path
sysjournal help This help
`new` OPTIONS
--type <t> change | setup | debug | fix | incident | note (default: change)
--status <s> deployed | resolved | unresolved | workaround | planned (default: deployed)
--tags a,b,c comma-separated tags
--machine <m> host the change was made on (default: `hostname -s`)
--review-by <YYYY-MM-DD> optional follow-up/expiry date
--summary "<one line>" optional lead line
WHAT TO JOURNAL
YES system config, services/daemons (systemd/launchd/cron), networking/DNS/
VPN/firewall, VMs & host containers, drivers/kernel/boot, disks/mounts,
build toolchains & global package installs, wiring an app into the host.
NO feature work, bug fixes, refactors, tests INSIDE a project repo.
Discriminator: does it change state outside the repo, on the host? If no, skip.
Straddle (build an app AND install it as a service): journal only the
host-wiring part (the unit/cron/port), not the app code.
EXAMPLES
sysjournal search systemd relay port
sysjournal new "WireGuard VPN to homelab" --type setup --tags wireguard,vpn,network
sysjournal new "DNS resolution flaky after netplan change" --type debug --status unresolved
EOF
}
cmd_search() {
ensure_dir
[ "$#" -ge 1 ] || die "search needs at least one keyword"
# OR-match the keywords so a few loosely-related terms still surface notes.
local pattern
pattern=$(printf '%s|' "$@"); pattern="${pattern%|}"
echo "# Journal matches in $SUBDIR for: $*"
echo
if ! rg -i --no-heading -n -C1 --color never -e "$pattern" "$INFRA" 2>/dev/null; then
echo "(no matches — nothing journaled on this yet)"
fi
}
cmd_list() {
ensure_dir
local n="${1:-20}"
# Newest first by mtime; strip the vault prefix for readability.
find "$INFRA" -maxdepth 1 -name '*.md' -printf '%T@ %p\n' 2>/dev/null \
| sort -rn | head -n "$n" | sed "s#[0-9.]* $INFRA/##"
}
cmd_path() { echo "$INFRA"; }
cmd_new() {
ensure_dir
local title="" type="change" status="deployed" tags="" machine review_by="" summary=""
machine="$(hostname -s 2>/dev/null || hostname)"
# First non-flag arg is the title.
while [ "$#" -gt 0 ]; do
case "$1" in
--type) type="${2:?--type needs a value}"; shift 2;;
--status) status="${2:?--status needs a value}"; shift 2;;
--tags) tags="${2:?--tags needs a value}"; shift 2;;
--machine) machine="${2:?--machine needs a value}"; shift 2;;
--review-by) review_by="${2:?--review-by needs a value}"; shift 2;;
--summary) summary="${2:?--summary needs a value}"; shift 2;;
--*) die "unknown option: $1";;
*) [ -z "$title" ] && title="$1" || die "unexpected arg: $1"; shift;;
esac
done
[ -n "$title" ] || die 'new needs a "<Title>"'
# Filename: keep the human title (Obsidian-friendly), drop only path-illegal chars.
local fname; fname=$(printf '%s' "$title" | tr '/\\' '--' | sed 's/[[:cntrl:]]//g; s/ */ /g; s/^ *//; s/ *$//')
local file="$INFRA/$fname.md"
if [ -e "$file" ]; then
echo "$file" # already exists — recall, don't clobber; edit/append this note.
echo "sysjournal: note already exists — edit it instead of creating a duplicate." >&2
return 0
fi
# YAML frontmatter — inline tag list, matching the MOC's own `tags: [moc]` style.
local yaml_tags="[]"
if [ -n "$tags" ]; then
yaml_tags="[$(printf '%s' "$tags" | sed 's/ *, */, /g')]"
fi
local date_today; date_today="$(date +%F)"
{
echo "---"
echo "date: $date_today"
echo "machine: $machine"
echo "type: $type"
echo "status: $status"
echo "tags: $yaml_tags"
[ -n "$review_by" ] && echo "review-by: $review_by"
echo "---"
echo
echo "# $title"
echo
[ -n "$summary" ] && { echo "$summary"; echo; }
echo "## Why"
echo
echo "## What changed"
echo
echo "## Design decisions / gotchas"
echo
echo "## Verify"
echo
echo '```'
echo '# command run + observed result'
echo '```'
echo
echo "## Status / follow-ups"
echo
echo "Related: "
} > "$file"
echo "$file"
}
main() {
local sub="${1:-help}"; shift || true
case "$sub" in
search|recall|grep) cmd_search "$@";;
new|add) cmd_new "$@";;
list|ls) cmd_list "$@";;
path|dir) cmd_path;;
help|-h|--help) usage;;
*) usage; die "unknown command: $sub";;
esac
}
main "$@"
+119
View File
@@ -0,0 +1,119 @@
#group pkg flag apt brew note
#
# Five columns, tab separated, greppable by hand. Anything that would have been
# a sixth column is an @ directive instead, so a package with no dependency and
# no install argument -- most of them -- costs nothing to read.
#
# @needs <group/pkg> <dep>... dep is group/pkg or a whole group.
# Closure is transitive in both directions.
# @spec <group/pkg> <arg>... Argument for a channel that cannot be named
# by the package: the npm spec, the flatpak
# app id, the .deb source. Defaults to the
# package name when absent.
# @<group> <note> Group note. Only needed where the members
# fail the safe test for different reasons;
# otherwise the worst child's note is right.
#
# A leading `-` in the apt or brew column means "not from this package manager":
# -tarball -npm -script -snap -deb -flatpak -uv -builtin -xcode.
# A bare `-` means unavailable there. Linux falls back to the brew column before
# giving up -- omp, herdr and lazygit have no apt package at all. The fallback is
# one-directional: there is no apt on a Mac, so a `-` in the brew column is the
# end of the road rather than a reason to read a column of Debian package names.
#
@needs networking/xrdp desktop
@needs networking/xorgxrdp desktop
@needs gpu/container-toolkit docker
@needs agents/codex core/node
@needs agents/pi core/node
@needs agents/pi-plugins core/node
@needs agents/pi-plugins agents/pi
@needs agents/specify-cli core/uv
@needs core/mermaid-cli core/node
@needs core/neovim core/imagemagick core/mermaid-cli
#
# npm names carry scopes that the plugin's short name does not. `npm i -g
# rpiv-btw` installs somebody else's package.
@spec agents/codex @openai/codex
@spec agents/pi @earendil-works/pi-coding-agent
@spec agents/pi-plugins @juicesharp/rpiv-ask-user-question @juicesharp/rpiv-btw @juicesharp/rpiv-todo @samfp/pi-memory @tmustier/pi-ralph-wiggum pi-markdown-preview pi-powerline-footer pi-simplify pi-subagents pi-web-access
@spec agents/specify-cli specify-cli
@spec core/mermaid-cli @mermaid-js/mermaid-cli
@spec core/bitwarden-cli bw
@spec apps/obsidian md.obsidian.Obsidian
@spec apps/chrome https://dl.google.com/linux/direct/google-chrome-stable_current_amd64.deb
@spec apps/ghostty gh:mkasberg/ghostty-ubuntu:_amd64.deb
core neovim safe -tarball neovim apt ships 0.9.5 — tarball to /opt/nvim
core ripgrep safe ripgrep ripgrep binary is rg
core fd safe fd-find fd binary is fdfind on ubuntu
core bat safe bat bat binary is batcat on ubuntu
core fzf safe fzf fzf for your shell (ctrl-r); the picker uses its own pinned copy
core eza safe eza eza
core zsh safe zsh -builtin login shell everywhere
core tmux safe tmux tmux
core git-lfs safe git-lfs git-lfs
core lazygit safe - lazygit no apt package
core gh safe gh gh
core chezmoi safe -script chezmoi bootstrap cannot come from the manifest
core go safe -tarball go /usr/local/go on linux
core uv safe -script uv astral installer to ~/.local/bin
core node safe nodejs npm node apt's nodejs ships node WITHOUT npm — both names needed
core imagemagick safe imagemagick imagemagick required by the nvim markdown render path
core mermaid-cli safe -npm -npm mmdc — nvim renders mermaid fences with it
core btop safe btop btop
core htop safe htop htop
core ncdu safe ncdu ncdu
core tree safe tree tree
core cmake safe cmake cmake
core ninja safe ninja-build ninja package name differs from binary
core build-tools safe build-essential -xcode
core magic-wormhole safe magic-wormhole magic-wormhole snap wormhole on dev
core bitwarden-cli safe -snap bitwarden-cli snap bw on ubuntu
core mosh safe mosh mosh
core nmap safe nmap nmap
core binwalk safe binwalk binwalk
core pipx safe pipx pipx
core age safe age age general purpose only now
agents codex safe -npm -npm @openai/codex — unpinned, always latest
agents pi safe -npm -npm @earendil-works/pi-coding-agent
agents pi-plugins safe -npm -npm 10 plugins: rpiv-*, pi-memory, pi-subagents, ...
agents omp safe - can1357/tap/omp oh my pi — tap only, pulls linuxbrew on linux
agents herdr safe - herdr terminal workspace manager for agents
agents specify-cli safe -uv -uv uv tool install
fonts hack-nerd-font safe fonts-powerline font-hack-nerd-font
fonts iosevka-nerd-font safe fonts-powerline font-iosevka-nerd-font
media ffmpeg safe ffmpeg ffmpeg
media sox safe sox sox
media p7zip safe p7zip-full p7zip
apps obsidian gui -flatpak obsidian
apps ghostty gui -deb ghostty
apps chrome gui -deb google-chrome
apps firefox gui firefox firefox
apps vlc gui vlc vlc
@networking daemons, listening ports, and setuid mount helpers
networking openssh-server invasive openssh-server -builtin opens port 22 on every network this box can reach
networking tailscale invasive tailscale tailscale daemon; joins a private network and rewrites DNS
networking avahi-daemon invasive avahi-daemon - daemon; broadcasts this host on the LAN
networking xrdp invasive xrdp - opens port 3389 · useless without the desktop group
networking xorgxrdp invasive xorgxrdp - xrdp's X backend
networking nfs-common invasive nfs-common - setuid mount helper
networking cifs-utils invasive cifs-utils - setuid mount helper
networking davfs2 invasive davfs2 - setuid mount helper
@docker daemon; membership in the docker group is root-equivalent
docker docker-ce invasive docker-ce - daemon; docker group is root-equivalent
docker docker-buildx invasive docker-buildx-plugin -
docker docker-compose invasive docker-compose-plugin -
@desktop changes the display manager — can leave you at a black screen
desktop xfce4 invasive xfce4 - changes the display manager
desktop lightdm invasive lightdm - CAN LEAVE YOU AT A BLACK SCREEN
@gpu kernel modules; a bad driver can break boot
gpu nvidia-driver invasive nvidia-driver-570 - kernel modules; can break boot
gpu cuda-toolkit invasive nvidia-cuda-toolkit -
gpu container-toolkit invasive nvidia-container-toolkit - requires docker
@virt daemon, bridges, and group membership
virt qemu invasive qemu-kvm -
virt libvirt invasive libvirt-daemon-system - daemon + group membership
virt virt-manager invasive virt-manager -
@private one password, typed after the install finishes
private private-repo private - - ~/.local/share/dotfiles-private — agent config, ssh config
private bws-secrets private - - 7 API keys into ~/.config/zsh/secrets.zsh
Can't render this file because it contains an unexpected character in line 17 and column 49.