Personal dotfiles for Ubuntu/Debian. One-command install (install.sh) with four
profiles, managed updates (update.sh), and a post-install test suite (test.sh).
CI tests all profile × Ubuntu-version combinations on every push.
install.sh entry point — profile selection, module orchestration
update.sh managed tool updates with --check mode + PATH shadow check
test.sh post-install validation suite (profile-aware)
ci-local.sh local Docker matrix runner — mirrors GitHub CI matrix
get.sh curl-pipe bootstrap (clones repo → runs install.sh)
Dockerfile bakes docker profile into an image at build time
Dockerfile.nosudo parameterized no-sudo test image (two variants: forced/auto)
lib/utils.sh shared logging, sudo detection, GitHub helpers, binary utils
modules/
base.sh apt packages + per-tool installers (fzf, zoxide, delta, eza, …)
zsh.sh oh-my-zsh, plugins, .zshrc symlink, default shell
tmux.sh tmux config symlinks, plugin cloning
neovim.sh GitHub binary release, config symlink, shadow detection
tools.sh uv, ruff, cheat, ripgrep/ranger config
scripts/
install-fonts.sh MesloLGS NF installer (local workstation only)
install-x11.sh Caps Lock remapping (X11 only)
zsh/ .zshrc
nvim/ .config/nvim/ (lazy.nvim, LSP, treesitter, etc.)
git/ .gitconfig, .gitattributes
tmux/ .tmux.conf, .tmux.conf.local
ripgrep/ .config/ripgrep/rc
ranger/ .config/ranger/ (individual file symlinks, not directory)
x11/ .xprofile, caps-remap.sh, autostart .desktop
VERSION semver string
CHANGELOG.md keep-a-changelog format
DOTFILES_DIR is set at the top of every script to the absolute repo root:
DOTFILES_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"Use it whenever referencing files inside the repo — never use relative paths.
Three profiles are valid install.sh arguments:
| Profile | Sudo | Installs |
|---|---|---|
minimal |
required for apt | base pkgs, zsh, tmux, git config |
workstation |
required for apt | minimal + neovim + tools (uv, ruff, cheat) |
docker |
optional | base pkgs, zsh, tmux, git config (no shell change) |
Profile selection: CLI arg → interactive wizard (tty only) → workstation default.
No-sudo mode is not a profile — it is an environment flag:
NOSUDO=1 ./install.sh minimal # forces CAN_SUDO=false; tools fetched to ~/.local/binnosudo appears as a profile name only in test.sh, where it validates that
the no-sudo install produced the correct binaries in ~/.local/bin.
Module orchestration in install.sh:
_run_minimal→ base → zsh → tmux → git link_run_workstation→ base → zsh → tmux plugins → tools → neovim → git link_run_docker→ base_docker → zsh (no shell change) → tmux → git link
log_step "Section name" # bold header: ── Section name ──
log_info "message" # blue →
log_ok "message" # green ✓
log_warn "message" # yellow ! — already writes to stderr internally
log_error "message" # red ✗ — already writes to stderr internally
die "message" # log_error + exit 1log_warn and log_error redirect to stderr inside the function — do NOT add
>&2 at call sites. Always use these; never echo directly.
set -euo pipefail — all scripts use strict mode. Key implication:
# WRONG — (( expr )) exits with status 1 when result is 0 (falsy), triggers set -e
(( count++ ))
# CORRECT
count=$(( count + 1 ))local declarations — every variable inside a function MUST be declared
local (or local -a for arrays). Undeclared variables leak into global scope
and can corrupt state when functions are called multiple times or sourced.
my_func() {
local var="value"
local -a arr=()
local result
result=$(some_command)
}detect_sudo (called once early) sets three globals:
SUDO=""/"sudo" # prefix for privileged commands
CAN_SUDO=true/false # boolean for branching
SUDO_STATUS=root|sudo_passwordless|sudo_password|nosudoPatterns:
if $CAN_SUDO; then
$SUDO apt-get ...
fi
# Refresh credential cache before long operations (download may take >15 min)
[ -n "${SUDO:-}" ] && sudo -v 2>/dev/null || trueCAN_SUDO + apt has it? → apt_install
CAN_SUDO + apt missing? → GitHub .deb (via _gh_release_info) or PPA
no sudo? → GitHub tarball → ~/.local/bin
Key helpers in lib/utils.sh:
| Function | Purpose |
|---|---|
has CMD |
command -v wrapper |
apt_install |
apt-get with DEBIAN_FRONTEND=noninteractive |
_gh_latest_tag_noapi REPO |
Tag via HTTP redirect — prefer this (no API, no rate limit) |
_gh_latest_tag REPO |
Tag via GitHub JSON API — use only when redirect fails |
_gh_latest_release REPO PATTERN |
Download URL matching asset pattern |
_gh_release_info REPO PATTERN |
Tag + URL in one API call → "TAG URL" — use when you need both |
_download_tar_bin URL BIN DEST |
Download tarball, extract named binary |
_cmd_version CMD [ARGS] |
Extract \d+\.\d+(\.\d+)? from --version |
_ver_older_than A B |
True if A < B via sort -V |
_verify_dest BIN DEST |
Warn if command -v BIN ≠ DEST |
_resolve_dest BIN FALLBACK |
Use current binary location; never /usr/* |
symlink SRC DST |
mkdir -p + ln -sf |
When to use which GitHub helper:
- Tag only, no download →
_gh_latest_tag_noapi(no API call) - Tag + URL together →
_gh_release_info(one API call) - URL only (pattern match) →
_gh_latest_release - Never construct
.debURLs manually — asset names change between releases (e.g. delta 0.19.0 renamedgit-delta→git-delta-musl); use_gh_release_info
Two modes:
./update.sh # update all tools
./update.sh --check # report current vs latest (read-only)
./update.sh rg neovim # update only named toolsKnown tools list — _KNOWN_TOOLS array at the top of update.sh:
apt omz tmux-plugins zsh-plugins fzf rg fd shellcheck
zoxide delta eza uv ruff neovim cheat pre-commit xcape
When adding a new tool, add its name here — the arg parser rejects unknown names.
_should_run pattern used at the top of each tool block:
_should_run "toolname" || return 0_update_std_tool helper covers standard single-binary GitHub tarball tools:
# Usage: _update_std_tool CMD LABEL REPO GNU_ARM [BINARY] [ASSET_PREFIX]
_update_std_tool rg "ripgrep" "BurntSushi/ripgrep" gnu
_update_std_tool eza "eza" "eza-community/eza" musl eza "eza_"PATH shadow check (_check_path_shadows) always runs at the end, read-only.
# Rust tools (most tools)
x86_64) arch="x86_64-unknown-linux-musl" ;;
aarch64) arch="aarch64-unknown-linux-gnu" ;; # or musl depending on tool
# Debian arch (for .deb)
_deb_arch() # dpkg --print-architecture; fallback: x86_64→amd64, aarch64→arm64
# Bare (shellcheck)
x86_64 / aarch64Always handle unsupported arch with log_warn "... — skipping"; return.
local tmp; tmp=$(mktemp -d)
# shellcheck disable=SC2064
trap "rm -rf '$tmp'" RETURN # RETURN fires on explicit return AND implicit exitSC2064 is disabled intentionally — $tmp must expand at trap-definition time
(it is a local variable; expanding later would find it unset). Use RETURN not
EXIT so the trap is function-scoped, not script-scoped.
Two surfaces:
-
Install time (
modules/neovim.sh_nvim_warn_shadows): direct file probes on$HOME/.local/bin/nvimand$HOME/bin/nvim— nevercommand -v, which resolves via install-time bash PATH and may itself return a shadow binary. -
Update time (
update.sh_check_path_shadows): PATH walk looking for executables before/usr/local/bin. Runs always, read-only.
Only /usr/local/bin tools need shadow checks — tools at ~/.local/bin are
already at the highest dotfiles-managed PATH priority.
| Dotfiles path | Linked to |
|---|---|
zsh/.zshrc |
~/.zshrc |
tmux/.tmux.conf |
~/.tmux.conf |
tmux/.tmux.conf.local |
~/.tmux.conf.local |
nvim/.config/nvim |
~/.config/nvim (directory symlink) |
git/.gitconfig |
~/.gitconfig |
git/.gitattributes |
~/.gitattributes |
ripgrep/rc |
~/.config/ripgrep/rc |
ranger/rc.conf, rifle.conf, scope.sh, commands*.py |
~/.config/ranger/ (individual files) |
x11/.xprofile |
~/.xprofile |
x11/.config/autostart/caps-remap.desktop |
~/.config/autostart/caps-remap.desktop |
Ranger is linked file-by-file (not as a directory) to keep runtime state
(bookmarks, history, tags) out of git.
Nvim: if ~/.config/nvim is a real directory (not a symlink), link_nvim_config
warns and bails rather than creating a link inside it.
fzf is installed via git clone to ~/.fzf/ (not apt, not a binary release).
The installer generates ~/.fzf.zsh which adds ~/.fzf/bin to PATH and registers
Ctrl+T/Ctrl+R/Alt+C bindings.
~/.fzf.zsh is sourced explicitly in .zshrc:
[[ -f ~/.fzf.zsh ]] && source ~/.fzf.zshThe oh-my-zsh fzf plugin was removed — this explicit source line is the only
thing that activates fzf shell integration. Do not remove it. Do not re-add the
oh-my-zsh fzf plugin without removing this line first.
| Flag | Set by | Read by |
|---|---|---|
_SHELL_IS_ZSH |
_set_default_shell() in modules/zsh.sh |
install.sh "next steps" |
CAN_SUDO |
detect_sudo() in lib/utils.sh |
everywhere |
CHECK_ONLY |
--check arg in update.sh |
update.sh per-tool blocks |
DOTFILES_DIR |
top of each script | modules, symlink helpers |
- Check before installing:
has cmd && { log_ok "already installed"; return; } - Check before cloning:
[ -d dest ] && return - Locale:
locale -a | grep -q 'en_US.utf8'guard beforelocale-gen apt_installis naturally idempotent
bash test.sh [docker|minimal|workstation|nosudo]Exits 0 (all pass), 1 (any fail). Skips are not failures. Runs in CI after every install + again after update.sh.
Core checks (all profiles): zsh, tmux, git, python3, fzf, ripgrep, git-delta, zoxide, jq, fd; fzf shell integration + functional filter; zsh syntax check; oh-my-zsh + plugins + powerlevel10k dirs; tmux detached session start; git config diff driver; zoxide init + add + query.
Profile-specific checks:
workstation— nvim, uv, cheat; config symlinks (nvim, ripgrep, ranger); tmux plugin dirs (tmux-fzf, tmux-cpu)minimal— ranger, tig, parallel, eza, shellcheck (all via apt)nosudo— strict~/.local/binpresence check for all 7 GitHub binaries (rg, fd, jq, fzf, zoxide, delta, eza); sudo availability info (not a failure condition — nosudo-forced has sudo available); functional smoke tests for each
Two nosudo scenarios are validated by Dockerfile.nosudo:
- nosudo-auto — no
sudobinary;detect_sudo()auto-detectsCAN_SUDO=false(GRANT_SUDO=false NOSUDO_INSTALL=""build args) - nosudo-forced — user has passwordless
sudobutNOSUDO=1overrides it (GRANT_SUDO=true NOSUDO_INSTALL=1build args)
Job install (9 combinations):
- Ubuntu 20.04 / 22.04 / 24.04 × docker / minimal / workstation
- testuser with passwordless sudo; curl auth via
~/.curlrc - Flow: install → idempotency re-run → test → update → re-test
Job install-nosudo (6 combinations — 3 Ubuntu × 2 variants):
- Ubuntu 20.04 / 22.04 / 24.04 × variant
auto/forced - Root pre-installs: git, curl, wget, ca-certificates, zsh, tmux, python3
auto: nosudobinary installed;detect_sudo()auto-detectsCAN_SUDO=falseforced: passwordlesssudoinstalled, butNOSUDO=1overrides it- Flow: install → test → idempotency re-run → update → re-test
Total: 15 CI combinations (9 regular + 6 nosudo)
1. Edit VERSION — bump semver (patch: fix, minor: feature, major: breaking)
2. Edit CHANGELOG.md — move items from [Unreleased] to [X.Y.Z] - YYYY-MM-DD
3. git add VERSION CHANGELOG.md && git commit -m "docs: bump version to X.Y.Z"
4. git tag vX.Y.Z
5. git push origin master && git push origin vX.Y.Z
6. gh release create vX.Y.Z --title "vX.Y.Z" --notes "..."
- Never construct GitHub release asset URLs from version + arch — use
_gh_release_infoor_gh_latest_release; asset names change between releases. - Never use
command -vat install time when PATH order matters — use direct[ -x /absolute/path ]probes (install-time bash PATH may resolve to shadow). - Never
git pushwithout confirmation — always ask first.