Close the Linux gaps in greenfield setup - #3
Conversation
Testing a clean install on Ubuntu 25.10 turned up several places where the Linux path is quietly worse than macOS. Terminal font is now set automatically (linux-terminal.sh). macOS links an iTerm2 dynamic profile with the font baked in; Linux only ever printed an instruction, so a fresh machine renders the powerline prompt as tofu until the user finds the setting. Covers Ptyxis (the default since Ubuntu 25.10), GNOME Terminal and Console over gsettings, and exits 0 when there is no gsettings so CI and headless installs are unaffected. The README pointed at GNOME Terminal, which Ubuntu 25.10 no longer ships -- x-terminal-emulator resolves to ptyxis, and gnome-terminal is not installed. Personal mode installs the cask tools on Linux too (linux-extras.sh). cask lines are stripped there, so personal mode silently lacked Claude Code, the gcloud CLI and the 1Password CLI. Installs each from its official Linux channel; the Claude desktop app is documented rather than automated. Two smaller fixes: - The nerd font step tested for the directory, so a run that created it and then failed to extract would skip the install permanently. It now looks for the .ttf files themselves and recovers. - The chsh guard read $SHELL, which is the running shell rather than the login shell -- it fires inside editors, CI or a nested bash even when zsh is already the login shell. Reads the passwd entry instead. Verified on Ubuntu 25.10: full install in a sandboxed HOME, idempotent on rerun, recovers from an empty font dir, and Ptyxis font set live.
The previous commit read the login shell with getent, which does not exist on macOS. Under `set -euo pipefail` the failed command substitution took the whole installer down: CI failed with exit 127 right after the Neovim step, while Ubuntu passed. Use getent on Linux and dscl on macOS, and guard both so a missing command falls back to $SHELL instead of aborting.
…n font Blocking: - gpg --dearmor prompts before overwriting an existing keyring, which blocks the installer on a tty and fails with exit 2 without one. The keyring outlives the package, so this fires on any re-run after a failed apt step, after apt remove, or for anyone who already followed the vendor docs by hand. All three calls now pass --batch --yes. - have_sudo() tested [[ -t 0 ]], but the documented greenfield path pipes bootstrap.sh into bash, so stdin is the curl pipe the whole way through and the check is always false. That silently skipped gcloud and 1Password in exactly the command the README recommends. Probes /dev/tty instead. - linux-terminal.sh overwrote a deliberate font on every run. The iTerm2 comparison does not hold: that is a separate profile the user opts into. It now leaves a custom font alone, reports the previous value when it does replace one, and honours DOTFILES_TERMINAL_FONT as an explicit override. - The new else branch printed "Login shell already zsh" whenever zsh was missing, regardless of the actual shell. Conditions split, and the zsh-absent case now warns. - gnupg was assumed but never installed; added to the apt bootstrap, and linux-extras.sh installs it on demand once sudo is confirmed. Also: - Terminal setup no longer sits inside the fc-cache check; the two are unrelated, and fontconfig is only apt-installed when brew was absent, so a machine with brew already present never got configured. - Both scripts now return a failure count, so the || warning handlers in install.sh can actually fire. - Claude Code checks ~/.local/bin/claude directly; PATH only gets it from zsh/.zshrc, and install.sh runs under bash, so re-runs re-downloaded it. - Next-steps only lists sign-in commands for tools that are present. - Brewfile.personal header pointed at vendor instructions; points at linux-extras.sh now. Fixed while testing: has_schema/has_key piped into grep -q under pipefail. grep exits at the first match, gsettings takes SIGPIPE, and the pipeline reports failure despite matching -- so Ptyxis was never detected. Both helpers capture output first. Verified on this machine: greenfield sets the font, an existing custom font is preserved, and the override forces a change.
Same latch as the font check, in two more places:
- install.sh tested [[ ! -d ~/.tmux/plugins/tpm ]], so an interrupted clone
left a partial directory that skipped the install forever. Tests .git now and
clears a partial directory first, matching what bootstrap.sh already did.
- tmux/.tmux.conf carried its own copy of the same test; fixed alongside.
Interactive detection, the same bug as have_sudo but worse:
- Git identity was gated on [[ -t 0 ]]. On the documented `curl | bash` path
stdin is the pipe, so the prompt was skipped, no ~/.gitconfig.local was
written, and git/.gitconfig includes it unconditionally -- git then commits
under a guessed user@hostname. Wrong data, not just a missing tool.
- One have_tty() helper now covers mode select, git identity and Toshy, and
prompts read from /dev/tty.
- have_tty opens /dev/tty rather than testing existence: on CI runners the node
exists with no controlling terminal, and a failed redirect on `read` would
abort the installer under set -e. Verified both ways.
Data safety:
- link() only backed up regular files, so a config that was already a symlink
(stow, chezmoi, another dotfiles repo) was replaced with nothing recorded.
The old target is now written to $BACKUP_DIR/replaced-symlinks.txt.
- The .zprofile append used echo, which joins onto a file with no trailing
newline: `export FOO=1eval "$(... shellenv)"`. Uses printf '\n%s\n'.
- The shellenv dedup grepped for any 'brew shellenv' line, so migrating Intel
-> ARM or /home/linuxbrew -> ~/.linuxbrew kept the stale line and never added
the right one. Matches the exact line, and replaces a stale one (with a
backup).
- Corporate mode skipped linking gifenc but never removed an existing link, so
personal -> corporate left a live symlink to a script whose ffmpeg dependency
corporate does not install.
Unguarded network call:
- The TPM clone was bare while every other network call carries || warning, so
a proxy or GitHub blip aborted the run before mise, Neovim, linux-extras and
chsh.
bin/:
- gifenc: unquoted $in/$out/$palette broke on exactly the filenames it exists
for; a fixed /tmp/palette.png raced between runs and was a predictable-name
write on shared machines; `cut -f1 -d.` cut at the first dot, so
/tmp/v1.2/clip.mov wrote to /tmp/v1.gif; and without set -e a failed
palettegen still ran the second pass. Quoted, mktemp with a trap, ${in%.*},
set -eu, and usage/existence checks.
- fswatch and rsync are needed by super-sync, which is linked in both modes,
but appeared in no Brewfile. Added to Brewfile.core.
CI:
- Personal mode was never executed anywhere, which is why linux-extras.sh
shipped with no coverage. Adds a Linux personal-mode job that installs it for
real and reruns it, so the keyrings already exist on the second pass -- the
case that would have caught the unattended-gpg hang.
zsh/.zshrc uses $HOMEBREW_PREFIX instead of forking `brew --prefix` on every
shell start.
Three bugs introduced by the previous revision, two of them visible in that
revision's own CI log:
- linux-terminal.sh exited 0 for "font set", "no gsettings" and "no terminal
found" alike, so install.sh reported "Terminal font is configured" two
minutes after printing "No supported terminal found" -- and the else branch
added last round became unreachable except on hard failure. It now returns
0 configured / 1 failed / 2 nothing to configure, and install.sh switches on
that. Its "leave the user's font alone" path also counted as configured; it
now only does so when that font is a Nerd Font, and warns otherwise, since
keeping a non-Nerd font and reporting success still leaves tofu.
- "${arr[*]}" joins on the FIRST character of IFS, so IFS=' / ' printed
"claude gcloud auth login op signin". Uses printf -v and trims.
- The .zprofile stale-line replacement used `grep -v ... > tmp && mv`. grep -v
exits 1 when it filters out every line -- exactly the single-line .zprofile
the old installer wrote, which is the Intel -> ARM case the fix targets -- so
mv was skipped, the stale line survived and a .tmp was orphaned, while still
printing that it had replaced it. Also anchors the pattern so a user's own
line mentioning "brew shellenv" survives, and puts the backup in BACKUP_DIR
with everything else.
The rerun regression test was inert: on a second run `command -v gcloud`
short-circuits before any gpg call, so it could not catch the bug it names.
It now removes the packages while keeping the keyrings, reruns, and asserts
both tools came back -- necessary because install.sh swallows the extras exit
code with || warning.
bin/gifenc used `mktemp -t <template>`, which GNU substitutes and BSD/macOS
treats as a prefix, yielding a file not ending in .png; ffmpeg infers the
palettegen muxer from the extension, so it would fail on macOS. Uses a private
mktemp -d with a fixed name inside.
Also: have_sudo now opens /dev/tty like have_tty rather than testing -e, the
weaker form have_tty's comment warns against; have_tty drops its `[[ -t 0 ]]`
shortcut, since callers always read from /dev/tty; a non-git tpm is moved to
BACKUP_DIR instead of deleted, in both install.sh and .tmux.conf; the "Previous
configs backed up" line no longer fires when only the symlink ledger is there;
and linux-extras.sh is back to 2-space indent.
Review of
|
ubuntu-latest ships the Google Cloud CLI preinstalled, so linux-extras.sh short-circuited on `command -v gcloud` and its own install path -- dearmor, sources.list, apt install -- never ran. Both previous runs logged "gcloud already installed", and the job's `gcloud --version` check was passing against the runner's binary. So the code that carried the unattended-gpg bug was untested by the job written to test it, and the new remove/rerun step then asserted a keyring that had never been written, which is why CI went red. The job now purges the runner's copy after checkout, and asserts both keyrings in the verify step so a future runner image preinstalling something cannot hollow it out again without failing loudly. Also from the review: - The Nerd Font heuristic only matched "Nerd", not the " NF" abbreviations that p10k's installer and several distro packages use, so someone on "MesloLGS NF" was told their correct font was wrong and the installer told them to change it. Matches both forms; "NFS Mono" still reads as non-nerd. - .tmux.conf moved a rescued tpm to ~/.tmux/plugins/tpm.bak-<epoch>, inside the directory TPM enumerates and clean_plugins prunes. Goes to ~/.tmux/tpm.bak now, and the fixed name removes the reliance on $(date +%s) surviving tmux's if-shell quoting -- a path that fires once and would fail silently. - The tpm `mv` in install.sh was unguarded while the neighbouring clone was. Guarded, and a failed move now skips the clone, which would otherwise fail anyway with the directory still in place. Not changed: linux-terminal.sh still reports failure when one of several terminals fails, even if another succeeded. The review called it defensible either way, and failing loud is the safer default. tmux is not installed here, so .tmux.conf could not be exercised with source-file; the inner /bin/sh command is verified with sh -n and the line no longer contains any command substitution.
Two lines that described behaviour from earlier in the branch: the font script now leaves a font the user chose themselves alone, and CI gained a personal-mode Ubuntu job. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01N9FcEhw1Ta4ZffQPxggzja
Tested a clean install on an Ubuntu 25.10 machine. The installer itself works — all symlinks correct,
.secrets.envat 600, font installed, no broken links — but a greenfield Linux user ends up with a worse result than a greenfield Mac user. Two review rounds since; everything below is fixed and verified.Terminal font is now set automatically
macOS links an iTerm2 dynamic profile with the font baked in. Linux got a printed instruction, so a fresh machine renders the starship powerline prompt as tofu boxes.
grep -rn "dconf\|gsettings"across the repo returned nothing.linux-terminal.shsets it over gsettings for Ptyxis (default since Ubuntu 25.10), GNOME Terminal and Console. It leaves an existing custom font alone — the iTerm2 profile is one the user opts into, so the parity move is not to clobber a deliberate choice — reports the previous value when it does replace one, and honoursDOTFILES_TERMINAL_FONTto force a change. Key names are checked vialist-keysbefore setting, so an unexpected schema warns rather than falsely succeeding.It also prints the thing that costs people 20 minutes: a running terminal caches fonts at startup, so a new tab is not enough.
The README targeted a terminal Ubuntu no longer ships
gnome-terminalis not installed;x-terminal-emulatorresolves to/usr/bin/ptyxis. README updated.Personal mode now installs the cask tools on Linux
casklines are stripped on Linux, so personal mode silently lacked Claude Code, gcloud and the 1Password CLI.linux-extras.shinstalls each from its official Linux channel.gnupgis installed on demand, since neither bootstrap guaranteed it.Latched installs — the
-dtrap, in three places[[ ! -d ... ]]cannot distinguish "done" from "done wrong": a directory created by a run that then failed skips the step forever..ttffilesinstall.sh— now checks.gitand clears a partial clonetmux/.tmux.conf— sameInteractive detection
The documented path is
curl … | bash -s -- personal, where stdin is the pipe for the whole run, so[[ -t 0 ]]is false with a user sat right there. That skipped the gcloud/1Password installs and the git identity prompt — and sincegit/.gitconfigincludes.gitconfig.localunconditionally, git then commits under a guesseduser@hostname. Wrong data, not a missing tool.One
have_tty()now covers mode select, identity and Toshy, and prompts read from/dev/tty. It opens/dev/ttyrather than testing existence: on CI runners the node exists with no controlling terminal, and a failed redirect onreadwould abort the installer underset -e.Data safety
link()only backed up regular files — a config that was already a symlink (stow, chezmoi, another dotfiles repo) was replaced with nothing recorded. The old target now goes to$BACKUP_DIR/replaced-symlinks.txt..zprofileappend usedecho, joining onto a file with no trailing newline:export FOO=1eval "$(… shellenv)". Nowprintf '\n%s\n'.brew shellenvline, so migrating Intel→ARM kept the stale line and never added the correct one. Matches the exact line and replaces a stale one, with a backup.gifencbut never removed an existing link, leaving a live symlink to a script whoseffmpegdependency corporate does not install.Unattended gpg
gpg --dearmorprompts before overwriting an existing keyring — blocking on a tty, exit 2 without one. The keyring outlives the package, so this fires on any re-run after a failed apt step. All three calls pass--batch --yes. Verified against the real Google key: exit 2 → exit 0.bin/gifencwas the weakest file in the repo: unquoted$in/$out/$palettebroke on exactly the filenames it exists for; a fixed/tmp/palette.pngraced between runs;cut -f1 -d.cut at the first dot, so/tmp/v1.2/clip.movwrote to/tmp/v1.gif. Now quoted,mktempwith a trap,${in%.*},set -eu.fswatchandrsyncare needed bysuper-sync— linked in both modes — but were in no Brewfile. Added toBrewfile.core.Smaller
fc-cachecheck;fontconfigis only apt-installed when brew was absent, so a machine with brew never got configured.|| warninghandlers can fire.~/.local/bin/claudedirectly; PATH only gets it fromzsh/.zshrc, andinstall.shruns under bash.chshreads the passwd entry (getent/dscl), not$SHELL; the zsh-absent case no longer claims success.zsh/.zshrcuses$HOMEBREW_PREFIXinstead of forkingbrew --prefixper shell.Testing
Adds a
personal-linuxCI job that runs personal mode for real and reruns it, so the keyrings already exist on the second pass — the exact case that would have caught the gpg hang. Personal mode was previously only parse-checked, which is whylinux-extras.shoriginally shipped with no coverage.Also verified locally: full install in a sandboxed
HOME, idempotent on rerun, recovers from an empty font dir, symlink backup recorded, newline corruption fixed,have_ttycorrect on a session with no controlling terminal, andlinux-terminal.shrun live against Ptyxis in both the greenfield and already-customised cases.Not verified: the real-terminal path (
curl | bashfrom an actual terminal) — the test session has no controlling terminal, so only the CI-safe branch was exercisable.org.gnome.Console'scustom-fontkey is unconfirmed;apply()checkslist-keysfirst, so it warns rather than lying.🤖 Generated with Claude Code
https://claude.ai/code/session_01Jyt6CQVjLRN6HHXam9qBU9