#!/usr/bin/env bash # update-configs — sync dotfiles configs into ~/.config # # Config: ~/.config/config-updater/updater.conf # Manifest: ~/.config/config-updater/manifest # # Syntax in updater.conf: # config [except ...] # copy SOURCE_BASE/ to ~/.config/, optionally skipping subdirs # flat # copy contents of SOURCE_BASE/ directly into ~/.config/ # ignore # present in source but intentionally not managed here 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 declare -A ENTRY_EXCL # name → space-separated list of excluded subdirs 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" # Parse optional "except sub1 sub2 ..." if [[ "${rest:-}" =~ ^except[[:space:]]+(.+)$ ]]; then ENTRY_EXCL["$name"]="${BASH_REMATCH[1]}" else ENTRY_EXCL["$name"]="" fi ;; *) 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 excl="${ENTRY_EXCL[$name]:-}" case "$type" in config) if [[ -z "$excl" ]]; then rm -rf "${TARGET:?}/${name}" cp -r "$src" "$TARGET/$name" ok "config $name" else # Copy all top-level items of $src except excluded subdirs mkdir -p "${TARGET}/${name}" item_errors=0 while IFS= read -r -d '' item; do item_name="$(basename "$item")" skip_item=false for e in $excl; do [[ "$item_name" == "$e" ]] && skip_item=true && break done [[ "$skip_item" == true ]] && continue rm -rf "${TARGET:?}/${name}/${item_name}" cp -r "$item" "${TARGET}/${name}/" || (( item_errors++ )) || true done < <(find "$src" -maxdepth 1 -mindepth 1 -print0 | sort -z) if (( item_errors > 0 )); then err "config $name (${item_errors} error(s))" (( errors++ )) || true else ok "config $name (excl: $excl)" fi fi ;; 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