diff --git a/.editorconfig b/.editorconfig index 007025f..0e36aef 100644 --- a/.editorconfig +++ b/.editorconfig @@ -28,7 +28,7 @@ indent_size = 1 indent_size = 1 indent_style = tab -[{local/bin/*,**/*.sh,**/zshrc,config/*,scripts/*}] +[{local/bin/*,local/dfm/*,**/*.sh,**/zshrc,config/*,scripts/*}] indent_size = 2 tab_width = 2 shell_variant = bash # --language-variant diff --git a/local/dfm/cmd/install.sh b/local/dfm/cmd/install.sh new file mode 100755 index 0000000..a4744d1 --- /dev/null +++ b/local/dfm/cmd/install.sh @@ -0,0 +1,38 @@ +# Installation functions for dfm, the dotfile manager +# +# @author Ismo Vuorinen +# @license MIT + +# @description Install all packages in the correct order +function all() +{ + lib::log "Installing all packages..." + fonts + brew + asdf +} + +# @description Install fonts +function fonts() +{ + lib::log "Installing fonts..." + # implement fonts installation +} + +# Install asdf and set it up. +# +# @description Install asdf +function asdf() +{ + lib::log "Installing asdf..." + # implement asdf installation +} + +# Install Homebrew and set it up. +# +# @description Installs Homebrew +function brew() +{ + lib::log "Installing Homebrew..." + # implement Homebrew installation +} diff --git a/local/dfm/dfm b/local/dfm/dfm new file mode 100755 index 0000000..3849215 --- /dev/null +++ b/local/dfm/dfm @@ -0,0 +1,58 @@ +#!/usr/bin/env bash +# dfm - dotfiles manager + +set -euo pipefail + +# define default variables +readonly SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +readonly CMD_DIR="${SCRIPT_DIR}/cmd" +readonly LIB_DIR="${SCRIPT_DIR}/lib" +readonly DEFAULT_CONFIG_PATH="$HOME/.config" +readonly MAX_RETRIES=3 +export DEFAULT_INSTALL_DIR="$HOME/.local" +export DEFAULT_VERBOSE=0 +export TEMP_DIR=$(mktemp -d) + +# Load the common and utility functions from the lib directory. +source "${LIB_DIR}/common.sh" +source "${LIB_DIR}/utils.sh" + +# Main function for the dfm script. +# +# The function checks if any arguments were provided. If no arguments are +# provided, it lists all available commands. If a command name is provided, +# it lists the available functions for that command. If a command and function +# name are provided, it executes the function with the provided arguments. +# +# @param args The command-line arguments. +# @return None +main() +{ + if [[ $# -eq 0 ]]; then + main::list_available_commands + return 0 + fi + + local cmd="$1" + shift + + if [[ $# -eq 0 ]]; then + # Näytä komennon saatavilla olevat funktiot + local cmd_file="${CMD_DIR}/${cmd}.sh" + if [[ -f "$cmd_file" ]]; then + list::print_group "Available functions for '$cmd'" + list::loop_functions "$cmd_file" + else + lib::error "Command '$cmd' not found" + return 1 + fi + return 0 + fi + + local func="$1" + shift + + main::execute_command "$cmd" "$func" "$@" +} + +main "$@" diff --git a/local/dfm/lib/common.sh b/local/dfm/lib/common.sh new file mode 100755 index 0000000..aef8731 --- /dev/null +++ b/local/dfm/lib/common.sh @@ -0,0 +1,236 @@ +#!/usr/bin/env bash +# dfm common functions for logging and error handling, etc. +# Source this file to use the functions in your scripts. +# +# @author Ismo Vuorinen +# @license MIT +set -euo pipefail + +declare -A ERROR_CODES=( + [SUCCESS]=0 + [INVALID_ARGUMENT]=1 + [COMMAND_NOT_FOUND]=2 + [FUNCTION_NOT_FOUND]=3 + [EXECUTION_FAILED]=4 +) + +declare -A LOG_LEVELS=( + [DEBUG]=0 + [INFO]=1 + [WARN]=2 + [ERROR]=3 +) +LOG_LEVEL="${LOG_LEVEL:-INFO}" + +# Simple logging function +# +# @example +# lib::log "Hello, world!" +# +# @description Log a message to the console +# @param $* Message to log +# @return void +lib::log() +{ + printf '[%s] %s\n' "$(date '+%Y-%m-%d %H:%M:%S')" "$*" +} + +# Simple error logging function +# +# @example +# lib::error "Something went wrong" +# +# @description Log an error message to the console +# @param $* Error message +# @return void +lib::error() +{ + printf '[%s] ERROR: %s\n' "$(date '+%Y-%m-%d %H:%M:%S')" "$*" >&2 +} + +# Handle an error by logging an error message to the console +# and exiting with an error code based on the error type. +# +# @example +# lib::error::handle $LINENO $0 +# +# @description Handle an error +# @param $1 Line number +# @param $2 Command +# @return void +lib::error::handle() +{ + local exit_code=$? + local line_no=$1 + local command=$2 + + case $exit_code in + ${ERROR_CODES[INVALID_ARGUMENT]}) + lib::error "Invalid argument at line $line_no in command '$command'" + ;; + ${ERROR_CODES[COMMAND_NOT_FOUND]}) + lib::error "Command not found at line $line_no" + ;; + ${ERROR_CODES[FUNCTION_NOT_FOUND]}) + lib::error "Function not found at line $line_no in command '$command'" + ;; + ${ERROR_CODES[EXECUTION_FAILED]}) + lib::error "Execution failed at line $line_no in command '$command'" + ;; + *) + lib::error "Unknown error ($exit_code) at line $line_no in command '$command'" + ;; + esac + + return $exit_code +} + +# Throw an error by logging an error message to the console and exiting +# with an error code based on the error type. The error code name is used +# to determine the error code from the ERROR_CODES associative array. +# The error message is passed as arguments to the function. +# +# @example +# lib::error::throw INVALID_ARGUMENT "Invalid argument" +# lib::error::throw COMMAND_NOT_FOUND "Command not found" +# lib::error::throw FUNCTION_NOT_FOUND "Function not found" +# lib::error::throw EXECUTION_FAILED "Execution failed" +# +# @description Throw an error +# @param $1 Error code name +# @param $* Error message +# @return void +lib::error::throw() +{ + local code_name=$1 + shift + local message=$* + + lib::error "$message" + return "${ERROR_CODES[$code_name]}" +} + +# Logs a message to the console if the current log level is set so that the +# message is displayed. The log level is compared to the log level of the +# message and if the message log level is greater than or equal to the current +# log level, the message is displayed. +# The log levels are defined in the LOG_LEVELS associative array. +# +# @example +# logger::log "INFO" "This is an info message" +# logger::log "DEBUG" "This is a debug message" +# logger::log "WARN" "This is a warning message" +# logger::log "ERROR" "This is an error message" +# +# @description Log a message to the console based on the log level setting. +# @param $1 Log level +# @param $2 Message +# @return void +logger::log() +{ + local level=$1 + shift + local msg=$1 + + if [[ ${LOG_LEVELS[$level]} -ge ${LOG_LEVELS[$LOG_LEVEL]} ]]; then + printf '[%s] [%s]: %s\n' "$(date '+%Y-%m-%d %H:%M:%S')" "$level" "$msg" >&2 + fi +} + +# Logs a debug message to the console, if the current log level is set to DEBUG or greater. +# The message is passed as arguments to the function. +# The function is defined above. +# +# @example +# logger::debug "This is a debug message" +# +# @description Log a debug message to the console +# @param $* Message +# @return void +logger::debug() +{ + logger::log "DEBUG" "$@" +} + +# Logs an info message to the console, if the current log level is set to INFO or greater. +# The message is passed as arguments to the function. +# The function is defined above. +# +# @example +# logger::info "This is an info message" +# +# @description Log an info message to the console +# @param $* Message +# @return void +logger::info() +{ + logger::log "INFO" "$@" +} + +# Logs a warning message to the console, if the current log level is set to WARN or greater. +# The message is passed as arguments to the function. +# The function is defined above. +# +# @example +# logger::warn "This is a warning message" +# +# @description Log a warning message to the console +# @param $* Message +# @return void +logger::warn() +{ + logger::log "WARN" "$@" +} + +# Logs an error message to the console, if the current log level is set to ERROR or greater. +# The message is passed as arguments to the function. +# The function is defined above. +# +# @example +# logger::error "This is an error message" +# +# @description Log an error message to the console +# @param $* Message +# @return void +logger::error() +{ + logger::log "ERROR" "$@" +} + +# Cleanup function to remove temporary files and directories +# when the script exits or is interrupted by a signal (e.g. Ctrl+C). +# The function is registered with the `EXIT` trap. +# +# @description Remove temporary files and directories +# @return void +cleanup() +{ + local exit_code=$? + [ -d "$TEMP_DIR" ] && rm -rf "$TEMP_DIR" + exit $exit_code +} + +# Register the cleanup function to run on EXIT signal. +# +# This will ensure that temporary files and directories are removed +# when the script exits or is interrupted by a signal (e.g. Ctrl+C). +# The cleanup function is defined above. +trap cleanup EXIT + +# Handle errors by logging an error message to the console. +# +# The function is registered with the `ERR` trap. +# The line number where the error occurred is passed as an argument to the function. +# The function is defined above. +# +# @description Handle an error +# @param $1 Line number +# @return void +handle_error() +{ + local exit_code=$? + local line_no=$1 + logger::error "Failed at line ${line_no} with exit code ${exit_code}" +} + +trap 'handle_error ${LINENO}' ERR diff --git a/local/dfm/lib/utils.sh b/local/dfm/lib/utils.sh new file mode 100755 index 0000000..a0828be --- /dev/null +++ b/local/dfm/lib/utils.sh @@ -0,0 +1,334 @@ +#!/usr/bin/env bash +# dfm utility functions for common tasks +# Source this file to use the functions in your scripts. +# +# @author Ismo Vuorinen +# @license MIT +set -euo pipefail + +# ANSI escape codes +readonly RESET="\033[0m" +readonly BOLD="\033[1m" +readonly DIM="\033[2m" +readonly ITALIC="\033[3m" +readonly UNDERLINE="\033[4m" + +# Colors +readonly BLACK="\033[30m" +readonly RED="\033[31m" +readonly GREEN="\033[32m" +readonly YELLOW="\033[33m" +readonly BLUE="\033[34m" +readonly MAGENTA="\033[35m" +readonly CYAN="\033[36m" +readonly WHITE="\033[37m" + +# Function to print formatted line +list::print_formatted() +{ + local format=$1 + shift + printf "${format}%s${RESET}\n" "$@" +} + +# Function to print a header +list::print_header() +{ + printf "\n ${BOLD}${BLUE}%s${RESET}\n" "$1" + printf "%s\n" " $(printf '%.s─' {1..60})" +} + +# Function to print a group header +list::print_group() +{ + local group=$1 + printf "\n ${YELLOW}${BOLD}%s${RESET}\n\n" "$group" +} + +# Function to print a command +list::print_command() +{ + local cmd=$1 + local desc=${2:-""} + printf " ${BOLD}${CYAN}%-15s${RESET} ${DIM}%s${RESET}\n" "$cmd" "$desc" +} + +# Function to print a subcommand +list::print_subcommand() +{ + local cmd=$1 + local desc=${2:-""} + printf " ${GREEN}%-13s${RESET} ${desc}\n" "$cmd" +} + +list::loop_functions() +{ + while IFS= read -r func; do + # Get the function description from the function definition in the + # command file. If no description is found, print only the function name. + # The description is printed without the @description prefix. + # If the function is not found, print only the function name. + # The function name is printed with a bullet point. + local doc + doc=$(main::get_function_description "$cmd_file" "$func") + if [[ -n "$doc" ]]; then + list::print_subcommand "$func:" "${doc#*@description}" + else + list::print_subcommand "$func" "" + fi + done < <(main::get_command_functions "$cmd_file") +} + +# Get the documentation for a function from a command file. +list::get_function_docs() +{ + local cmd_file="$1" + local func="$2" + + awk -v func="$func" ' + # Start collecting documentation when a function is found and the line contains @ + /^[[:space:]]*#[[:space:]]*@/ { + tag = $2 + sub(/^[[:space:]]*#[[:space:]]*@[[:space:]]*[a-zA-Z]+[[:space:]]*/, "") + docs[tag] = $0 + last_tag = tag + } + + # Collect multi-line documentation + /^[[:space:]]*#/ && last_tag && !/^[[:space:]]*#[[:space:]]*@/ { + sub(/^[[:space:]]*#[[:space:]]*/, "") + docs[last_tag] = docs[last_tag] " " $0 + } + + # Empty line or comment line ends documentation + !/^[[:space:]]*#/ { + last_tag = "" + } + + # When the function is found, print the documentation + $0 ~ "^[[:space:]]*(function[[:space:]]+)?" func "\\(\\)" { + for (tag in docs) { + printf "@%s %s\n", tag, docs[tag] + } + } + ' "$cmd_file" +} + +# Check if a command exists in the current environment and return 0 if it does. +# Otherwise, return 1. +# +# @example +# if utils::is_installed curl; then +# echo "curl is installed" +# else +# echo "curl is not installed" +# fi +# +# @description Check if a command exists +# @param $1 Command to check +# @return 0 if the command exists, 1 otherwise +utils::is_installed() +{ + command -v "$1" > /dev/null 2>&1 +} + +# Check if a directory exists in the current env PATH and return 0 if it does. +# Otherwise, return 1. +# +# @example +# if utils::in_path /usr/local/bin; then +# echo "/usr/local/bin is in PATH" +# else +# echo "/usr/local/bin is not in PATH" +# fi +# +# @description Check if a directory is in PATH +# @param $1 Directory to check +# @return 0 if the directory is in PATH, 1 otherwise +utils::in_path() +{ + local cmd=$1 + local result=1 + IFS=: read -ra path <<< "$PATH" + for p in "${path[@]}"; do + if [[ -x "$p/$cmd" ]]; then + result=0 + break + fi + done + return $result +} + +# Retry a command until it succeeds or the maximum number of retries is reached. +# Logs a warning message if the command fails and is retried after a short delay. +# +# @example +# if utils::retry 3 curl -sSL https://example.com; then +# echo "Success" +# else +# echo "Failed" +# fi +# +# @description Retry a command +# @param $1 Maximum number of retries +# @param $2.. Command to run +# @return 0 if the command succeeds, 1 otherwise +# @dependencies logger::warn +utils::retry() +{ + local tries=$1 + shift + local count=1 + + until "$@"; do + [[ $count -gt $tries ]] && return 1 + logger::warn "Failed, retry $count/$tries" + ((count++)) + sleep 1 + done + return 0 +} + +# Ask for confirmation before proceeding. The default value is used if the user +# presses Enter without providing an answer. +# +# @example +# if utils::interactive::confirm "Are you sure?"; then +# echo "Confirmed" +# else +# echo "Not confirmed" +# fi +# +# @description Confirm an action +# @param $1 Prompt message +# @param $2 Default value +# @return 0 if the user confirms, 1 otherwise +utils::interactive::confirm() +{ + local prompt=$1 + local default=${2:-Y} + + while true; do + read -rp "$prompt [Y/n]: " response + case ${response:-$default} in + [Yy]*) return 0 ;; + [Nn]*) return 1 ;; + *) echo "Please answer yes or no" ;; + esac + done +} + +# Find all command files in the cmd directory and return them +# as a space-separated string of filenames (e.g. "cmd1.sh cmd2.sh"). +# +# The function uses a while loop to read the output of the find command +# line by line. The -print0 option is used to separate the filenames with +# a null character (\0) instead of a newline. This is necessary to handle +# filenames with spaces correctly. +# +# The read command reads the null-separated filenames and appends them to +# the cmd_files array. Finally, the function prints the array elements +# separated by a space. +# +# @return A space-separated string of command files. +main::find_commands() +{ + local cmd_files=() + while IFS= read -r -d '' file; do + cmd_files+=("$file") + done < <(find "$CMD_DIR" -type f -name "*.sh" -print0) + echo "${cmd_files[@]}" +} + +# Get the function names from a command file. +# +# The function uses grep to find function definitions (function xxx() or xxx()) +# and sed to extract the function names. The function names are printed one per +# line. +# +# @param cmd_file The command file to extract function names from. +# @return A list of function names. +main::get_command_functions() +{ + local cmd_file="$1" + # Etsitään funktiomäärittelyt (function xxx() tai xxx()) + grep -E '^[[:space:]]*(function[[:space:]]+)?[a-zA-Z0-9_]+\(\)[[:space:]]*{' "$cmd_file" \ + | sed -E 's/^[[:space:]]*(function[[:space:]]+)?([a-zA-Z0-9_]+).*/\2/' +} + +# Get the description of a function from a command file. +# +# The function uses grep to find the function definition and sed to extract +# the description. The description is printed without the @description prefix. +# +# @param cmd_file The command file to extract the function description from. +# @param func The function name. +# @return The function description. +main::get_function_description() +{ + local cmd_file="$1" + local func="$2" + + grep -B1 "^[[:space:]]*\(function[[:space:]]*\)\{0,1\}$func().*{" "$cmd_file" \ + | grep "@description" \ + | sed -E 's/^[[:space:]]*#[[:space:]]*@description[[:space:]]*//' +} + +# List all available commands and their functions. +# +# The function uses main::find_commands to get a list of command files. +# It then iterates over the files and prints the command name and +# its functions. +# +# @return None +main::list_available_commands() +{ + local cmd_files + cmd_files=$(main::find_commands) + + list::print_header "dfm - dotfiles manager" + list::print_group "Available commands" + + for cmd_file in $cmd_files; do + local cmd_name + cmd_name=$(basename "$cmd_file" .sh) + list::print_command "$cmd_name" + + list::loop_functions "$cmd_file" + done +} + +# Execute a command function. +# +# The function loads the command file and checks if the function exists. +# If the function exists, it executes the function with the provided arguments. +# +# @param cmd The command name. +# @param func The function name. +# @param args The function arguments. +# @return None +main::execute_command() +{ + local cmd="$1" + shift + local func="$1" + shift + + local cmd_file="${CMD_DIR}/${cmd}.sh" + if [[ ! -f "$cmd_file" ]]; then + lib::error "Command '$cmd' not found" + return 1 + fi + + # Source the command file + source "$cmd_file" + + # Check if the function exists + if ! declare -f "$func" > /dev/null; then + lib::error "Function '$func' not found in command '$cmd'" + return 1 + fi + + # Run the function with the provided arguments + "$func" "$@" +}