#!/bin/sh set -e _state_dir="$HOME/.local/share/dotkit" _install_dir="$HOME/.local/bin" _install_url="https://dotkit.run/install.sh" _dk_color=36 # 36=cyan 35=purple 32=green 33=yellow 34=blue if [ -t 1 ]; then _bold=$(printf '\033[1;%sm' "$_dk_color") _reset=$(printf '\033[0m') else _bold='' _reset='' fi _cleanup_dirs="" _cleanup_pids="" _ssh_keys_done=0 _do_cleanup() { [ -n "$_cleanup_pids" ] && kill $_cleanup_pids 2>/dev/null for _dc_d in $_cleanup_dirs; do rm -rf "$_dc_d" 2>/dev/null; done } trap '_do_cleanup' EXIT trap '_do_cleanup; exit 130' INT trap '_do_cleanup; exit 143' TERM _fetch() { if command -v curl >/dev/null 2>&1; then curl -fsSL "$@" elif command -v wget >/dev/null 2>&1; then wget -qO- "$@" else printf 'dotkit: curl or wget required\n' >&2; exit 1 fi } _opt_defaults=0 _opt_username="" _opt_repo="" _opt_profile="" _opt_target="" _opt_no_sudo=0 _opt_cli="" _opt_pat="${DOTKIT_PAT:-}" _opt_dry_run=0 _parse_args() { while [ $# -gt 0 ]; do case "$1" in --defaults|-d) _opt_defaults=1 ;; --username|-u) _opt_username="${2:?'--username requires a value'}"; shift ;; --repo|-r) _opt_repo="${2:?'--repo requires a value'}"; shift ;; --profile|-p) _opt_profile="${2:?'--profile requires a value'}"; shift ;; --target|-t) _opt_target="${2:?'--target requires a value'}"; shift ;; --no-sudo) _opt_no_sudo=1 ;; --dry-run) _opt_dry_run=1 ;; --cli) _opt_cli="${2:?'--cli requires a value'}"; shift ;; --pat) printf 'Warning: --pat visible in shell history and process list. Use DOTKIT_PAT env var for better security.\n' >&2 _opt_pat="${2:?'--pat requires a value'}"; shift ;; --) shift; break ;; -*) printf 'Unknown option: %s\n' "$1" >&2; exit 1 ;; esac shift done if [ -n "$_opt_username" ] && [ -n "$_opt_repo" ]; then printf 'Error: --username and --repo are mutually exclusive.\n' >&2; exit 1 fi # expand ~ in --target case "$_opt_target" in '~'|'~/'*) _opt_target="$HOME/${_opt_target#\~/}" ;; esac } _install_curl() { case "$(uname -s)" in Linux) # Debian/Ubuntu if command -v apt-get >/dev/null 2>&1; then sudo apt-get install -y curl # RHEL/Fedora elif command -v rpm-ostree >/dev/null 2>&1; then sudo rpm-ostree install curl printf 'Reboot required. After reboot, re-run.\n' exit 1 elif command -v dnf >/dev/null 2>&1; then sudo dnf install -y curl elif command -v microdnf >/dev/null 2>&1; then sudo microdnf install -y curl elif command -v yum >/dev/null 2>&1; then sudo yum install -y curl # Arch btw elif command -v pacman >/dev/null 2>&1; then sudo pacman -Sy --noconfirm curl # Alpine elif command -v apk >/dev/null 2>&1; then sudo apk add --no-cache curl # openSUSE elif command -v zypper >/dev/null 2>&1; then sudo zypper install -y curl else printf 'Install curl manually, then re-run.\n' >&2; exit 1 fi ;; *) printf 'Install curl manually, then re-run.\n' >&2; exit 1 ;; esac } _check_offline_capable() { _oc_ok=1 if [ -z "$_opt_cli" ] && [ ! -f "$_install_dir/dotkit" ]; then printf 'No curl/wget: --cli required (CLI not installed).\n' >&2 _oc_ok=0 fi _oc_local_repo=0 case "${_opt_repo:-}" in /*|./*|~/*) _oc_local_repo=1 ;; esac if [ "$_oc_local_repo" = "0" ] && ! command -v git >/dev/null 2>&1; then printf 'No curl/wget: --repo required, or install git for remote repos.\n' >&2 _oc_ok=0 fi if [ "$_opt_defaults" != "1" ]; then printf 'Note: SSH keys setup requires curl/wget. Use --defaults to skip that prompt.\n' >&2 fi [ "$_oc_ok" = "1" ] } _check_curl() { if command -v curl >/dev/null 2>&1; then printf 'curl ok\n'; return 0 elif command -v wget >/dev/null 2>&1; then printf 'wget ok\n'; return 0 fi if [ "$_opt_defaults" = "1" ]; then _cc_yn="Y" else printf 'No curl or wget found. Install curl? [Y/n]: ' read -r _cc_yn /dev/null || { printf 'keepalive unavailable, skipping.\n'; return 0; } printf 'keepalive active\n' while true; do sudo -n true; sleep 50; done & _cleanup_pids="$! $_cleanup_pids" } _platform_preamble() { case "$(uname -s)" in Darwin) if [ "$_opt_defaults" = "1" ]; then _pp_yn="Y" else printf 'Keep display awake during setup? [Y/n]: ' read -r _pp_yn &2 printf 'Remove it first: rm %s\n' "$_install_dir" >&2 exit 1 fi if [ "$_opt_dry_run" = "1" ]; then printf '[dry-run] would copy %s/dotkit* -> %s\n' "$_cc_src" "$_install_dir" export PATH="$_cc_src:$PATH" return 0 fi mkdir -p "$_install_dir" for _cc_f in "$_cc_src"/dotkit*; do [ -f "$_cc_f" ] || continue cp "$_cc_f" "$_install_dir/" chmod 755 "$_install_dir/$(basename "$_cc_f")" done [ -d "$_cc_src/dotkit_help" ] && cp -r "$_cc_src/dotkit_help" "$_install_dir/dotkit_help" } _install_cli_from_local() { _icl=$1 case "$_icl" in '~'|'~/'*) _icl="$HOME/${_icl#\~/}" ;; esac case "$_icl" in *.tar.gz|*.tgz) [ -f "$_icl" ] || { printf 'File not found: %s\n' "$_icl" >&2; exit 1; } tar -xz -C "$_ic_tmp" --strip-components=1 < "$_icl" ;; *) [ -d "$_icl" ] || { printf 'Path not found: %s\n' "$_icl" >&2; exit 1; } _ic_tmp="$_icl" ;; esac } _install_cli() { if [ -f "$_install_dir/dotkit" ] && [ -z "$_opt_cli" ]; then if [ "$_opt_defaults" = "1" ]; then _ic_yn="N" else printf 'dotkit already installed. Re-install? [y/N]: ' read -r _ic_yn /dev/null || true if ! ls "$_ic_tmp"/dotkit* >/dev/null 2>&1; then printf 'Download failed. Local path to CLI directory or tarball: ' read -r _ic_local /dev/null || true command -v git >/dev/null 2>&1 && return 0 printf 'Installing git via Xcode Command Line Tools...\n' xcode-select --install 2>/dev/null || true printf 'Complete the CLT installation popup, then re-run.\n' exit 1 ;; Linux) # Debian/Ubuntu if command -v apt-get >/dev/null 2>&1; then sudo apt-get install -y git # RHEL/Fedora elif command -v rpm-ostree >/dev/null 2>&1; then sudo rpm-ostree install git printf 'Reboot required. After reboot, re-run.\n' exit 1 elif command -v dnf >/dev/null 2>&1; then sudo dnf install -y git elif command -v microdnf >/dev/null 2>&1; then sudo microdnf install -y git elif command -v yum >/dev/null 2>&1; then sudo yum install -y git # Arch btw elif command -v pacman >/dev/null 2>&1; then sudo pacman -Sy --noconfirm git # Alpine elif command -v apk >/dev/null 2>&1; then sudo apk add --no-cache git # openSUSE elif command -v zypper >/dev/null 2>&1; then sudo zypper install -y git else printf 'Install git manually, then re-run.\n' >&2; exit 1 fi ;; *) printf 'Install git manually, then re-run.\n' >&2; exit 1 ;; esac } _accept_xcode_license() { [ "$(uname -s)" = "Darwin" ] || return 0 sudo xcodebuild -license accept 2>/dev/null || true } _check_git() { if command -v git >/dev/null 2>&1 && git clone --help >/dev/null 2>&1; then printf 'git ok\n' return 0 fi if [ "$_opt_defaults" = "1" ]; then _cg_yn="Y" elif command -v git >/dev/null 2>&1; then printf 'git found but clone not available (minimal install). Install full git? [Y/n]: ' read -r _cg_yn &2; return 1 ;; esac printf 'GitHub PAT (to download SSH keys): ' stty -echo 2>/dev/null || true read -r _sk_pat /dev/null || true printf '\n' _sk_get() { curl -fsSL -H "Authorization: token $_sk_pat" "$1" 2>/dev/null || wget -qO- --header="Authorization: token $_sk_pat" "$1" 2>/dev/null; } _sk_dl() { curl -fsSL -H "Authorization: token $_sk_pat" "$1" -o "$2" 2>/dev/null || wget -qO "$2" --header="Authorization: token $_sk_pat" "$1" 2>/dev/null; } _sk_tmp=$(mktemp -d) _cleanup_dirs="$_sk_tmp $_cleanup_dirs" _sk_age="$_sk_tmp/age" _sk_bp="$_sk_tmp/age-plugin-batchpass" printf 'Downloading age binary...\n' _sk_dl "${_sk_raw}/age/${_sk_os}-${_sk_arch}/age" "$_sk_age" _sk_dl "${_sk_raw}/age/${_sk_os}-${_sk_arch}/age-plugin-batchpass" "$_sk_bp" chmod +x "$_sk_age" "$_sk_bp" mkdir -p "$HOME/.ssh" && chmod 700 "$HOME/.ssh" _sk_entries=$(_sk_get "$_sk_url" \ | awk -F'"' ' $2=="name" && $4~/\.age$/ { name=$4 } $2=="download_url" && name!="" { print name "|" $4; name="" } ') [ -n "$_sk_entries" ] || { printf 'No .age files found in repo.\n' >&2; return 1; } printf '%s\n' "$_sk_entries" | while IFS='|' read -r _sk_f _sk_dlurl; do _sk_dl "$_sk_dlurl" "$HOME/.ssh/$_sk_f" done export PATH="$_sk_tmp:$PATH" printf 'age passphrase: ' stty -echo 2>/dev/null || true read -r _sk_pass /dev/null || true printf '\n' export AGE_PASSPHRASE="$_sk_pass" printf 'Decrypting SSH keys...\n' printf '%s\n' "$_sk_entries" | while IFS='|' read -r _sk_f _; do _sk_out="${_sk_f%.age}" "$_sk_age" --decrypt -j batchpass -o "$HOME/.ssh/$_sk_out" "$HOME/.ssh/$_sk_f" chmod 600 "$HOME/.ssh/$_sk_out" rm -f "$HOME/.ssh/$_sk_f" done rm -rf "$_sk_tmp" unset AGE_PASSPHRASE _ssh_keys_done=1 printf 'SSH keys installed.\n' } _is_public() { if command -v curl >/dev/null 2>&1; then curl -fsSL -I "$1" >/dev/null 2>&1 elif command -v wget >/dev/null 2>&1; then wget --spider -q "$1" 2>/dev/null fi } _get_repo_pat() { _gp_slug=$1; _gp_dest=$2 if [ -n "$_opt_pat" ]; then _gp_pat="$_opt_pat" else printf 'GitHub PAT: ' stty -echo 2>/dev/null || true read -r _gp_pat /dev/null || true printf '\n' fi mkdir -p "$_gp_dest" if command -v curl >/dev/null 2>&1; then curl -fsSL -H "Authorization: token $_gp_pat" \ "https://api.github.com/repos/$_gp_slug/tarball/main" \ | tar -xz -C "$_gp_dest" --strip-components=1 else wget -qO- --header="Authorization: token $_gp_pat" \ "https://api.github.com/repos/$_gp_slug/tarball/main" \ | tar -xz -C "$_gp_dest" --strip-components=1 fi DOTKIT_DIR="$_gp_dest"; export DOTKIT_DIR } # Sets _gr_host (github|generic|ssh) and _gr_url/_gr_slug. # Returns 1 if input was a local path (DOTKIT_DIR already set). _resolve_repo_input() { _ri=$1 case "$_ri" in /*|./*|~/*) case "$_ri" in '~'|'~/'*) _ri="$HOME/${_ri#\~/}" ;; esac [ -d "$_ri" ] || { printf 'Path not found: %s\n' "$_ri" >&2; exit 1; } DOTKIT_DIR="$_ri"; export DOTKIT_DIR return 1 ;; git@*) _gr_url="$_ri"; _gr_host="ssh" ;; https://github.com/*|github.com/*) _gr_slug="${_ri#https://github.com/}"; _gr_slug="${_gr_slug#github.com/}" _gr_host="github"; _gr_url="https://github.com/$_gr_slug" ;; https://*) _gr_url="$_ri"; _gr_host="generic" ;; */*) case "${_ri%%/*}" in *.*) _gr_url="https://$_ri"; _gr_host="generic" ;; *) _gr_slug="$_ri"; _gr_host="github"; _gr_url="https://github.com/$_ri" ;; esac ;; esac } _clone_repo() { _cr_dest=$1 if ! command -v git >/dev/null 2>&1; then [ "$_gr_host" = "github" ] || { printf 'git not available - install git and re-run.\n' >&2; exit 1; } _cr_tarball="https://github.com/$_gr_slug/archive/refs/heads/main.tar.gz" if _is_public "$_cr_tarball"; then mkdir -p "$_cr_dest" _fetch "$_cr_tarball" | tar -xz -C "$_cr_dest" --strip-components=1 else printf 'Private repo - no git available. PAT required.\n' _get_repo_pat "$_gr_slug" "$_cr_dest" fi return 0 fi printf 'Cloning %s...\n' "$_gr_url" git clone "$_gr_url" "$_cr_dest" 2>/dev/null && return 0 if [ "$_gr_host" = "github" ]; then if [ "$_ssh_keys_done" = "1" ]; then printf 'HTTPS clone failed (private). SSH keys found, trying SSH...\n' git clone "git@github.com:$_gr_slug.git" "$_cr_dest" 2>/dev/null && return 0 if [ -n "$_opt_pat" ]; then rm -rf "$_cr_dest"; _get_repo_pat "$_gr_slug" "$_cr_dest" else printf 'SSH clone failed. Try GitHub PAT? [Y/n]: ' read -r _cr_yn &2; exit 1 fi printf 'HTTPS clone failed. Try SSH? [Y/n]: ' read -r _cr_ssh /dev/null)" ]; then if [ "$_opt_defaults" = "1" ]; then DOTKIT_DIR="$_gr_dest"; export DOTKIT_DIR; return 0 fi printf 'Repo found at %s. Re-clone? [y/N]: ' "$_gr_dest" read -r _gr_reclone &2; exit 1; } _sp_count=0 _sp_list="" for _sp_d in "$_sp_dir"/*/; do [ -d "$_sp_d" ] || continue _sp_name="${_sp_d%/}"; _sp_name="${_sp_name##*/}" _sp_count=$((_sp_count + 1)) _sp_list="${_sp_list}${_sp_name} " printf ' %d) %s\n' "$_sp_count" "$_sp_name" done [ "$_sp_count" -gt 0 ] || { printf 'No profiles found.\n' >&2; exit 1; } if [ -n "$_opt_profile" ]; then DOTKIT_PROFILE=$(printf '%s' "$_sp_list" | grep -x "$_opt_profile" | head -1) [ -n "$DOTKIT_PROFILE" ] || { printf 'Profile not found: %s\n' "$_opt_profile" >&2; exit 1; } elif [ "$_sp_count" -eq 1 ]; then _sp_only=$(printf '%s' "$_sp_list" | head -1) if [ "$_opt_defaults" = "1" ]; then _sp_yn="Y" else printf 'Apply '\''%s'\''? [Y/n]: ' "$_sp_only" read -r _sp_yn &2; exit 1; } fi export DOTKIT_PROFILE export DOTKIT_PROFILE_DIR="$DOTKIT_DIR/profiles/$DOTKIT_PROFILE" } _write_state() { if [ "$_opt_dry_run" = "1" ]; then printf '[dry-run] would write state to %s\n' "$_state_dir" return 0 fi mkdir -p "$_state_dir" printf 'export DOTKIT_DIR="%s"\n' "$DOTKIT_DIR" > "$_state_dir/export.env" printf '%s/%s\n' "$(uname -s)" "$DOTKIT_PROFILE" > "$_state_dir/active" printf 'State saved to %s\n' "$_state_dir" } _run_apply() { printf 'Provisioning %s...\n' "$DOTKIT_PROFILE" if [ "$_opt_dry_run" = "1" ]; then dotkit apply --dry-run "$DOTKIT_PROFILE_DIR" else dotkit apply "$DOTKIT_PROFILE_DIR" fi } _save_script() { # Only save when running via pipe ($0 is a shell, not a file) [ -f "$0" ] && return 0 _ss_path="/tmp/dotkit_install_$(date +%Y%m%d%H%M%S).sh" _fetch "$_install_url" > "$_ss_path" 2>/dev/null || return 0 chmod +x "$_ss_path" printf 'Install script saved to %s - re-run anytime without re-downloading.\n' "$_ss_path" } printf '%s' "$_bold" printf ' _ _ _ _ _ \n' printf ' __| | ___ | |_| | _(_) |_ \n' printf ' / _` |/ _ \| __| |/ / | __|\n' printf ' | (_| | (_) | |_| <| | |_ \n' printf ' \__,_|\___/ \__|_|\_\_|\__|\n' printf '%s\n' "$_reset" printf 'Machine provisioning made simple\n' _parse_args "$@" printf '\n%sdependencies%s\n' "$_bold" "$_reset" _check_curl _check_git _save_script _sudo_keepalive _accept_xcode_license _platform_preamble _install_cli if [ "$_opt_defaults" != "1" ]; then printf '\n%sssh keys%s\n' "$_bold" "$_reset" printf 'Set up SSH keys? [y/N]: ' read -r _main_ssh