From f530c7466e071f077d1f4fa8fdaca3b2179760ea Mon Sep 17 00:00:00 2001 From: "Aaron D. Lee" Date: Mon, 22 Dec 2025 00:04:07 -0500 Subject: [PATCH] "Update" --- bin/dotfiles-doctor.sh | 626 +++++++++++++++------------------------ bin/dotfiles-stats.sh | 651 ++++++++++++----------------------------- bin/dotfiles-sync.sh | 608 +++++++++++++------------------------- bin/dotfiles-vault.sh | 607 ++++++++++++++------------------------ install.sh | 11 +- 5 files changed, 850 insertions(+), 1653 deletions(-) diff --git a/bin/dotfiles-doctor.sh b/bin/dotfiles-doctor.sh index 24d5764..e3cd873 100755 --- a/bin/dotfiles-doctor.sh +++ b/bin/dotfiles-doctor.sh @@ -1,461 +1,314 @@ #!/usr/bin/env bash # ============================================================================ -# Dotfiles Doctor - Diagnostic Tool -# ============================================================================ -# Checks the health of your dotfiles installation -# -# Usage: -# dotfiles-doctor.sh # Run all checks -# dotfiles-doctor.sh --fix # Attempt to fix issues -# dotfiles-doctor.sh --quiet # Only show errors +# Dotfiles Health Check (Arch/CachyOS) # ============================================================================ set -e -# ============================================================================ -# Options -# ============================================================================ +readonly DOTFILES_HOME="${DOTFILES_HOME:-.}" +readonly DOTFILES_VERSION="3.0.0" -FIX_MODE=false -QUIET_MODE=false +# Color codes +readonly RED='\033[0;31m' +readonly GREEN='\033[0;32m' +readonly YELLOW='\033[1;33m' +readonly BLUE='\033[0;34m' +readonly CYAN='\033[0;36m' +readonly NC='\033[0m' -for arg in "$@"; do - case "$arg" in - --fix) - FIX_MODE=true - ;; - --quiet|-q) - QUIET_MODE=true - ;; - --help|-h) - echo "Usage: dotfiles-doctor.sh [OPTIONS]" - echo - echo "Options:" - echo " --fix Attempt to automatically fix issues" - echo " --quiet Only show errors and warnings" - echo " --help Show this help message" - echo - echo "Aliases:" - echo " dfd, doctor Run diagnostics" - echo " dffix Run with --fix" - echo - exit 0 - ;; - esac -done +# Track results +TOTAL_CHECKS=0 +PASSED_CHECKS=0 +FAILED_CHECKS=0 +WARNING_CHECKS=0 # ============================================================================ -# Load Configuration -# ============================================================================ - -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -DOTFILES_CONF="${SCRIPT_DIR}/../dotfiles.conf" -[[ -f "$DOTFILES_CONF" ]] || DOTFILES_CONF="${SCRIPT_DIR}/dotfiles.conf" -[[ -f "$DOTFILES_CONF" ]] || DOTFILES_CONF="$HOME/.dotfiles/dotfiles.conf" - -if [[ -f "$DOTFILES_CONF" ]]; then - source "$DOTFILES_CONF" -else - DOTFILES_DIR="$HOME/.dotfiles" - DOTFILES_VERSION="unknown" - ZSH_THEME_NAME="adlee" -fi - -# ============================================================================ -# Colors -# ============================================================================ - -RED='\033[0;31m' -GREEN='\033[0;32m' -YELLOW='\033[1;33m' -BLUE='\033[0;34m' -CYAN='\033[0;36m' -NC='\033[0m' - -# ============================================================================ -# Counters -# ============================================================================ - -PASS_COUNT=0 -WARN_COUNT=0 -FAIL_COUNT=0 - -# ============================================================================ -# Helper Functions +# Print MOTD-style header # ============================================================================ print_header() { - if [[ "$QUIET_MODE" != true ]]; then - echo -e "\n${BLUE}╔════════════════════════════════════════════════════════════╗${NC}" - echo -e "${BLUE}║${NC} Dotfiles Doctor ${CYAN}v${DOTFILES_VERSION}${NC} ${BLUE}║${NC}" - echo -e "${BLUE}╚════════════════════════════════════════════════════════════╝${NC}\n" - fi + local user="${USER:-root}" + local hostname="${HOSTNAME:-localhost}" + local timestamp=$(date '+%a %b %d %H:%M') + + echo "" + printf "${CYAN}+ ${NC}%-20s %30s %25s\n" "$user@$hostname" "dotfiles-doctor" "$timestamp" + echo "" } +# ============================================================================ +# Health check functions +# ============================================================================ + print_section() { - if [[ "$QUIET_MODE" != true ]]; then - echo -e "\n${BLUE}━━━ $1 ━━━${NC}" - fi + echo -e "\n${BLUE}▶${NC} $1" } -pass() { - ((PASS_COUNT++)) - if [[ "$QUIET_MODE" != true ]]; then - echo -e "${GREEN}✓${NC} $1" - fi +check_pass() { + ((PASSED_CHECKS++)) + ((TOTAL_CHECKS++)) + echo -e " ${GREEN}✓${NC} $1" } -warn() { - ((WARN_COUNT++)) - echo -e "${YELLOW}⚠${NC} $1" +check_fail() { + ((FAILED_CHECKS++)) + ((TOTAL_CHECKS++)) + echo -e " ${RED}✗${NC} $1" } -fail() { - ((FAIL_COUNT++)) - echo -e "${RED}✗${NC} $1" -} - -info() { - if [[ "$QUIET_MODE" != true ]]; then - echo -e "${CYAN}ℹ${NC} $1" - fi +check_warn() { + ((WARNING_CHECKS++)) + ((TOTAL_CHECKS++)) + echo -e " ${YELLOW}⚠${NC} $1" } # ============================================================================ -# Check Functions +# Health checks # ============================================================================ -check_dotfiles_dir() { - print_section "Dotfiles Directory" - - if [[ -d "$DOTFILES_DIR" ]]; then - pass "Dotfiles directory exists: $DOTFILES_DIR" - - # Check if it's a git repo - if [[ -d "$DOTFILES_DIR/.git" ]]; then - pass "Is a git repository" - - # Check for uncommitted changes - cd "$DOTFILES_DIR" - if git diff --quiet 2>/dev/null; then - pass "No uncommitted changes" - else - warn "Uncommitted changes in dotfiles" - fi - - # Check if up to date with remote - git fetch origin --quiet 2>/dev/null || true - local local_hash=$(git rev-parse HEAD 2>/dev/null) - local remote_hash=$(git rev-parse origin/${DOTFILES_BRANCH:-main} 2>/dev/null || echo "") - - if [[ -n "$remote_hash" && "$local_hash" == "$remote_hash" ]]; then - pass "Up to date with remote" - elif [[ -n "$remote_hash" ]]; then - warn "Behind remote (run: cd ~/.dotfiles && git pull)" - fi - cd - > /dev/null +check_os() { + print_section "Operating System" + + if [[ "$OSTYPE" == "linux-gnu" ]]; then + if grep -qi "arch\|cachyos" /etc/os-release 2>/dev/null; then + check_pass "Running on Arch/CachyOS" else - warn "Not a git repository" - fi - - # Check config file - if [[ -f "$DOTFILES_DIR/dotfiles.conf" ]]; then - pass "Config file exists: dotfiles.conf" - else - fail "Config file missing: dotfiles.conf" + check_fail "Not running on Arch/CachyOS" fi else - fail "Dotfiles directory not found: $DOTFILES_DIR" + check_fail "Not running on Linux" + fi +} + +check_shell() { + print_section "Shell Configuration" + + if [[ -f "$HOME/.zshrc" ]]; then + check_pass "Zsh configuration exists" + else + check_fail "Zsh configuration missing" + fi + + if [[ "$SHELL" == *"zsh"* ]]; then + check_pass "Zsh is default shell" + else + check_warn "Zsh is not default shell (current: $SHELL)" fi } check_symlinks() { print_section "Symlinks" - - local symlinks=( - "$HOME/.zshrc:$DOTFILES_DIR/zsh/.zshrc" - "$HOME/.gitconfig:$DOTFILES_DIR/git/.gitconfig" - "$HOME/.vimrc:$DOTFILES_DIR/vim/.vimrc" - "$HOME/.tmux.conf:$DOTFILES_DIR/tmux/.tmux.conf" - "$HOME/.oh-my-zsh/themes/${ZSH_THEME_NAME}.zsh-theme:$DOTFILES_DIR/zsh/themes/${ZSH_THEME_NAME}.zsh-theme" - ) - - local valid_count=0 - local total_count=0 - - for entry in "${symlinks[@]}"; do - local link="${entry%%:*}" - local target="${entry##*:}" - local name=$(basename "$link") - ((total_count++)) - - if [[ -L "$link" ]]; then - local actual_target=$(readlink -f "$link" 2>/dev/null) - local expected_target=$(readlink -f "$target" 2>/dev/null) - - if [[ "$actual_target" == "$expected_target" ]]; then - pass "Symlink valid: $name" - ((valid_count++)) + + local symlink_count=0 + local broken_count=0 + + for symlink in ~/.zshrc ~/.gitconfig ~/.vimrc ~/.tmux.conf; do + if [[ -L "$symlink" ]]; then + ((symlink_count++)) + if [[ -e "$symlink" ]]; then + check_pass "$(basename $symlink) → $(readlink $symlink)" else - warn "Symlink points elsewhere: $name" - info " Expected: $target" - info " Actual: $actual_target" + ((broken_count++)) + check_fail "$(basename $symlink) is broken" fi - elif [[ -f "$link" ]]; then - warn "Regular file (not symlink): $name" - if [[ "$FIX_MODE" == true ]]; then - if [[ -f "$target" ]]; then - mv "$link" "$link.backup" - ln -sf "$target" "$link" - pass "Fixed: $name (backup saved)" - ((valid_count++)) - fi - fi - elif [[ -f "$target" ]]; then - fail "Symlink missing: $name" - if [[ "$FIX_MODE" == true ]]; then - ln -sf "$target" "$link" - pass "Fixed: Created symlink for $name" - ((valid_count++)) - fi - else - info "Source not present: $name (optional)" fi done - - # Check espanso symlink - if [[ -L "$HOME/.config/espanso" ]]; then - pass "Symlink valid: espanso config" - elif [[ -d "$HOME/.config/espanso" ]]; then - warn "Espanso config is directory (not symlink)" - elif [[ -d "$DOTFILES_DIR/espanso" ]]; then - fail "Espanso symlink missing" - fi - - info "Symlinks: $valid_count/$total_count valid" -} - -check_shell() { - print_section "Shell" - - # Check current shell - if [[ "$SHELL" == *"zsh"* ]]; then - pass "Default shell is zsh" - else - warn "Default shell is not zsh: $SHELL" - info " Change with: chsh -s \$(which zsh)" - fi - - # Check oh-my-zsh - if [[ -d "$HOME/.oh-my-zsh" ]]; then - pass "oh-my-zsh installed" - else - fail "oh-my-zsh not installed" - fi - - # Check theme - if [[ -f "$HOME/.oh-my-zsh/themes/${ZSH_THEME_NAME}.zsh-theme" ]]; then - pass "Theme installed: ${ZSH_THEME_NAME}" - else - fail "Theme missing: ${ZSH_THEME_NAME}" - fi - - # Check ZSH_THEME in .zshrc - if grep -q "ZSH_THEME=\"${ZSH_THEME_NAME}\"" "$HOME/.zshrc" 2>/dev/null; then - pass "Theme configured in .zshrc" - else - warn "Theme may not be configured in .zshrc" + + if [[ $symlink_count -eq 0 ]]; then + check_warn "No symlinks found (may not be installed yet)" fi } -check_zsh_plugins() { - print_section "Zsh Plugins" - - local custom_dir="${ZSH_CUSTOM:-$HOME/.oh-my-zsh/custom}/plugins" - - # zsh-autosuggestions - if [[ -d "$custom_dir/zsh-autosuggestions" ]]; then - pass "Plugin installed: zsh-autosuggestions" +check_vim() { + print_section "Editor Configuration" + + if command -v vim &> /dev/null; then + local vim_version=$(vim --version | head -1) + check_pass "Vim installed: $vim_version" else - fail "Plugin missing: zsh-autosuggestions" - if [[ "$FIX_MODE" == true ]]; then - git clone --depth 1 https://github.com/zsh-users/zsh-autosuggestions "$custom_dir/zsh-autosuggestions" - pass "Fixed: Installed zsh-autosuggestions" - else - info " Install: git clone https://github.com/zsh-users/zsh-autosuggestions $custom_dir/zsh-autosuggestions" - fi + check_fail "Vim not installed" fi - - # zsh-syntax-highlighting - if [[ -d "$custom_dir/zsh-syntax-highlighting" ]]; then - pass "Plugin installed: zsh-syntax-highlighting" + + if command -v nvim &> /dev/null; then + local nvim_version=$(nvim --version | head -1) + check_pass "Neovim installed: $nvim_version" else - fail "Plugin missing: zsh-syntax-highlighting" - if [[ "$FIX_MODE" == true ]]; then - git clone --depth 1 https://github.com/zsh-users/zsh-syntax-highlighting "$custom_dir/zsh-syntax-highlighting" - pass "Fixed: Installed zsh-syntax-highlighting" - else - info " Install: git clone https://github.com/zsh-users/zsh-syntax-highlighting $custom_dir/zsh-syntax-highlighting" - fi + check_warn "Neovim not installed (optional)" fi } check_git() { print_section "Git Configuration" - - # Check git installed - if command -v git &>/dev/null; then - pass "git installed: $(git --version | cut -d' ' -f3)" - else - fail "git not installed" - return - fi - - # Check user.name - local git_name=$(git config --global user.name 2>/dev/null) - if [[ -n "$git_name" ]]; then - pass "Git user.name: $git_name" - else - fail "Git user.name not configured" - info " Set with: git config --global user.name \"Your Name\"" - fi - - # Check user.email - local git_email=$(git config --global user.email 2>/dev/null) - if [[ -n "$git_email" ]]; then - pass "Git user.email: $git_email" - else - fail "Git user.email not configured" - info " Set with: git config --global user.email \"you@example.com\"" - fi - - # Check credential helper - local cred_helper=$(git config --global credential.helper 2>/dev/null) - if [[ -n "$cred_helper" ]]; then - pass "Git credential helper: $cred_helper" - else - warn "Git credential helper not configured" - fi -} - -check_espanso() { - print_section "Espanso" - - if command -v espanso &>/dev/null; then - pass "espanso installed: $(espanso --version 2>/dev/null | head -1)" - - # Check if running - if espanso status 2>/dev/null | grep -q "running"; then - pass "espanso service running" + + if command -v git &> /dev/null; then + check_pass "Git installed" + + if git config --global user.name &> /dev/null; then + local git_user=$(git config --global user.name) + check_pass "Git user configured: $git_user" else - warn "espanso service not running" - info " Start with: espanso service start" + check_fail "Git user not configured" fi - - # Check config - if [[ -f "$HOME/.config/espanso/match/base.yml" ]]; then - pass "espanso config present" + + if git config --global user.email &> /dev/null; then + local git_email=$(git config --global user.email) + check_pass "Git email configured: $git_email" else - warn "espanso base.yml not found" + check_fail "Git email not configured" fi else - info "espanso not installed (optional)" + check_fail "Git not installed" fi } check_optional_tools() { print_section "Optional Tools" - - # fzf - if command -v fzf &>/dev/null; then - pass "fzf installed" + + if command -v fzf &> /dev/null; then + check_pass "fzf installed (fuzzy finder)" else - info "fzf not installed (optional)" + check_warn "fzf not installed (command palette requires this)" fi - - # bat/batcat - if command -v bat &>/dev/null || command -v batcat &>/dev/null; then - pass "bat installed" + + if command -v lastpass-cli &> /dev/null || command -v lpass &> /dev/null; then + check_pass "LastPass CLI installed" else - info "bat not installed (optional)" + check_warn "LastPass CLI not installed (password manager)" fi - - # eza - if command -v eza &>/dev/null; then - pass "eza installed" + + if command -v tmux &> /dev/null; then + check_pass "Tmux installed" else - info "eza not installed (optional)" + check_warn "Tmux not installed (workspaces require this)" fi - - # fd - if command -v fd &>/dev/null; then - pass "fd installed" + + if command -v age &> /dev/null || command -v gpg &> /dev/null; then + check_pass "Encryption tool available (age or gpg)" else - info "fd not installed (optional)" + check_warn "No encryption tool (vault requires age or gpg)" + fi + + if command -v bat &> /dev/null; then + check_pass "bat installed (syntax highlighting)" + else + check_warn "bat not installed (optional enhancement)" + fi + + if command -v eza &> /dev/null; then + check_pass "eza installed (ls replacement)" + else + check_warn "eza not installed (optional enhancement)" fi } -check_bin_scripts() { - print_section "Bin Scripts" +check_pacman() { + print_section "Package Manager" + + if command -v pacman &> /dev/null; then + check_pass "Pacman available" + else + check_fail "Pacman not found (this is Arch/CachyOS only)" + fi +} - local bin_dir="$HOME/.local/bin" - - if [[ -d "$bin_dir" ]]; then - local script_count=0 - local valid_count=0 - - for script in "$DOTFILES_DIR/bin"/*; do - if [[ -f "$script" ]]; then - ((script_count++)) - local name=$(basename "$script") - local link="$bin_dir/$name" - - if [[ -L "$link" ]]; then - ((valid_count++)) - elif [[ -f "$link" ]]; then - warn "Script is regular file: $name" - else - fail "Script not linked: $name" - fi - fi - done - - if [[ $script_count -gt 0 ]]; then - pass "Bin scripts: $valid_count/$script_count linked" - fi - - # Check PATH - if [[ ":$PATH:" == *":$bin_dir:"* ]]; then - pass "$bin_dir is in PATH" +check_permissions() { + print_section "File Permissions" + + if [[ -f "$DOTFILES_HOME/install.sh" ]]; then + if [[ -x "$DOTFILES_HOME/install.sh" ]]; then + check_pass "install.sh is executable" else - warn "$bin_dir not in PATH" - info " Add to .zshrc: export PATH=\"\$HOME/.local/bin:\$PATH\"" + check_fail "install.sh is not executable" + fi + fi + + if [[ -d "$DOTFILES_HOME/bin" ]]; then + local non_exec=$(find "$DOTFILES_HOME/bin" -type f ! -perm /u+x 2>/dev/null | wc -l) + if [[ $non_exec -eq 0 ]]; then + check_pass "All scripts in bin/ are executable" + else + check_fail "$non_exec scripts in bin/ are not executable" fi - else - warn "~/.local/bin directory doesn't exist" fi } +check_zsh_plugins() { + print_section "Zsh Plugins" + + if [[ -d "$HOME/.oh-my-zsh" ]]; then + check_pass "Oh My Zsh installed" + + if [[ -d "$HOME/.oh-my-zsh/custom/plugins/zsh-autosuggestions" ]]; then + check_pass "zsh-autosuggestions installed" + else + check_warn "zsh-autosuggestions not installed" + fi + + if [[ -d "$HOME/.oh-my-zsh/custom/plugins/zsh-syntax-highlighting" ]]; then + check_pass "zsh-syntax-highlighting installed" + else + check_warn "zsh-syntax-highlighting not installed" + fi + + if [[ -f "$HOME/.oh-my-zsh/themes/adlee.zsh-theme" ]]; then + check_pass "adlee theme installed" + else + check_warn "adlee theme not installed" + fi + else + check_warn "Oh My Zsh not installed" + fi +} + +check_dotfiles_dir() { + print_section "Dotfiles Directory" + + if [[ -d "$DOTFILES_HOME" ]]; then + check_pass "Dotfiles directory found: $DOTFILES_HOME" + else + check_fail "Dotfiles directory not found: $DOTFILES_HOME" + return + fi + + if [[ -f "$DOTFILES_HOME/dotfiles.conf" ]]; then + check_pass "Configuration file exists" + else + check_warn "Configuration file missing" + fi + + if [[ -d "$DOTFILES_HOME/.git" ]]; then + check_pass "Git repository initialized" + else + check_warn "Not a git repository" + fi +} + +# ============================================================================ +# Print summary +# ============================================================================ + print_summary() { - echo - echo -e "${BLUE}━━━ Summary ━━━${NC}" - echo - echo -e " ${GREEN}Passed:${NC} $PASS_COUNT" - echo -e " ${YELLOW}Warnings:${NC} $WARN_COUNT" - echo -e " ${RED}Failed:${NC} $FAIL_COUNT" - echo - - if [[ $FAIL_COUNT -eq 0 && $WARN_COUNT -eq 0 ]]; then - echo -e "${GREEN}✓ All checks passed! Your dotfiles are healthy.${NC}" - elif [[ $FAIL_COUNT -eq 0 ]]; then - echo -e "${YELLOW}⚠ Some warnings, but no critical issues.${NC}" + echo "" + printf "${CYAN}─%.0s${NC}" {1..70}; echo "" + + if [[ $FAILED_CHECKS -eq 0 ]]; then + echo -e "${GREEN}✓${NC} All checks passed ($PASSED_CHECKS/$TOTAL_CHECKS)" else - echo -e "${RED}✗ Some issues found.${NC}" - if [[ "$FIX_MODE" != true ]]; then - echo -e " Run ${CYAN}dffix${NC} or ${CYAN}dotfiles-doctor.sh --fix${NC} to attempt automatic fixes." + echo -e "${RED}✗${NC} Some checks failed" + echo -e " ${GREEN}Passed:${NC} $PASSED_CHECKS" + echo -e " ${RED}Failed:${NC} $FAILED_CHECKS" + if [[ $WARNING_CHECKS -gt 0 ]]; then + echo -e " ${YELLOW}Warnings:${NC} $WARNING_CHECKS" fi fi - echo + + echo "" + + if [[ $FAILED_CHECKS -gt 0 ]]; then + echo -e "${YELLOW}💡 Tip:${NC} Run 'dotfiles-doctor.sh --fix' to attempt automatic fixes" + echo "" + return 1 + fi } # ============================================================================ @@ -464,20 +317,19 @@ print_summary() { main() { print_header - + + check_os + check_pacman + check_shell + check_vim + check_git check_dotfiles_dir check_symlinks - check_shell check_zsh_plugins - check_git - check_espanso check_optional_tools - check_bin_scripts - + check_permissions + print_summary - - # Exit with error code if there were failures - [[ $FAIL_COUNT -eq 0 ]] } main "$@" diff --git a/bin/dotfiles-stats.sh b/bin/dotfiles-stats.sh index 0121941..73393d1 100755 --- a/bin/dotfiles-stats.sh +++ b/bin/dotfiles-stats.sh @@ -1,526 +1,241 @@ #!/usr/bin/env bash # ============================================================================ -# Shell Stats - Command Analytics Dashboard -# ============================================================================ -# Analyzes your shell history to provide insights and suggestions -# -# Usage: -# shell-stats.sh # Show dashboard -# shell-stats.sh --top [n] # Top N commands -# shell-stats.sh --suggest # Suggest aliases -# shell-stats.sh --hours # Commands by hour -# shell-stats.sh --dirs # Most used directories -# shell-stats.sh --export # Export stats as JSON +# Dotfiles Shell Analytics (Arch/CachyOS) # ============================================================================ set -e -# ============================================================================ -# Configuration -# ============================================================================ - -HISTFILE="${HISTFILE:-$HOME/.zsh_history}" -BASH_HISTFILE="$HOME/.bash_history" -STATS_CACHE="$HOME/.cache/shell-stats" -STATS_FILE="$STATS_CACHE/stats.json" - -mkdir -p "$STATS_CACHE" +# Color codes +readonly RED='\033[0;31m' +readonly GREEN='\033[0;32m' +readonly YELLOW='\033[1;33m' +readonly BLUE='\033[0;34m' +readonly CYAN='\033[0;36m' +readonly MAGENTA='\033[0;35m' +readonly NC='\033[0m' # ============================================================================ -# Colors +# Print MOTD-style header # ============================================================================ -RED='\033[0;31m' -GREEN='\033[0;32m' -YELLOW='\033[1;33m' -BLUE='\033[0;34m' -CYAN='\033[0;36m' -MAGENTA='\033[0;35m' -DIM='\033[2m' -BOLD='\033[1m' -NC='\033[0m' +print_header() { + local user="${USER:-root}" + local hostname="${HOSTNAME:-localhost}" + local timestamp=$(date '+%a %b %d %H:%M') + + echo "" + printf "${CYAN}+ ${NC}%-20s %30s %25s\n" "$user@$hostname" "dotfiles-stats" "$timestamp" + echo "" +} # ============================================================================ -# History Parsing +# Helper functions # ============================================================================ -get_history_file() { - if [[ -f "$HISTFILE" ]]; then - echo "$HISTFILE" - elif [[ -f "$BASH_HISTFILE" ]]; then - echo "$BASH_HISTFILE" - else - echo "" +print_section() { + echo "" + echo -e "${BLUE}▶${NC} $1" + echo -e "${CYAN}─────────────────────────────────────────────────────────────${NC}" +} + +# Get command history +get_history() { + if [[ -f "$HOME/.bash_history" ]]; then + cat "$HOME/.bash_history" + elif [[ -f "$HOME/.zsh_history" ]]; then + grep "^:" "$HOME/.zsh_history" | cut -d';' -f2 || cat "$HOME/.zsh_history" fi } -parse_zsh_history() { - # Zsh extended history format: : timestamp:0;command - local histfile=$(get_history_file) - [[ -z "$histfile" ]] && return - - if [[ "$histfile" == *"zsh"* ]]; then - # Zsh format - cat "$histfile" 2>/dev/null | sed 's/^: [0-9]*:[0-9]*;//' | grep -v '^$' - else - # Bash format - cat "$histfile" 2>/dev/null | grep -v '^#' | grep -v '^$' - fi -} - -get_command_count() { - parse_zsh_history | wc -l | tr -d ' ' -} - -get_unique_commands() { - parse_zsh_history | awk '{print $1}' | sort -u | wc -l | tr -d ' ' -} - # ============================================================================ -# Analysis Functions +# Statistics functions # ============================================================================ -top_commands() { - local count="${1:-15}" - - parse_zsh_history | \ - awk '{print $1}' | \ - sort | \ - uniq -c | \ - sort -rn | \ - head -n "$count" -} - -top_full_commands() { - local count="${1:-10}" - - parse_zsh_history | \ - sort | \ - uniq -c | \ - sort -rn | \ - head -n "$count" -} - -commands_by_hour() { - local histfile=$(get_history_file) - [[ -z "$histfile" ]] && return - - # Try to extract timestamps from zsh history - if [[ "$histfile" == *"zsh"* ]]; then - grep '^:' "$histfile" 2>/dev/null | \ - sed 's/^: \([0-9]*\):.*/\1/' | \ - while read -r ts; do - date -d "@$ts" '+%H' 2>/dev/null || date -r "$ts" '+%H' 2>/dev/null - done | \ - sort | \ - uniq -c | \ - sort -k2 -n - else - echo "Timestamp analysis requires zsh extended history" - fi -} - -most_used_dirs() { - parse_zsh_history | \ - grep -E '^cd ' | \ - sed 's/^cd //' | \ - sort | \ - uniq -c | \ - sort -rn | \ - head -15 -} - -git_commands() { - parse_zsh_history | \ - grep -E '^git ' | \ - awk '{print $1" "$2}' | \ - sort | \ - uniq -c | \ - sort -rn | \ - head -15 -} - -docker_commands() { - parse_zsh_history | \ - grep -E '^docker ' | \ - awk '{print $1" "$2}' | \ - sort | \ - uniq -c | \ - sort -rn | \ - head -10 -} - -# ============================================================================ -# Suggestion Engine -# ============================================================================ - -suggest_aliases() { - echo -e "${CYAN}Suggested Aliases${NC}" - echo -e "${DIM}Based on your most-typed commands${NC}" - echo - - # Get commands typed more than 10 times that are longer than 5 chars - parse_zsh_history | \ - awk 'length($0) > 8' | \ - sort | \ - uniq -c | \ - sort -rn | \ - head -20 | \ - while read -r count cmd; do - # Skip if count is too low - [[ $count -lt 5 ]] && continue - - # Skip single-word commands that are likely already short - local words=$(echo "$cmd" | wc -w | tr -d ' ') - [[ $words -lt 2 && ${#cmd} -lt 6 ]] && continue - - # Generate alias suggestion - local alias_name="" - - # Common patterns - case "$cmd" in - "git status") - alias_name="gs" - ;; - "git add .") - alias_name="ga" - ;; - "git commit"*) - alias_name="gc" - ;; - "git push"*) - alias_name="gp" - ;; - "git pull"*) - alias_name="gl" - ;; - "docker ps"*) - alias_name="dps" - ;; - "docker-compose up"*) - alias_name="dcup" - ;; - "docker-compose down"*) - alias_name="dcdown" - ;; - "kubectl get"*) - alias_name="kg" - ;; - "ls -la"*|"ls -al"*) - alias_name="ll" - ;; - "cd ..") - alias_name=".." - ;; - *) - # Generate from first letters - alias_name=$(echo "$cmd" | awk '{for(i=1;i<=NF && i<=3;i++) printf substr($i,1,1)}') - ;; - esac - - # Check if alias already exists - if alias "$alias_name" &>/dev/null 2>&1; then - echo -e " ${GREEN}✓${NC} ${DIM}$alias_name${NC} already defined (used $count times)" - else - local saved_chars=$(( (${#cmd} - ${#alias_name}) * count )) - echo -e " ${YELLOW}→${NC} alias ${CYAN}$alias_name${NC}='$cmd'" - echo -e " ${DIM}Used $count times, would save ~$saved_chars keystrokes${NC}" - fi - done -} - -# ============================================================================ -# Dashboard -# ============================================================================ - -draw_bar() { - local value=$1 - local max=$2 - local width=${3:-30} - - # Avoid division by zero - [[ $max -eq 0 ]] && max=1 - - local filled=$((value * width / max)) - local empty=$((width - filled)) - - # Build filled portion - local filled_bar="" - local empty_bar="" - local i - - for ((i=0; i 15' | sort | uniq -c | sort -rn | head -1) - local long_count=$(echo "$long_cmd" | awk '{print $1}') - local long_text=$(echo "$long_cmd" | sed 's/^[[:space:]]*[0-9]*[[:space:]]*//') - - if [[ $long_count -gt 10 ]]; then - echo -e " ${YELLOW}→${NC} You've typed '${CYAN}$long_text${NC}' $long_count times" - echo -e " Consider creating an alias for it!" - fi - - # Check for common inefficiencies - local cd_dots=$(parse_zsh_history | grep -c '^cd \.\.' || echo 0) - if [[ $cd_dots -gt 50 ]]; then - echo -e " ${YELLOW}→${NC} You use 'cd ..' a lot ($cd_dots times)" - echo -e " Tip: alias ..='cd ..' and ...='cd ../..'" - fi - - echo - echo -e "${DIM}Run 'shell-stats.sh --suggest' for detailed alias suggestions${NC}" + echo "" } -# ============================================================================ -# Export -# ============================================================================ - -export_stats() { - local output="${1:-$STATS_FILE}" +show_suggestions() { + print_section "Suggested Aliases" - echo "{" - echo " \"generated\": \"$(date -Iseconds)\"," - echo " \"total_commands\": $(get_command_count)," - echo " \"unique_commands\": $(get_unique_commands)," - echo " \"top_commands\": [" + local total=$(get_history | wc -l) - top_commands 20 | awk 'BEGIN{first=1} { - if (!first) printf ",\n" - printf " {\"command\": \"%s\", \"count\": %d}", $2, $1 - first=0 - }' + echo "" + get_history | awk '{print $1}' | sort | uniq -c | sort -rn | head -20 | \ + while read count cmd; do + if [[ $count -gt 50 ]]; then + printf " ${YELLOW}Suggestion:${NC} ${GREEN}alias ${cmd:0:2}='$cmd'${NC} (used $count times)\n" + fi + done - echo - echo " ]" - echo "}" + echo "" } -# ============================================================================ -# Activity Heatmap -# ============================================================================ +show_breakdown() { + print_section "Command Breakdown" + + echo "" + echo -e " ${CYAN}Git Commands:${NC}" + get_history | grep "^git" | wc -l | xargs printf " %d\n" + + echo -e " ${CYAN}Navigation (cd):${NC}" + get_history | grep "^cd" | wc -l | xargs printf " %d\n" + + echo -e " ${CYAN}File Operations (ls):${NC}" + get_history | grep "^ls" | wc -l | xargs printf " %d\n" + + echo -e " ${CYAN}Package Management (pacman/paru/yay):${NC}" + get_history | grep -E "^(pacman|paru|yay)" | wc -l | xargs printf " %d\n" + + echo -e " ${CYAN}Editing (vim/nvim):${NC}" + get_history | grep -E "^(vim|nvim)" | wc -l | xargs printf " %d\n" + + echo -e " ${CYAN}Dotfiles Commands (dotfiles-):${NC}" + get_history | grep "^dotfiles-" | wc -l | xargs printf " %d\n" + + echo "" +} show_heatmap() { - echo -e "${CYAN}Activity by Hour${NC}" - echo + print_section "Activity by Hour" - # Create array for 24 hours - declare -a hours - for i in {0..23}; do - hours[$i]=0 + echo "" + if [[ -f "$HOME/.zsh_history" ]]; then + # Extract hour from zsh history timestamp + grep "^:" "$HOME/.zsh_history" | awk -F'[: ]' '{print $2}' | \ + date -f - "+%H" 2>/dev/null | sort | uniq -c | sort -k2n | while read count hour; do + local bar_length=$((count / 5)) + local bar=$(printf '█%.0s' $(seq 1 $bar_length)) + printf " ${CYAN}%02d:00${NC} ${MAGENTA}%5d${NC} ${GREEN}${bar}${NC}\n" "$hour" "$count" + done + else + echo " ${YELLOW}⚠${NC} Zsh history file required for hourly breakdown" + fi + + echo "" +} + +show_dirs() { + print_section "Most Visited Directories" + + echo "" + if [[ -f "$HOME/.zsh_history" ]]; then + grep "cd " "$HOME/.zsh_history" | awk '{print $NF}' | sort | uniq -c | \ + sort -rn | head -15 | while read count dir; do + printf " ${CYAN}%4d${NC} ${YELLOW}%s${NC}\n" "$count" "$dir" + done + else + echo " ${YELLOW}⚠${NC} Zsh history file required" + fi + + echo "" +} + +show_git_breakdown() { + print_section "Git Command Breakdown" + + echo "" + local total=$(get_history | grep "^git" | wc -l) + + if [[ $total -eq 0 ]]; then + echo " ${YELLOW}No git commands found${NC}" + return + fi + + get_history | grep "^git " | awk '{print $2}' | sort | uniq -c | sort -rn | \ + head -10 | while read count subcmd; do + local percent=$((count * 100 / total)) + printf " ${YELLOW}git %-15s${NC} ${CYAN}%4d${NC} (${MAGENTA}%3d%%${NC})\n" \ + "$subcmd" "$count" "$percent" done - # Count commands per hour - local histfile=$(get_history_file) - if [[ "$histfile" == *"zsh"* && -f "$histfile" ]]; then - while IFS= read -r line; do - if [[ "$line" =~ ^:\ ([0-9]+): ]]; then - local ts="${BASH_REMATCH[1]}" - local hour=$(date -d "@$ts" '+%H' 2>/dev/null || date -r "$ts" '+%H' 2>/dev/null) - hour=${hour#0} # Remove leading zero - ((hours[$hour]++)) || true - fi - done < "$histfile" - - # Find max for scaling - local max=1 - for count in "${hours[@]}"; do - [[ $count -gt $max ]] && max=$count - done - - # Draw heatmap - echo -n " " - for i in {0..23}; do - local intensity=$((hours[$i] * 4 / max)) - case $intensity in - 0) echo -ne "${DIM}░${NC}" ;; - 1) echo -ne "${GREEN}▒${NC}" ;; - 2) echo -ne "${YELLOW}▓${NC}" ;; - 3) echo -ne "${RED}█${NC}" ;; - *) echo -ne "${MAGENTA}█${NC}" ;; - esac - done - echo - - echo -ne " " - echo -e "${DIM}0 6 12 18 23${NC}" - echo - - # Peak hours - local peak_hour=0 - local peak_count=0 - for i in {0..23}; do - if [[ ${hours[$i]} -gt $peak_count ]]; then - peak_count=${hours[$i]} - peak_hour=$i - fi - done - - echo -e " Peak activity: ${GREEN}${peak_hour}:00${NC} ($peak_count commands)" - else - echo -e " ${YELLOW}⚠${NC} Heatmap requires zsh with extended history" - fi + echo "" } # ============================================================================ # Main # ============================================================================ -show_help() { - echo "Usage: dotfiles-stats.sh [COMMAND] [OPTIONS]" - echo - echo "Commands:" - echo " (none) Show dashboard" - echo " --top [n] Top N commands (default: 15)" - echo " --full [n] Top N full command lines" - echo " --suggest Suggest aliases based on usage" - echo " --hours Show activity by hour" - echo " --heatmap Show activity heatmap" - echo " --dirs Most visited directories" - echo " --git Git command breakdown" - echo " --docker Docker command breakdown" - echo " --export Export stats as JSON" - echo " --help Show this help" - echo - echo "Aliases:" - echo " dfstats, stats Show dashboard" - echo " tophist Top commands" - echo " suggest Suggest aliases" - echo -} - main() { - local histfile=$(get_history_file) + print_header - if [[ -z "$histfile" || ! -f "$histfile" ]]; then - echo -e "${RED}✗${NC} No history file found" - echo " Checked: $HISTFILE" - echo " Checked: $BASH_HISTFILE" - exit 1 - fi - - case "${1:-}" in - --top|-t) - echo -e "${CYAN}Top Commands${NC}" - echo - top_commands "${2:-15}" | while read -r count cmd; do - printf " %5d %s\n" "$count" "$cmd" - done - ;; - --full|-f) - echo -e "${CYAN}Top Full Commands${NC}" - echo - top_full_commands "${2:-10}" | while read -r count cmd; do - printf " %5d %s\n" "$count" "$cmd" - done - ;; - --suggest|-s) - suggest_aliases - ;; - --hours) - commands_by_hour - ;; - --heatmap|-m) - show_heatmap - ;; - --dirs|-d) - echo -e "${CYAN}Most Visited Directories${NC}" - echo - most_used_dirs | while read -r count dir; do - printf " %5d %s\n" "$count" "$dir" - done - ;; - --git|-g) - echo -e "${CYAN}Git Commands${NC}" - echo - git_commands | while read -r count cmd; do - printf " %5d %s\n" "$count" "$cmd" - done - ;; - --docker) - echo -e "${CYAN}Docker Commands${NC}" - echo - docker_commands | while read -r count cmd; do - printf " %5d %s\n" "$count" "$cmd" - done - ;; - --export|-e) - export_stats "${2:-}" - ;; - --help|-h) - show_help - ;; - "") + case "${1:-dashboard}" in + dashboard) show_dashboard ;; + top) + show_top_n "${2:-20}" + ;; + suggest) + show_suggestions + ;; + breakdown) + show_breakdown + ;; + heatmap) + show_heatmap + ;; + dirs) + show_dirs + ;; + git) + show_git_breakdown + ;; + export) + # Export as JSON + echo "{" + echo " \"total_commands\": $(get_history | wc -l)," + echo " \"unique_commands\": $(get_history | sort | uniq | wc -l)," + echo " \"timestamp\": \"$(date -Iseconds)\"" + echo "}" + ;; *) - echo "Unknown command: $1" - show_help + echo "Usage: $0 {dashboard|top [n]|suggest|breakdown|heatmap|dirs|git|export}" + echo "" + echo "Commands:" + echo " dashboard Show full dashboard (default)" + echo " top [n] Show top N commands (default: 20)" + echo " suggest Suggest aliases" + echo " breakdown Command category breakdown" + echo " heatmap Activity by hour" + echo " dirs Most visited directories" + echo " git Git command breakdown" + echo " export Export as JSON" exit 1 ;; esac diff --git a/bin/dotfiles-sync.sh b/bin/dotfiles-sync.sh index 1ad5259..329c324 100755 --- a/bin/dotfiles-sync.sh +++ b/bin/dotfiles-sync.sh @@ -1,454 +1,256 @@ #!/usr/bin/env bash # ============================================================================ -# Dotfiles Sync - Auto-sync across machines -# ============================================================================ -# Keeps your dotfiles synchronized across multiple machines -# -# Usage: -# dotfiles-sync.sh # Interactive sync -# dotfiles-sync.sh --push # Push local changes -# dotfiles-sync.sh --pull # Pull remote changes -# dotfiles-sync.sh --status # Show sync status -# dotfiles-sync.sh --watch # Watch for changes (daemon mode) -# dotfiles-sync.sh --auto # Auto-sync on shell start +# Dotfiles Synchronization (Arch/CachyOS) # ============================================================================ set -e -# ============================================================================ -# Load Configuration -# ============================================================================ +readonly DOTFILES_HOME="${DOTFILES_HOME:-.}" +readonly DOTFILES_VERSION="3.0.0" -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -DOTFILES_CONF="$HOME/.dotfiles/dotfiles.conf" - -if [[ -f "$DOTFILES_CONF" ]]; then - source $DOTFILES_CONF -else - DOTFILES_DIR="$HOME/.dotfiles" - DOTFILES_BRANCH="main" -fi - -SYNC_STATE_FILE="$DOTFILES_DIR/.sync_state" -SYNC_LOG_FILE="$DOTFILES_DIR/.sync_log" -HOSTNAME=$(hostname -s) +# Color codes +readonly RED='\033[0;31m' +readonly GREEN='\033[0;32m' +readonly YELLOW='\033[1;33m' +readonly BLUE='\033[0;34m' +readonly CYAN='\033[0;36m' +readonly MAGENTA='\033[0;35m' +readonly NC='\033[0m' # ============================================================================ -# Colors -# ============================================================================ - -RED='\033[0;31m' -GREEN='\033[0;32m' -YELLOW='\033[1;33m' -BLUE='\033[0;34m' -CYAN='\033[0;36m' -DIM='\033[2m' -NC='\033[0m' - -# ============================================================================ -# Helper Functions +# Print MOTD-style header # ============================================================================ print_header() { - echo -e "\n${BLUE}╔════════════════════════════════════════════════════════════╗${NC}" - echo -e "${BLUE}║${NC} Dotfiles Sync ${BLUE}║${NC}" - echo -e "${BLUE}╚════════════════════════════════════════════════════════════╝${NC}\n" -} - -log_sync() { - local action="$1" - local details="$2" - echo "$(date -Iseconds) | $HOSTNAME | $action | $details" >> "$SYNC_LOG_FILE" -} - -get_local_status() { - cd "$DOTFILES_DIR" - - local status="" - local ahead=0 - local behind=0 - local modified=0 - local untracked=0 - - # Fetch quietly - git fetch origin --quiet 2>/dev/null || true - - # Count commits ahead/behind - ahead=$(git rev-list HEAD..origin/${DOTFILES_BRANCH} --count 2>/dev/null || echo 0) - behind=$(git rev-list origin/${DOTFILES_BRANCH}..HEAD --count 2>/dev/null || echo 0) - - # Count modified and untracked - modified=$(git diff --name-only 2>/dev/null | wc -l | tr -d ' ') - untracked=$(git ls-files --others --exclude-standard 2>/dev/null | wc -l | tr -d ' ') - - echo "$ahead|$behind|$modified|$untracked" -} - -has_local_changes() { - cd "$DOTFILES_DIR" - ! git diff --quiet 2>/dev/null || [[ -n $(git ls-files --others --exclude-standard) ]] -} - -has_remote_changes() { - cd "$DOTFILES_DIR" - git fetch origin --quiet 2>/dev/null || true - local behind=$(git rev-list HEAD..origin/${DOTFILES_BRANCH} --count 2>/dev/null || echo 0) - [[ $behind -gt 0 ]] + local user="${USER:-root}" + local hostname="${HOSTNAME:-localhost}" + local timestamp=$(date '+%a %b %d %H:%M') + + echo "" + printf "${CYAN}+ ${NC}%-20s %30s %25s\n" "$user@$hostname" "dotfiles-sync" "$timestamp" + echo "" } # ============================================================================ -# Sync Functions +# Helper functions # ============================================================================ +print_status() { + echo -e "${CYAN}⎯${NC} $1" +} + +print_success() { + echo -e "${GREEN}✓${NC} $1" +} + +print_error() { + echo -e "${RED}✗${NC} $1" >&2 +} + +print_warning() { + echo -e "${YELLOW}⚠${NC} $1" +} + +print_section() { + echo "" + echo -e "${BLUE}▶${NC} $1" + echo -e "${CYAN}─────────────────────────────────────────────────────────────${NC}" +} + +# ============================================================================ +# Sync functions +# ============================================================================ + +check_git_repo() { + if ! git -C "$DOTFILES_HOME" rev-parse --git-dir > /dev/null 2>&1; then + print_error "Not a git repository: $DOTFILES_HOME" + exit 1 + fi +} + +check_git_config() { + if ! git config --global user.name > /dev/null 2>&1; then + print_error "Git user.name not configured" + exit 1 + fi + + if ! git config --global user.email > /dev/null 2>&1; then + print_error "Git user.email not configured" + exit 1 + fi +} + +get_sync_status() { + cd "$DOTFILES_HOME" + + local local_commits=$(git rev-list --count @{u}..HEAD 2>/dev/null || echo 0) + local remote_commits=$(git rev-list --count HEAD..@{u} 2>/dev/null || echo 0) + + echo "$local_commits:$remote_commits" +} + show_status() { - print_header - - echo -e "${CYAN}Machine:${NC} $HOSTNAME" - echo -e "${CYAN}Branch:${NC} $DOTFILES_BRANCH" - echo -e "${CYAN}Path:${NC} $DOTFILES_DIR" - echo - - cd "$DOTFILES_DIR" - - IFS='|' read -r behind ahead modified untracked <<< "$(get_local_status)" - - echo -e "${CYAN}Status:${NC}" - - # Remote status - if [[ $behind -gt 0 ]]; then - echo -e " ${YELLOW}↓${NC} $behind commit(s) behind remote" - elif [[ $ahead -gt 0 ]]; then - echo -e " ${GREEN}↑${NC} $ahead commit(s) ahead of remote" - else - echo -e " ${GREEN}✓${NC} In sync with remote" + print_section "Sync Status" + + cd "$DOTFILES_HOME" + + print_status "Local branch: $(git rev-parse --abbrev-ref HEAD)" + print_status "Last commit: $(git log -1 --pretty=format:'%h - %s' 2>/dev/null || echo 'N/A')" + print_status "Last update: $(git log -1 --pretty=format:'%ar' 2>/dev/null || echo 'N/A')" + + local status=$(get_sync_status) + local local_commits="${status%:*}" + local remote_commits="${status#*:}" + + echo "" + if [[ $local_commits -gt 0 ]]; then + print_warning "$local_commits commit(s) ahead of remote" fi - - # Local changes - if [[ $modified -gt 0 ]]; then - echo -e " ${YELLOW}●${NC} $modified modified file(s)" + + if [[ $remote_commits -gt 0 ]]; then + print_warning "$remote_commits commit(s) behind remote" fi - - if [[ $untracked -gt 0 ]]; then - echo -e " ${YELLOW}+${NC} $untracked untracked file(s)" - fi - - if [[ $modified -eq 0 && $untracked -eq 0 ]]; then - echo -e " ${GREEN}✓${NC} Working directory clean" - fi - - # Show recent changes - echo - echo -e "${CYAN}Recent changes:${NC}" - git log --oneline -5 2>/dev/null | while read -r line; do - echo -e " ${DIM}$line${NC}" - done - - # Show modified files - if [[ $modified -gt 0 || $untracked -gt 0 ]]; then - echo - echo -e "${CYAN}Changed files:${NC}" - git status --short 2>/dev/null | head -10 | while read -r line; do - echo -e " $line" - done - local total=$((modified + untracked)) - [[ $total -gt 10 ]] && echo -e " ${DIM}... and $((total - 10)) more${NC}" - fi - - # Show last sync - if [[ -f "$SYNC_STATE_FILE" ]]; then - echo - local last_sync=$(cat "$SYNC_STATE_FILE") - echo -e "${CYAN}Last sync:${NC} $last_sync" - fi -} - -do_push() { - local message="${1:-Auto-sync from $HOSTNAME}" - - cd "$DOTFILES_DIR" - - if ! has_local_changes; then - echo -e "${GREEN}✓${NC} No local changes to push" - return 0 - fi - - echo -e "${BLUE}==>${NC} Pushing local changes..." - - # Stage all changes - git add -A - - # Show what we're committing - echo -e "${CYAN}Changes:${NC}" - git diff --cached --stat | head -10 - - echo - - # Commit - git commit -m "$message" || { - echo -e "${YELLOW}⚠${NC} Nothing to commit" - return 0 - } - - # Push - if git push origin "$DOTFILES_BRANCH"; then - echo -e "${GREEN}✓${NC} Changes pushed successfully" - log_sync "push" "$message" - date -Iseconds > "$SYNC_STATE_FILE" - else - echo -e "${RED}✗${NC} Failed to push changes" - return 1 - fi -} - -do_pull() { - cd "$DOTFILES_DIR" - - echo -e "${BLUE}==>${NC} Pulling remote changes..." - - # Stash local changes if any - local had_changes=false - if has_local_changes; then - echo -e "${YELLOW}⚠${NC} Stashing local changes..." - git stash push -m "Auto-stash before pull" - had_changes=true - fi - - # Pull - if git pull origin "$DOTFILES_BRANCH"; then - echo -e "${GREEN}✓${NC} Changes pulled successfully" - log_sync "pull" "from origin/$DOTFILES_BRANCH" - date -Iseconds > "$SYNC_STATE_FILE" - - # Show what changed - echo -e "${CYAN}Updates:${NC}" - git log --oneline ORIG_HEAD..HEAD 2>/dev/null | while read -r line; do - echo -e " ${GREEN}+${NC} $line" - done - else - echo -e "${RED}✗${NC} Failed to pull changes" - - # Restore stash on failure - if [[ "$had_changes" == true ]]; then - git stash pop - fi - return 1 - fi - - # Restore stash - if [[ "$had_changes" == true ]]; then - echo -e "${BLUE}==>${NC} Restoring local changes..." - if git stash pop; then - echo -e "${GREEN}✓${NC} Local changes restored" - else - echo -e "${YELLOW}⚠${NC} Conflict restoring local changes" - echo " Resolve conflicts and run: git stash drop" - fi - fi -} - -do_sync() { - print_header - - cd "$DOTFILES_DIR" - - local has_local=$(has_local_changes && echo "yes" || echo "no") - local has_remote=$(has_remote_changes && echo "yes" || echo "no") - - if [[ "$has_local" == "no" && "$has_remote" == "no" ]]; then - echo -e "${GREEN}✓${NC} Everything is in sync!" - return 0 - fi - - if [[ "$has_remote" == "yes" ]]; then - echo -e "${CYAN}Remote changes available${NC}" - do_pull - echo - fi - - if [[ "$has_local" == "yes" ]]; then - echo -e "${CYAN}Local changes detected${NC}" - - # Show changes - git status --short - echo - - read -p "Push these changes? [Y/n]: " confirm - if [[ "${confirm:-y}" =~ ^[Yy] ]]; then - read -p "Commit message [Auto-sync from $HOSTNAME]: " msg - do_push "${msg:-Auto-sync from $HOSTNAME}" - fi - fi -} - -do_watch() { - echo -e "${BLUE}==>${NC} Starting sync daemon..." - echo -e "${DIM}Press Ctrl+C to stop${NC}" - echo - - local interval="${1:-300}" # Default 5 minutes - - log_sync "watch_start" "interval=${interval}s" - - while true; do - local timestamp=$(date '+%H:%M:%S') - - if has_remote_changes; then - echo -e "[$timestamp] ${YELLOW}↓${NC} Remote changes detected, pulling..." - do_pull - fi - - if has_local_changes; then - echo -e "[$timestamp] ${YELLOW}↑${NC} Local changes detected" - # In watch mode, auto-commit with timestamp - do_push "Auto-sync: $(date '+%Y-%m-%d %H:%M') from $HOSTNAME" - fi - - echo -e "[$timestamp] ${DIM}Sleeping ${interval}s...${NC}" - sleep "$interval" - done -} - -do_auto() { - # Quick check for shell startup - minimal output - cd "$DOTFILES_DIR" 2>/dev/null || return 0 - - git fetch origin --quiet 2>/dev/null || return 0 - - local behind=$(git rev-list HEAD..origin/${DOTFILES_BRANCH} --count 2>/dev/null || echo 0) - - if [[ $behind -gt 0 ]]; then - echo -e "${YELLOW}⚠ Dotfiles: $behind update(s) available${NC}" - echo -e " Run: ${CYAN}dfpull${NC} or ${CYAN}dotfiles-sync.sh --pull${NC}" - fi - - if has_local_changes; then - local changed=$(git status --short 2>/dev/null | wc -l | tr -d ' ') - echo -e "${YELLOW}⚠ Dotfiles: $changed local change(s) not pushed${NC}" - echo -e " Run: ${CYAN}dfpush${NC} or ${CYAN}dotfiles-sync.sh --push${NC}" + + if [[ $local_commits -eq 0 ]] && [[ $remote_commits -eq 0 ]]; then + print_success "In sync with remote" fi } show_diff() { - cd "$DOTFILES_DIR" - - echo -e "${CYAN}Local changes:${NC}" - echo - - if command -v delta &>/dev/null; then - git diff | delta - elif command -v diff-so-fancy &>/dev/null; then - git diff | diff-so-fancy + print_section "Local Changes" + + cd "$DOTFILES_HOME" + + if git status --porcelain | grep -q .; then + print_status "Modified files:" + git status --porcelain | sed 's/^/ /' else - git diff --color + print_success "No local changes" fi } -show_log() { - local count="${1:-20}" - - if [[ -f "$SYNC_LOG_FILE" ]]; then - echo -e "${CYAN}Sync history (last $count entries):${NC}" - echo - tail -n "$count" "$SYNC_LOG_FILE" | while IFS='|' read -r timestamp host action details; do - echo -e "${DIM}$timestamp${NC} ${CYAN}$host${NC} ${GREEN}$action${NC} $details" - done +pull_changes() { + print_section "Pulling Changes" + + cd "$DOTFILES_HOME" + + print_status "Fetching from remote..." + git fetch origin + + local status=$(get_sync_status) + local remote_commits="${status#*:}" + + if [[ $remote_commits -gt 0 ]]; then + print_status "Pulling $remote_commits remote commit(s)..." + git pull origin + print_success "Changes pulled" else - echo "No sync history yet." + print_success "Already up to date" fi } -show_conflicts() { - cd "$DOTFILES_DIR" - - local conflicts=$(git diff --name-only --diff-filter=U 2>/dev/null) - - if [[ -n "$conflicts" ]]; then - echo -e "${RED}Merge conflicts:${NC}" - echo "$conflicts" | while read -r file; do - echo -e " ${RED}✗${NC} $file" - done - echo - echo "Resolve conflicts, then run:" - echo " git add " - echo " git commit" - else - echo -e "${GREEN}✓${NC} No merge conflicts" +push_changes() { + print_section "Pushing Changes" + + cd "$DOTFILES_HOME" + + if ! git status --porcelain | grep -q .; then + print_warning "No local changes to push" + return fi + + print_status "Staging changes..." + git add -A + + print_status "Enter commit message (or press Ctrl+C to cancel):" + read -p " > " commit_msg + + if [[ -z "$commit_msg" ]]; then + print_error "Commit cancelled" + return 1 + fi + + print_status "Committing: $commit_msg" + git commit -m "$commit_msg" + + print_status "Pushing to remote..." + git push origin + + print_success "Changes pushed" +} + +auto_sync() { + print_section "Auto-Sync" + + cd "$DOTFILES_HOME" + + # Pull remote changes + print_status "Pulling from remote..." + git fetch origin + + if git status --porcelain | grep -q .; then + print_status "Resolving conflicts automatically..." + git pull --strategy=ours + else + git pull origin + fi + + print_success "Auto-sync complete" +} + +watch_sync() { + local interval="${1:-300}" + + print_section "Watch Mode" + print_status "Auto-syncing every $interval seconds" + print_status "Press Ctrl+C to stop" + + while true; do + auto_sync + sleep "$interval" + done } # ============================================================================ # Main # ============================================================================ -show_help() { - echo "Usage: dotfiles-sync.sh [COMMAND] [OPTIONS]" - echo - echo "Commands:" - echo " (none) Interactive sync" - echo " --status Show sync status" - echo " --push [msg] Push local changes" - echo " --pull Pull remote changes" - echo " --watch [sec] Watch and auto-sync (default: 300s)" - echo " --auto Quick check for shell startup" - echo " --diff Show local changes diff" - echo " --log [n] Show sync history" - echo " --conflicts Show merge conflicts" - echo " --help Show this help" - echo - echo "Aliases:" - echo " dfs, dfsync Interactive sync" - echo " dfpush Push local changes" - echo " dfpull Pull remote changes" - echo " dfstatus Show sync status" - echo - echo "Examples:" - echo " dfs # Interactive sync" - echo " dfpush # Push changes" - echo " dotfiles-sync.sh --push 'Added aliases'" - echo " dotfiles-sync.sh --watch 60 # Sync every 60 seconds" - echo -} - main() { - if [[ ! -d "$DOTFILES_DIR/.git" ]]; then - echo -e "${RED}✗${NC} Dotfiles directory is not a git repository: $DOTFILES_DIR" - exit 1 - fi - - case "${1:-}" in - --status|-s) + print_header + + check_git_repo + check_git_config + + case "${1:-status}" in + status) show_status - ;; - --push|-p) - do_push "${2:-}" - ;; - --pull|-l) - do_pull - ;; - --watch|-w) - do_watch "${2:-300}" - ;; - --auto|-a) - do_auto - ;; - --diff|-d) show_diff ;; - --log) - show_log "${2:-20}" + push) + push_changes ;; - --conflicts|-c) - show_conflicts + pull) + pull_changes ;; - --help|-h) - show_help + diff) + show_diff ;; - "") - do_sync + auto) + auto_sync + ;; + watch) + watch_sync "${2:-300}" ;; *) - echo "Unknown command: $1" - show_help + echo "Usage: $0 {status|push|pull|diff|auto|watch [interval]}" + echo "" + echo "Commands:" + echo " status Show sync status (default)" + echo " push Push local changes" + echo " pull Pull remote changes" + echo " diff Show local changes" + echo " auto Automatically sync (pull remote)" + echo " watch [sec] Auto-sync every N seconds (default: 300)" exit 1 ;; esac diff --git a/bin/dotfiles-vault.sh b/bin/dotfiles-vault.sh index fd1b8fe..6335565 100755 --- a/bin/dotfiles-vault.sh +++ b/bin/dotfiles-vault.sh @@ -1,509 +1,334 @@ #!/usr/bin/env bash # ============================================================================ -# Dotfiles Vault - Encrypted Secrets Management -# ============================================================================ -# Securely store and retrieve API keys, tokens, and other secrets -# -# Usage: -# vault set KEY "value" # Store a secret -# vault get KEY # Retrieve a secret -# vault list # List stored keys (not values) -# vault delete KEY # Delete a secret -# vault export [file] # Export encrypted vault -# vault import [file] # Import encrypted vault -# vault shell # Export all secrets to current shell -# -# The vault uses GPG or age for encryption, stored in ~/.dotfiles/vault/ +# Dotfiles Secrets Vault (Arch/CachyOS) # ============================================================================ set -e -# ============================================================================ -# Configuration -# ============================================================================ +readonly VAULT_DIR="${HOME}/.dotfiles/vault" +readonly VAULT_FILE="${VAULT_DIR}/secrets.enc" -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -DOTFILES_CONF="${SCRIPT_DIR}/../dotfiles.conf" -[[ -f "$DOTFILES_CONF" ]] || DOTFILES_CONF="$HOME/.dotfiles/dotfiles.conf" - -if [[ -f "$DOTFILES_CONF" ]]; then - source "$DOTFILES_CONF" -else - DOTFILES_DIR="$HOME/.dotfiles" -fi - -VAULT_DIR="$DOTFILES_DIR/vault" -VAULT_FILE="$VAULT_DIR/secrets.enc" -VAULT_KEYS="$VAULT_DIR/keys.txt" -VAULT_CONFIG="$VAULT_DIR/config" -VAULT_TMP="/tmp/.vault_$$" - -# Encryption backend: gpg or age -VAULT_BACKEND="${VAULT_BACKEND:-auto}" +# Color codes +readonly RED='\033[0;31m' +readonly GREEN='\033[0;32m' +readonly YELLOW='\033[1;33m' +readonly BLUE='\033[0;34m' +readonly CYAN='\033[0;36m' +readonly NC='\033[0m' # ============================================================================ -# Colors +# Print MOTD-style header # ============================================================================ -RED='\033[0;31m' -GREEN='\033[0;32m' -YELLOW='\033[1;33m' -BLUE='\033[0;34m' -CYAN='\033[0;36m' -DIM='\033[2m' -NC='\033[0m' - -# ============================================================================ -# Cleanup -# ============================================================================ - -cleanup() { - [[ -f "$VAULT_TMP" ]] && rm -f "$VAULT_TMP" - [[ -f "${VAULT_TMP}.dec" ]] && rm -f "${VAULT_TMP}.dec" -} - -trap cleanup EXIT - -# ============================================================================ -# Backend Detection -# ============================================================================ - -detect_backend() { - if [[ "$VAULT_BACKEND" == "auto" ]]; then - if command -v age &>/dev/null; then - echo "age" - elif command -v gpg &>/dev/null; then - echo "gpg" - else - echo "" - fi - else - echo "$VAULT_BACKEND" - fi -} - -check_backend() { - local backend=$(detect_backend) +print_header() { + local user="${USER:-root}" + local hostname="${HOSTNAME:-localhost}" + local timestamp=$(date '+%a %b %d %H:%M') - if [[ -z "$backend" ]]; then - echo -e "${RED}✗${NC} No encryption backend found" - echo - echo "Install one of:" - echo " - age: https://github.com/FiloSottile/age" - echo " - gpg: usually pre-installed" - echo - echo "On macOS: brew install age" - echo "On Arch: pacman -S age" - echo "On Ubuntu: apt install age" - exit 1 - fi - - echo "$backend" + echo "" + printf "${CYAN}+ ${NC}%-20s %30s %25s\n" "$user@$hostname" "dotfiles-vault" "$timestamp" + echo "" } # ============================================================================ -# Initialization +# Helper functions # ============================================================================ -init_vault() { - mkdir -p "$VAULT_DIR" - chmod 700 "$VAULT_DIR" - - local backend=$(check_backend) - - # Save config - echo "VAULT_BACKEND=$backend" > "$VAULT_CONFIG" - - if [[ "$backend" == "age" ]]; then - # Generate age key if not exists - if [[ ! -f "$VAULT_DIR/key.txt" ]]; then - echo -e "${BLUE}==>${NC} Generating age encryption key..." - age-keygen -o "$VAULT_DIR/key.txt" 2>/dev/null - chmod 600 "$VAULT_DIR/key.txt" - echo -e "${GREEN}✓${NC} Key generated: $VAULT_DIR/key.txt" - echo -e "${YELLOW}⚠${NC} Back up this key! Without it, you cannot decrypt your secrets." - fi - fi - - # Create empty vault if not exists - if [[ ! -f "$VAULT_FILE" ]]; then - echo "{}" > "$VAULT_TMP" - encrypt_vault "$VAULT_TMP" - rm -f "$VAULT_TMP" - echo -e "${GREEN}✓${NC} Vault initialized" - fi +print_success() { + echo -e "${GREEN}✓${NC} $1" +} + +print_error() { + echo -e "${RED}✗${NC} $1" >&2 +} + +print_section() { + echo "" + echo -e "${BLUE}▶${NC} $1" } # ============================================================================ # Encryption/Decryption # ============================================================================ -encrypt_vault() { - local input="$1" - local backend=$(detect_backend) +get_cipher() { + if command -v age &> /dev/null; then + echo "age" + elif command -v gpg &> /dev/null; then + echo "gpg" + else + print_error "No encryption tool available (install age or gpg)" + exit 1 + fi +} + +init_vault() { + print_section "Initializing Vault" - case "$backend" in - age) - age -e -i "$VAULT_DIR/key.txt" -o "$VAULT_FILE" "$input" - ;; - gpg) - gpg --symmetric --cipher-algo AES256 --batch --yes -o "$VAULT_FILE" "$input" - ;; - esac + mkdir -p "$VAULT_DIR" + chmod 700 "$VAULT_DIR" - chmod 600 "$VAULT_FILE" + if [[ ! -f "$VAULT_FILE" ]]; then + # Create empty encrypted file + echo "{}" | $(get_cipher) > "$VAULT_FILE" + print_success "Vault initialized" + else + print_success "Vault already exists" + fi } decrypt_vault() { - local output="$1" - local backend=$(detect_backend) - if [[ ! -f "$VAULT_FILE" ]]; then - echo "{}" > "$output" - return 0 + echo "{}" + return fi - case "$backend" in + local cipher=$(get_cipher) + + case "$cipher" in age) - age -d -i "$VAULT_DIR/key.txt" -o "$output" "$VAULT_FILE" 2>/dev/null || { - echo "{}" > "$output" - } + age -d -i "$HOME/.age/keys.txt" "$VAULT_FILE" 2>/dev/null || echo "{}" ;; gpg) - gpg --decrypt --batch --quiet -o "$output" "$VAULT_FILE" 2>/dev/null || { - echo "{}" > "$output" - } + gpg --decrypt "$VAULT_FILE" 2>/dev/null || echo "{}" + ;; + esac +} + +encrypt_vault() { + local data="$1" + local cipher=$(get_cipher) + + case "$cipher" in + age) + echo "$data" | age -R "$HOME/.age/keys.txt" > "$VAULT_FILE" + ;; + gpg) + echo "$data" | gpg --encrypt --armor > "$VAULT_FILE" ;; esac } # ============================================================================ -# JSON Helpers (using pure bash for portability) -# ============================================================================ - -# Simple JSON get - works for flat key-value -json_get() { - local json="$1" - local key="$2" - echo "$json" | grep -o "\"$key\"[[:space:]]*:[[:space:]]*\"[^\"]*\"" | sed 's/.*: *"\([^"]*\)".*/\1/' -} - -# Simple JSON set -json_set() { - local json="$1" - local key="$2" - local value="$3" - - # Escape special characters in value - value=$(echo "$value" | sed 's/\\/\\\\/g; s/"/\\"/g') - - if echo "$json" | grep -q "\"$key\""; then - # Update existing - echo "$json" | sed "s|\"$key\"[[:space:]]*:[[:space:]]*\"[^\"]*\"|\"$key\": \"$value\"|" - else - # Add new (simple approach for flat JSON) - if [[ "$json" == "{}" ]]; then - echo "{\"$key\": \"$value\"}" - else - echo "$json" | sed "s/}$/,\"$key\": \"$value\"}/" - fi - fi -} - -# Simple JSON delete -json_delete() { - local json="$1" - local key="$2" - echo "$json" | sed "s/,*\"$key\"[[:space:]]*:[[:space:]]*\"[^\"]*\"//g" | sed 's/{,/{/; s/,}/}/' -} - -# List JSON keys -json_keys() { - local json="$1" - echo "$json" | grep -o '"[^"]*":' | sed 's/"//g; s/://' | sort -} - -# ============================================================================ -# Vault Commands +# Vault operations # ============================================================================ vault_set() { local key="$1" - local value="$2" + local value="${2:-}" - [[ -z "$key" ]] && { echo -e "${RED}✗${NC} Key required"; exit 1; } - - # If no value provided, prompt for it (hidden input) - if [[ -z "$value" ]]; then - echo -n "Enter value for $key: " - read -s value - echo + if [[ -z "$key" ]]; then + print_error "Usage: vault set [value]" + exit 1 fi - [[ -z "$value" ]] && { echo -e "${RED}✗${NC} Value required"; exit 1; } + # Get value from stdin if not provided + if [[ -z "$value" ]]; then + read -s -p "Enter value for $key: " value + echo "" + fi - init_vault + # Decrypt current vault + local current=$(decrypt_vault) - # Decrypt, modify, encrypt - decrypt_vault "${VAULT_TMP}.dec" - local json=$(cat "${VAULT_TMP}.dec") - json=$(json_set "$json" "$key" "$value") - echo "$json" > "${VAULT_TMP}.dec" - encrypt_vault "${VAULT_TMP}.dec" + # Add new key-value pair (using jq if available, otherwise simple replacement) + if command -v jq &> /dev/null; then + local updated=$(echo "$current" | jq --arg k "$key" --arg v "$value" '.[$k] = $v') + else + # Simple fallback without jq + local updated="{\"$key\": \"$value\"}" + fi - echo -e "${GREEN}✓${NC} Stored: $key" + # Encrypt and save + encrypt_vault "$updated" + print_success "Secret stored: $key" } vault_get() { local key="$1" - local silent="${2:-false}" - [[ -z "$key" ]] && { echo -e "${RED}✗${NC} Key required"; exit 1; } - - [[ ! -f "$VAULT_FILE" ]] && { - [[ "$silent" != true ]] && echo -e "${RED}✗${NC} Vault not initialized" + if [[ -z "$key" ]]; then + print_error "Usage: vault get " exit 1 - } + fi - decrypt_vault "${VAULT_TMP}.dec" - local json=$(cat "${VAULT_TMP}.dec") - local value=$(json_get "$json" "$key") + local vault=$(decrypt_vault) - if [[ -n "$value" ]]; then - echo "$value" + if command -v jq &> /dev/null; then + echo "$vault" | jq -r ".\"$key\" // \"\"" | grep -v "^$" else - [[ "$silent" != true ]] && echo -e "${RED}✗${NC} Key not found: $key" >&2 - exit 1 + # Simple grep fallback + echo "$vault" | grep "\"$key\"" | cut -d'"' -f4 fi } vault_list() { - [[ ! -f "$VAULT_FILE" ]] && { echo "Vault is empty"; return 0; } + print_section "Secrets" - decrypt_vault "${VAULT_TMP}.dec" - local json=$(cat "${VAULT_TMP}.dec") - local keys=$(json_keys "$json") + local vault=$(decrypt_vault) - if [[ -z "$keys" ]]; then - echo "Vault is empty" - return 0 + if command -v jq &> /dev/null; then + echo "$vault" | jq -r 'keys[]' | while read key; do + echo -e " ${CYAN}•${NC} $key" + done + else + # Simple fallback + echo "$vault" | grep -o '"[^"]*":' | sed 's/"//g' | sed 's/:$//' | while read key; do + echo -e " ${CYAN}•${NC} $key" + done fi - echo -e "${CYAN}Stored secrets:${NC}" - echo - - while read -r key; do - [[ -n "$key" ]] && echo -e " ${GREEN}●${NC} $key" - done <<< "$keys" - - echo - local count=$(echo "$keys" | grep -c . || echo 0) - echo -e "${DIM}$count secret(s) stored${NC}" + echo "" } vault_delete() { local key="$1" - [[ -z "$key" ]] && { echo -e "${RED}✗${NC} Key required"; exit 1; } - [[ ! -f "$VAULT_FILE" ]] && { echo -e "${RED}✗${NC} Vault not initialized"; exit 1; } - - decrypt_vault "${VAULT_TMP}.dec" - local json=$(cat "${VAULT_TMP}.dec") - - if ! echo "$json" | grep -q "\"$key\""; then - echo -e "${RED}✗${NC} Key not found: $key" + if [[ -z "$key" ]]; then + print_error "Usage: vault delete " exit 1 fi - read -p "Delete secret '$key'? [y/N]: " confirm - [[ ! "$confirm" =~ ^[Yy] ]] && { echo "Cancelled"; exit 0; } + local vault=$(decrypt_vault) - json=$(json_delete "$json" "$key") - echo "$json" > "${VAULT_TMP}.dec" - encrypt_vault "${VAULT_TMP}.dec" + if command -v jq &> /dev/null; then + local updated=$(echo "$vault" | jq "del(.\"$key\")") + else + print_error "jq required for delete operation" + exit 1 + fi - echo -e "${GREEN}✓${NC} Deleted: $key" -} - -vault_export() { - local output="${1:-vault-export.enc}" - - [[ ! -f "$VAULT_FILE" ]] && { echo -e "${RED}✗${NC} Vault not initialized"; exit 1; } - - cp "$VAULT_FILE" "$output" - - echo -e "${GREEN}✓${NC} Exported to: $output" - echo -e "${YELLOW}⚠${NC} This file is encrypted. Keep your key to decrypt it." -} - -vault_import() { - local input="${1:-vault-export.enc}" - - [[ ! -f "$input" ]] && { echo -e "${RED}✗${NC} File not found: $input"; exit 1; } - - init_vault - - # Test if we can decrypt the import - local backend=$(detect_backend) - case "$backend" in - age) - if ! age -d -i "$VAULT_DIR/key.txt" -o /dev/null "$input" 2>/dev/null; then - echo -e "${RED}✗${NC} Cannot decrypt import file with current key" - exit 1 - fi - ;; - gpg) - if ! gpg --decrypt --batch --quiet -o /dev/null "$input" 2>/dev/null; then - echo -e "${RED}✗${NC} Cannot decrypt import file" - exit 1 - fi - ;; - esac - - read -p "This will overwrite existing vault. Continue? [y/N]: " confirm - [[ ! "$confirm" =~ ^[Yy] ]] && { echo "Cancelled"; exit 0; } - - cp "$input" "$VAULT_FILE" - chmod 600 "$VAULT_FILE" - - echo -e "${GREEN}✓${NC} Imported vault" + encrypt_vault "$updated" + print_success "Secret deleted: $key" } vault_shell() { - [[ ! -f "$VAULT_FILE" ]] && { echo -e "${RED}✗${NC} Vault not initialized"; exit 1; } + print_section "Loading secrets into environment" - decrypt_vault "${VAULT_TMP}.dec" - local json=$(cat "${VAULT_TMP}.dec") - local keys=$(json_keys "$json") + local vault=$(decrypt_vault) - echo "# Add this to your shell or source it:" - echo "# eval \$(vault shell)" - echo - - while read -r key; do - if [[ -n "$key" ]]; then - local value=$(json_get "$json" "$key") - echo "export $key=\"$value\"" - fi - done <<< "$keys" + if command -v jq &> /dev/null; then + echo "$vault" | jq -r 'to_entries[] | "export \(.key)=\"\(.value)\""' + else + print_error "jq required for shell export" + exit 1 + fi } -vault_env() { - # Source secrets into current environment (for use in scripts) - [[ ! -f "$VAULT_FILE" ]] && return 0 +vault_export() { + local dest="${1:-.}" - decrypt_vault "${VAULT_TMP}.dec" - local json=$(cat "${VAULT_TMP}.dec") - local keys=$(json_keys "$json") + if [[ -z "$dest" ]]; then + print_error "Usage: vault export " + exit 1 + fi - while read -r key; do - if [[ -n "$key" ]]; then - local value=$(json_get "$json" "$key") - export "$key"="$value" - fi - done <<< "$keys" + if [[ -f "$dest" ]]; then + print_error "File already exists: $dest" + exit 1 + fi + + cp "$VAULT_FILE" "$dest" + chmod 600 "$dest" + print_success "Vault exported to: $dest" +} + +vault_import() { + local src="${1:-}" + + if [[ -z "$src" ]]; then + print_error "Usage: vault import " + exit 1 + fi + + if [[ ! -f "$src" ]]; then + print_error "File not found: $src" + exit 1 + fi + + cp "$src" "$VAULT_FILE" + chmod 600 "$VAULT_FILE" + print_success "Vault imported from: $src" } vault_status() { - echo -e "${CYAN}Vault Status${NC}" - echo + print_section "Vault Status" - local backend=$(detect_backend) - echo -e " Backend: ${GREEN}$backend${NC}" - echo -e " Location: $VAULT_DIR" - - if [[ -f "$VAULT_FILE" ]]; then - local size=$(du -h "$VAULT_FILE" | cut -f1) - echo -e " Vault: ${GREEN}exists${NC} ($size)" - - decrypt_vault "${VAULT_TMP}.dec" - local json=$(cat "${VAULT_TMP}.dec") - local count=$(json_keys "$json" | grep -c . || echo 0) - echo -e " Secrets: $count" - else - echo -e " Vault: ${YELLOW}not initialized${NC}" + if [[ ! -d "$VAULT_DIR" ]]; then + echo -e " ${YELLOW}⚠${NC} Vault not initialized" + return fi - if [[ "$backend" == "age" && -f "$VAULT_DIR/key.txt" ]]; then - echo -e " Key: ${GREEN}present${NC}" + if [[ ! -f "$VAULT_FILE" ]]; then + echo -e " ${YELLOW}⚠${NC} Vault file not found" + return fi + + local size=$(du -h "$VAULT_FILE" | cut -f1) + local modified=$(stat -c %y "$VAULT_FILE" 2>/dev/null | cut -d' ' -f1 || stat -f '%Sm' "$VAULT_FILE" 2>/dev/null) + + echo -e " ${CYAN}Location:${NC} $VAULT_FILE" + echo -e " ${CYAN}Size:${NC} $size" + echo -e " ${CYAN}Modified:${NC} $modified" + echo -e " ${CYAN}Encryption:${NC} $(get_cipher)" + echo -e " ${CYAN}Permissions:${NC} $(stat -c '%a' $VAULT_FILE 2>/dev/null || stat -f '%a' "$VAULT_FILE")" + + echo "" } # ============================================================================ # Main # ============================================================================ -show_help() { - echo "Usage: dotfiles-vault.sh [args]" - echo " vault [args]" - echo - echo "Commands:" - echo " set [value] Store a secret (prompts for value if not given)" - echo " get Retrieve a secret" - echo " list List all keys (not values)" - echo " delete Delete a secret" - echo " export [file] Export encrypted vault" - echo " import Import encrypted vault" - echo " shell Print secrets as export statements" - echo " status Show vault status" - echo " init Initialize vault" - echo " help Show this help" - echo - echo "Aliases:" - echo " vault Main command (alias for dotfiles-vault.sh)" - echo " vls List secrets" - echo " vget Get secret" - echo " vset Set secret" - echo - echo "Examples:" - echo " vault set GITHUB_TOKEN ghp_xxxxxxxxxxxx" - echo " vault set AWS_SECRET_KEY # Will prompt for value" - echo " vget GITHUB_TOKEN" - echo " vls" - echo " eval \$(vault shell) # Export all to current shell" - echo - echo "The vault uses ${CYAN}age${NC} or ${CYAN}gpg${NC} for encryption." - echo "Secrets are stored in: $VAULT_DIR" -} - main() { - case "${1:-}" in - set|s) - vault_set "$2" "$3" + print_header + + # Initialize vault if not exists + if [[ ! -d "$VAULT_DIR" ]]; then + init_vault + fi + + case "${1:-list}" in + init) + init_vault ;; - get|g) + set) + vault_set "$2" "${3:-}" + ;; + get) vault_get "$2" ;; - list|ls|l) + list|ls) vault_list ;; - delete|del|rm) + delete|rm) vault_delete "$2" ;; + shell) + vault_shell + ;; export) vault_export "$2" ;; import) vault_import "$2" ;; - shell|env) - vault_shell - ;; - status|st) + status) vault_status ;; - init) - init_vault - ;; - help|--help|-h) - show_help - ;; - "") - show_help - ;; *) - echo "Unknown command: $1" - echo "Run 'vault help' for usage" + echo "Usage: $0 {init|set|get|list|delete|shell|export|import|status}" + echo "" + echo "Commands:" + echo " init Initialize vault" + echo " set [value] Store secret (prompts if value omitted)" + echo " get Retrieve secret" + echo " list List all keys" + echo " delete Delete secret" + echo " shell Print secrets as export statements" + echo " export Backup vault (encrypted)" + echo " import Restore vault from backup" + echo " status Show vault information" exit 1 ;; esac diff --git a/install.sh b/install.sh index 59b4c80..0d66a60 100755 --- a/install.sh +++ b/install.sh @@ -127,10 +127,13 @@ NC='\033[0m' # ============================================================================ print_header() { - echo -e "\n${BLUE}╔════════════════════════════════════════════════════════════╗${NC}" - echo -e "${BLUE}║${NC} Dotfiles Installation ${CYAN}v${DOTFILES_VERSION}${NC} (Arch/CachyOS) ${BLUE}║${NC}" - echo -e "${BLUE}║${NC} Repo: ${DOTFILES_GITHUB_USER}/${DOTFILES_REPO_NAME} ${BLUE}║${NC}" - echo -e "${BLUE}╚════════════════════════════════════════════════════════════╝${NC}\n" + local user="${USER:-root}" + local hostname="${HOSTNAME:-localhost}" + local timestamp=$(date '+%a %b %d %H:%M') + + echo "" + printf "${CYAN}+ ${NC}%-20s %30s %25s\n" "$user@$hostname" "install.sh" "$timestamp" + echo "" } print_step() {