"Update"
This commit is contained in:
@@ -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 "$@"
|
||||
|
||||
@@ -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<filled; i++)); do
|
||||
filled_bar+="█"
|
||||
done
|
||||
for ((i=0; i<empty; i++)); do
|
||||
empty_bar+="░"
|
||||
done
|
||||
|
||||
# Use echo -ne to interpret escape sequences
|
||||
echo -ne "\033[0;32m${filled_bar}\033[2m${empty_bar}\033[0m"
|
||||
}
|
||||
|
||||
show_dashboard() {
|
||||
clear
|
||||
print_section "Command History Dashboard"
|
||||
|
||||
local total=$(get_command_count)
|
||||
local unique=$(get_unique_commands)
|
||||
local total=$(get_history | wc -l)
|
||||
local unique=$(get_history | sort | uniq | wc -l)
|
||||
|
||||
echo -e "${BLUE}╔═══════════════════════════════════════════════════════════════════╗${NC}"
|
||||
echo -e "${BLUE}║${NC} ${BOLD}Shell Analytics Dashboard${NC} ${BLUE}║${NC}"
|
||||
echo -e "${BLUE}╚═══════════════════════════════════════════════════════════════════╝${NC}"
|
||||
echo
|
||||
echo ""
|
||||
echo -e " ${CYAN}Total Commands:${NC} $total"
|
||||
echo -e " ${CYAN}Unique Commands:${NC} $unique"
|
||||
echo ""
|
||||
|
||||
# Summary stats
|
||||
echo -e "${CYAN}Overview${NC}"
|
||||
echo -e " Total commands: ${GREEN}${total}${NC}"
|
||||
echo -e " Unique commands: ${GREEN}${unique}${NC}"
|
||||
echo -e " History file: ${DIM}$(get_history_file)${NC}"
|
||||
echo
|
||||
print_section "Top 15 Commands"
|
||||
|
||||
# Top commands
|
||||
echo -e "${CYAN}Top Commands${NC}"
|
||||
echo
|
||||
|
||||
local max_count=$(top_commands 1 | awk '{print $1}')
|
||||
|
||||
top_commands 10 | while read -r count cmd; do
|
||||
printf " %-12s %5d " "$cmd" "$count"
|
||||
draw_bar "$count" "$max_count" 25
|
||||
echo
|
||||
get_history | awk '{print $1}' | sort | uniq -c | sort -rn | head -15 | while read count cmd; do
|
||||
local percent=$((count * 100 / total))
|
||||
local bar_length=$((percent / 5))
|
||||
local bar=$(printf '█%.0s' $(seq 1 $bar_length))
|
||||
printf " %-20s ${GREEN}%5d${NC} ${MAGENTA}%3d%%${NC} ${bar}\n" "$cmd" "$count" "$percent"
|
||||
done
|
||||
|
||||
echo
|
||||
echo ""
|
||||
}
|
||||
|
||||
show_top_n() {
|
||||
local n="${1:-20}"
|
||||
|
||||
# Git breakdown (if git is in top commands)
|
||||
if parse_zsh_history | grep -q '^git '; then
|
||||
echo -e "${CYAN}Git Commands${NC}"
|
||||
echo
|
||||
|
||||
git_commands | head -5 | while read -r count cmd; do
|
||||
printf " %-20s %5d\n" "$cmd" "$count"
|
||||
done
|
||||
echo
|
||||
fi
|
||||
print_section "Top $n Commands"
|
||||
|
||||
# Directory usage
|
||||
echo -e "${CYAN}Most Visited Directories${NC}"
|
||||
echo
|
||||
local total=$(get_history | wc -l)
|
||||
|
||||
most_used_dirs | head -5 | while read -r count dir; do
|
||||
printf " %-35s %5d\n" "$dir" "$count"
|
||||
get_history | awk '{print $1}' | sort | uniq -c | sort -rn | head -"$n" | \
|
||||
while read count cmd; do
|
||||
local percent=$((count * 100 / total))
|
||||
printf " ${YELLOW}%4d${NC} %-30s ${CYAN}%3d%%${NC}\n" "$count" "$cmd" "$percent"
|
||||
done
|
||||
echo
|
||||
|
||||
# Quick suggestions
|
||||
echo -e "${CYAN}💡 Quick Tips${NC}"
|
||||
echo
|
||||
|
||||
# Find most-typed long command
|
||||
local long_cmd=$(parse_zsh_history | awk 'length($0) > 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
|
||||
|
||||
@@ -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 <file>"
|
||||
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
|
||||
|
||||
@@ -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 <key> [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 <key>"
|
||||
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 <key>"
|
||||
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 <filename>"
|
||||
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 <filename>"
|
||||
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 <command> [args]"
|
||||
echo " vault <command> [args]"
|
||||
echo
|
||||
echo "Commands:"
|
||||
echo " set <key> [value] Store a secret (prompts for value if not given)"
|
||||
echo " get <key> Retrieve a secret"
|
||||
echo " list List all keys (not values)"
|
||||
echo " delete <key> Delete a secret"
|
||||
echo " export [file] Export encrypted vault"
|
||||
echo " import <file> 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 <key> Get secret"
|
||||
echo " vset <key> 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 <key> [value] Store secret (prompts if value omitted)"
|
||||
echo " get <key> Retrieve secret"
|
||||
echo " list List all keys"
|
||||
echo " delete <key> Delete secret"
|
||||
echo " shell Print secrets as export statements"
|
||||
echo " export <file> Backup vault (encrypted)"
|
||||
echo " import <file> Restore vault from backup"
|
||||
echo " status Show vault information"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
11
install.sh
11
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() {
|
||||
|
||||
Reference in New Issue
Block a user