280 lines
8.1 KiB
Bash
Executable File
280 lines
8.1 KiB
Bash
Executable File
#!/usr/bin/env bash
|
|
|
|
set -o errexit
|
|
set -o pipefail
|
|
set -o nounset
|
|
|
|
SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" >/dev/null 2>&1 && pwd)" \
|
|
|| exit 1
|
|
readonly SCRIPT_DIR
|
|
readonly PROFILES_DIR="${SCRIPT_DIR}/profiles"
|
|
readonly -a STOW_ARGS=("--no-folding" "--adopt" "--dir" "${PROFILES_DIR}" "--target" "${HOME}")
|
|
readonly SCRIPT_NAME="${0##*/}"
|
|
|
|
print_opt() {
|
|
local -r option="$1"
|
|
local -r description="$2"
|
|
printf " %-22s %s\n" "${option}" "${description}"
|
|
}
|
|
|
|
shell_quote() {
|
|
local -r string="$1"
|
|
printf "'%s'" "${string//'/'\\''}"
|
|
}
|
|
|
|
array_contains() {
|
|
local -r target_item="$1"; shift
|
|
local -a -r array=( "$@" )
|
|
local item
|
|
for item in "${array[@]}"; do
|
|
if [[ ${item} == "${target_item}" ]]; then
|
|
return 0
|
|
fi
|
|
done
|
|
return 1
|
|
}
|
|
|
|
die() {
|
|
local -r message="$1"
|
|
printf "%s: %b\n" "${SCRIPT_NAME}" "${message}" >&2
|
|
exit 1
|
|
}
|
|
|
|
invalid_option() {
|
|
local -r option="$1"
|
|
die "invalid option $(shell_quote "${option}")\nTry '${SCRIPT_NAME} --help' for usage."
|
|
}
|
|
|
|
check_deps() {
|
|
if ! command -v stow >/dev/null 2>&1; then
|
|
cat >&2 <<EOF
|
|
Error: 'stow' not found in PATH.
|
|
|
|
GNU Stow is a symlink farm manager used to deploy dotfiles/packages
|
|
by creating symbolic links into \${HOME}.
|
|
|
|
Install it first:
|
|
Arch Linux: pacman -S stow
|
|
Debian/Ubuntu: apt install stow
|
|
Fedora: dnf install stow
|
|
Typically, on other Linux distributions you can use their own package
|
|
managers. For other systems, you can find in the Internet installation
|
|
instructions appropriate to them or build manually GNU Stow from the
|
|
source if GNU Stow is not packaged or your system requires it.
|
|
|
|
Project: https://www.gnu.org/software/stow/
|
|
EOF
|
|
exit 1
|
|
fi
|
|
}
|
|
|
|
require_profiles_dir() {
|
|
if [[ ! -d "${PROFILES_DIR}" ]]; then
|
|
cat >&2 <<EOF
|
|
Error: profiles directory not found: ${PROFILES_DIR}
|
|
|
|
This directory must contain profile subdirectories with dotfiles.
|
|
|
|
Make sure you:
|
|
- run the script from the repository root, or
|
|
- create the directory, or
|
|
- clone the dotfiles repository correctly.
|
|
|
|
More information about what is profile can be found using '${SCRIPT_NAME} --help profile'.
|
|
EOF
|
|
exit 1
|
|
fi
|
|
mapfile -t PROFILES < <(find "${PROFILES_DIR}" -mindepth 1 -maxdepth 1 -type d -printf '%f\n')
|
|
readonly PROFILES
|
|
}
|
|
|
|
parse_args() {
|
|
list_profiles=""
|
|
prune_symlinks=""
|
|
verbose=""
|
|
profile=""
|
|
while (( $# > 0 )); do
|
|
case "$1" in
|
|
-h)
|
|
show_general_help
|
|
exit 0
|
|
;;
|
|
--help)
|
|
if (( $# >= 2 )) && [[ -n $2 ]] && [[ $2 != -* ]]; then
|
|
show_help_topic "$2"
|
|
else
|
|
show_general_help
|
|
fi
|
|
exit 0
|
|
;;
|
|
--help=*)
|
|
local -r topic="${1#*=}"
|
|
[[ -z ${topic} ]] && show_general_help && exit 0
|
|
show_help_topic "${topic}"
|
|
exit 0
|
|
;;
|
|
-l|--list-profiles) list_profiles="true"; shift ;;
|
|
-P|--prune-symlinks) prune_symlinks="true"; shift ;;
|
|
-v|--verbose) verbose="true"; shift ;;
|
|
-p|--profile)
|
|
( (( $# < 2 )) || [[ -z "$2" ]] ) && die "--profile requires an argument"
|
|
profile="$2"
|
|
shift 2
|
|
;;
|
|
*) invalid_option "$1" ;;
|
|
esac
|
|
done
|
|
}
|
|
|
|
validate_args() {
|
|
if [[ ${list_profiles} && ( ${prune_symlinks} || ${verbose} || ${profile} ) ]]; then
|
|
die "options '--list-profiles' and others cannot be used together"
|
|
fi
|
|
|
|
if [[ -n ${profile} ]] && ! array_contains "${profile}" "${PROFILES[@]}"; then
|
|
die "profile $(shell_quote "${profile}") cannot be found"
|
|
fi
|
|
}
|
|
|
|
show_general_help() {
|
|
echo "Usage: ${SCRIPT_NAME} [options]"
|
|
echo " ${SCRIPT_NAME} -l | --list-profiles"
|
|
echo " ${SCRIPT_NAME} --help[=TOPIC]"
|
|
echo
|
|
echo "Options:"
|
|
print_opt "-h, --help[=TOPIC]" "show general help or help for a specific topic"
|
|
print_opt "-p, --profile PROFILE" "select profile"
|
|
print_opt "-v, --verbose" "increase verbosity"
|
|
print_opt "-P, --prune-symlinks" "remove dangling symlinks in home directory"
|
|
print_opt "-l, --list-profiles" "list available profiles (cannot be combined with other options)"
|
|
echo
|
|
echo "Topics:"
|
|
print_opt "profile" "explanation of profile structure and usage"
|
|
echo
|
|
echo "Profile:"
|
|
echo " Directory containing dotfiles plus optional parent and hooks/."
|
|
echo " Used by the installer to group and deploy related configurations."
|
|
}
|
|
|
|
show_help_topic() {
|
|
local topic="$1"
|
|
case "${topic}" in
|
|
profile)
|
|
cat <<EOF
|
|
A profile is a directory under 'profiles/' that groups dotfiles to be installed
|
|
together using GNU Stow.
|
|
|
|
Contents:
|
|
dotfiles/ files or directories to deploy (e.g. .config)
|
|
parent optional text file with parent profile name
|
|
hooks/pre/ scripts run before installation
|
|
hooks/post/ scripts run after installation
|
|
|
|
Only executable files inside hooks are executed.
|
|
|
|
Profiles may inherit from a parent. The installer builds the dependency
|
|
tree automatically and installs profiles in the correct order.
|
|
|
|
Use .stow-local-ignore to prevent linking internal files such as hooks/ and parent.
|
|
EOF
|
|
;;
|
|
*) die "unknown help topic: ${topic}" ;;
|
|
esac
|
|
}
|
|
|
|
run_pre_hooks() {
|
|
local -r target_profile="$1"
|
|
for hook in "${PROFILES_DIR}/${target_profile}"/hooks/pre/*; do
|
|
if [[ -f "${hook}" && -x "${hook}" ]]; then
|
|
echo "Running pre-install hook ${hook##*/}"
|
|
"${hook}"
|
|
fi
|
|
done
|
|
}
|
|
|
|
run_post_hooks() {
|
|
local -r target_profile="$1"
|
|
for hook in "${PROFILES_DIR}/${target_profile}"/hooks/post/*; do
|
|
if [[ -f "${hook}" && -x "${hook}" ]]; then
|
|
echo "Running post-install hook ${hook##*/}"
|
|
"${hook}"
|
|
fi
|
|
done
|
|
}
|
|
|
|
prune_symlinks() {
|
|
echo -n "Removing dangling symlinks in home directory... "
|
|
|
|
pushd "${HOME}" >/dev/null
|
|
local common_files
|
|
local -r target_profile="$1"
|
|
mapfile -t common_files < <(comm -12 <(find "${HOME}" -type l -printf '%P\n' 2>/dev/null | sort) \
|
|
<(find "${PROFILES_DIR}/${target_profile}" -type f -printf '%P\n' | sort))
|
|
local target_file
|
|
for target_file in "${common_files[@]}"; do
|
|
local -a rm_args=( "--force" )
|
|
[[ ${verbose} ]] && rm_args+=( "--verbose" )
|
|
rm "${rm_args[@]}" "${target_file}"
|
|
done
|
|
popd >/dev/null
|
|
|
|
echo "OK"
|
|
}
|
|
|
|
stow_profile() {
|
|
local -r target_profile="$1"
|
|
local error_log
|
|
error_log="$(stow "${STOW_ARGS[@]}" "${target_profile}" 2>&1)"
|
|
if (( $? == 0 )); then
|
|
echo "Profile ${target_profile} has been stowed"
|
|
else
|
|
echo "Failed to create symlinks pointing to dotfiles in profile ${target_profile}"
|
|
echo "Executed command: stow ${STOW_ARGS[*]} ${target_profile}"
|
|
fi
|
|
if [[ -n "${error_log}" ]]; then
|
|
echo "${error_log}" > /tmp/stow-error.log
|
|
[[ ${verbose} ]] && echo "${error_log}"
|
|
echo "Error log has been saved into /tmp/stow-error.log"
|
|
fi
|
|
}
|
|
|
|
fetch_parents() {
|
|
local -r target_profile="$1"
|
|
if [[ -s "${PROFILES_DIR}/${target_profile}/parent" ]]; then
|
|
local parent
|
|
parent=$(cat "${PROFILES_DIR}/${target_profile}/parent")
|
|
if [[ -s "${PROFILES_DIR}/${parent}/parent" ]]; then
|
|
fetch_parents "${parent}"
|
|
fi
|
|
echo "${parent}"
|
|
fi
|
|
}
|
|
|
|
build_deptree() {
|
|
local -r target_profile="$1"
|
|
fetch_parents "${target_profile}"
|
|
echo "${target_profile}"
|
|
}
|
|
|
|
install_dotfiles() {
|
|
local target_profile
|
|
for target_profile in $(build_deptree "${profile}"); do
|
|
run_pre_hooks "${target_profile}"
|
|
[[ ${prune_symlinks} ]] && prune_symlinks "${target_profile}"
|
|
stow_profile "${target_profile}"
|
|
run_post_hooks "${target_profile}"
|
|
done
|
|
}
|
|
|
|
main() {
|
|
check_deps
|
|
parse_args "$@"
|
|
require_profiles_dir
|
|
(( $# == 0 )) && show_general_help && exit 1
|
|
validate_args
|
|
[[ -n ${list_profiles} ]] && echo "${PROFILES[@]}" && exit 0
|
|
install_dotfiles
|
|
}
|
|
|
|
main "$@"
|