Files
dotfiles/local/bin/dfm
Ismo Vuorinen 5f2502e33b fix(pre-commit): remove md from biome check file pattern
Biome 2.x does not support Markdown checking, causing errors on
CLAUDE.md. Remove md from the hook's file filter. Also includes
minor autofix changes from biome (trailing newlines).
2026-03-18 19:38:16 +02:00

729 lines
19 KiB
Bash
Executable File

#!/usr/bin/env bash
#
# Dotfiles manager and install helper
# (c) Ismo Vuorinen <https://github.com/ivuorinen> 2022
# Licensed under MIT, see LICENSE
#
# vim: ft=bash ts=2 sw=2 et
# shellcheck source-path=$HOME/.dotfiles/local/bin
#
# Helper variables, override with ENVs like `VERBOSE=1 dfm help`
: "${VERBOSE:=0}"
: "${DOTFILES:=$HOME/.dotfiles}"
: "${BREWFILE:=$DOTFILES/config/homebrew/Brewfile}"
: "${HOSTFILES:=$DOTFILES/hosts}"
export DOTFILES BREWFILE HOSTFILES
SCRIPT=$(basename "$0")
# Require bash 4.0+ for associative arrays and mapfile
if ((BASH_VERSINFO[0] < 4)); then
echo "dfm requires bash 4.0+, found ${BASH_VERSION}"
if [[ "$(uname)" == "Darwin" ]]; then
if ! command -v brew &> /dev/null; then
echo "Installing Homebrew..."
/bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"
fi
echo "Installing modern bash via Homebrew..."
brew install bash
echo "Done. Restart your shell and run dfm again."
else
echo "Install bash 4.0+ and try again."
fi
exit 1
fi
# shellcheck disable=SC1091
source "$DOTFILES/config/shared.sh"
# shellcheck disable=SC1090
source "${DOTFILES}/local/bin/msgr"
# Get description from a script file's @description tag
get_script_description()
{
local file="$1"
local desc
desc=$(sed -n '/@description/s/.*@description *\(.*\)/\1/p' "$file" | head -1)
echo "${desc:-No description available}"
}
# Menu builder
menu_builder()
{
local title=$1
local commands=("${@:2}")
local width=60
printf "\n%s\n" "$(printf '%.s─' $(seq 1 "$width"))"
printf "%-${width}s\n" " $title"
printf "%s\n" "$(printf '%.s─' $(seq 1 "$width"))"
for cmd in "${commands[@]}"; do
local name=${cmd%%:*}
local desc=${cmd#*:}
printf " %-20s %s\n" "$name" "$desc"
done
}
# Handle install section commands
section_install()
{
USAGE_PREFIX="$SCRIPT install <command>"
MENU=(
"all:Installs everything in the correct order"
"apt-packages:Install apt packages (Debian/Ubuntu)"
"cheat-databases:Install cheat external cheatsheet databases"
"composer:Install composer"
"dnf-packages:Install dnf packages (Fedora/RHEL)"
"fonts:Install programming fonts"
"gh:Install GitHub CLI Extensions"
"git-crypt:Install git-crypt from source"
"imagick:Install ImageMagick CLI"
"macos:Setup nice macOS defaults"
"mise:Install tools via mise (runtimes + CLI tools)"
"mise-cleanup:Remove old version manager installations (--dry-run supported)"
"ntfy:Install ntfy notification tool"
"python-libs:Install Python libraries (libtmux, pynvim)"
"shellspec:Install shellspec testing framework"
"xcode-cli-tools:Install Xcode CLI tools (macOS)"
"z:Install z"
)
case "$1" in
all)
msgr msg "Starting to install all and reloading configurations..."
# Tier 0: Platform foundations (OS packages, build tools)
[[ "$(uname)" == "Darwin" ]] && $0 install macos
[[ "$(uname)" == "Darwin" ]] && $0 install xcode-cli-tools
command -v apt &> /dev/null && $0 install apt-packages
command -v dnf &> /dev/null && $0 install dnf-packages
# Tier 1: Package managers & fonts
$0 brew install
$0 install fonts
# Tier 2: Runtimes and CLI tools via mise, then remaining installers
$0 install mise || exit 1
$0 install composer || exit 1
$0 install python-libs || exit 1
# Tier 3: Tool-dependent installers
$0 install cheat-databases
$0 install gh
$0 install git-crypt
$0 install ntfy
# Tier 4: Independent utilities
$0 install shellspec
$0 install z
msgr msg "Reloading configurations again..."
# shellcheck disable=SC1091
source "$DOTFILES/config/shared.sh"
msgr yay "All done!"
;;
cheat-databases)
msgr run "Installing cheat databases..."
for database in "$DOTFILES"/scripts/install-cheat-*.sh; do
bash "$database" \
&& msgr run_done "Cheat: $database run"
done
;;
composer)
msgr run "Installing composer..."
bash "$DOTFILES/scripts/install-composer.sh" \
&& msgr run_done "composer installed!"
;;
fonts)
msgr run "Installing fonts..."
bash "$DOTFILES/scripts/install-fonts.sh" \
&& msgr yay "Installed fonts!"
;;
gh)
msgr run "Installing GitHub CLI Extensions..."
bash "$DOTFILES/scripts/install-gh-extensions.sh" \
&& msgr yay "github cli extensions installed!"
;;
imagick)
msgr run "Downloading and installing ImageMagick CLI..."
curl -L https://imagemagick.org/archive/binaries/magick > "$XDG_BIN_HOME/magick" \
&& chmod +x "$XDG_BIN_HOME/magick" \
&& msgr yay "imagick downloaded and installed!"
;;
macos)
msgr run "Setting up macOS defaults..."
bash "$DOTFILES/scripts/install-macos-defaults.sh" \
&& msgr yay "macOS defaults set!"
;;
mise)
msgr run "Installing tools via mise..."
if ! command -v mise &> /dev/null; then
msgr nested "Installing mise..."
curl -fsSL https://mise.run | sh || {
msgr err "Failed to install mise"
exit 1
}
export PATH="${XDG_BIN_HOME:-$HOME/.local/bin}:$PATH"
fi
mise install --yes || {
msgr err "mise install failed"
exit 1
}
mise reshim || {
msgr err "mise reshim failed"
exit 1
}
msgr yay "mise tools installed!"
;;
mise-cleanup)
msgr run "Cleaning up old version manager installations..."
bash "$DOTFILES/scripts/cleanup-old-version-managers.sh" "${@:2}"
;;
apt-packages)
msgr run "Installing apt packages..."
bash "$DOTFILES/scripts/install-apt-packages.sh" \
&& msgr yay "apt packages installed!"
;;
dnf-packages)
msgr run "Installing dnf packages..."
bash "$DOTFILES/scripts/install-dnf-packages.sh" \
&& msgr yay "dnf packages installed!"
;;
git-crypt)
msgr run "Installing git-crypt..."
bash "$DOTFILES/scripts/install-git-crypt.sh" \
&& msgr yay "git-crypt installed!"
;;
ntfy)
msgr run "Installing ntfy..."
bash "$DOTFILES/scripts/install-ntfy.sh" \
&& msgr yay "ntfy installed!"
;;
python-libs)
msgr run "Installing Python libraries..."
bash "$DOTFILES/scripts/install-python-packages.sh" \
&& msgr yay "Python libraries installed!"
;;
xcode-cli-tools)
msgr run "Installing Xcode CLI tools..."
bash "$DOTFILES/scripts/install-xcode-cli-tools.sh" \
&& msgr yay "Xcode CLI tools installed!"
;;
shellspec)
msgr run "Installing shellspec..."
bash "$DOTFILES/scripts/install-shellspec.sh" \
&& msgr yay "shellspec has been installed!"
;;
z)
msgr run "Installing z..."
bash "$DOTFILES/scripts/install-z.sh" \
&& msgr yay "z has been installed!"
;;
*) menu_builder "$USAGE_PREFIX" "${MENU[@]}" ;;
esac
}
# Handle Homebrew section commands
section_brew()
{
USAGE_PREFIX="$SCRIPT brew <command>"
MENU=(
"install:Installs items defined in Brewfile"
"update:Updates and upgrades brew packages"
"updatebundle:Updates Brewfile with descriptions"
"autoupdate:Setups brew auto-update and runs it immediately"
"leaves:List brew leaves (installed on request)"
"clean:Clean up brew packages"
"untracked:List untracked brew packages"
)
if ! x-have brew; then
msgr warn "brew not available, skipping"
return 0
fi
case "$1" in
install)
brew bundle install --file="$BREWFILE" --force --quiet && msgr yay "Done!"
;;
update)
if brew update && brew outdated && brew upgrade && brew cleanup; then
msgr yay "Done!"
else
msgr err "brew update failed"
fi
;;
updatebundle)
# Updates .dotfiles/homebrew/Brewfile with descriptions
brew bundle dump \
--force \
--file="$BREWFILE" \
--cleanup \
--tap \
--formula \
--cask \
--describe && msgr yay "Done!"
;;
leaves)
brew leaves --installed-on-request
;;
untracked)
declare -a BREW_LIST_ALL
while IFS= read -r line; do
BREW_LIST_ALL+=("$line")
done < <(brew list --formula --installed-on-request -1 --full-name)
while IFS= read -r c; do
BREW_LIST_ALL+=("$c")
done < <(brew list --cask -1 --full-name)
# Remove entries that are installed as dependencies
declare -a BREW_LIST_DEPENDENCIES
while IFS= read -r l; do
BREW_LIST_DEPENDENCIES+=("$l")
done < <(brew list -1 --installed-as-dependency)
declare -a BREW_LIST_BUNDLED
while IFS= read -r b; do
BREW_LIST_BUNDLED+=("$b")
done < <(brew bundle list --all --file="$BREWFILE")
declare -a BREW_LIST_TRACKED_WITHOUT_DEPS
for f in "${BREW_LIST_ALL[@]}"; do
# shellcheck disable=SC2199
if [[ " ${BREW_LIST_DEPENDENCIES[@]} " != *" ${f} "* ]]; then
BREW_LIST_TRACKED_WITHOUT_DEPS+=("$f")
fi
done
array_diff BREW_LIST_UNTRACKED BREW_LIST_TRACKED_WITHOUT_DEPS BREW_LIST_BUNDLED
# If there are no untracked packages, return
if [ ${#BREW_LIST_UNTRACKED[@]} -eq 0 ]; then
msgr yay "No untracked packages found!"
return 0
fi
echo "Untracked:"
for f in "${BREW_LIST_UNTRACKED[@]}"; do
echo " $f"
done
;;
autoupdate)
brew autoupdate delete
brew autoupdate start 43200 --upgrade --cleanup --immediate
;;
clean) brew bundle cleanup --file="$BREWFILE" && msgr yay "Done!" ;;
*) menu_builder "$USAGE_PREFIX" "${MENU[@]}" ;;
esac
}
# Handle helper utility commands
section_helpers()
{
USAGE_PREFIX="$SCRIPT helpers <command>"
MENU=(
"aliases:<shell> (bash, zsh, fish) Show aliases"
"colors:Show colors"
"env:Show environment variables"
"functions:Show functions"
"nvim:Show nvim keybindings"
'path:Show $PATH dir by dir'
"tmux:Show tmux keybindings"
"wezterm:Show wezterm keybindings"
)
CMD="${1:-}"
[[ $# -gt 0 ]] && shift
SECTION="${1:-}"
[[ $# -gt 0 ]] && shift
case "$CMD" in
path)
# shellcheck disable=2001
for i in $(echo "$PATH" | sed 's/:/ /g'); do echo "$i"; done
;;
aliases)
case "$SECTION" in
"zsh")
zsh -ixc : 2>&1 | grep -E '> alias' | sed "s|$HOME|~|" | grep -v "(eval)"
;;
"bash")
bash -ixc : 2>&1 | grep -E '> alias' | sed "s|$HOME|~|" | grep -v "(eval)"
;;
"fish")
fish -ic "alias" | sed "s|$HOME|~|"
;;
*)
echo "$SCRIPT helpers aliases <shell> (bash, zsh, fish)"
;;
esac
;;
"colors")
max=255
start=0
while [ "$start" -le "$max" ]; do
for i in $(seq "$start" $((start + 9))); do
if [ "$i" -le "$max" ]; then
# Outputs colored number
# printf " \e[38;5;%sm%4s\e[0m" "$i" "$i"
# Outputs colored block with number inside
# printf " \e[48;5;%sm\e[38;5;15m%5s \e[0m" "$i" "$i"
# Outputs colored block and color number
# printf " \e[48;5;%sm \e[0m %3d" "$i" "$i"
# Outputs color number and colored block
printf "%3d \e[48;5;%sm \e[0m " "$i" "$i"
fi
done
printf "\n"
start=$((start + 10))
done
;;
"env") env | sort ;;
"functions") declare -F ;;
"nvim") cat "$DOTFILES/docs/nvim-keybindings.md" ;;
"tmux") cat "$DOTFILES/docs/tmux-keybindings.md" ;;
"wezterm") cat "$DOTFILES/docs/wezterm-keybindings.md" ;;
*) menu_builder "$USAGE_PREFIX" "${MENU[@]}" ;;
esac
}
# Handle apt package manager commands
section_apt()
{
USAGE_PREFIX="$SCRIPT apt <command>"
MENU=(
"upkeep:Run update, upgrade, autoremove and clean"
'install:Install packages from $DOTFILES/tools/apt.txt'
"update:Update apt packages"
"upgrade:Upgrade apt packages"
"autoremove:Remove unused apt packages"
"clean:Clean apt cache"
)
if ! x-have apt; then
msgr warn "apt not available, skipping"
return 0
fi
case "$1" in
upkeep)
sudo apt update \
&& sudo apt upgrade -y \
&& sudo apt autoremove -y \
&& sudo apt clean
;;
install)
# if apt.txt is not found, return with error
if [ ! -f "$DOTFILES/tools/apt.txt" ]; then
msgr err "apt.txt not found"
return 1
fi
# Load apt.txt, remove comments (even if trailing comment) and empty lines.
#
# Ignoring "Quote this to prevent word splitting."
# shellcheck disable=SC2046
sudo apt install \
-y $(
grep -vE '^\s*#' "$DOTFILES/tools/apt.txt" \
| sed -e 's/#.*//' \
| tr '\n' ' '
)
# If there's a apt.txt file under hosts/$hostname/apt.txt,
# run install on those lines too.
HOSTNAME=$(hostname -s)
HOST_APT="$DOTFILES/hosts/$HOSTNAME/apt.txt"
[[ -f $HOST_APT ]] && {
# shellcheck disable=SC2046
sudo apt install -y $(
grep -vE '^\s*#' "$HOST_APT" \
| sed -e 's/#.*//' \
| tr '\n' ' '
)
}
# Try this for an alternative way to install packages
# xargs -a <(awk '! /^ *(#|$)/' "$packagelist") -r -- sudo apt-get install -y
;;
update) sudo apt update ;;
upgrade) sudo apt upgrade -y ;;
autoremove) sudo apt autoremove -y ;;
clean) sudo apt clean ;;
*) menu_builder "$USAGE_PREFIX" "${MENU[@]}" ;;
esac
}
# Handle documentation generation commands
section_docs()
{
USAGE_PREFIX="$SCRIPT docs <command>"
MENU=(
"all:Update all keybindings documentations"
"tmux:Update tmux keybindings documentation"
"nvim:Update nvim keybindings documentation"
"wezterm:Update wezterm keybindings documentation"
)
case "$1" in
all)
$0 docs tmux
$0 docs nvim
$0 docs wezterm
;;
tmux) bash "$DOTFILES/local/bin/x-dfm-docs-xterm-keybindings" ;;
nvim) bash "$DOTFILES/scripts/create-nvim-keymaps.sh" ;;
wezterm) bash "$DOTFILES/scripts/create-wezterm-keymaps.sh" ;;
*) menu_builder "$USAGE_PREFIX" "${MENU[@]}" ;;
esac
}
# Handle dotfiles formatting and reset commands
section_dotfiles()
{
USAGE_PREFIX="$SCRIPT dotfiles <command>"
MENU=(
"fmt:Run all formatters"
"yamlfmt:Run yamlfmt to all dotfiles, which are in our control"
"shfmt:Run shfmt to all dotfiles"
"reset_all:Reset everything, runs all configured reset actions"
"reset_nvim:Resets nvim. Deletes caches, removes nvim folders and relinks nvim folders"
)
case "$1" in
fmt)
msgr run "Running all formatters"
$0 dotfiles yamlfmt \
&& $0 dotfiles shfmt \
&& msgr run_done "...done!"
;;
reset_all)
msgr ok "Running all reset commands"
$0 dotfiles reset_nvim
;;
reset_nvim)
msgr run "Cleaning nvim state, cache and config"
rm -rf \
~/.local/share/nvim \
~/.local/state/nvim \
~/.cache/nvim \
~/.config/nvim \
&& msgr ok "Deleted old nvim files (share, state and cache + config)"
ln -s "$DOTFILES/config/nvim" ~/.config/nvim \
&& msgr ok "Linked nvim and astronvim"
$0 install mise || {
msgr err "Failed to install mise tools"
exit 1
}
msgr ok "Installed packages"
msgr run_done "nvim reset!"
;;
yamlfmt)
# format yaml files
x-have yamlfmt && yamlfmt -conf "$DOTFILES/.yamlfmt"
! x-have yamlfmt && msgr err "yamlfmt not found"
;;
shfmt)
# If system doesn't have fd or shfmt installed, exit
! x-have fd && msgr err "fd not found, install it to continue"
! x-have shfmt && msgr err "shfmt not found, install it to continue"
# Format shell scripts according to following rules.
fd --full-path "$DOTFILES" -tx \
--hidden \
-E '*.pl' -E '*.php' -E '*.py' -E '*.zsh' -E 'plugins' -E 'fzf' -E 'dotbot' \
-E 'test' -E '**/tldr/*' \
-x shfmt \
--language-dialect bash \
--func-next-line --list --write \
--indent 2 --case-indent --space-redirects \
--binary-next-line {} \; \
&& msgr yay "dotfiles have been shfmt formatted!"
;;
*) menu_builder "$USAGE_PREFIX" "${MENU[@]}" ;;
esac
}
# Handle system check commands (arch, hostname)
section_check()
{
USAGE_PREFIX="$SCRIPT check <command>"
X_HOSTNAME=$(hostname)
X_ARCH=$(uname)
MENU=(
"arch <arch>:Empty <arch> returns current. Exit code 0=match to current, 1=no match."
"host <host>:Empty <host> returns current. Exit code 0=match to current, 1=no match."
)
case "$1" in
a | arch)
[[ $2 == "" ]] && echo "$X_ARCH" && return 0
[[ $X_ARCH == "$2" ]] && return 0 || return 1
;;
h | host | hostname)
[[ $2 == "" ]] && echo "$X_HOSTNAME" && return 0
[[ $X_HOSTNAME == "$2" ]] && return 0 || return 1
;;
*) menu_builder "$USAGE_PREFIX" "${MENU[@]}" ;;
esac
}
# Handle install script execution
section_scripts()
{
USAGE_PREFIX="$SCRIPT scripts <command>"
# Collect scripts and their descriptions
local menu_items=()
for script in "$DOTFILES/scripts/install-"*.sh; do
if [ -f "$script" ]; then
name=$(basename "$script" .sh | sed 's/install-//')
desc=$(get_script_description "$script")
menu_items+=("$name:$desc")
fi
done
case "$1" in
"")
menu_builder "$USAGE_PREFIX" "${menu_items[@]}"
;;
*)
# Run the chosen script
script_path="$DOTFILES/scripts/install-$1.sh"
if [ -f "$script_path" ]; then
bash "$script_path"
else
msgr err "Script not found: $1"
fi
;;
esac
}
# Secret menu for visual tests
section_tests()
{
USAGE_PREFIX="$SCRIPT tests <command>"
MENU=(
"msgr:List all available msgr message types"
"params:List all parameters"
)
case "$1" in
params)
echo "All parameters:"
for i in "$@"; do
echo " $i"
done
;;
msgr)
# shellcheck disable=SC1010
msgr done "msgr done"
msgr done_suffix "msgr done_suffix"
msgr err "msgr err"
msgr nested "msgr nested"
msgr nested_done "msgr nested_done"
msgr ok "msgr ok"
msgr prompt "msgr prompt"
msgr prompt_done "msgr prompt_done"
msgr run "msgr run" "second_param"
msgr run_done "msgr run_done" "second_param"
msgr warn "msgr warn"
msgr yay "msgr yay"
msgr yay_done "msgr yay_done"
;;
*) menu_builder "$USAGE_PREFIX" "${MENU[@]}" ;;
esac
}
# Display main usage information for all sections
usage()
{
echo ""
msgr prompt "Usage: $SCRIPT <section> <command>"
echo " Empty <command> prints <section> help."
echo ""
section_install
echo ""
section_apt
echo ""
section_brew
echo ""
section_check
echo ""
section_dotfiles
echo ""
section_docs
echo ""
section_scripts
echo ""
section_helpers
}
# Parse section argument and dispatch to handler
main()
{
SECTION="${1:-}"
[[ $# -gt 0 ]] && shift
# The main loop. The first keyword after $0 triggers section, or help.
case "$SECTION" in
install) section_install "$@" ;;
apt) section_apt "$@" ;;
brew) section_brew "$@" ;;
check) section_check "$@" ;;
dotfiles) section_dotfiles "$@" ;;
helpers) section_helpers "$@" ;;
docs) section_docs "$@" ;;
scripts) section_scripts "$@" ;;
tests) section_tests "$@" ;;
*) usage && return 0 ;;
esac
}
main "$@"