Skip to content

Close the Linux gaps in greenfield setup - #3

Merged
ikwach merged 7 commits into
masterfrom
linux-greenfield-fixes
Aug 6, 2026
Merged

Close the Linux gaps in greenfield setup#3
ikwach merged 7 commits into
masterfrom
linux-greenfield-fixes

Conversation

@ikwach

@ikwach ikwach commented Aug 5, 2026

Copy link
Copy Markdown
Owner

Tested a clean install on an Ubuntu 25.10 machine. The installer itself works — all symlinks correct, .secrets.env at 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.sh sets 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 honours DOTFILES_TERMINAL_FONT to force a change. Key names are checked via list-keys before 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-terminal is not installed; x-terminal-emulator resolves to /usr/bin/ptyxis. README updated.

Personal mode now installs the cask tools on Linux

cask lines are stripped on Linux, so personal mode silently lacked Claude Code, gcloud and the 1Password CLI. linux-extras.sh installs each from its official Linux channel. gnupg is installed on demand, since neither bootstrap guaranteed it.

Latched installs — the -d trap, in three places

[[ ! -d ... ]] cannot distinguish "done" from "done wrong": a directory created by a run that then failed skips the step forever.

  • Nerd font — now checks for .ttf files
  • tmux TPM in install.sh — now checks .git and clears a partial clone
  • the second copy in tmux/.tmux.conf — same

Interactive 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 since git/.gitconfig includes .gitconfig.local unconditionally, git then commits under a guessed user@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/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.

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.
  • The .zprofile append used echo, joining onto a file with no trailing newline: export FOO=1eval "$(… shellenv)". Now printf '\n%s\n'.
  • The shellenv dedup matched any brew shellenv line, 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.
  • Corporate mode skipped linking gifenc but never removed an existing link, leaving a live symlink to a script whose ffmpeg dependency corporate does not install.

Unattended gpg

gpg --dearmor prompts 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/

gifenc was the weakest file in the repo: unquoted $in/$out/$palette broke on exactly the filenames it exists for; a fixed /tmp/palette.png raced between runs; cut -f1 -d. cut at the first dot, so /tmp/v1.2/clip.mov wrote to /tmp/v1.gif. Now quoted, mktemp with a trap, ${in%.*}, set -eu.

fswatch and rsync are needed by super-sync — linked in both modes — but were in no Brewfile. Added to Brewfile.core.

Smaller

  • Terminal setup no longer nested inside the fc-cache check; fontconfig is only apt-installed when brew was absent, so a machine with brew never got configured.
  • Both new scripts return failure counts, so the || warning handlers can fire.
  • Claude Code checks ~/.local/bin/claude directly; PATH only gets it from zsh/.zshrc, and install.sh runs under bash.
  • Next-steps only lists sign-in commands for tools that are present.
  • The TPM clone was bare while every other network call is guarded.
  • chsh reads the passwd entry (getent/dscl), not $SHELL; the zsh-absent case no longer claims success.
  • zsh/.zshrc uses $HOMEBREW_PREFIX instead of forking brew --prefix per shell.

Testing

Adds a personal-linux CI 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 why linux-extras.sh originally 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_tty correct on a session with no controlling terminal, and linux-terminal.sh run live against Ptyxis in both the greenfield and already-customised cases.

Not verified: the real-terminal path (curl | bash from an actual terminal) — the test session has no controlling terminal, so only the CI-safe branch was exercisable. org.gnome.Console's custom-font key is unconfirmed; apply() checks list-keys first, so it warns rather than lying.

🤖 Generated with Claude Code

https://claude.ai/code/session_01Jyt6CQVjLRN6HHXam9qBU9

ikwach added 5 commits August 5, 2026 21:41
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.
@ikwach

ikwach commented Aug 5, 2026

Copy link
Copy Markdown
Owner Author

Review of 65a7715

CI is red — personal-linux fails — and the failure is worth reading closely, because it exposes that the job has never tested what it claims.

The failure

The new "Remove the packages, keep the keyrings" step exits 1 at one of the test -f assertions (no command -v output in the log, so it's a silent test). Root cause is in the first step's log:

[OK] gcloud already installed

ubuntu-latest ships Google Cloud CLI preinstalled, so linux-extras.sh's gcloud block short-circuits on command -v gcloud and never creates /usr/share/keyrings/cloud.google.gpg. The new step then removes the preinstalled package and asserts a keyring that was never written.

I ruled out the alternative explanation by pulling both packages: google-cloud-cli 576.0.0-0 ships nothing under /usr/share/keyrings and has no postrm at all (only postinst/prerm); 1password-cli 2.38.1-1 likewise ships no keyring and never mentions one in its maintainer scripts. Neither removes the file — it was simply never created.

The implication is bigger than the red X. The gcloud half of linux-extras.sh has never executed in CI — not in this run, and not in the previous one (both say "gcloud already installed"). The gcloud --version check in "Verify the cask-equivalent tools actually installed" has been passing against the runner's preinstalled binary the whole time. So the code path that carried the original unattended-gpg hang — dearmor, sources.list, apt install — is still completely untested, by a job whose stated purpose is testing it.

Purge the runner's copy first, right after checkout:

- name: Drop the runner's preinstalled gcloud so the installer's own path runs
  run: |
    sudo apt-get remove -y google-cloud-cli || true
    sudo rm -f /usr/share/keyrings/cloud.google.gpg \
               /etc/apt/sources.list.d/google-cloud-sdk.list
    ! command -v gcloud

Then the remove/rerun/assert sequence added here works as designed. Worth also asserting test -f /usr/share/keyrings/cloud.google.gpg in the first verify step, so a future runner image preinstalling something can't hollow the job out again without failing loudly.

Round-three fixes — all verified

  • Exit-code states. "$SCRIPT" && terminal_rc=0 || terminal_rc=$? captures 0/1/2 correctly (tested all three). The contradiction is gone from the log — the same run now reads No terminal configured with a Nerd Font. Set your terminal font to: ... and then 2. Set your terminal font to JetBrainsMono Nerd Font.
  • List join. printf -v joined '%s / ' + ${joined% / } produces claude / gcloud auth login / op signin — confirmed in the CI output.
  • .zprofile. Verified end-to-end against the exact single-line file the old installer wrote: stale line removed, no orphaned .tmp, one correct line, backup in $BACKUP_DIR. The anchored ERE matches both real forms including leading whitespace, and correctly leaves alone a comment, an export, and an echo that merely mention brew shellenv — all five cases tested.
  • have_tty shortcut removed with a correct rationale; linux-extras.sh's have_sudo now uses the identical (exec 3</dev/tty) probe.
  • tpm moved aside rather than deleted.
  • Backup announcement no longer fires for the symlink ledger alone. find -mindepth 1 ! -name ... -print -quit is fine on macOS — the macos-latest job printed "Previous configs backed up to ...", so -quit executed successfully there.
  • gifenc. Checked the Darwin man page rather than guessing: "If no arguments are passed or if only the -d flag is passed mktemp behaves as if -t tmp was supplied." Bare mktemp -d is supported on macOS, so the private-directory approach is correct and portable.
  • linux-extras.sh reindented back to 2 spaces.

Minor

a. Nerd-font heuristic misses the abbreviated nameslinux-terminal.sh:79

*[Nn]erd* doesn't match MesloLGS NF, JetBrainsMono NF, Hack NF — the naming p10k's font installer and several distro packages use. Those users get a spurious "does not look like a Nerd Font", the script exits 2, and the installer tells them to change a font that's already right. Add the NF forms.

b. The tmux backup lands inside TPM's plugin directory.tmux.conf:71

mv ~/.tmux/plugins/tpm ~/.tmux/plugins/tpm.bak-$(date +%s) puts the rescue copy in ~/.tmux/plugins/, which is exactly the directory TPM enumerates — clean_plugins targets unlisted directories there. Move it to ~/.tmux/ instead. Separately, install.sh and .tmux.conf now rescue the same directory to two different places by two different mechanisms ($BACKUP_DIR/.tmux/plugins/tpm vs ~/.tmux/plugins/tpm.bak-<epoch>).

c. $(date +%s) inside the if-shell string is untested. It should survive — tmux expands $VAR/${VAR} inside double quotes but not $(...), and the inner single quotes reach /bin/sh intact. Still, it's a path that only fires once and would fail silently, so it's worth one tmux source-file ~/.tmux.conf by hand — or sidestep it with a fixed tpm.bak name.

d. mv is unguardedinstall.sh:361. The neighbouring git clone is wrapped in if, but the mv above it isn't; a permissions failure aborts the installer before Neovim, the extras and chsh.

e. linux-terminal.sh tests failures > 0 before configured == 0, so with two terminals present and one failing it returns 1 and never mentions the one that worked. Defensible either way.


Fix the CI preinstall problem and this is done — a/b are small and d is one word (||). Item a is the only one a user would actually notice.

ikwach and others added 2 commits August 6, 2026 03:36
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
@ikwach
ikwach merged commit 494dc79 into master Aug 6, 2026
3 checks passed
@ikwach
ikwach deleted the linux-greenfield-fixes branch August 6, 2026 10:08
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant