Skip to content

Instantly share code, notes, and snippets.

@thrila
Created July 3, 2026 22:16
Show Gist options
  • Select an option

  • Save thrila/ad24f5d0e203719cc97b8efc28429897 to your computer and use it in GitHub Desktop.

Select an option

Save thrila/ad24f5d0e203719cc97b8efc28429897 to your computer and use it in GitHub Desktop.
Auto Install
#!/usr/bin/env bash
#
# bootstrap.sh — fresh machine setup for macOS or Linux (Ubuntu/Debian, Fedora/RHEL,
# Arch, openSUSE)
# Installs: Homebrew, Chrome, VLC, OBS, WezTerm, Ghostty, Neovim (+deps), Docker, Zed
#
# Run as your normal user, NOT as root — the script calls sudo itself
# whenever it needs privileges (and only asks for your password once).
#
# Usage: chmod +x bootstrap.sh && ./bootstrap.sh
#
set -euo pipefail
# ---------------------------------------------------------------------------
# Dotfiles config — fill this in
# ---------------------------------------------------------------------------
DOTFILES_REPO="git@github.com:yourusername/dotfiles.git" # <-- CHANGE ME
DOTFILES_DIR="$HOME/.dotfiles"
DOTFILES_NVIM_SUBDIR="nvim"
DOTFILES_GHOSTTY_SUBDIR="ghostty"
log() { printf "\033[1;32m==>\033[0m %s\n" "$1"; }
warn() { printf "\033[1;33m==>\033[0m %s\n" "$1"; }
err() { printf "\033[1;31m==>\033[0m %s\n" "$1" >&2; }
OS="$(uname -s)"
if [[ "$EUID" -eq 0 ]]; then
err "Don't run this as root/with sudo — run it as your normal user."
exit 1
fi
# Suppress every interactive prompt apt/dpkg might throw (needrestart's
# "restart services?" dialog is the usual silent offender on fresh Ubuntu).
export DEBIAN_FRONTEND=noninteractive
export NEEDRESTART_MODE=a
export NEEDRESTART_SUSPEND=1
# ---------------------------------------------------------------------------
# Ask for sudo once, then keep it alive in the background for the whole
# script so nothing prompts for a password a second time.
# ---------------------------------------------------------------------------
request_sudo() {
log "Requesting sudo access (you'll only be asked once)..."
sudo -v
( while true; do
sudo -n true
sleep 60
kill -0 "$$" 2>/dev/null || exit
done
) &
SUDO_KEEPALIVE_PID=$!
trap 'kill "$SUDO_KEEPALIVE_PID" 2>/dev/null || true' EXIT
}
# ---------------------------------------------------------------------------
# 1. Linux package-manager detection
# ---------------------------------------------------------------------------
# PM_FAMILY drives which branch app installers take: debian / fedora / arch / suse
# PM is the actual binary to invoke (yay/paru take priority over bare pacman
# on Arch so AUR packages "just work").
PM=""
PM_FAMILY=""
detect_pkg_manager() {
if command -v yay >/dev/null 2>&1; then
PM="yay"; PM_FAMILY="arch"
elif command -v paru >/dev/null 2>&1; then
PM="paru"; PM_FAMILY="arch"
elif command -v pacman >/dev/null 2>&1; then
PM="pacman"; PM_FAMILY="arch"
elif command -v apt-get >/dev/null 2>&1; then
PM="apt-get"; PM_FAMILY="debian"
elif command -v dnf >/dev/null 2>&1; then
PM="dnf"; PM_FAMILY="fedora"
elif command -v yum >/dev/null 2>&1; then
PM="yum"; PM_FAMILY="fedora"
elif command -v zypper >/dev/null 2>&1; then
PM="zypper"; PM_FAMILY="suse"
elif command -v apk >/dev/null 2>&1; then
PM="apk"; PM_FAMILY="alpine"
else
err "No supported package manager found (looked for yay/paru/pacman/apt/dnf/yum/zypper/apk)."
exit 1
fi
log "Detected package manager: $PM ($PM_FAMILY family)"
}
# Arch package install helper. yay/paru must run as a normal user (they
# refuse under sudo and escalate internally); bare pacman needs sudo
# explicitly. Using "$PM" -S directly everywhere would break the moment
# PM falls back to pacman, so every Arch install goes through this.
arch_install() {
if [[ "$PM" == "pacman" ]]; then
sudo pacman -S --noconfirm --needed "$@"
else
"$PM" -S --noconfirm --needed "$@"
fi
}
# On Arch, bootstrap an AUR helper if neither yay nor paru is present —
# several of our apps (chrome on all setups, wezterm/ghostty as a fallback)
# live in the AUR. Tries yay-bin first, falls back to paru-bin if that fails.
ensure_aur_helper() {
[[ "$PM_FAMILY" == "arch" ]] || return 0
[[ "$PM" == "yay" || "$PM" == "paru" ]] && return 0
sudo pacman -Sy --noconfirm --needed base-devel git
local tmp; tmp="$(mktemp -d)"
log "No AUR helper found — bootstrapping yay..."
if git clone --depth 1 https://aur.archlinux.org/yay-bin.git "$tmp/yay-bin" \
&& ( cd "$tmp/yay-bin" && makepkg -si --noconfirm ); then
PM="yay"
else
warn "yay-bin build failed — trying paru-bin instead..."
rm -rf "$tmp"; tmp="$(mktemp -d)"
if git clone --depth 1 https://aur.archlinux.org/paru-bin.git "$tmp/paru-bin" \
&& ( cd "$tmp/paru-bin" && makepkg -si --noconfirm ); then
PM="paru"
else
err "Could not bootstrap an AUR helper (yay or paru). AUR-only packages (Chrome) will fail."
PM="pacman"
fi
fi
rm -rf "$tmp"
}
# ---------------------------------------------------------------------------
# 2. Base OS setup
# ---------------------------------------------------------------------------
install_xcode_clt() {
if xcode-select -p >/dev/null 2>&1; then
log "Xcode Command Line Tools already installed."
return
fi
log "Installing Xcode Command Line Tools (non-interactively)..."
local trigger="/tmp/.com.apple.dt.CommandLineTools.installondemand.in-progress"
touch "$trigger"
local label
label="$(softwareupdate -l 2>/dev/null \
| grep -B1 -E '\*.*Command Line Tools' \
| awk -F'\* ' '/^\ *\*/ {print $2}' \
| sed 's/^ *//;s/ *$//' \
| tail -n1)"
if [[ -n "$label" ]]; then
sudo softwareupdate -i "$label" --agree-to-license
rm -f "$trigger"
else
rm -f "$trigger"
warn "Could not auto-detect the CLT package name — falling back to interactive install."
xcode-select --install
read -r -p "Press [Enter] once the popup installer finishes > "
fi
}
install_linux_prereqs() {
log "Installing base build tools via $PM..."
case "$PM_FAMILY" in
debian)
sudo apt-get update -y
sudo apt-get install -y build-essential procps curl file git wget \
apt-transport-https ca-certificates gnupg lsb-release software-properties-common
;;
fedora)
sudo "$PM" groupinstall -y "Development Tools" 2>/dev/null \
|| sudo "$PM" group install -y "development-tools" 2>/dev/null \
|| warn "Couldn't install the Development Tools group — continuing anyway."
sudo "$PM" install -y curl file git wget gnupg2 which dnf-plugins-core
;;
arch)
sudo pacman -Sy --noconfirm --needed base-devel curl file git wget gnupg which
ensure_aur_helper
;;
suse)
sudo zypper --non-interactive install -t pattern devel_basis
sudo zypper --non-interactive install curl file git wget gpg2 which
;;
alpine)
sudo apk add --no-cache build-base curl file git wget gnupg
;;
esac
}
install_homebrew() {
if command -v brew >/dev/null 2>&1; then
log "Homebrew already installed."
else
log "Installing Homebrew..."
NONINTERACTIVE=1 /bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"
fi
if [[ "$OS" == "Darwin" ]]; then
if [[ -d /opt/homebrew/bin ]]; then
eval "$(/opt/homebrew/bin/brew shellenv)"
else
eval "$(/usr/local/bin/brew shellenv)"
fi
else
eval "$(/home/linuxbrew/.linuxbrew/bin/brew shellenv)"
fi
local profile="$HOME/.profile"
[[ -n "${ZSH_VERSION:-}" ]] && profile="$HOME/.zprofile"
if ! grep -q "brew shellenv" "$profile" 2>/dev/null; then
if [[ "$OS" == "Darwin" ]]; then
echo 'eval "$(/opt/homebrew/bin/brew shellenv)"' >> "$profile"
else
echo 'eval "$(/home/linuxbrew/.linuxbrew/bin/brew shellenv)"' >> "$profile"
fi
fi
}
# ---------------------------------------------------------------------------
# 3. Dotfiles
# ---------------------------------------------------------------------------
clone_dotfiles() {
if [[ "$DOTFILES_REPO" == *"yourusername"* ]]; then
warn "DOTFILES_REPO is still a placeholder — skipping dotfiles clone/link."
warn "Edit DOTFILES_REPO at the top of this script and re-run."
return 1
fi
if [[ -d "$DOTFILES_DIR/.git" ]]; then
log "Dotfiles repo already cloned at $DOTFILES_DIR, pulling latest..."
git -C "$DOTFILES_DIR" pull --ff-only
else
log "Cloning dotfiles from $DOTFILES_REPO..."
git clone "$DOTFILES_REPO" "$DOTFILES_DIR"
fi
}
link_config() {
local subdir="$1"
local target="$HOME/.config/$subdir"
local source="$DOTFILES_DIR/$subdir"
if [[ ! -d "$source" ]]; then
warn "No $subdir/ found in dotfiles repo — skipping link."
return
fi
mkdir -p "$HOME/.config"
if [[ -L "$target" ]]; then
rm "$target"
elif [[ -e "$target" ]]; then
warn "Backing up existing $target -> $target.bak"
mv "$target" "$target.bak"
fi
ln -s "$source" "$target"
log "Linked $target -> $source"
}
# ---------------------------------------------------------------------------
# 4. App installers (each branches on PM_FAMILY for Linux)
# ---------------------------------------------------------------------------
install_chrome() {
# Chrome is not in Ubuntu/Debian, Fedora, openSUSE, or Arch's default
# repos (Google only ships their own repo/AUR package) — every branch
# here pulls straight from Google or the AUR, confirmed against each
# distro's official package search.
if [[ "$OS" == "Darwin" ]]; then
brew install --cask google-chrome
return
fi
if command -v google-chrome >/dev/null 2>&1 || command -v google-chrome-stable >/dev/null 2>&1; then
log "Chrome already installed."
return
fi
case "$PM_FAMILY" in
debian)
wget -q -O /tmp/chrome.deb https://dl.google.com/linux/direct/google-chrome-stable_current_amd64.deb
sudo apt-get install -y /tmp/chrome.deb
rm -f /tmp/chrome.deb
;;
fedora)
sudo "$PM" install -y https://dl.google.com/linux/direct/google-chrome-stable_current_x86_64.rpm
;;
arch)
arch_install google-chrome
;;
suse)
sudo rpm --import https://dl.google.com/linux/linux_signing_key.pub
sudo zypper ar -f https://dl.google.com/linux/chrome/rpm/stable/x86_64 google-chrome
sudo zypper --non-interactive install google-chrome-stable
;;
*)
warn "Don't know how to install Chrome on this distro — skipping."
;;
esac
}
install_vlc() {
if [[ "$OS" == "Darwin" ]]; then
brew install --cask vlc
return
fi
case "$PM_FAMILY" in
debian) sudo apt-get install -y vlc ;;
fedora)
sudo "$PM" install -y https://download1.rpmfusion.org/free/fedora/rpmfusion-free-release-"$(rpm -E %fedora)".noarch.rpm 2>/dev/null || true
sudo "$PM" install -y vlc
;;
arch) arch_install vlc ;;
suse) sudo zypper --non-interactive install vlc-noX 2>/dev/null || sudo zypper --non-interactive install vlc ;;
*) warn "Don't know how to install VLC on this distro — skipping." ;;
esac
}
install_obs() {
if [[ "$OS" == "Darwin" ]]; then
brew install --cask obs
return
fi
case "$PM_FAMILY" in
debian)
sudo add-apt-repository -y ppa:obsproject/obs-studio
sudo apt-get update -y
sudo apt-get install -y obs-studio
;;
fedora)
sudo "$PM" install -y https://download1.rpmfusion.org/free/fedora/rpmfusion-free-release-"$(rpm -E %fedora)".noarch.rpm 2>/dev/null || true
sudo "$PM" install -y obs-studio
;;
arch) arch_install obs-studio ;;
suse) sudo zypper --non-interactive install obs-studio ;;
*) warn "Don't know how to install OBS on this distro — skipping." ;;
esac
}
install_wezterm() {
if [[ "$OS" == "Darwin" ]]; then
brew install --cask wezterm
return
fi
case "$PM_FAMILY" in
debian)
curl -fsSL https://apt.fury.io/wez/gpg.key | sudo gpg --yes --dearmor -o /usr/share/keyrings/wezterm-fury.gpg
echo 'deb [signed-by=/usr/share/keyrings/wezterm-fury.gpg] https://apt.fury.io/wez/ * *' | \
sudo tee /etc/apt/sources.list.d/wezterm.list
sudo apt-get update -y
sudo apt-get install -y wezterm
;;
fedora)
sudo dnf copr enable -y wezfurlong/wezterm-nightly
sudo "$PM" install -y wezterm
;;
arch) arch_install wezterm ;;
suse) warn "No official WezTerm package for openSUSE — install manually from wezfurlong.org/wezterm" ;;
*) warn "Don't know how to install WezTerm on this distro — skipping." ;;
esac
}
install_ghostty() {
if [[ "$OS" == "Darwin" ]]; then
brew install --cask ghostty
link_config "$DOTFILES_GHOSTTY_SUBDIR"
return
fi
case "$PM_FAMILY" in
arch)
arch_install ghostty
;;
fedora)
sudo dnf copr enable -y scottames/ghostty
sudo "$PM" install -y ghostty
;;
*)
if brew install ghostty 2>/dev/null; then
log "Ghostty installed via brew."
else
warn "No packaged Ghostty build for this distro yet."
warn "Install manually from https://ghostty.org/download"
fi
;;
esac
link_config "$DOTFILES_GHOSTTY_SUBDIR"
}
install_neovim() {
log "Installing Neovim + common dependencies via brew..."
brew install neovim ripgrep fd git node python lazygit fzf
link_config "$DOTFILES_NVIM_SUBDIR"
}
install_docker() {
if [[ "$OS" == "Darwin" ]]; then
brew install --cask docker-desktop
warn "Docker Desktop installed — open it once from /Applications to finish setup."
return
fi
if command -v docker >/dev/null 2>&1; then
log "Docker already installed."
return
fi
# Docker's own docs mark the get.docker.com convenience script as
# "testing/dev only" and recommend installing from Docker's official
# package repo instead — that's what each branch below does.
case "$PM_FAMILY" in
debian)
log "Installing Docker Engine via Docker's official apt repo..."
for pkg in docker.io docker-doc docker-compose docker-compose-v2 podman-docker containerd runc; do
sudo apt-get remove -y "$pkg" 2>/dev/null || true
done
sudo apt-get update -y
sudo apt-get install -y ca-certificates curl
sudo install -m 0755 -d /etc/apt/keyrings
sudo curl -fsSL https://download.docker.com/linux/ubuntu/gpg -o /etc/apt/keyrings/docker.asc
sudo chmod a+r /etc/apt/keyrings/docker.asc
echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/ubuntu $(. /etc/os-release && echo "${UBUNTU_CODENAME:-$VERSION_CODENAME}") stable" | \
sudo tee /etc/apt/sources.list.d/docker.list > /dev/null
sudo apt-get update -y
sudo apt-get install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin
;;
fedora)
log "Installing Docker Engine via Docker's official dnf repo..."
for pkg in docker docker-client docker-client-latest docker-common docker-latest \
docker-latest-logrotate docker-logrotate docker-selinux docker-engine-selinux docker-engine; do
sudo "$PM" remove -y "$pkg" 2>/dev/null || true
done
sudo dnf config-manager --add-repo https://download.docker.com/linux/fedora/docker-ce.repo 2>/dev/null \
|| sudo dnf config-manager addrepo --from-repofile=https://download.docker.com/linux/fedora/docker-ce.repo
sudo "$PM" install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin
;;
arch)
log "Installing Docker via the official Arch [extra] repo..."
arch_install docker docker-compose docker-buildx
;;
suse)
log "Installing Docker via zypper..."
sudo zypper --non-interactive install docker docker-compose
;;
*)
warn "Don't know the native Docker install path for this distro — falling back to the convenience script (dev/test only)."
curl -fsSL https://get.docker.com | sudo sh
;;
esac
sudo systemctl enable --now docker 2>/dev/null || true
sudo usermod -aG docker "$USER"
warn "Log out/in (or run 'newgrp docker') for the docker group change to apply."
}
install_zed() {
if [[ "$OS" == "Darwin" ]]; then
brew install --cask zed
else
log "Installing Zed via official installer..."
curl -fsSL https://zed.dev/install.sh | sh
fi
}
# ---------------------------------------------------------------------------
# 5. Main
# ---------------------------------------------------------------------------
main() {
request_sudo
case "$OS" in
Darwin)
log "Detected macOS."
install_xcode_clt
install_homebrew
;;
Linux)
log "Detected Linux."
detect_pkg_manager
install_linux_prereqs
install_homebrew
;;
*)
err "Unsupported OS: $OS"
exit 1
;;
esac
log "Setting up dotfiles..."
clone_dotfiles || true
log "Installing apps..."
install_chrome
install_vlc
install_obs
install_wezterm
install_ghostty
install_neovim
install_docker
install_zed
log "Done. Restart your terminal (or 'source ~/.profile' / ~/.zprofile) to pick up brew's PATH."
}
main "$@"
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment