From 2fdc5ddbd7213de71f7378953cdf5e9cdd08b426 Mon Sep 17 00:00:00 2001 From: Sergei Romanov Date: Wed, 5 Aug 2026 21:41:08 +1000 Subject: [PATCH 1/7] Close the Linux gaps in greenfield setup 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. --- README.md | 10 +++++- install.sh | 46 +++++++++++++++++++------ linux-extras.sh | 86 +++++++++++++++++++++++++++++++++++++++++++++++ linux-terminal.sh | 79 +++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 209 insertions(+), 12 deletions(-) create mode 100755 linux-extras.sh create mode 100755 linux-terminal.sh diff --git a/README.md b/README.md index e4b5736..643f19e 100644 --- a/README.md +++ b/README.md @@ -60,7 +60,13 @@ Corporate mode also prompts for your **work** git email, and nothing personal is ### Linux -Packages come from [Homebrew on Linux](https://docs.brew.sh/Homebrew-on-Linux) on both OSes, so there is one manifest with identical tool versions and no `batcat`/`fdfind` renames. apt is only used to bootstrap Homebrew's build dependencies. Casks (iTerm2, the GUI apps in personal mode) are macOS-only and get skipped; JetBrains Mono Nerd Font is installed from the nerd-fonts release into `~/.local/share/fonts` instead. For the stock GNOME Terminal, set that font in your profile and use [catppuccin/gnome-terminal](https://github.com/catppuccin/gnome-terminal) for the colors. The `install` CI workflow runs corporate mode on both Ubuntu and macOS on every PR. +Packages come from [Homebrew on Linux](https://docs.brew.sh/Homebrew-on-Linux) on both OSes, so there is one manifest with identical tool versions and no `batcat`/`fdfind` renames. apt is only used to bootstrap Homebrew's build dependencies. iTerm2 and the other casks are macOS-only and get skipped; JetBrains Mono Nerd Font is installed from the nerd-fonts release into `~/.local/share/fonts` instead. + +**Terminal font is set for you.** `linux-terminal.sh` points the terminal at the nerd font over gsettings, covering Ptyxis (the default from Ubuntu 25.10), GNOME Terminal and Console - the counterpart to the iTerm2 dynamic profile on macOS. Override the font with `DOTFILES_TERMINAL_FONT`. Quit the terminal completely afterwards: a running one caches the font list from startup, so a new tab still shows tofu. For colors, Ptyxis ships palettes in its preferences; GNOME Terminal users can use [catppuccin/gnome-terminal](https://github.com/catppuccin/gnome-terminal). + +**Personal mode installs the cask tools too.** Since `cask` lines are stripped on Linux, `linux-extras.sh` installs Claude Code (native installer), the gcloud CLI and the 1Password CLI from their official Linux channels, so personal mode is not quietly thinner than on macOS. The Claude desktop app is the one exception - see [desktop on Linux](https://code.claude.com/docs/en/desktop-linux). + +The `install` CI workflow runs corporate mode on both Ubuntu and macOS on every PR. ## What's where @@ -71,6 +77,8 @@ Brewfile.macos # macOS-only: iTerm2, nerd font cask, mas Brewfile.personal # extras for personal mode install.sh # installer (brew, symlinks, git identity, secrets, theme caches) macos.sh # opt-in macOS system defaults (--with-macos-defaults) +linux-terminal.sh # sets the nerd font in Ptyxis/GNOME Terminal/Console +linux-extras.sh # Linux installs for the personal-mode cask tools iterm2/ # dynamic profile (Dracula+ colors + nerd font, auto-loaded) zsh/ # .zshrc + antidote plugin list starship/ # prompt config (catppuccin mocha powerline) diff --git a/install.sh b/install.sh index b997fa8..16e4bf0 100755 --- a/install.sh +++ b/install.sh @@ -189,7 +189,10 @@ fi if [[ "$OS" == "linux" ]] && command -v fc-cache >/dev/null 2>&1; then FONT_DIR="$HOME/.local/share/fonts/JetBrainsMonoNerdFont" - if [[ ! -d "$FONT_DIR" ]]; then + # Check for actual font files, not just the directory: if a previous run + # created the directory and then failed to extract, testing -d alone would + # skip the install forever and leave the prompt rendering as tofu. + if ! compgen -G "$FONT_DIR/*.ttf" >/dev/null 2>&1; then info "Installing JetBrains Mono Nerd Font..." font_tmp="$(mktemp -d)" if curl -fsSL -o "$font_tmp/JetBrainsMono.tar.xz" \ @@ -197,11 +200,19 @@ if [[ "$OS" == "linux" ]] && command -v fc-cache >/dev/null 2>&1; then && mkdir -p "$FONT_DIR" \ && tar -xJf "$font_tmp/JetBrainsMono.tar.xz" -C "$FONT_DIR"; then fc-cache -f "$FONT_DIR" >/dev/null || true - success "Nerd Font installed (set it in your terminal profile)" + success "Nerd Font installed" else warning "Font download failed; install a Nerd Font manually for prompt icons" fi rm -rf "$font_tmp" + else + success "Nerd Font already installed" + fi + + # Point the terminal at it. macOS gets the equivalent via the iTerm2 dynamic + # profile; without this, Linux leaves the prompt as tofu until set by hand. + if [[ -x "$DOTFILES_DIR/linux-terminal.sh" ]]; then + "$DOTFILES_DIR/linux-terminal.sh" || warning "Terminal font setup failed; set it manually" fi fi @@ -293,6 +304,14 @@ if command -v nvim >/dev/null 2>&1; then success "Neovim ready" fi +# Personal mode on macOS installs Claude Code, gcloud and the 1Password CLI as +# casks. Those lines are stripped on Linux, so install the same tools from the +# vendors' Linux channels to keep the two platforms at parity. +if [[ "$MODE" == "personal" && "$OS" == "linux" && -x "$DOTFILES_DIR/linux-extras.sh" ]]; then + info "Installing personal-mode tools that are casks on macOS..." + "$DOTFILES_DIR/linux-extras.sh" || warning "Some Linux extras failed; see the output above" +fi + if [[ "$WITH_MACOS_DEFAULTS" == 1 && "$OS" == "macos" ]]; then info "Applying macOS defaults..." if "$DOTFILES_DIR/macos.sh"; then success "macOS defaults applied"; else warning "macos.sh failed"; fi @@ -310,9 +329,15 @@ if [[ "$WITH_MAC_KEYS" == 1 && "$OS" == "linux" ]]; then fi fi -if [[ "$SHELL" != *zsh ]] && command -v zsh >/dev/null 2>&1; then - info "Setting zsh as default shell..." +# $SHELL reflects the shell that happens to be running, which is not the login +# shell inside editors, CI or a nested bash. Read the passwd entry instead. +LOGIN_SHELL="$(getent passwd "$(id -un)" 2>/dev/null | cut -d: -f7)" +LOGIN_SHELL="${LOGIN_SHELL:-$SHELL}" +if [[ "$LOGIN_SHELL" != *zsh ]] && command -v zsh >/dev/null 2>&1; then + info "Setting zsh as default shell (currently $LOGIN_SHELL)..." chsh -s "$(command -v zsh)" || warning "Could not change the default shell; run chsh manually" +else + success "Login shell already zsh" fi # ---------------------------------------- @@ -328,16 +353,15 @@ if [[ "$OS" == "macos" ]]; then echo " 2. iTerm2: Settings > Profiles > 'dotfiles' > Other Actions > Set as Default" echo " (Dracula+ colors and the nerd font are baked into the profile)" else - echo " 2. Set your terminal font to JetBrainsMono Nerd Font" - echo " (GNOME Terminal catppuccin: github.com/catppuccin/gnome-terminal)" + echo " 2. Terminal font was set automatically (Ptyxis / GNOME Terminal / Console)." + echo " Quit the terminal completely and reopen -- a new tab is not enough," + echo " since a running terminal caches the font list from startup." fi echo " 3. Import history: atuin import zsh" if [[ "$MODE" == "personal" ]]; then - if [[ "$OS" == "macos" ]]; then - echo " 4. Sign in: claude / gcloud auth login / op signin" - else - echo " 4. Casks are macOS-only: install Claude Code, gcloud and the" - echo " 1Password CLI from their vendor instructions, then sign in" + echo " 4. Sign in: claude / gcloud auth login / op signin" + if [[ "$OS" == "linux" ]]; then + echo " Claude desktop app: https://code.claude.com/docs/en/desktop-linux" fi fi echo "" diff --git a/linux-extras.sh b/linux-extras.sh new file mode 100755 index 0000000..77029c3 --- /dev/null +++ b/linux-extras.sh @@ -0,0 +1,86 @@ +#!/usr/bin/env bash +# +# Personal-mode tools that ship as Homebrew casks on macOS. +# +# install.sh strips `cask` lines on Linux, so personal mode there silently ends +# up without Claude Code, the gcloud CLI and the 1Password CLI -- the installer +# just prints "install these yourself". This installs them from the vendors' +# official Linux channels instead. +# +# Deliberately NOT covered: +# claude (desktop app) - see https://code.claude.com/docs/en/desktop-linux +# +# Everything is skipped when already present, so this is safe to re-run. +set -uo pipefail + +info() { echo -e "\033[0;34m[INFO]\033[0m $1"; } +success() { echo -e "\033[0;32m[OK]\033[0m $1"; } +warning() { echo -e "\033[1;33m[!]\033[0m $1"; } + +command -v apt-get >/dev/null 2>&1 || { warning "Not a Debian/Ubuntu system; skipping Linux extras"; exit 0; } + +# gcloud and 1Password add apt repositories, which needs a usable sudo. Claude +# Code installs into $HOME and does not. +have_sudo() { sudo -n true 2>/dev/null || { [[ -t 0 ]] && sudo -v 2>/dev/null; }; } + +# --- Claude Code ----------------------------------------------------------- +# Native installer; lands in ~/.local/bin/claude and self-updates. +# https://code.claude.com/docs/en/setup +if command -v claude >/dev/null 2>&1; then + success "Claude Code already installed" +else + info "Installing Claude Code..." + if curl -fsSL https://claude.ai/install.sh | bash; then + success "Claude Code installed (run 'claude' to sign in)" + else + warning "Claude Code install failed; see https://code.claude.com/docs/en/setup" + fi +fi + +# --- Google Cloud CLI ------------------------------------------------------ +# https://docs.cloud.google.com/sdk/docs/install +if command -v gcloud >/dev/null 2>&1; then + success "gcloud already installed" +elif ! have_sudo; then + warning "sudo unavailable; skipping gcloud (needs an apt repository)" +else + info "Installing Google Cloud CLI..." + if curl -fsSL https://packages.cloud.google.com/apt/doc/apt-key.gpg \ + | sudo gpg --dearmor -o /usr/share/keyrings/cloud.google.gpg && + echo "deb [signed-by=/usr/share/keyrings/cloud.google.gpg] https://packages.cloud.google.com/apt cloud-sdk main" \ + | sudo tee /etc/apt/sources.list.d/google-cloud-sdk.list >/dev/null && + sudo apt-get update -qq && sudo apt-get install -y -qq google-cloud-cli; then + success "gcloud installed (run 'gcloud auth login')" + else + warning "gcloud install failed; see https://docs.cloud.google.com/sdk/docs/install" + fi +fi + +# --- 1Password CLI --------------------------------------------------------- +# https://www.1password.dev/cli/get-started/ +if command -v op >/dev/null 2>&1; then + success "1Password CLI already installed" +elif ! have_sudo; then + warning "sudo unavailable; skipping 1Password CLI (needs an apt repository)" +else + info "Installing 1Password CLI..." + arch="$(dpkg --print-architecture)" + if curl -sS https://downloads.1password.com/linux/keys/1password.asc \ + | sudo gpg --dearmor --output /usr/share/keyrings/1password-archive-keyring.gpg && + echo "deb [arch=$arch signed-by=/usr/share/keyrings/1password-archive-keyring.gpg] https://downloads.1password.com/linux/debian/$arch stable main" \ + | sudo tee /etc/apt/sources.list.d/1password.list >/dev/null && + sudo mkdir -p /etc/debsig/policies/AC2D62742012EA22/ && + curl -sS https://downloads.1password.com/linux/debian/debsig/1password.pol \ + | sudo tee /etc/debsig/policies/AC2D62742012EA22/1password.pol >/dev/null && + sudo mkdir -p /usr/share/debsig/keyrings/AC2D62742012EA22 && + curl -sS https://downloads.1password.com/linux/keys/1password.asc \ + | sudo gpg --dearmor --output /usr/share/debsig/keyrings/AC2D62742012EA22/debsig.gpg && + sudo apt-get update -qq && sudo apt-get install -y -qq 1password-cli; then + success "1Password CLI installed (run 'op signin')" + else + warning "1Password CLI install failed; see https://www.1password.dev/cli/get-started/" + fi +fi + +info "Claude desktop app on Linux: https://code.claude.com/docs/en/desktop-linux" +exit 0 diff --git a/linux-terminal.sh b/linux-terminal.sh new file mode 100755 index 0000000..30dba5e --- /dev/null +++ b/linux-terminal.sh @@ -0,0 +1,79 @@ +#!/usr/bin/env bash +# +# Configure the terminal font on Linux. +# +# macOS gets this for free: install.sh links an iTerm2 dynamic profile with the +# nerd font baked in. On Linux the font was only ever a printed instruction, so +# a fresh machine renders the starship powerline prompt as tofu boxes until the +# user finds the setting themselves. +# +# Terminal emulators here are configured through dconf/gsettings rather than +# files, so this sets the font directly. +# +# Supported: Ptyxis (Ubuntu 25.10+ default), GNOME Terminal, Console/Kgx. +# Anything else prints the font name to set manually. +# +# Safe to run repeatedly. Exits 0 on headless machines and in CI. +set -uo pipefail + +FONT="${DOTFILES_TERMINAL_FONT:-JetBrainsMono Nerd Font Mono 12}" + +info() { echo -e "\033[0;34m[INFO]\033[0m $1"; } +success() { echo -e "\033[0;32m[OK]\033[0m $1"; } +warning() { echo -e "\033[1;33m[!]\033[0m $1"; } + +# No gsettings (headless, container, CI) -> nothing to do, and not an error. +if ! command -v gsettings >/dev/null 2>&1; then + info "gsettings not available; skipping terminal font setup" + exit 0 +fi + +has_schema() { gsettings list-schemas 2>/dev/null | grep -qx "$1"; } + +configured=0 + +# --- Ptyxis: the default terminal on Ubuntu 25.10+ ------------------------- +if has_schema org.gnome.Ptyxis; then + if gsettings set org.gnome.Ptyxis use-system-font false 2>/dev/null && + gsettings set org.gnome.Ptyxis font-name "$FONT" 2>/dev/null; then + success "Ptyxis font set to $FONT" + configured=1 + else + warning "Could not set the Ptyxis font" + fi +fi + +# --- GNOME Terminal: per-profile, so resolve the default profile UUID ------ +if has_schema org.gnome.Terminal.ProfilesList; then + profile="$(gsettings get org.gnome.Terminal.ProfilesList default 2>/dev/null | tr -d \"\')" + if [[ -n "${profile:-}" ]]; then + path="/org/gnome/terminal/legacy/profiles:/:${profile}/" + if gsettings set "org.gnome.Terminal.Legacy.Profile:$path" use-system-font false 2>/dev/null && + gsettings set "org.gnome.Terminal.Legacy.Profile:$path" font "$FONT" 2>/dev/null; then + success "GNOME Terminal font set to $FONT" + configured=1 + else + warning "Could not set the GNOME Terminal font" + fi + fi +fi + +# --- GNOME Console (kgx) --------------------------------------------------- +if has_schema org.gnome.Console; then + if gsettings set org.gnome.Console use-system-font false 2>/dev/null && + gsettings set org.gnome.Console custom-font "$FONT" 2>/dev/null; then + success "GNOME Console font set to $FONT" + configured=1 + fi +fi + +if [[ "$configured" -eq 0 ]]; then + warning "No supported terminal found. Set your terminal font to: $FONT" +fi + +# A running terminal caches fontconfig at startup, so a newly installed font is +# invisible to it until the process fully restarts -- a new tab or window in the +# same process is not enough. Worth saying, because it looks like the font +# simply failed to install. +info "Fully quit and reopen your terminal for the font to take effect" +exit 0 From cf5c6b9ed332507558d1f8bf26c2adc76e503061 Mon Sep 17 00:00:00 2001 From: Sergei Romanov Date: Wed, 5 Aug 2026 22:05:41 +1000 Subject: [PATCH 2/7] Fix the login-shell lookup on macOS 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. --- install.sh | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/install.sh b/install.sh index 16e4bf0..78831ae 100755 --- a/install.sh +++ b/install.sh @@ -330,8 +330,15 @@ if [[ "$WITH_MAC_KEYS" == 1 && "$OS" == "linux" ]]; then fi # $SHELL reflects the shell that happens to be running, which is not the login -# shell inside editors, CI or a nested bash. Read the passwd entry instead. -LOGIN_SHELL="$(getent passwd "$(id -un)" 2>/dev/null | cut -d: -f7)" +# shell inside editors, CI or a nested bash. Read the account record instead -- +# getent on Linux, dscl on macOS, which has no getent. Both are guarded because +# a missing command would otherwise take the whole script down under `set -e`. +LOGIN_SHELL="" +if command -v getent >/dev/null 2>&1; then + LOGIN_SHELL="$(getent passwd "$(id -un)" 2>/dev/null | cut -d: -f7)" || LOGIN_SHELL="" +elif command -v dscl >/dev/null 2>&1; then + LOGIN_SHELL="$(dscl . -read "/Users/$(id -un)" UserShell 2>/dev/null | awk '{print $2}')" || LOGIN_SHELL="" +fi LOGIN_SHELL="${LOGIN_SHELL:-$SHELL}" if [[ "$LOGIN_SHELL" != *zsh ]] && command -v zsh >/dev/null 2>&1; then info "Setting zsh as default shell (currently $LOGIN_SHELL)..." From d39e0803bc766aacd0406d9a30882249bf1fd2ab Mon Sep 17 00:00:00 2001 From: Sergei Romanov Date: Wed, 5 Aug 2026 22:18:18 +1000 Subject: [PATCH 3/7] Address review: unattended gpg, sudo probe, and don't clobber a chosen 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. --- Brewfile.personal | 5 +- install.sh | 45 +++++++++++++----- linux-extras.sh | 118 +++++++++++++++++++++++++++++----------------- linux-terminal.sh | 111 ++++++++++++++++++++++++++++++------------- 4 files changed, 188 insertions(+), 91 deletions(-) diff --git a/Brewfile.personal b/Brewfile.personal index 0216dec..663c029 100644 --- a/Brewfile.personal +++ b/Brewfile.personal @@ -1,6 +1,7 @@ # Brewfile.personal - extras installed ONLY in personal mode. -# On Linux the cask lines are skipped (see install.sh); install those -# apps through their vendor instructions instead. +# On Linux the cask lines are skipped (see install.sh); linux-extras.sh then +# installs the same tools from their official Linux channels. The Claude +# desktop app is the exception - see https://code.claude.com/docs/en/desktop-linux # --- AI tooling --- cask "claude-code" # Claude Code CLI diff --git a/install.sh b/install.sh index 78831ae..5645c30 100755 --- a/install.sh +++ b/install.sh @@ -70,8 +70,11 @@ echo "" if ! command -v brew >/dev/null 2>&1; then if [[ "$OS" == "linux" ]] && command -v apt-get >/dev/null 2>&1; then info "Installing Homebrew build dependencies via apt..." + # gnupg is not a Homebrew dependency, but linux-extras.sh needs it to + # dearmor the gcloud and 1Password repository keys, and minimal images + # do not ship it. if ! (sudo apt-get update -qq && sudo apt-get install -y -qq \ - build-essential procps curl file git zsh fontconfig); then + build-essential procps curl file git zsh fontconfig gnupg); then warning "apt dependencies failed; Homebrew install may not work" fi fi @@ -208,11 +211,18 @@ if [[ "$OS" == "linux" ]] && command -v fc-cache >/dev/null 2>&1; then else success "Nerd Font already installed" fi +fi - # Point the terminal at it. macOS gets the equivalent via the iTerm2 dynamic - # profile; without this, Linux leaves the prompt as tofu until set by hand. - if [[ -x "$DOTFILES_DIR/linux-terminal.sh" ]]; then - "$DOTFILES_DIR/linux-terminal.sh" || warning "Terminal font setup failed; set it manually" +# Point the terminal at the font. Deliberately outside the fc-cache check above: +# installing font files and configuring the terminal are independent, and +# fontconfig is only apt-installed when Homebrew was missing -- so a machine +# that already had brew would otherwise never get its terminal configured. +TERMINAL_CONFIGURED=0 +if [[ "$OS" == "linux" && -x "$DOTFILES_DIR/linux-terminal.sh" ]]; then + if "$DOTFILES_DIR/linux-terminal.sh"; then + TERMINAL_CONFIGURED=1 + else + warning "Terminal font setup failed; set it manually" fi fi @@ -340,11 +350,13 @@ elif command -v dscl >/dev/null 2>&1; then LOGIN_SHELL="$(dscl . -read "/Users/$(id -un)" UserShell 2>/dev/null | awk '{print $2}')" || LOGIN_SHELL="" fi LOGIN_SHELL="${LOGIN_SHELL:-$SHELL}" -if [[ "$LOGIN_SHELL" != *zsh ]] && command -v zsh >/dev/null 2>&1; then +if [[ "$LOGIN_SHELL" == *zsh ]]; then + success "Login shell already zsh" +elif command -v zsh >/dev/null 2>&1; then info "Setting zsh as default shell (currently $LOGIN_SHELL)..." chsh -s "$(command -v zsh)" || warning "Could not change the default shell; run chsh manually" else - success "Login shell already zsh" + warning "zsh is not installed; login shell left as $LOGIN_SHELL" fi # ---------------------------------------- @@ -359,14 +371,25 @@ echo " 1. Restart your terminal" if [[ "$OS" == "macos" ]]; then echo " 2. iTerm2: Settings > Profiles > 'dotfiles' > Other Actions > Set as Default" echo " (Dracula+ colors and the nerd font are baked into the profile)" +elif [[ "$TERMINAL_CONFIGURED" == 1 ]]; then + echo " 2. Terminal font is configured. Quit the terminal completely and reopen" + echo " -- a new tab is not enough, since a running terminal caches the font" + echo " list from startup." else - echo " 2. Terminal font was set automatically (Ptyxis / GNOME Terminal / Console)." - echo " Quit the terminal completely and reopen -- a new tab is not enough," - echo " since a running terminal caches the font list from startup." + echo " 2. Set your terminal font to JetBrainsMono Nerd Font" fi echo " 3. Import history: atuin import zsh" if [[ "$MODE" == "personal" ]]; then - echo " 4. Sign in: claude / gcloud auth login / op signin" + # Only name tools that are actually here: on Linux the apt-based ones are + # skipped when sudo is unavailable, and telling someone to run a command + # they do not have is worse than saying nothing. + signins=() + { command -v claude >/dev/null 2>&1 || [[ -x "$HOME/.local/bin/claude" ]]; } && signins+=("claude") + command -v gcloud >/dev/null 2>&1 && signins+=("gcloud auth login") + command -v op >/dev/null 2>&1 && signins+=("op signin") + if [[ ${#signins[@]} -gt 0 ]]; then + echo " 4. Sign in: $(IFS=' / '; echo "${signins[*]}")" + fi if [[ "$OS" == "linux" ]]; then echo " Claude desktop app: https://code.claude.com/docs/en/desktop-linux" fi diff --git a/linux-extras.sh b/linux-extras.sh index 77029c3..70f9de6 100755 --- a/linux-extras.sh +++ b/linux-extras.sh @@ -10,77 +10,107 @@ # Deliberately NOT covered: # claude (desktop app) - see https://code.claude.com/docs/en/desktop-linux # -# Everything is skipped when already present, so this is safe to re-run. +# Exits non-zero if a tool actually failed to install, so the caller can report +# it. Tools that are already present, or skipped for want of sudo, are not +# failures. set -uo pipefail info() { echo -e "\033[0;34m[INFO]\033[0m $1"; } success() { echo -e "\033[0;32m[OK]\033[0m $1"; } warning() { echo -e "\033[1;33m[!]\033[0m $1"; } +failures=0 + command -v apt-get >/dev/null 2>&1 || { warning "Not a Debian/Ubuntu system; skipping Linux extras"; exit 0; } -# gcloud and 1Password add apt repositories, which needs a usable sudo. Claude -# Code installs into $HOME and does not. -have_sudo() { sudo -n true 2>/dev/null || { [[ -t 0 ]] && sudo -v 2>/dev/null; }; } +# The documented greenfield path is `curl ... | bash -s -- personal`, where +# stdin is the curl pipe all the way through install.sh -- so `[[ -t 0 ]]` is +# false even though a user is sitting there. Probing /dev/tty asks the terminal +# directly, which is what makes the advertised one-liner actually install these. +have_sudo() { + sudo -n true 2>/dev/null && return 0 + [[ -e /dev/tty ]] && sudo -v /dev/null && return 0 + return 1 +} + +# Both apt repositories below need gpg to dearmor their signing keys, and +# neither bootstrap.sh nor install.sh guarantees gnupg on a minimal image. +ensure_gnupg() { + command -v gpg >/dev/null 2>&1 && return 0 + info "Installing gnupg (required to verify the repository keys)..." + sudo apt-get update -qq && sudo apt-get install -y -qq gnupg +} # --- Claude Code ----------------------------------------------------------- # Native installer; lands in ~/.local/bin/claude and self-updates. # https://code.claude.com/docs/en/setup -if command -v claude >/dev/null 2>&1; then - success "Claude Code already installed" +# +# ~/.local/bin is put on PATH by zsh/.zshrc, but this script runs under bash, +# so check the path directly as well or a re-run downloads it again. +if command -v claude >/dev/null 2>&1 || [[ -x "$HOME/.local/bin/claude" ]]; then + success "Claude Code already installed" else - info "Installing Claude Code..." - if curl -fsSL https://claude.ai/install.sh | bash; then - success "Claude Code installed (run 'claude' to sign in)" - else - warning "Claude Code install failed; see https://code.claude.com/docs/en/setup" - fi + info "Installing Claude Code..." + if curl -fsSL https://claude.ai/install.sh | bash; then + success "Claude Code installed (run 'claude' to sign in)" + else + warning "Claude Code install failed; see https://code.claude.com/docs/en/setup" + failures=$((failures + 1)) + fi fi # --- Google Cloud CLI ------------------------------------------------------ # https://docs.cloud.google.com/sdk/docs/install if command -v gcloud >/dev/null 2>&1; then - success "gcloud already installed" + success "gcloud already installed" elif ! have_sudo; then - warning "sudo unavailable; skipping gcloud (needs an apt repository)" + warning "sudo unavailable; skipping gcloud (needs an apt repository)" else - info "Installing Google Cloud CLI..." - if curl -fsSL https://packages.cloud.google.com/apt/doc/apt-key.gpg \ - | sudo gpg --dearmor -o /usr/share/keyrings/cloud.google.gpg && - echo "deb [signed-by=/usr/share/keyrings/cloud.google.gpg] https://packages.cloud.google.com/apt cloud-sdk main" \ - | sudo tee /etc/apt/sources.list.d/google-cloud-sdk.list >/dev/null && - sudo apt-get update -qq && sudo apt-get install -y -qq google-cloud-cli; then - success "gcloud installed (run 'gcloud auth login')" - else - warning "gcloud install failed; see https://docs.cloud.google.com/sdk/docs/install" - fi + info "Installing Google Cloud CLI..." + # --batch --yes: without them gpg prompts before overwriting an existing + # keyring and blocks the installer. 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 Google's docs by hand. + if ensure_gnupg && + curl -fsSL https://packages.cloud.google.com/apt/doc/apt-key.gpg \ + | sudo gpg --batch --yes --dearmor -o /usr/share/keyrings/cloud.google.gpg && + echo "deb [signed-by=/usr/share/keyrings/cloud.google.gpg] https://packages.cloud.google.com/apt cloud-sdk main" \ + | sudo tee /etc/apt/sources.list.d/google-cloud-sdk.list >/dev/null && + sudo apt-get update -qq && sudo apt-get install -y -qq google-cloud-cli; then + success "gcloud installed (run 'gcloud auth login')" + else + warning "gcloud install failed; see https://docs.cloud.google.com/sdk/docs/install" + failures=$((failures + 1)) + fi fi # --- 1Password CLI --------------------------------------------------------- # https://www.1password.dev/cli/get-started/ if command -v op >/dev/null 2>&1; then - success "1Password CLI already installed" + success "1Password CLI already installed" elif ! have_sudo; then - warning "sudo unavailable; skipping 1Password CLI (needs an apt repository)" + warning "sudo unavailable; skipping 1Password CLI (needs an apt repository)" else - info "Installing 1Password CLI..." - arch="$(dpkg --print-architecture)" - if curl -sS https://downloads.1password.com/linux/keys/1password.asc \ - | sudo gpg --dearmor --output /usr/share/keyrings/1password-archive-keyring.gpg && - echo "deb [arch=$arch signed-by=/usr/share/keyrings/1password-archive-keyring.gpg] https://downloads.1password.com/linux/debian/$arch stable main" \ - | sudo tee /etc/apt/sources.list.d/1password.list >/dev/null && - sudo mkdir -p /etc/debsig/policies/AC2D62742012EA22/ && - curl -sS https://downloads.1password.com/linux/debian/debsig/1password.pol \ - | sudo tee /etc/debsig/policies/AC2D62742012EA22/1password.pol >/dev/null && - sudo mkdir -p /usr/share/debsig/keyrings/AC2D62742012EA22 && - curl -sS https://downloads.1password.com/linux/keys/1password.asc \ - | sudo gpg --dearmor --output /usr/share/debsig/keyrings/AC2D62742012EA22/debsig.gpg && - sudo apt-get update -qq && sudo apt-get install -y -qq 1password-cli; then - success "1Password CLI installed (run 'op signin')" - else - warning "1Password CLI install failed; see https://www.1password.dev/cli/get-started/" - fi + info "Installing 1Password CLI..." + arch="$(dpkg --print-architecture)" + if ensure_gnupg && + curl -sS https://downloads.1password.com/linux/keys/1password.asc \ + | sudo gpg --batch --yes --dearmor --output /usr/share/keyrings/1password-archive-keyring.gpg && + echo "deb [arch=$arch signed-by=/usr/share/keyrings/1password-archive-keyring.gpg] https://downloads.1password.com/linux/debian/$arch stable main" \ + | sudo tee /etc/apt/sources.list.d/1password.list >/dev/null && + sudo mkdir -p /etc/debsig/policies/AC2D62742012EA22/ && + curl -sS https://downloads.1password.com/linux/debian/debsig/1password.pol \ + | sudo tee /etc/debsig/policies/AC2D62742012EA22/1password.pol >/dev/null && + sudo mkdir -p /usr/share/debsig/keyrings/AC2D62742012EA22 && + curl -sS https://downloads.1password.com/linux/keys/1password.asc \ + | sudo gpg --batch --yes --dearmor --output /usr/share/debsig/keyrings/AC2D62742012EA22/debsig.gpg && + sudo apt-get update -qq && sudo apt-get install -y -qq 1password-cli; then + success "1Password CLI installed (run 'op signin')" + else + warning "1Password CLI install failed; see https://www.1password.dev/cli/get-started/" + failures=$((failures + 1)) + fi fi info "Claude desktop app on Linux: https://code.claude.com/docs/en/desktop-linux" -exit 0 +exit $(( failures > 0 ? 1 : 0 )) diff --git a/linux-terminal.sh b/linux-terminal.sh index 30dba5e..a488122 100755 --- a/linux-terminal.sh +++ b/linux-terminal.sh @@ -1,73 +1,115 @@ #!/usr/bin/env bash # -# Configure the terminal font on Linux. +# Point the terminal at the nerd font on Linux. # # macOS gets this for free: install.sh links an iTerm2 dynamic profile with the -# nerd font baked in. On Linux the font was only ever a printed instruction, so -# a fresh machine renders the starship powerline prompt as tofu boxes until the +# font baked in. On Linux the font was only ever a printed instruction, so a +# fresh machine renders the starship powerline prompt as tofu boxes until the # user finds the setting themselves. # # Terminal emulators here are configured through dconf/gsettings rather than # files, so this sets the font directly. # +# An existing custom font is left alone. The iTerm2 profile on macOS is a +# separate profile the user opts into, so it never overwrites their own; doing +# the equivalent here means not touching a terminal that already has a +# deliberate font set. Force a change with DOTFILES_TERMINAL_FONT. +# # Supported: Ptyxis (Ubuntu 25.10+ default), GNOME Terminal, Console/Kgx. -# Anything else prints the font name to set manually. # -# Safe to run repeatedly. Exits 0 on headless machines and in CI. +# Exit 0 when everything worked or there was nothing to do (headless, CI). +# Exit 1 only if a terminal was found and setting its font actually failed. set -uo pipefail FONT="${DOTFILES_TERMINAL_FONT:-JetBrainsMono Nerd Font Mono 12}" +FORCED="${DOTFILES_TERMINAL_FONT:+yes}" # explicit request overrides a custom font info() { echo -e "\033[0;34m[INFO]\033[0m $1"; } success() { echo -e "\033[0;32m[OK]\033[0m $1"; } warning() { echo -e "\033[1;33m[!]\033[0m $1"; } -# No gsettings (headless, container, CI) -> nothing to do, and not an error. if ! command -v gsettings >/dev/null 2>&1; then info "gsettings not available; skipping terminal font setup" exit 0 fi -has_schema() { gsettings list-schemas 2>/dev/null | grep -qx "$1"; } - +failures=0 configured=0 -# --- Ptyxis: the default terminal on Ubuntu 25.10+ ------------------------- -if has_schema org.gnome.Ptyxis; then - if gsettings set org.gnome.Ptyxis use-system-font false 2>/dev/null && - gsettings set org.gnome.Ptyxis font-name "$FONT" 2>/dev/null; then - success "Ptyxis font set to $FONT" +# Capture first, then match. Piping into `grep -q` under `set -o pipefail` is a +# trap: grep exits at the first match, gsettings dies with SIGPIPE, and pipefail +# reports the whole pipeline as failed even though the match succeeded. +has_schema() { + local schemas + schemas="$(gsettings list-schemas 2>/dev/null)" || return 1 + [[ $'\n'"$schemas"$'\n' == *$'\n'"$1"$'\n'* ]] +} +# Key names differ between terminals and versions, so confirm a key exists +# before setting it rather than relying on the set failing cleanly. +has_key() { + local keys + keys="$(gsettings list-keys "$1" 2>/dev/null)" || return 1 + [[ $'\n'"$keys"$'\n' == *$'\n'"$2"$'\n'* ]] +} + +# apply