#!/usr/bin/env bash # Modernized dotfile manager set -euo pipefail DOTS_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" PREFIX="$HOME" FORCE=0 COMMAND="" # Symbols with colors C_RESET='\033[0m' SYM_INFO='\033[34mi' SYM_WARN='\033[33m!' SYM_ERROR='\033[31m✘' SYM_SUCCESS='\033[32m✔' SYM_ACTION='\033[36m→' SYM_PARTIAL='\033[33m◐' SYM_EMPTY='\033[90m∅' declare -a TOOL_NAMES=() declare -a TOOL_PATHS=() declare -a REMAINING_ARGS=() declare -a MANIFEST_ENTRY_SOURCE=() declare -a MANIFEST_ENTRY_TARGET=() declare -a MANIFEST_ENTRY_MODE=() declare -a MANIFEST_ENTRY_PERM=() declare -a MANIFEST_DIR_PATH=() declare -a MANIFEST_DIR_PERM=() declare -a MANIFEST_POST_INSTALL=() MANIFEST_ROOT_TARGET="" MANIFEST_ROOT_MODE="" MANIFEST_ROOT_PERM="" trim() { local var="$1" # shellcheck disable=SC2001 echo "$var" | sed -e 's/^[[:space:]]*//' -e 's/[[:space:]]*$//' } strip_quotes() { local value="$1" if [[ ${#value} -ge 2 ]]; then local first="${value:0:1}" local last="${value: -1}" if [[ ( $first == '"' && $last == '"' ) || ( $first == "'" && $last == "'" ) ]]; then value="${value:1:${#value}-2}" fi fi printf '%s' "$value" } usage() { cat <<'HELP' Usage: dots [--prefix DIR] [--force] [tool...] Commands: install Link tool files into the prefix (default: $HOME) remove Remove previously linked files diff Show pending changes without applying list Display available tools and status doctor Check environment and report issues Options: --prefix DIR Override installation prefix --force Replace existing files when installing -h, --help Show this help message HELP } log_info() { printf "${SYM_INFO}${C_RESET} %s\n" "$*"; } log_warn() { printf "${SYM_WARN}${C_RESET} %s\n" "$*"; } log_error() { printf "${SYM_ERROR}${C_RESET} %s\n" "$*"; } log_success() { printf "${SYM_SUCCESS}${C_RESET} %s\n" "$*"; } log_action() { printf "${SYM_ACTION}${C_RESET} %s\n" "$*"; } log_section() { printf '%s\n' "$*"; } fail() { log_error "$1" exit 1 } join_path() { local base="$1" local rel="$2" if [[ $rel == /* ]]; then printf '%s' "$rel" elif [[ $base == */ ]]; then printf '%s%s' "$base" "$rel" else printf '%s/%s' "$base" "$rel" fi } expand_target() { local path="$1" if [[ $path == ~* ]]; then path="$PREFIX${path:1}" fi # shellcheck disable=SC2086 eval "printf '%s' "$path"" } absolute_path() { local path="$1" if [[ -d $path ]]; then (cd "$path" && pwd) else local dir="$(dirname "$path")" local file="$(basename "$path")" (cd "$dir" && printf '%s/%s' "$(pwd)" "$file") fi } ensure_parent_dir() { local target="$1" local parent="$(dirname "$target")" [[ -d $parent ]] || mkdir -p "$parent" } normalize_tool_name() { local tool="$1" while [[ $tool == */ && -n $tool ]]; do tool="${tool%/}" done if [[ $tool == ./* ]]; then tool="${tool#./}" fi printf '%s' "$tool" } reset_manifest_state() { MANIFEST_ENTRY_SOURCE=() MANIFEST_ENTRY_TARGET=() MANIFEST_ENTRY_MODE=() MANIFEST_ENTRY_PERM=() MANIFEST_DIR_PATH=() MANIFEST_DIR_PERM=() MANIFEST_POST_INSTALL=() MANIFEST_ROOT_TARGET="" MANIFEST_ROOT_MODE="" MANIFEST_ROOT_PERM="" } discover_tools() { TOOL_NAMES=() TOOL_PATHS=() local entry name while IFS= read -r -d '' entry; do name="$(basename "$entry")" case "$name" in .|..|.git|.github|node_modules) continue ;; esac TOOL_NAMES+=("$name") TOOL_PATHS+=("$entry") done < <(find "$DOTS_DIR" -mindepth 1 -maxdepth 1 -type d -print0) if [[ ${#TOOL_NAMES[@]} -gt 0 ]]; then local sorted_names sorted_paths tool IFS=$' ' sorted_names=($(printf '%s ' "${TOOL_NAMES[@]}" | sort)) sorted_paths=() for tool in "${sorted_names[@]}"; do sorted_paths+=("$(tool_path_for "$tool" 2>/dev/null)") done TOOL_NAMES=("${sorted_names[@]}") TOOL_PATHS=("${sorted_paths[@]}") fi } tool_path_for() { local tool="$1" local idx for idx in "${!TOOL_NAMES[@]}"; do if [[ "${TOOL_NAMES[$idx]}" == "$tool" ]]; then printf '%s' "${TOOL_PATHS[$idx]}" return 0 fi done return 1 } available_tool() { tool_path_for "$1" >/dev/null } parse_manifest() { local tool="$1" local manifest="$2" local defaults_mode="link" local defaults_permissions="" local section="" local entry_index=-1 local dir_index=-1 local line trimmed key value current_entry=-1 reset_manifest_state while IFS=$' ' read -r line || [[ -n $line ]]; do line="${line%%#*}" trimmed="$(trim "$line")" [[ -z $trimmed ]] && continue if [[ $trimmed =~ ^([A-Za-z_]+):[[:space:]]*$ ]]; then section="${BASH_REMATCH[1]}" continue fi case "$section" in defaults) if [[ $trimmed =~ ^([A-Za-z_]+):[[:space:]]*(.+)$ ]]; then key="${BASH_REMATCH[1]}" value="${BASH_REMATCH[2]}" value="$(strip_quotes "$(trim "$value")")" case "$key" in mode) defaults_mode="$value" ;; permissions) defaults_permissions="$value" ;; *) fail "Unknown defaults key '$key' in $tool/tool.config.yaml" ;; esac else fail "Unable to parse defaults in $tool/tool.config.yaml" fi ;; root) if [[ $trimmed =~ ^([A-Za-z_]+):[[:space:]]*(.+)$ ]]; then key="${BASH_REMATCH[1]}" value="${BASH_REMATCH[2]}" value="$(strip_quotes "$(trim "$value")")" case "$key" in target) MANIFEST_ROOT_TARGET="$value" ;; mode) MANIFEST_ROOT_MODE="$value" ;; permissions) MANIFEST_ROOT_PERM="$value" ;; *) fail "Unknown root key '$key' in $tool/tool.config.yaml" ;; esac else fail "Unable to parse root in $tool/tool.config.yaml" fi ;; entries) if [[ $trimmed =~ ^-[[:space:]]*([A-Za-z_]+):[[:space:]]*(.+)$ ]]; then ((entry_index++)) current_entry=$entry_index MANIFEST_ENTRY_SOURCE[$entry_index]='' MANIFEST_ENTRY_TARGET[$entry_index]='' MANIFEST_ENTRY_MODE[$entry_index]="$defaults_mode" MANIFEST_ENTRY_PERM[$entry_index]="$defaults_permissions" key="${BASH_REMATCH[1]}" value="${BASH_REMATCH[2]}" value="$(strip_quotes "$(trim "$value")")" case "$key" in source) MANIFEST_ENTRY_SOURCE[$entry_index]="$value" ;; target) MANIFEST_ENTRY_TARGET[$entry_index]="$value" ;; mode) MANIFEST_ENTRY_MODE[$entry_index]="$value" ;; permissions) MANIFEST_ENTRY_PERM[$entry_index]="$value" ;; *) fail "Unknown key '$key' in entries for $tool" ;; esac elif [[ $trimmed =~ ^([A-Za-z_]+):[[:space:]]*(.+)$ ]]; then if [[ $current_entry -lt 0 ]]; then fail "Entry value before any entry is defined in $tool/tool.config.yaml" fi key="${BASH_REMATCH[1]}" value="${BASH_REMATCH[2]}" value="$(strip_quotes "$(trim "$value")")" case "$key" in source) MANIFEST_ENTRY_SOURCE[$current_entry]="$value" ;; target) MANIFEST_ENTRY_TARGET[$current_entry]="$value" ;; mode) MANIFEST_ENTRY_MODE[$current_entry]="$value" ;; permissions) MANIFEST_ENTRY_PERM[$current_entry]="$value" ;; *) fail "Unknown key '$key' in entries for $tool" ;; esac else fail "Unable to parse entry line '$trimmed' in $tool/tool.config.yaml" fi ;; directories) if [[ $trimmed =~ ^-[[:space:]]*path:[[:space:]]*(.+)$ ]]; then ((dir_index++)) MANIFEST_DIR_PERM[$dir_index]='' MANIFEST_DIR_PATH[$dir_index]="$(strip_quotes "$(trim "${BASH_REMATCH[1]}")")" elif [[ $trimmed =~ ^([A-Za-z_]+):[[:space:]]*(.+)$ ]]; then if [[ $dir_index -lt 0 ]]; then fail "Directory value before any directory entry in $tool/tool.config.yaml" fi key="${BASH_REMATCH[1]}" value="${BASH_REMATCH[2]}" value="$(strip_quotes "$(trim "$value")")" case "$key" in permissions) MANIFEST_DIR_PERM[$dir_index]="$value" ;; path) MANIFEST_DIR_PATH[$dir_index]="$value" ;; *) fail "Unknown key '$key' in directories for $tool" ;; esac else fail "Unable to parse directories line '$trimmed' in $tool/tool.config.yaml" fi ;; post_install) if [[ $trimmed =~ ^-[[:space:]]*(.+)$ ]]; then value="${BASH_REMATCH[1]}" value="$(strip_quotes "$(trim "$value")")" MANIFEST_POST_INSTALL+=("$value") else fail "Unable to parse post_install line '$trimmed' in $tool/tool.config.yaml" fi ;; *) fail "Unknown section or misplaced content in $tool/tool.config.yaml" ;; esac done < "$manifest" local i for i in "${!MANIFEST_ENTRY_SOURCE[@]}"; do if [[ -z ${MANIFEST_ENTRY_SOURCE[$i]} ]]; then fail "Entry $((i+1)) missing source in $tool/tool.config.yaml" fi done } should_emit_directories() { local root_mode="$1" if [[ $root_mode == "link" || $root_mode == "dirlink" ]]; then return 1 fi return 0 } plan_default_tool() { local tool="$1" local tool_path tool_path="$(tool_path_for "$tool")" || fail "Unknown tool: $tool" local entry rel while IFS= read -r -d '' entry; do rel="${entry#$tool_path/}" case "$rel" in tool.config.yaml|README|README.md|LICENSE) continue ;; esac printf 'link|%s|%s| ' "$entry" "$(join_path "$PREFIX" "$rel")" done < <(find "$tool_path" -mindepth 1 -maxdepth 1 -print0) } plan_manifest_tool() { local tool="$1" local tool_path="$2" local manifest="$tool_path/tool.config.yaml" parse_manifest "$tool" "$manifest" local root_mode="$MANIFEST_ROOT_MODE" [[ -z $root_mode ]] && root_mode="merge" if should_emit_directories "$root_mode"; then local i path perm for i in "${!MANIFEST_DIR_PATH[@]}"; do path="$(expand_target "${MANIFEST_DIR_PATH[$i]}")" perm="${MANIFEST_DIR_PERM[$i]}" printf 'ensure_dir|%s|%s| ' "$path" "$perm" done fi local root_handled=0 if [[ -n $MANIFEST_ROOT_TARGET ]]; then local root_target="$(expand_target "$MANIFEST_ROOT_TARGET")" local root_perm="$MANIFEST_ROOT_PERM" case "$root_mode" in merge) printf 'ensure_dir|%s|%s| ' "$root_target" "$root_perm" while IFS= read -r -d '' entry; do local rel="${entry#$tool_path/}" case "$rel" in tool.config.yaml|README|README.md|LICENSE|.DS_Store) continue ;; esac printf 'link|%s|%s| ' "$entry" "$(join_path "$root_target" "$rel")" done < <(find "$tool_path" -mindepth 1 -maxdepth 1 -print0) ;; link|dirlink) printf 'link|%s|%s|%s ' "$tool_path" "$root_target" "$root_perm" root_handled=1 ;; *) fail "Unknown root mode '$root_mode' in $tool/tool.config.yaml" ;; esac fi if [[ $root_handled -eq 1 ]]; then return fi local i source_rel path target mode perm for i in "${!MANIFEST_ENTRY_SOURCE[@]}"; do source_rel="${MANIFEST_ENTRY_SOURCE[$i]}" path="$(join_path "$tool_path" "$source_rel")" if [[ ! -e $path && ! -L $path ]]; then fail "Missing source '$source_rel' in $tool" fi mode="${MANIFEST_ENTRY_MODE[$i]}" [[ -z $mode ]] && mode="link" target="${MANIFEST_ENTRY_TARGET[$i]}" if [[ -z $target ]]; then local rel="$source_rel" [[ $rel == ./* ]] && rel="${rel:1}" target="$(join_path "$PREFIX" "$rel")" else target="$(expand_target "$target")" fi perm="${MANIFEST_ENTRY_PERM[$i]}" printf '%s|%s|%s|%s ' "$mode" "$path" "$target" "$perm" done } plan_tool() { local tool="$1" local tool_path tool_path="$(tool_path_for "$tool")" || fail "Unknown tool: $tool" if [[ -f "$tool_path/tool.config.yaml" ]]; then plan_manifest_tool "$tool" "$tool_path" else plan_default_tool "$tool" fi } apply_install_action() { local mode="$1" source="$2" target="$3" perm="$4" case "$mode" in ensure_dir) local dir="$source" local dir_perm="$target" if [[ -d $dir ]]; then if [[ -n $dir_perm ]]; then chmod "$dir_perm" "$dir" 2>/dev/null || log_warn "chmod failed for ${dir/$PREFIX/~}" fi log_success "exists ${dir/$PREFIX/~}" return fi if [[ -e $dir || -L $dir ]]; then if [[ $FORCE -eq 1 ]]; then rm -rf "$dir" log_warn "removed existing path ${dir/$PREFIX/~} to create directory" else log_warn "cannot create directory, path exists: ${dir/$PREFIX/~}" return fi fi mkdir -p "$dir" if [[ -n $dir_perm ]]; then chmod "$dir_perm" "$dir" 2>/dev/null || log_warn "chmod failed for ${dir/$PREFIX/~}" fi log_action "created ${dir/$PREFIX/~}" ;; link) local abs_source="$(absolute_path "$source")" if [[ -L $target ]]; then local current="$(readlink "$target")" if [[ $current == "$abs_source" ]]; then log_success "already linked ${target/$PREFIX/~}" return fi if [[ $FORCE -eq 1 ]]; then rm "$target" else log_warn "exists with different target: ${target/$PREFIX/~}" return fi elif [[ -e $target ]]; then if [[ $FORCE -eq 1 ]]; then rm -rf "$target" else log_warn "skipping existing path: ${target/$PREFIX/~}" return fi fi ensure_parent_dir "$target" ln -s "$abs_source" "$target" if [[ -n $perm ]]; then chmod "$perm" "$target" 2>/dev/null || log_warn "chmod failed for ${target/$PREFIX/~}" fi log_success "linked ${target/$PREFIX/~}" ;; *) log_warn "unknown mode '$mode' for $source" ;; esac } apply_remove_action() { local mode="$1" source="$2" target="$3" case "$mode" in ensure_dir) return ;; link) if [[ -L $target ]]; then local abs_source="$(absolute_path "$source")" local current="$(readlink "$target")" if [[ $current == "$abs_source" ]]; then rm "$target" log_success "removed ${target/$PREFIX/~}" else log_warn "different symlink at ${target/$PREFIX/~}" fi elif [[ -e $target ]]; then log_warn "not removing non-symlink ${target/$PREFIX/~}" else log_info "already absent ${target/$PREFIX/~}" fi ;; *) ;; esac } show_diff_action() { local mode="$1" source="$2" target="$3" case "$mode" in ensure_dir) local friendly="${source/$PREFIX/~}" if [[ -d $source ]]; then log_success "exists $friendly" else log_action "would create $friendly" fi ;; link) local abs_source="$(absolute_path "$source")" local friendly="${target/$PREFIX/~}" if [[ -L $target ]]; then local current="$(readlink "$target")" if [[ $current == "$abs_source" ]]; then log_success "in sync $friendly" else log_warn "different link $friendly -> $current" fi elif [[ -e $target ]]; then log_warn "conflict $friendly exists" diff -ru "$abs_source" "$target" || true else log_action "would link $friendly" fi ;; *) ;; esac } status_for_tool() { local tool="$1" local line mode source target perm local ok=0 conflicts=0 total=0 while IFS= read -r line; do IFS='|' read -r mode source target perm <<<"$line" case "$mode" in link) ((total++)) local abs_source="$(absolute_path "$source")" if [[ -L $target && $(readlink "$target") == "$abs_source" ]]; then ((ok++)) elif [[ -e $target ]]; then ((conflicts++)) fi ;; esac done < <(plan_tool "$tool") if (( conflicts > 0 )); then printf "${SYM_WARN}${C_RESET}" elif (( ok > 0 && ok == total )); then printf "${SYM_SUCCESS}${C_RESET}" elif (( total == 0 )); then printf "${SYM_EMPTY}${C_RESET}" else printf "${SYM_PARTIAL}${C_RESET}" fi } command_list() { local plain=0 local no_header=0 while [[ $# -gt 0 ]]; do case "$1" in --plain) plain=1 shift ;; --no-header) no_header=1 shift ;; --*) log_warn "unknown option '$1' for list" shift ;; *) break ;; esac done if [[ $no_header -eq 0 ]]; then log_section "Available tools" printf " ${SYM_SUCCESS}${C_RESET} installed ${SYM_WARN}${C_RESET} conflicts ${SYM_PARTIAL}${C_RESET} partial ${SYM_EMPTY}${C_RESET} empty\n\n" fi local tool icon marker for tool in "${TOOL_NAMES[@]}"; do icon="$(status_for_tool "$tool")" if [[ $plain -eq 1 ]]; then case "$icon" in ✔) marker='[OK]' ;; !) marker='[WARN]' ;; ◐) marker='[TODO]' ;; ∅) marker='[NONE]' ;; *) marker='[UNK]' ;; esac else marker=$icon fi printf '%s %s ' "$marker" "$tool" done } run_post_install_hooks() { local tool="$1" local tool_path tool_path="$(tool_path_for "$tool")" || return 0 local manifest="$tool_path/tool.config.yaml" [[ -f $manifest ]] || return 0 reset_manifest_state parse_manifest "$tool" "$manifest" if [[ ${#MANIFEST_POST_INSTALL[@]} -gt 0 ]]; then local cmd for cmd in "${MANIFEST_POST_INSTALL[@]}"; do log_action "running: $cmd" if ! eval "$cmd"; then log_warn "hook failed: $cmd" fi done fi } command_install() { local -a tools if [[ $# -eq 0 ]]; then tools=("${TOOL_NAMES[@]}") else tools=() while [[ $# -gt 0 ]]; do case "$1" in -f|--force) FORCE=1 ;; *) tools+=("$(normalize_tool_name "$1")") ;; esac shift done fi local tool line mode source target perm for tool in "${tools[@]}"; do [[ -z $tool ]] && continue if ! available_tool "$tool"; then log_warn "unknown tool '$tool'" continue fi log_section "Installing $tool" while IFS= read -r line; do IFS='|' read -r mode source target perm <<<"$line" apply_install_action "$mode" "$source" "$target" "$perm" done < <(plan_tool "$tool") run_post_install_hooks "$tool" done } command_remove() { local -a tools if [[ $# -eq 0 ]]; then tools=("${TOOL_NAMES[@]}") else tools=() while [[ $# -gt 0 ]]; do tools+=("$(normalize_tool_name "$1")") shift done fi local tool line mode source target perm for tool in "${tools[@]}"; do [[ -z $tool ]] && continue if ! available_tool "$tool"; then log_warn "unknown tool '$tool'" continue fi log_section "Removing $tool" while IFS= read -r line; do IFS='|' read -r mode source target perm <<<"$line" apply_remove_action "$mode" "$source" "$target" done < <(plan_tool "$tool") done } command_diff() { local -a tools if [[ $# -eq 0 ]]; then tools=("${TOOL_NAMES[@]}") else tools=() while [[ $# -gt 0 ]]; do tools+=("$(normalize_tool_name "$1")") shift done fi local tool line mode source target perm for tool in "${tools[@]}"; do [[ -z $tool ]] && continue if ! available_tool "$tool"; then log_warn "unknown tool '$tool'" continue fi log_section "Diff for $tool" while IFS= read -r line; do IFS='|' read -r mode source target perm <<<"$line" show_diff_action "$mode" "$source" "$target" done < <(plan_tool "$tool") done } command_doctor() { log_section "Environment" printf ' Bash version: %s\n' "$BASH_VERSION" printf ' Repository: %s\n' "$DOTS_DIR" printf ' Prefix: %s\n' "$PREFIX" if [[ -w $PREFIX ]]; then log_success "prefix is writable" else log_warn "prefix is not writable" fi printf '\n' log_section "Prerequisites" local missing=0 cmd for cmd in ln find mkdir sed grep diff readlink chmod; do if command -v "$cmd" >/dev/null 2>&1; then log_success "found $cmd" else log_warn "missing $cmd" missing=1 fi done if [[ $missing -eq 1 ]]; then log_warn "install the missing commands above before running installs" fi printf '\n' log_section "Conflicts" local reported=0 tool line mode source target perm for tool in "${TOOL_NAMES[@]}"; do while IFS= read -r line; do IFS='|' read -r mode source target perm <<<"$line" [[ $mode != link ]] && continue local abs_source="$(absolute_path "$source")" if [[ -L $target ]]; then local current="$(readlink "$target")" if [[ $current != "$abs_source" ]]; then log_warn "$tool: ${target/$PREFIX/~} -> $current (expected $abs_source)" reported=1 fi elif [[ -e $target ]]; then log_warn "$tool: conflicts with existing ${target/$PREFIX/~}" reported=1 fi done < <(plan_tool "$tool") done if [[ $reported -eq 0 ]]; then log_success "no conflicts detected" fi printf '\n' log_section "Legend" printf " ${SYM_SUCCESS}${C_RESET} fully installed\n" printf " ${SYM_WARN}${C_RESET} conflicts detected\n" printf " ${SYM_PARTIAL}${C_RESET} partially installed\n" printf " ${SYM_EMPTY}${C_RESET} empty (no files)\n" printf '\n' log_section "Available tools" command_list --no-header } parse_global_opts() { local -a args=("$@") local idx=0 total=${#args[@]} while [[ $idx -lt $total ]]; do case "${args[$idx]}" in --prefix) (( idx + 1 < total )) || fail "--prefix requires a directory" PREFIX="${args[$((idx+1))]}" idx=$((idx+2)) ;; -f|--force) FORCE=1 idx=$((idx+1)) ;; -h|--help) usage exit 0 ;; install|remove|diff|list|doctor) COMMAND="${args[$idx]}" idx=$((idx+1)) break ;; -*) fail "Unknown option '${args[$idx]}'" ;; *) COMMAND="${args[$idx]}" idx=$((idx+1)) break ;; esac done [[ -n $COMMAND ]] || fail "Missing command" REMAINING_ARGS=("${args[@]:$idx}") } main() { if [[ $# -eq 0 ]]; then usage exit 0 fi parse_global_opts "$@" discover_tools if [[ ! -d $PREFIX ]]; then fail "Prefix '$PREFIX' does not exist" fi case "$COMMAND" in install) if [[ ${#REMAINING_ARGS[@]} -gt 0 ]]; then command_install "${REMAINING_ARGS[@]}" else command_install fi ;; remove) if [[ ${#REMAINING_ARGS[@]} -gt 0 ]]; then command_remove "${REMAINING_ARGS[@]}" else command_remove fi ;; diff) if [[ ${#REMAINING_ARGS[@]} -gt 0 ]]; then command_diff "${REMAINING_ARGS[@]}" else command_diff fi ;; list) if [[ ${#REMAINING_ARGS[@]} -gt 0 ]]; then command_list "${REMAINING_ARGS[@]}" else command_list fi ;; doctor) command_doctor ;; *) fail "Unknown command '$COMMAND'" ;; esac } main "$@"