#!/usr/bin/env bash # update-configs — sync dotfiles configs into ~/.config # # Config: ~/.config/config-updater/updater.conf # Manifest: ~/.config/config-updater/manifest set -euo pipefail CONF_DIR="${XDG_CONFIG_HOME:-$HOME/.config}/config-updater" CONF_FILE="${CONF_DIR}/updater.conf" MANIFEST="${CONF_DIR}/manifest" TARGET="${XDG_CONFIG_HOME:-$HOME/.config}" RED='\033[0;31m'; YEL='\033[1;33m'; GRN='\033[0;32m'; DIM='\033[2m'; RST='\033[0m' err() { printf "${RED}✖ %s${RST}\n" "$*" >&2; } warn() { printf "${YEL}⚠ %s${RST}\n" "$*"; } ok() { printf "${GRN}✔ %s${RST}\n" "$*"; } note() { printf "${DIM} %s${RST}\n" "$*"; } die() { err "$*"; exit 1; } [[ -f "$CONF_FILE" ]] || die "Config not found: $CONF_FILE" # ── parse updater.conf ──────────────────────────────────────────────────────── SOURCE_BASE="" declare -A ENTRY_TYPE # name → config | flat | ignore while IFS= read -r line; do line="${line%%#*}" line="${line#"${line%%[! ]*}"}" line="${line%"${line##*[! ]}"}" [[ -z "$line" ]] && continue if [[ "$line" =~ ^SOURCE_BASE[[:space:]]*=[[:space:]]*(.+)$ ]]; then src="${BASH_REMATCH[1]%"${BASH_REMATCH[1]##*[! ]}"}" SOURCE_BASE="${src/#\~/$HOME}" continue fi read -r type name _rest <<< "$line" [[ -z "${name:-}" ]] && continue case "$type" in config|flat|ignore) ENTRY_TYPE["$name"]="$type" ;; *) warn "Unknown type '$type' for '$name' — skipping" ;; esac done < "$CONF_FILE" [[ -n "$SOURCE_BASE" ]] || die "SOURCE_BASE not defined in $CONF_FILE" [[ -d "$SOURCE_BASE" ]] || die "SOURCE_BASE not found: $SOURCE_BASE" # ── load previous manifest ──────────────────────────────────────────────────── declare -A PREV_MANIFEST if [[ -f "$MANIFEST" ]]; then while IFS=: read -r mtype mname; do [[ -n "${mtype:-}" && -n "${mname:-}" ]] && PREV_MANIFEST["$mname"]="$mtype" done < "$MANIFEST" fi warned=0 # ── warn: manifest entries no longer in updater.conf ───────────────────────── for mname in "${!PREV_MANIFEST[@]}"; do [[ -n "${ENTRY_TYPE[$mname]:-}" ]] && continue warn "'$mname' was previously managed but is no longer in updater.conf" note "Remove ~/.config/$mname manually if it is no longer needed" warned=1 done # ── warn: source items not covered by updater.conf ─────────────────────────── while IFS= read -r -d '' item; do name="$(basename "$item")" [[ "$name" == .* ]] && continue [[ -n "${ENTRY_TYPE[$name]:-}" ]] && continue warn "Untracked source item: '$name' — add to updater.conf (config / flat / ignore)" warned=1 done < <(find "$SOURCE_BASE" -maxdepth 1 -mindepth 1 -print0 | sort -z) (( warned )) && printf '\n' # ── apply ───────────────────────────────────────────────────────────────────── errors=0 new_manifest=() for name in "${!ENTRY_TYPE[@]}"; do type="${ENTRY_TYPE[$name]}" [[ "$type" == ignore ]] && continue src="${SOURCE_BASE}/${name}" if [[ ! -e "$src" ]]; then err "Source missing: $src" (( errors++ )) || true continue fi case "$type" in config) rm -rf "${TARGET:?}/${name}" cp -r "$src" "$TARGET/$name" ok "config $name" ;; flat) [[ -d "$src" ]] || { err "flat entry '$name' must be a directory: $src" (( errors++ )) || true continue } cp -r "${src}/." "$TARGET/" ok "flat $name → (contents into ~/.config/)" ;; esac new_manifest+=("${type}:${name}") done # ── write manifest ──────────────────────────────────────────────────────────── printf '%s\n' "${new_manifest[@]}" | sort > "$MANIFEST" printf '\n' if (( errors > 0 )); then err "$errors error(s) — manifest may be incomplete" exit 1 else ok "Done — manifest updated at $MANIFEST" fi