diff --git a/CHANGELOG b/CHANGELOG index 759fd6f..92c1843 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -1,3 +1,7 @@ +0.6.0 2024-09-03: File management with the tree strategy + File backups + Extract authorization command to top level + ask and tildify utility functions 0.5.0 2024-07-18: "Decide in editor" package conflict resolution strategy Drop pipefail shell option for dash compatibility Add numerical debug levels to the log utility function diff --git a/README.md b/README.md index c25a9a3..138a64c 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ tori is a tool to track your personal systems' configurations and replicate them. -If you'd like a more detailed description of what it is, its purpose, origins and goals, see the [announcement blog post](https://blog.jutty.dev/posts/introducing-tori.html). +If you'd like a more detailed description of what it is, its purpose, origins and goals, see the [announcement blog post](https://blog.jutty.dev/posts/introducing-tori/). Refer to the [project website](https://tori.jutty.dev) for updates and access to [documentation](https://tori.jutty.dev/docs). diff --git a/src/check.sh b/src/check.sh index b0df8ff..b3923b7 100644 --- a/src/check.sh +++ b/src/check.sh @@ -6,4 +6,5 @@ check() { log debug "collected bkp files:\n$bkp_files" scan_packages + merge_files "$base_files" } diff --git a/src/configuration.sh b/src/configuration.sh index 71a3d5a..0a9faf3 100644 --- a/src/configuration.sh +++ b/src/configuration.sh @@ -15,7 +15,7 @@ scan_directory() { done fi - echo "$files" + printf "%b" "$files" } scan_packages() { diff --git a/src/file/backup.sh b/src/file/backup.sh new file mode 100644 index 0000000..7f992a7 --- /dev/null +++ b/src/file/backup.sh @@ -0,0 +1,40 @@ +# takes a list of newline-separated absolute paths +# backs each path up, creating canonical or ephemeral copies as needed +backup_paths() { + local paths="$1" + local canonical_path= + local ephemeral_path= + + for path in $paths; do + canonical_path="$BACKUP_ROOT/canonical$path" + timestamp="$(date +'%Y-%m-%dT%H-%M-%S')" + ephemeral_path="$BACKUP_ROOT/ephemeral${path}_$timestamp" + + if [ -f "$canonical_path" ]; then + log debug "[backup] Creating ephemeral copy for $path" + mkdir -p "$(dirname "$ephemeral_path")" + if [ -f "$ephemeral_path" ]; then + log debug "[backup] Overwriting ephemeral copy for $path" + if [ -r "$path" ]; then + cp -f "$path" "$ephemeral_path" + else + $AUTHORIZE_COMMAND cp -f "$path" "$ephemeral_path" + fi + else + if [ -r "$path" ]; then + cp "$path" "$ephemeral_path" + else + $AUTHORIZE_COMMAND cp "$path" "$ephemeral_path" + fi + fi + else + log debug "[backup] Creating canonical copy for $path" + mkdir -p "$(dirname "$canonical_path")" + if [ -r "$path" ]; then + cp "$path" "$canonical_path" + else + $AUTHORIZE_COMMAND cp "$path" "$canonical_path" + fi + fi + done +} diff --git a/src/file/file_merge.sh b/src/file/file_merge.sh new file mode 100644 index 0000000..ffd61f4 --- /dev/null +++ b/src/file/file_merge.sh @@ -0,0 +1,85 @@ +merge_files() { + local base_files="$1" + local strategy="${2:-tree}" + + if [ "$strategy" == tree ]; then + log info "[merge_files] Merging with $strategy strategy" + if ! file_scan_tree "$base_files"; then + if ! file_merge_tree "$base_files"; then + merge_files "$base_files" + fi + fi + fi +} + +file_scan_tree() { + local base_files="$1" + + for file in $base_files; do + local absolute_path="$(echo "$file" | sed 's/^base//')" + local config_path="$CONFIG_ROOT/$file" + + if ! diff "$absolute_path" "$config_path" > /dev/null 2>&1; then + return 1 + fi + done + return 0 +} + +file_merge_tree() { + local base_files="$1" + local overwrite_choice= + + for file in $base_files; do + log debug "[merge_tree] Processing $file" + local absolute_path="$(echo "$file" | sed 's/^base//')" + log debug "[merge_tree] Absolute path: $absolute_path" + local config_path="$CONFIG_ROOT/$file" + log debug "[merge_tree] Config path: $config_path" + + if diff "$absolute_path" "$config_path" > /dev/null 2>&1; then + log debug "[merge_tree] Files match" + else + log debug "[merge_tree] Files differ" + local prompt_verb="Differs" + local prompt_options="Overwrite system,Overwrite configuration,Show difference" + if ! [ -f "$absolute_path" ]; then + local prompt_verb="In configuration only" + local prompt_options="Copy to system" + fi + overwrite_choice="$(ask "$prompt_verb: $(tildify "$absolute_path")" "$prompt_options")" + log debug "[merge_tree] Overwrite choice: $overwrite_choice" + + if [ "$overwrite_choice" -eq 0 ]; then + return 0 + elif [ "$overwrite_choice" -eq 1 ]; then + backup_paths "$absolute_path" + if [ -r "$config_path" ] && [ -w "$absolute_path" ]; then + cp -vi "$config_path" "$absolute_path" + else + $AUTHORIZE_COMMAND cp -vi "$config_path" "$absolute_path" + fi + return 1 + elif [ "$overwrite_choice" -eq 2 ]; then + backup_paths "$config_path" + if [ -r "$absolute_path" ] && [ -w "$config_path" ]; then + cp -vi "$absolute_path" "$config_path" + else + $AUTHORIZE_COMMAND cp -vi "$absolute_path" "$config_path" + fi + return 1 + elif [ "$overwrite_choice" -eq 3 ]; then + echo "< $(tildify "$absolute_path") | $(echo "$config_path" | sed "s*$CONFIG_ROOT/**") >" + if [ -r "$absolute_path" ] && [ -r "$config_path" ]; then + diff "$absolute_path" "$config_path" + else + $AUTHORIZE_COMMAND diff "$absolute_path" "$config_path" + fi + return 1 + else + log user 'Invalid choice' + return 1 + fi + fi + done +} diff --git a/src/index.sh b/src/index.sh index f168b74..0af2c73 100644 --- a/src/index.sh +++ b/src/index.sh @@ -9,3 +9,6 @@ . "$TORI_ROOT/src/package/validate_input_packages.sh" . "$TORI_ROOT/src/package/package_conflict_input_parser.sh" . "$TORI_ROOT/src/package/update_package_cache.sh" + +. "$TORI_ROOT/src/file/file_merge.sh" +. "$TORI_ROOT/src/file/backup.sh" diff --git a/src/package/package_conflict_input_parser.sh b/src/package/package_conflict_input_parser.sh index 6c5d9e7..547db98 100644 --- a/src/package/package_conflict_input_parser.sh +++ b/src/package/package_conflict_input_parser.sh @@ -1,8 +1,8 @@ package_conflict_input_parser() { local packages="$1" local conflict_type="$2" - local input="$TMP_DIR/package_conflict_input" - local input_choices="$TMP_DIR/package_conflict_input_choices" + local input="$TMP_ROOT/package_conflict_input" + local input_choices="$TMP_ROOT/package_conflict_input_choices" local choices= local packages_to_install= local packages_to_uninstall= diff --git a/src/package/package_conflict_resolution.sh b/src/package/package_conflict_resolution.sh index f2ba8dd..e7b4efc 100644 --- a/src/package/package_conflict_resolution.sh +++ b/src/package/package_conflict_resolution.sh @@ -3,18 +3,18 @@ resolve_packages() { local input_packages= # shellcheck disable=SC2154 - ( echo "$system_packages" > "$TMP_DIR/system_packages" - echo "$user_packages" > "$TMP_DIR/user_packages" ) + ( echo "$system_packages" > "$TMP_ROOT/system_packages" + echo "$user_packages" > "$TMP_ROOT/user_packages" ) local packages_not_on_configuration="$(grep -v -x -f \ - "$TMP_DIR/user_packages" "$TMP_DIR/system_packages" | xargs)" + "$TMP_ROOT/user_packages" "$TMP_ROOT/system_packages" | xargs)" if [ -n "$packages_not_on_configuration" ]; then not_on_configuration_dialog "$packages_not_on_configuration" fi local packages_not_installed=$(grep -v -x -f \ - "$TMP_DIR/system_packages" "$TMP_DIR/user_packages" | xargs) + "$TMP_ROOT/system_packages" "$TMP_ROOT/user_packages" | xargs) if [ -n "$packages_not_installed" ]; then not_installed_dialog "$packages_not_installed" diff --git a/src/package/package_manager.sh b/src/package/package_manager.sh index f96f132..4303ce8 100644 --- a/src/package/package_manager.sh +++ b/src/package/package_manager.sh @@ -2,7 +2,6 @@ package_manager() { local command="$1" local manager - local authorizer="sudo" local args__install local args__uninstall local args__get_manually_installed @@ -25,11 +24,11 @@ package_manager() { if [ "$command" = 'get_manually_installed' ]; then eval $manager "$args__get_manually_installed" elif [ "$command" = 'install' ]; then - $authorizer $manager $args__install $args__user_args + $AUTHORIZE_COMMAND $manager $args__install $args__user_args elif [ "$command" = 'uninstall' ]; then - $authorizer $manager $args__uninstall $args__user_args + $AUTHORIZE_COMMAND $manager $args__uninstall $args__user_args elif [ "$command" = 'update' ]; then - $authorizer $manager $args__update + $AUTHORIZE_COMMAND $manager $args__update elif [ "$command" = 'get_available' ]; then eval $manager "$args__get_available" else diff --git a/src/utility.sh b/src/utility.sh index 441ed38..78b6de3 100644 --- a/src/utility.sh +++ b/src/utility.sh @@ -5,11 +5,11 @@ log() { local message="$2" print_user_message() { - echo "[tori] $(date "+%H:%M:%S"): $1" 1>&2 + printf "%b\n" "[tori] $(date "+%H:%M:%S"): $1" 1>&2 } print_debug_message() { - echo "$(date "+%H:%M:%N") $1" 1>&2 + printf "%b\n" "$(date "+%H:%M:%N") $1" 1>&2 } if [ -z "$DEBUG" ]; then @@ -43,6 +43,56 @@ log() { fi } +confirm() { + local question="$1" + local answer= + read -rp "$question [y/N] " answer + + if [ "$answer" == y ] || [ "$answer" == Y ]; then + return 0; + else + return 1; + fi +} + +ask() { + local question="$1" + local options="$2" + local answer= + local options_count=0 + local dialog_options= + + local IFS=, + for option in $options; do + _=$((options_count+=1)) + dialog_options="$dialog_options\n [$options_count] $option" + done; + IFS= + dialog_options="$dialog_options\n [0] Exit" + + printf "%s" "$question" >&2 + printf "%b" "$dialog_options" >&2 + printf "\n%s" "Choose an option number: " >&2 + read -r read_answer + answer="$(echo "$read_answer" | xargs)" + + if [ -z "$answer" ]; then + log info "[ask] Invalid choice" + echo -1 + return 1 + elif [ "$answer" -ge 0 ] 2> /dev/null && [ "$answer" -le $options_count ]; then + echo "$answer" + else + log info "[ask] Invalid choice" + echo -1 + return 1 + fi +} + +tildify() { + echo "$1" | sed "s*$HOME*~*" +} + set_opts() { local target="$1" local sign= @@ -74,12 +124,22 @@ set_opts() { } prepare_directories() { - if ! [ -d "$TMP_DIR" ]; then - mkdir "$TMP_DIR" + if ! [ -d "$TMP_ROOT" ]; then + mkdir "$TMP_ROOT" fi - if ! [ -d "$CACHE_DIR" ]; then - mkdir -p "$CACHE_DIR" + if ! [ -d "$CACHE_ROOT" ]; then + mkdir -p "$CACHE_ROOT" + fi + + if ! [ -d "$BACKUP_ROOT" ]; then + mkdir -p "$BACKUP_ROOT" + if ! [ -d "$BACKUP_ROOT/canonical" ]; then + mkdir "$BACKUP_ROOT/canonical" + fi + if ! [ -d "$BACKUP_ROOT/ephemeral" ]; then + mkdir "$BACKUP_ROOT/ephemeral" + fi fi if ! [ -d "$CONFIG_ROOT" ]; then diff --git a/tori b/tori index 6009659..2d0e2b5 100755 --- a/tori +++ b/tori @@ -2,11 +2,12 @@ main() { # paths - VERSION="0.5.0 2024-07-18" + VERSION="0.6.0 2024-09-03" TORI_ROOT="$HOME/.local/share/tori" CONFIG_ROOT="$HOME/.config/tori" - TMP_DIR="/tmp/tori" - CACHE_DIR="$HOME/.cache/tori" + BACKUP_ROOT="$HOME/.local/state/tori/backup" + TMP_ROOT="/tmp/tori" + CACHE_ROOT="$HOME/.cache/tori" # os-independent state @@ -18,14 +19,14 @@ main() { parameter="$2" # import source - check_core_paths . "$TORI_ROOT/src/index.sh" - set_opts on ## os-dependent state + set_opts on OS="$(get_operating_system)" - PACKAGE_CACHE="$CACHE_DIR/${OS}_packages.cache" + PACKAGE_CACHE="$CACHE_ROOT/${OS}_packages.cache" + AUTHORIZE_COMMAND="sudo" base_files= bkp_files=