#!/bin/sh # dotfiles installer # Discovers the dots published in dirs.json, checks each folder's metadata # against your machine, and lets you pick what to install. # Existing files are never overwritten: you get a dry-run plan and a # confirmation, and colliding files are skipped. # # A dir may also ship an install.sh, which is run after its files are copied # (like a dependency install step). It gets DOTFILES_DEST, DOTFILES_DIR, # DOTFILES_NAME, DOTFILES_SYSTEM and DOTFILES_ASSUME_YES in its environment. # # usage (from a clone): sh install.sh [options] # usage (standalone): curl -fsSL https://dot.rplawski.pl | sh -s -- [options] # # options: # -l, --list list available dots and exit # -y, --yes skip confirmation prompts # -d, --dirs "..." install dots by name or number, space/comma separated # -D, --dest PATH destination directory (default: $HOME) # -h, --help show this help # # environment: # DOTFILES_BASE_URL forgejo base url (default: https://git.rplawski.pl) # DOTFILES_REPO owner/repo (default: robertplawski/dotfiles) # DOTFILES_REF branch or tag (default: main) # DOTFILES_TOKEN access token, for private repos # DOTFILES_DEST destination directory # DOTFILES_DIRS same as --dirs # DOTFILES_SYSTEM override detected system (e.g. linux/fedora-44) # DOTFILES_PARSER auto | python | jq | awk # NO_COLOR disable colors set -eu set -f BASE_URL=${DOTFILES_BASE_URL:-https://git.rplawski.pl} REPO=${DOTFILES_REPO:-robertplawski/dotfiles} REF=${DOTFILES_REF:-main} SELECTION=${DOTFILES_DIRS:-} DEST=${DOTFILES_DEST:-} ASSUME_YES=0 LIST_ONLY=0 TAB=$(printf '\t') die() { printf '\nerror: %s\n' "$*" >&2; exit 1; } warn() { printf ' warning: %s\n' "$*" >&2; } if [ -t 1 ] && [ -z "${NO_COLOR:-}" ]; then esc=$(printf '\033') BOLD="$esc[1m"; DIM="$esc[2m"; RESET="$esc[0m" CYAN="$esc[36m"; GREEN="$esc[32m"; YELLOW="$esc[33m"; RED="$esc[31m" else BOLD=; DIM=; RESET=; CYAN=; GREEN=; YELLOW=; RED= fi usage() { cat <<'EOF' dotfiles installer - discovers dots via dirs.json, lets you pick, installs them. usage: sh install.sh [options] options: -l, --list list available dots and exit -y, --yes skip confirmation prompts -d, --dirs "..." install dots by name or number, space/comma separated -D, --dest PATH destination directory (default: $HOME) -h, --help show this help environment: DOTFILES_BASE_URL forgejo base url (default: https://git.rplawski.pl) DOTFILES_REPO owner/repo (default: robertplawski/dotfiles) DOTFILES_REF branch or tag (default: main) DOTFILES_TOKEN access token, for private repos DOTFILES_DEST destination directory DOTFILES_DIRS same as --dirs DOTFILES_SYSTEM override detected system (e.g. linux/fedora-44) DOTFILES_PARSER auto | python | jq | awk NO_COLOR disable colors EOF } fetch() { if [ -n "${DOTFILES_TOKEN:-}" ]; then curl -fsSL -H "Authorization: token $DOTFILES_TOKEN" "$1" else curl -fsSL "$1" fi } fetch_to() { if [ -n "${DOTFILES_TOKEN:-}" ]; then curl -fsSL -H "Authorization: token $DOTFILES_TOKEN" -o "$2" "$1" else curl -fsSL -o "$2" "$1" fi } read_line() { if [ -t 0 ]; then IFS= read -r REPLY else IFS= read -r REPLY /dev/null fi } detect_system() { os=$(uname -s 2>/dev/null || printf 'unknown') case "$os" in Linux) id=linux ver= if [ -r /etc/os-release ]; then id=$(sed -n 's/^ID=//p' /etc/os-release | head -n 1 | tr -d '"' | tr '[:upper:]' '[:lower:]') ver=$(sed -n 's/^VERSION_ID=//p' /etc/os-release | head -n 1 | tr -d '"') [ -n "$id" ] || id=linux fi if [ -n "$ver" ]; then printf 'linux/%s-%s' "$id" "$ver" else printf 'linux/%s' "$id" fi ;; Darwin) ver=$(sw_vers -productVersion 2>/dev/null || printf '') if [ -n "$ver" ]; then printf 'macos/%s' "$ver" else printf 'macos' fi ;; MINGW* | MSYS* | CYGWIN* | Windows_NT) printf 'microsoft/windows' ;; *) printf '%s' "$os" | tr '[:upper:]' '[:lower:]' ;; esac } system_matches() { [ -n "$SYSTEM" ] || return 1 case "$1" in '') return 1 ;; "$SYSTEM") return 0 ;; *'*') prefix=${1%'*'} case "$SYSTEM" in "$prefix"*) return 0 ;; esac ;; esac return 1 } is_compatible() { [ -n "$SYSTEM" ] || return 1 old_ifs=$IFS IFS=, for s in $1; do if system_matches "$s"; then IFS=$old_ifs return 0 fi done IFS=$old_ifs return 1 } parse_dirs() { parser=${DOTFILES_PARSER:-auto} if [ "$parser" = python ] || { [ "$parser" = auto ] && command -v python3 >/dev/null 2>&1; }; then python3 -c ' import json, sys try: doc = json.load(sys.stdin) except Exception as exc: sys.stderr.write("invalid dirs.json: %s\n" % exc) sys.exit(1) for name, meta in (doc.get("dirs") or {}).items(): systems = (meta or {}).get("systems") or [] if not isinstance(systems, list): systems = [] print("%s\t%s" % (name, ",".join(str(s) for s in systems))) ' return $? fi if [ "$parser" = jq ] || { [ "$parser" = auto ] && command -v jq >/dev/null 2>&1; }; then jq -r '.dirs // {} | to_entries[] | "\(.key)\t\((.value.systems // []) | join(","))"' return $? fi awk -f "$tmpdir/parse-dirs.awk" } read_dirs() { COUNT=0 while IFS="$TAB" read -r name systems; do COUNT=$((COUNT + 1)) eval "NAME_$COUNT=\$name" eval "SYSTEMS_$COUNT=\$systems" done < "$tmpdir/dirs.tsv" } index_of_name() { n=1 while [ "$n" -le "$COUNT" ]; do eval "name=\$NAME_$n" if [ "$name" = "$1" ]; then printf '%s' "$n" return 0 fi n=$((n + 1)) done return 1 } parse_selection() { chosen= for tok in $(printf '%s' "$1" | tr ',' ' '); do case "$tok" in *[!0-9]*) tok=$(index_of_name "$tok") || return 1 ;; esac if ! [ "$tok" -ge 1 ] 2>/dev/null || ! [ "$tok" -le "$COUNT" ]; then return 1 fi case " $chosen " in *" $tok "*) ;; *) chosen="$chosen $tok" ;; esac done [ -n "$chosen" ] || return 1 CHOSEN=${chosen# } return 0 } selection_names() { out= for n in $CHOSEN; do eval "name=\$NAME_$n" if [ -n "$out" ]; then out="$out, $name" else out="$name" fi done printf '%s' "$out" } print_menu() { printf '\n %sdotfiles%s %s%s @ %s%s\n' "$BOLD" "$RESET" "$DIM" "$REPO" "$REF" "$RESET" printf ' %syoursystem:%s %s%s%s\n\n' "$DIM" "$RESET" "$BOLD" "$SYSTEM" "$RESET" n=1 while [ "$n" -le "$COUNT" ]; do eval "name=\$NAME_$n" eval "systems=\$SYSTEMS_$n" pretty=$(printf '%s' "$systems" | tr ',' ' ') [ -n "$pretty" ] || pretty="no metadata" mark='' if is_compatible "$systems"; then mark=" ${GREEN}[match]${RESET}" fi printf ' %s%2d)%s %s%-24s%s %s%s%s%s\n' \ "$CYAN" "$n" "$RESET" "$BOLD" "$name" "$RESET" "$DIM" "$pretty" "$RESET" "$mark" n=$((n + 1)) done } ask_selection() { while :; do printf '\n %sselect dots%s (numbers or names, e.g. "1 3", %sa%s all, %sq%s quit, enter = default)\n' \ "$BOLD" "$RESET" "$DIM" "$RESET" "$DIM" "$RESET" printf ' %sdefault:%s %s\n' "$DIM" "$RESET" "${DEFAULT_SEL:-none}" printf ' %s>%s ' "$CYAN" "$RESET" read_line || return 1 ans=$REPLY case "$ans" in '' ) if [ -n "$DEFAULT_SEL" ]; then CHOSEN=$DEFAULT_SEL return 0 fi warn "nothing to preselect, pick at least one dot" ;; q | Q | quit | exit) printf '\n %scancelled%s\n' "$DIM" "$RESET" exit 0 ;; a | A | all) CHOSEN= n=1 while [ "$n" -le "$COUNT" ]; do CHOSEN="$CHOSEN $n" n=$((n + 1)) done CHOSEN=${CHOSEN# } return 0 ;; *) if parse_selection "$ans"; then return 0 fi warn "invalid selection, try again (e.g. 1 3)" ;; esac done } expand_path() { case "$1" in '~') printf '%s' "$HOME" ;; '~/'*) printf '%s/%s' "$HOME" "$(printf '%s' "$1" | sed 's|^~/||')" ;; *) printf '%s' "$1" ;; esac } cleanup() { if [ -n "${tmpdir:-}" ]; then rm -rf "$tmpdir" fi } main() { command -v curl >/dev/null 2>&1 || die "curl is required" command -v tar >/dev/null 2>&1 || die "tar is required" BASE_URL=${BASE_URL%/} SYSTEM=${DOTFILES_SYSTEM:-$(detect_system)} tmpdir=$(mktemp -d "${TMPDIR:-/tmp}/dotfiles.XXXXXX") || die "cannot create a temp directory" trap cleanup EXIT HUP INT TERM cat > "$tmpdir/parse-dirs.awk" <<'AWK' function cur_ctx() { return depth ? stack[depth] : "" } function push(s) { depth++; stack[depth] = s } function pop() { if (depth) depth-- } function do_str() { c = cur_ctx() if (c == "systems") systems = systems == "" ? val : systems "," val else if (c != "junk") pending = val } function do_tok(t) { c = cur_ctx() if (t == "{") { if (depth == 0) push("root") else if (c == "root" && pending == "dirs") push("dirs") else if (c == "dirs") { push("dir"); cur = pending; systems = "" } else push("junk") pending = "" } else if (t == "[") { if (c == "dir" && pending == "systems") push("systems") else push("junk") pending = "" } else if (t == "}" || t == "]") { c = cur_ctx() pop() if (c == "dir") print cur "\t" systems pending = "" } } { n = length($0) for (i = 1; i <= n; i++) { ch = substr($0, i, 1) if (instr) { if (ch == "\\") { i++; buf = buf substr($0, i, 1); continue } if (ch == "\"") { val = buf; buf = ""; instr = 0; do_str(); continue } buf = buf ch } else { if (ch == "\"") { instr = 1; buf = "" } else if (ch == " " || ch == "\t" || ch == "\r") continue else do_tok(ch) } } } AWK printf '\n %sdotfiles installer%s\n' "$BOLD" "$RESET" printf ' %schecking %s ...%s\n' "$DIM" "$BASE_URL/$REPO @ $REF" "$RESET" json=$(fetch "$BASE_URL/$REPO/raw/branch/$REF/dirs.json") \ || die "could not download dirs.json from $BASE_URL/$REPO/raw/branch/$REF/dirs.json" if ! printf '%s\n' "$json" | parse_dirs > "$tmpdir/dirs.tsv"; then die "dirs.json is malformed" fi read_dirs [ "$COUNT" -gt 0 ] || die "no dots published in dirs.json yet" DEFAULT_SEL= n=1 while [ "$n" -le "$COUNT" ]; do eval "systems=\$SYSTEMS_$n" if is_compatible "$systems"; then DEFAULT_SEL="$DEFAULT_SEL $n" fi n=$((n + 1)) done DEFAULT_SEL=${DEFAULT_SEL# } print_menu if [ "$LIST_ONLY" -eq 1 ]; then printf '\n %sdefault selection would be:%s %s\n' "$DIM" "$RESET" "${DEFAULT_SEL:-none}" exit 0 fi if [ -n "$SELECTION" ]; then parse_selection "$SELECTION" || die "invalid --dirs selection: $SELECTION" INTERACTIVE=0 else ask_selection || die "no terminal to ask for a selection - use --dirs \"...\" or DOTFILES_DIRS" INTERACTIVE=1 fi DEST=${DEST:-$HOME} DEST=$(expand_path "$DEST") if [ "$INTERACTIVE" -eq 1 ] && [ "$ASSUME_YES" -eq 0 ]; then printf '\n %sdestination:%s %s\n' "$BOLD" "$RESET" "$DEST" printf ' %spress enter to accept, or type another path:%s ' "$DIM" "$RESET" if read_line; then if [ -n "$REPLY" ]; then DEST=$(expand_path "$REPLY") fi fi fi printf '\n %sinstall:%s %s\n' "$BOLD" "$RESET" "$(selection_names)" printf ' %sinto:%s %s\n' "$BOLD" "$RESET" "$DEST" printf '\n %sdownloading archive...%s\n' "$DIM" "$RESET" fetch_to "$BASE_URL/$REPO/archive/$REF.tar.gz" "$tmpdir/archive.tar.gz" \ || die "could not download archive" mkdir -p "$tmpdir/x" tar -xzf "$tmpdir/archive.tar.gz" -C "$tmpdir/x" 2>/dev/null || die "could not extract archive" root=$(find "$tmpdir/x" -mindepth 1 -maxdepth 1 -type d 2>/dev/null | head -n 1) [ -n "$root" ] || die "unexpected archive layout (no top-level directory)" # dry run: classify every file as new or already existing : > "$tmpdir/new.list" : > "$tmpdir/skip.list" : > "$tmpdir/scripts.list" for n in $CHOSEN; do eval "name=\$NAME_$n" src="$root/$name" if [ ! -d "$src" ]; then warn "skipping $name, not present in the archive" continue fi if [ -f "$src/install.sh" ]; then printf '%s\n' "$name" >> "$tmpdir/scripts.list" fi (cd "$src" && find . \( -type f -o -type l \) ! -name metadata.json \ ! -path './install.sh' ! -path './install.ps1' | sed 's|^\./||' | sort) | while IFS= read -r rel; do if [ -e "$DEST/$rel" ] || [ -L "$DEST/$rel" ]; then printf '%s\t%s\n' "$name" "$rel" >> "$tmpdir/skip.list" else printf '%s\t%s\n' "$name" "$rel" >> "$tmpdir/new.list" fi done done new_count=$(wc -l < "$tmpdir/new.list" | tr -d ' ') skip_count=$(wc -l < "$tmpdir/skip.list" | tr -d ' ') script_count=$(wc -l < "$tmpdir/scripts.list" | tr -d ' ') printf '\n %splan:%s\n' "$BOLD" "$RESET" new_plural='' [ "$new_count" -eq 1 ] || new_plural=s printf ' %s%d new file%s to install%s\n' "$GREEN" "$new_count" "$new_plural" "$RESET" if [ "$skip_count" -gt 0 ]; then skip_plural='' [ "$skip_count" -eq 1 ] || skip_plural=s printf ' %s%d existing file%s to skip (kept as-is):%s\n' "$YELLOW" "$skip_count" "$skip_plural" "$RESET" awk -F "$TAB" '{ printf " %s\n", $2 }' "$tmpdir/skip.list" | head -n 15 if [ "$skip_count" -gt 15 ]; then printf ' %s... and %d more%s\n' "$DIM" "$((skip_count - 15))" "$RESET" fi fi if [ "$script_count" -gt 0 ]; then script_plural='' [ "$script_count" -eq 1 ] || script_plural=s printf ' %s%d install script%s to run after copying:%s\n' "$CYAN" "$script_count" "$script_plural" "$RESET" while IFS= read -r name; do printf ' %s (install.sh)\n' "$name" done < "$tmpdir/scripts.list" fi if [ "$new_count" -eq 0 ] && [ "$script_count" -eq 0 ]; then printf '\n %severything already exists, nothing to do%s\n\n' "$DIM" "$RESET" exit 0 fi if [ "$INTERACTIVE" -eq 1 ] && [ "$ASSUME_YES" -eq 0 ]; then what="install $new_count new file$new_plural" if [ "$script_count" -gt 0 ]; then what="$what and run $script_count install script$script_plural" fi printf ' %s%s? [Y/n]%s ' "$CYAN" "$what" "$RESET" if read_line; then case "$REPLY" in n | N | no | No | NO) printf '\n %scancelled%s\n' "$DIM" "$RESET" exit 0 ;; esac fi fi mkdir -p "$DEST" total=0 while IFS="$TAB" read -r name rel; do [ -n "$rel" ] || continue src="$root/$name/$rel" target="$DEST/$rel" pdir=$(dirname "$target") [ -d "$pdir" ] || mkdir -p "$pdir" cp -pP "$src" "$target" || die "failed to install $name/$rel" total=$((total + 1)) done < "$tmpdir/new.list" ran=0 for n in $CHOSEN; do eval "name=\$NAME_$n" src="$root/$name" [ -d "$src" ] || continue script_note= if [ -f "$src/install.sh" ]; then printf ' %srun%s %s%-24s%s %s(install.sh)%s\n' \ "$CYAN" "$RESET" "$BOLD" "$name" "$RESET" "$DIM" "$RESET" ( cd "$src" && DOTFILES_DEST="$DEST" \ DOTFILES_DIR="$src" \ DOTFILES_NAME="$name" \ DOTFILES_SYSTEM="$SYSTEM" \ DOTFILES_ASSUME_YES="$ASSUME_YES" \ sh ./install.sh ) || die "install.sh for $name failed" ran=$((ran + 1)) script_note=", ran install.sh" fi c=$(awk -F "$TAB" -v d="$name" '$1 == d' "$tmpdir/new.list" | wc -l | tr -d ' ') s=$(awk -F "$TAB" -v d="$name" '$1 == d' "$tmpdir/skip.list" | wc -l | tr -d ' ') if [ "$c" -gt 0 ]; then plural='' [ "$c" -eq 1 ] || plural=s note= if [ "$s" -gt 0 ]; then note=", $s skipped" fi printf ' %sok%s %s%-24s%s %s(%s file%s%s%s)%s\n' \ "$GREEN" "$RESET" "$BOLD" "$name" "$RESET" "$DIM" "$c" "$plural" "$note" "$script_note" "$RESET" elif [ "$s" -gt 0 ]; then plural='' [ "$s" -eq 1 ] || plural=s printf ' %s--%s %s%-24s%s %s(all %s file%s already exist%s)%s\n' \ "$YELLOW" "$RESET" "$BOLD" "$name" "$RESET" "$DIM" "$s" "$plural" "$script_note" "$RESET" elif [ -n "$script_note" ]; then printf ' %sok%s %s%-24s%s %s(nothing to copy%s)%s\n' \ "$GREEN" "$RESET" "$BOLD" "$name" "$RESET" "$DIM" "$script_note" "$RESET" else printf ' %s--%s %s%-24s%s %s(no files yet, only metadata.json)%s\n' \ "$DIM" "$RESET" "$BOLD" "$name" "$RESET" "$DIM" "$RESET" fi done plural='' [ "$total" -eq 1 ] || plural=s skip_note= if [ "$skip_count" -gt 0 ]; then skip_note=", $skip_count existing skipped" fi ran_note= if [ "$ran" -gt 0 ]; then ran_plural='' [ "$ran" -eq 1 ] || ran_plural=s ran_note=", $ran install script$ran_plural run" fi printf '\n %sdone%s %s dot(s), %s file%s into %s%s%s\n\n' \ "$GREEN" "$RESET" "$(printf '%s' "$CHOSEN" | wc -w | tr -d ' ')" "$total" "$plural" "$DEST" "$skip_note" "$ran_note" } while [ $# -gt 0 ]; do case "$1" in -l | --list) LIST_ONLY=1 ;; -y | --yes) ASSUME_YES=1 ;; -d | --dirs) shift [ $# -gt 0 ] || die "missing value for --dirs" SELECTION=$1 ;; --dirs=*) SELECTION=${1#--dirs=} ;; -D | --dest) shift [ $# -gt 0 ] || die "missing value for --dest" DEST=$1 ;; --dest=*) DEST=${1#--dest=} ;; -h | --help) usage exit 0 ;; *) die "unknown option: $1 (see --help)" ;; esac shift done main