diff --git a/.github/workflows/shellcheck.yml b/.github/workflows/shellcheck.yml index dfc6122..099b054 100644 --- a/.github/workflows/shellcheck.yml +++ b/.github/workflows/shellcheck.yml @@ -1,24 +1,18 @@ -# This is a basic workflow to help you get started with Actions - name: ShellCheck -# Controls when the workflow will run on: - # Triggers the workflow on push or pull request events but only for the "main" branch push: branches: ["main"] pull_request: branches: ["main"] - - # Allows you to run this workflow manually from the Actions tab workflow_dispatch: jobs: shellcheck: - name: Shellcheck + name: ShellCheck runs-on: ubuntu-latest steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - name: Run ShellCheck uses: ludeeus/action-shellcheck@master with: diff --git a/.gitignore b/.gitignore index 8385d4a..6f4e211 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,13 @@ +# Secrets and local config (never commit) +.zshrc.local +*.local +.env +.envrc +.envrc.local +__pycache__/ +node_modules/ +.node_version + # Compiled *.elc diff --git a/bootstrap.sh b/bootstrap.sh new file mode 100755 index 0000000..f191949 --- /dev/null +++ b/bootstrap.sh @@ -0,0 +1,179 @@ +#!/usr/bin/env bash +# bootstrap.sh — One-command dotfiles setup with feature selection +# Usage: bash bootstrap.sh [--all | --module | --list | --non-interactive] +set -euo pipefail + +DOTFILES_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +export DOTFILES_ROOT + +# shellcheck disable=SC1091 +. "${DOTFILES_ROOT}/modules/lib.sh" +# shellcheck disable=SC1091 +. "${DOTFILES_ROOT}/modules/core.sh" +# shellcheck disable=SC1091 +. "${DOTFILES_ROOT}/modules/shell.sh" +# shellcheck disable=SC1091 +. "${DOTFILES_ROOT}/modules/dev.sh" +# shellcheck disable=SC1091 +. "${DOTFILES_ROOT}/modules/node.sh" +# shellcheck disable=SC1091 +. "${DOTFILES_ROOT}/modules/docker.sh" + +# ── Feature registry ─────────────────────────────────────────────────────────── +FEATURE_NAMES=("core" "shell" "dev" "node" "docker") +FEATURE_LABELS=( + "Core Tools zsh, tmux, lsd, emacs, htop, neofetch, yazi" + "Shell starship, fzf, zoxide, atuin" + "Dev Tools gh, ripgrep, direnv, bat, delta, fd, jq, lazygit" + "Node.js fnm + Node LTS + commitizen" + "Docker Docker CE + Docker Compose" +) +# Default: core/shell/dev ON, node/docker OFF +FEATURE_SELECTED=(1 1 1 0 0) + +# ── Banner ───────────────────────────────────────────────────────────────────── +print_banner() { + detect_os + printf "\n" + printf "${BOLD}╔══════════════════════════════════════════════════════════════╗${RESET}\n" + printf "${BOLD}║ dotfiles bootstrap — long-910/dotfiles ║${RESET}\n" + printf "${BOLD}╚══════════════════════════════════════════════════════════════╝${RESET}\n" + printf " OS: ${GREEN}%s${RESET} Package manager: ${GREEN}%s${RESET}\n\n" "$OS" "$PKG_MGR" +} + +# ── Interactive menu ─────────────────────────────────────────────────────────── +print_menu() { + printf " ${BOLD}機能ブロック選択 / Feature Selection${RESET}\n" + printf " %s\n" "────────────────────────────────────────────────────────────" + local i=0 + for name in "${FEATURE_NAMES[@]}"; do + local mark=" " + [ "${FEATURE_SELECTED[$i]}" = "1" ] && mark="x" + printf " [%s] %d. %s\n" "$mark" "$((i+1))" "${FEATURE_LABELS[$i]}" + i=$((i + 1)) + done + printf " %s\n" "────────────────────────────────────────────────────────────" + printf " 番号で切替 / [a]全選択 / [n]全解除 / [Enter]インストール / [q]終了\n" + printf " > " +} + +run_menu() { + while true; do + printf "\033[2J\033[H" # clear screen + print_banner + print_menu + read -r input + + case "$input" in + q|Q|quit|exit) + info "Aborted." + exit 0 + ;; + a|A) + FEATURE_SELECTED=(1 1 1 1 1) + ;; + n|N) + FEATURE_SELECTED=(0 0 0 0 0) + ;; + "") + break + ;; + *) + # Toggle by number(s) + # shellcheck disable=SC2086 + for tok in $input; do + if printf '%s' "$tok" | grep -qE '^[1-5]$'; then + local idx=$((tok - 1)) + if [ "${FEATURE_SELECTED[$idx]}" = "1" ]; then + FEATURE_SELECTED[$idx]=0 + else + FEATURE_SELECTED[$idx]=1 + fi + fi + done + ;; + esac + done +} + +# ── Install selected features ────────────────────────────────────────────────── +install_selected() { + local i=0 + for name in "${FEATURE_NAMES[@]}"; do + if [ "${FEATURE_SELECTED[$i]}" = "1" ]; then + case "$name" in + core) install_core ;; + shell) install_shell ;; + dev) install_dev ;; + node) install_node ;; + docker) install_docker ;; + esac + fi + i=$((i + 1)) + done +} + +# ── CLI argument handling ────────────────────────────────────────────────────── +list_modules() { + printf "Available modules:\n" + local i=0 + for name in "${FEATURE_NAMES[@]}"; do + printf " %s — %s\n" "$name" "${FEATURE_LABELS[$i]}" + i=$((i + 1)) + done +} + +# ── Main ─────────────────────────────────────────────────────────────────────── +main() { + detect_os + + case "${1:-}" in + --all) + FEATURE_SELECTED=(1 1 1 1 1) + print_banner + install_selected + ;; + --module) + local mod="${2:-}" + if [ -z "$mod" ]; then + error "--module requires a name. Use --list for available modules." + exit 1 + fi + print_banner + FEATURE_SELECTED=(0 0 0 0 0) + local i=0 + for name in "${FEATURE_NAMES[@]}"; do + [ "$name" = "$mod" ] && FEATURE_SELECTED[$i]=1 + i=$((i + 1)) + done + install_selected + ;; + --list) + list_modules + exit 0 + ;; + --non-interactive) + print_banner + install_selected + ;; + "") + print_banner + run_menu + install_selected + ;; + *) + error "Unknown option: ${1}" + printf "Usage: %s [--all | --module | --list | --non-interactive]\n" "$0" + exit 1 + ;; + esac + + # Always append the sourcing block to ~/.zshrc + setup_zshrc_sourcing + + printf "\n" + success "Bootstrap complete!" + printf "\n Run: ${BOLD}source ~/.zshrc${RESET} to apply changes in your current shell.\n\n" +} + +main "$@" diff --git a/config/atuin/config.toml b/config/atuin/config.toml new file mode 100644 index 0000000..70adfa8 --- /dev/null +++ b/config/atuin/config.toml @@ -0,0 +1,27 @@ +# config/atuin/config.toml — Atuin shell history configuration + +# Search mode: fuzzy matching +search_mode = "fuzzy" + +# Keybinding style: emacs (consistent with .zshrc bindkey -e) +keymap_mode = "emacs" + +# Filter mode: search all history (not just current session/directory) +filter_mode = "global" +filter_mode_shell_up_key_binding = "global" + +# Do not sync automatically (opt-in sync only) +auto_sync = false +sync_frequency = "0" + +# Show stats in search UI +show_preview = true + +# Inline mode (results below current line, not full-screen) +inline_height = 20 + +# Style +style = "compact" + +# Exit immediately on unique match with Enter +exit_mode = "return-original" diff --git a/config/starship.toml b/config/starship.toml new file mode 100644 index 0000000..92d3850 --- /dev/null +++ b/config/starship.toml @@ -0,0 +1,104 @@ +# config/starship.toml — Starship prompt configuration +# Two-line prompt matching existing .zshrc style + +format = """ +$username\ +$hostname\ +[┌──(](bold green)$username[@](bold green)$hostname[)─[](bold green)$directory[$git_branch$git_status](bold green)[]\n](bold green)\ +$git_state\ +$nodejs$python$rust$golang\ +$cmd_duration\ +$line_break\ +[└─](bold green)$character""" + +# Prompt character +[character] +success_symbol = "[❯](bold blue)" +error_symbol = "[❯](bold red)" +vimcmd_symbol = "[❮](bold green)" + +# Directory +[directory] +style = "bold reset" +truncation_length = 4 +truncate_to_repo = true +format = "[$path]($style)[$read_only]($read_only_style)" + +# Username — show only in SSH sessions +[username] +show_always = false +format = "[$user]($style)" +style_user = "bold blue" + +# Hostname — show only in SSH sessions +[hostname] +ssh_only = true +format = "[@$hostname]($style)" +style = "bold blue" + +# Git branch +[git_branch] +format = " [$symbol$branch(:$remote_branch)]($style)" +style = "bold purple" +symbol = " " + +# Git status +[git_status] +format = "[$all_status$ahead_behind]($style) " +style = "bold red" +conflicted = "⚡" +ahead = "⇡${count}" +behind = "⇣${count}" +diverged = "⇡${ahead_count}⇣${behind_count}" +untracked = "?" +modified = "!" +staged = "+" +renamed = "»" +deleted = "✘" + +# Git state (rebase, merge, etc.) +[git_state] +format = "[$state($progress_current/$progress_total)]($style) " +style = "bold yellow" + +# Node.js — show only when relevant files present +[nodejs] +format = "[ $version]($style) " +style = "bold green" +detect_files = ["package.json", ".nvmrc", ".node-version"] +detect_folders = ["node_modules"] +detect_extensions = ["js", "mjs", "cjs", "ts"] + +# Python — show only when relevant +[python] +format = "[ $version]($style) " +style = "bold yellow" +detect_extensions = ["py"] +detect_files = ["requirements.txt", "pyproject.toml", "setup.py", "Pipfile"] + +# Rust +[rust] +format = "[ $version]($style) " +style = "bold red" +detect_extensions = ["rs"] +detect_files = ["Cargo.toml"] + +# Go +[golang] +format = "[ $version]($style) " +style = "bold cyan" +detect_extensions = ["go"] +detect_files = ["go.mod"] + +# Command duration — show for commands > 2s +[cmd_duration] +min_time = 2000 +format = "[ $duration]($style) " +style = "bold yellow" + +# Disable modules we don't use +[package] +disabled = true + +[time] +disabled = true diff --git a/dotfiles/.gitmessage b/dotfiles/.gitmessage new file mode 100644 index 0000000..a3c4274 --- /dev/null +++ b/dotfiles/.gitmessage @@ -0,0 +1,28 @@ +# (): +# |←----- 50 chars max -------------------------------------------→| +# +# Types: +# feat A new feature +# fix A bug fix +# docs Documentation only changes +# style Formatting, missing semicolons (no logic change) +# refactor Code change that is neither fix nor feature +# test Adding or updating tests +# chore Build process, dependency updates, tooling +# perf Performance improvements +# ci CI/CD configuration changes +# build Build system changes +# +# Scope (optional): component or area affected (e.g., auth, api, ui) +# +# Subject: imperative mood, lowercase, no period at end +# +# ── Body (optional, 72 chars/line) ──────────────────────────────────────────── +# +# Explain WHY the change was made and what problem it solves. +# Not HOW (the code shows that). +# +# ── Footer (optional) ───────────────────────────────────────────────────────── +# +# Reference issues: Closes #123, Fixes #456 +# Breaking changes: BREAKING CHANGE: diff --git a/dotfiles/.zshrc b/dotfiles/.zshrc index 90d8b42..a906ec2 100644 --- a/dotfiles/.zshrc +++ b/dotfiles/.zshrc @@ -151,3 +151,14 @@ function yy() { fi rm -f -- "$tmp" } + +# === dotfiles modular config === +# Load per-tool configs from ~/.zshrc.d/ (added by bootstrap.sh) +if [ -d "$HOME/.zshrc.d" ]; then + for _f in "$HOME/.zshrc.d"/*.zsh; do + [ -r "$_f" ] && . "$_f" + done + unset _f +fi +# Load machine-specific / secret config (never committed) +[ -f "$HOME/.zshrc.local" ] && . "$HOME/.zshrc.local" diff --git a/dotfiles/.zshrc.d/atuin.zsh b/dotfiles/.zshrc.d/atuin.zsh new file mode 100644 index 0000000..94e9e8d --- /dev/null +++ b/dotfiles/.zshrc.d/atuin.zsh @@ -0,0 +1,6 @@ +# ~/.zshrc.d/atuin.zsh — Atuin shell history +# --disable-up-arrow keeps default ↑ behavior; use Ctrl-R for atuin search + +if command -v atuin >/dev/null 2>&1; then + eval "$(atuin init zsh --disable-up-arrow)" +fi diff --git a/dotfiles/.zshrc.d/commit.zsh b/dotfiles/.zshrc.d/commit.zsh new file mode 100644 index 0000000..d074c33 --- /dev/null +++ b/dotfiles/.zshrc.d/commit.zsh @@ -0,0 +1,69 @@ +# ~/.zshrc.d/commit.zsh — Git aliases and conventional commit helper + +if command -v git >/dev/null 2>&1; then + +# ── Status / diff ────────────────────────────────────────────────────────────── +alias gs='git status -sb' +alias gd='git diff' +alias gds='git diff --staged' + +# ── Staging / commit ─────────────────────────────────────────────────────────── +alias ga='git add' +alias gc='git commit' +alias gcm='git commit -m' + +# ── Branch / checkout ───────────────────────────────────────────────────────── +alias gco='git checkout' +alias gcb='git checkout -b' +alias gbr='git branch -vv' + +# ── Push / pull ──────────────────────────────────────────────────────────────── +alias gp='git push' +alias gpf='git push --force-with-lease' +alias gpl='git pull --rebase' + +# ── Log ─────────────────────────────────────────────────────────────────────── +alias glog='git log --oneline --decorate -20' +alias glogg='git log --oneline --decorate --graph --all -30' + +# ── gcommit: conventional commit helper ─────────────────────────────────────── +# Usage: gcommit "" [scope] +# Example: gcommit feat "add dark mode" ui +gcommit() { + local type="${1:?Usage: gcommit [scope]}" + local desc="${2:?Usage: gcommit [scope]}" + local scope="${3:-}" + local subject + if [ -n "$scope" ]; then + subject="${type}(${scope}): ${desc}" + else + subject="${type}: ${desc}" + fi + git commit -m "$subject" +} + +# ── newproject: create project from template ────────────────────────────────── +# Usage: newproject (type: node | python) +newproject() { + local tmpl_type="${1:?Usage: newproject (node|python)}" + local name="${2:?Usage: newproject }" + local tmpl_dir + # Resolve template dir relative to this file's repo location + # Fall back gracefully if DOTFILES_ROOT not set + tmpl_dir="${DOTFILES_ROOT:-${HOME}/.dotfiles}/templates/${tmpl_type}" + if [ ! -d "$tmpl_dir" ]; then + echo "Template not found: ${tmpl_dir}" >&2 + return 1 + fi + mkdir -p "$name" + cp -r "${tmpl_dir}/." "$name/" + # Replace placeholder PROJECT_NAME in files + find "$name" -type f | while read -r f; do + sed -i "s/PROJECT_NAME/${name}/g" "$f" 2>/dev/null || true + done + cd "$name" || return + git init + echo "Created ${tmpl_type} project '${name}'" +} + +fi # command -v git diff --git a/dotfiles/.zshrc.d/direnv.zsh b/dotfiles/.zshrc.d/direnv.zsh new file mode 100644 index 0000000..e12cc05 --- /dev/null +++ b/dotfiles/.zshrc.d/direnv.zsh @@ -0,0 +1,5 @@ +# ~/.zshrc.d/direnv.zsh — direnv per-directory environment + +if command -v direnv >/dev/null 2>&1; then + eval "$(direnv hook zsh)" +fi diff --git a/dotfiles/.zshrc.d/docker.zsh b/dotfiles/.zshrc.d/docker.zsh new file mode 100644 index 0000000..2adb966 --- /dev/null +++ b/dotfiles/.zshrc.d/docker.zsh @@ -0,0 +1,49 @@ +# ~/.zshrc.d/docker.zsh — Docker aliases and helpers + +if command -v docker >/dev/null 2>&1; then + alias d='docker' + alias di='docker images' + alias dps='docker ps' + alias dpsa='docker ps -a' + + # docker compose: prefer plugin form + if docker compose version >/dev/null 2>&1; then + alias dc='docker compose' + elif command -v docker-compose >/dev/null 2>&1; then + alias dc='docker-compose' + fi + + # dex [cmd]: exec into container (defaults to bash/sh) + dex() { + local container="${1:?Usage: dex [cmd]}" + local cmd="${2:-}" + if [ -z "$cmd" ]; then + docker exec -it "$container" bash 2>/dev/null || docker exec -it "$container" sh + else + docker exec -it "$container" "$cmd" + fi + } + + # dlogs [lines]: tail logs + dlogs() { + docker logs -f --tail "${2:-100}" "${1:?Usage: dlogs [lines]}" + } + + # dclean: remove stopped containers, dangling images, unused volumes + dclean() { + docker container prune -f + docker image prune -f + docker volume prune -f + } + + # dsh : open shell + dsh() { + dex "${1:?Usage: dsh }" + } + + # dip : show container IP + dip() { + docker inspect -f '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' \ + "${1:?Usage: dip }" + } +fi diff --git a/dotfiles/.zshrc.d/fzf.zsh b/dotfiles/.zshrc.d/fzf.zsh new file mode 100644 index 0000000..0aef048 --- /dev/null +++ b/dotfiles/.zshrc.d/fzf.zsh @@ -0,0 +1,51 @@ +# ~/.zshrc.d/fzf.zsh — fzf keybindings and helpers + +# Source fzf shell integration (try common locations) +if command -v fzf >/dev/null 2>&1; then + # fzf >= 0.48 has built-in --zsh flag + if fzf --version 2>/dev/null | awk -F'[. ]' '{exit ($1 > 0 || $2 >= 48) ? 0 : 1}' 2>/dev/null; then + eval "$(fzf --zsh)" + else + for _fzf_src in \ + "${HOME}/.fzf.zsh" \ + /usr/share/doc/fzf/examples/key-bindings.zsh \ + /opt/homebrew/opt/fzf/shell/key-bindings.zsh; do + [ -r "$_fzf_src" ] && source "$_fzf_src" && break + done + unset _fzf_src + fi + + # Use ripgrep as default command if available (respects .gitignore) + if command -v rg >/dev/null 2>&1; then + export FZF_DEFAULT_COMMAND='rg --files --hidden --follow --glob "!.git"' + export FZF_CTRL_T_COMMAND="$FZF_DEFAULT_COMMAND" + fi + + # Preview with bat (or cat fallback) + if command -v bat >/dev/null 2>&1; then + _FZF_PREVIEW_CMD='bat --color=always --style=numbers --line-range=:200 {}' + elif command -v batcat >/dev/null 2>&1; then + _FZF_PREVIEW_CMD='batcat --color=always --style=numbers --line-range=:200 {}' + else + _FZF_PREVIEW_CMD='cat {}' + fi + + export FZF_DEFAULT_OPTS="--height 40% --layout=reverse --border --preview-window=right:50%:wrap" + export FZF_CTRL_T_OPTS="--preview '${_FZF_PREVIEW_CMD}'" + unset _FZF_PREVIEW_CMD + + # fcd: interactive directory jump + fcd() { + local dir + dir=$(find "${1:-.}" -type d 2>/dev/null | fzf +m --preview 'ls -la {}') && cd "$dir" || return + } + + # fkill: fuzzy kill process + fkill() { + local pid + pid=$(ps -ef | sed 1d | fzf -m | awk '{print $2}') + if [ -n "$pid" ]; then + echo "$pid" | xargs kill -"${1:-9}" + fi + } +fi diff --git a/dotfiles/.zshrc.d/node.zsh b/dotfiles/.zshrc.d/node.zsh new file mode 100644 index 0000000..015c097 --- /dev/null +++ b/dotfiles/.zshrc.d/node.zsh @@ -0,0 +1,5 @@ +# ~/.zshrc.d/node.zsh — fnm Node.js version manager (lazy-load) + +if command -v fnm >/dev/null 2>&1; then + eval "$(fnm env --use-on-cd)" +fi diff --git a/dotfiles/.zshrc.d/starship.zsh b/dotfiles/.zshrc.d/starship.zsh new file mode 100644 index 0000000..ea350d6 --- /dev/null +++ b/dotfiles/.zshrc.d/starship.zsh @@ -0,0 +1,5 @@ +# ~/.zshrc.d/starship.zsh — Starship prompt + +if command -v starship >/dev/null 2>&1; then + eval "$(starship init zsh)" +fi diff --git a/dotfiles/.zshrc.d/zoxide.zsh b/dotfiles/.zshrc.d/zoxide.zsh new file mode 100644 index 0000000..d8d1c80 --- /dev/null +++ b/dotfiles/.zshrc.d/zoxide.zsh @@ -0,0 +1,5 @@ +# ~/.zshrc.d/zoxide.zsh — zoxide smart cd + +if command -v zoxide >/dev/null 2>&1; then + eval "$(zoxide init zsh)" +fi diff --git a/dotfiles/.zshrc.local.example b/dotfiles/.zshrc.local.example new file mode 100644 index 0000000..eb73af0 --- /dev/null +++ b/dotfiles/.zshrc.local.example @@ -0,0 +1,37 @@ +# .zshrc.local.example — Machine-specific and secret configuration +# +# Copy this file to ~/.zshrc.local (never committed to git) +# It is sourced automatically if it exists. +# +# cp dotfiles/.zshrc.local.example ~/.zshrc.local + +# ── API Keys (never commit real values) ─────────────────────────────────────── +# export ANTHROPIC_API_KEY="" +# export OPENAI_API_KEY="" +# export GITHUB_TOKEN="" +# export AWS_ACCESS_KEY_ID="" +# export AWS_SECRET_ACCESS_KEY="" +# export AWS_DEFAULT_REGION="ap-northeast-1" + +# ── Proxy settings ───────────────────────────────────────────────────────────── +# export HTTP_PROXY="http://proxy.example.com:8080" +# export HTTPS_PROXY="http://proxy.example.com:8080" +# export NO_PROXY="localhost,127.0.0.1,.internal.example.com" + +# ── Machine-specific PATH additions ─────────────────────────────────────────── +# export PATH="${HOME}/.local/bin:${PATH}" +# export PATH="/opt/custom-tool/bin:${PATH}" + +# ── Custom aliases ───────────────────────────────────────────────────────────── +# alias vpn='sudo openconnect vpn.example.com' +# alias work='cd ~/work/myproject' + +# ── SSH agent ───────────────────────────────────────────────────────────────── +# if [ -z "$SSH_AUTH_SOCK" ]; then +# eval "$(ssh-agent -s)" +# ssh-add ~/.ssh/id_ed25519 +# fi + +# ── Work-specific git config ─────────────────────────────────────────────────── +# export GIT_AUTHOR_EMAIL="you@work.example.com" +# export GIT_COMMITTER_EMAIL="you@work.example.com" diff --git a/modules/core.sh b/modules/core.sh new file mode 100755 index 0000000..71b80f8 --- /dev/null +++ b/modules/core.sh @@ -0,0 +1,108 @@ +#!/usr/bin/env bash +# modules/core.sh — Core tools: zsh, tmux, lsd, emacs, htop, neofetch, yazi + +# shellcheck source=modules/lib.sh +: "${DOTFILES_ROOT:=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)}" +# shellcheck disable=SC1091 +. "${DOTFILES_ROOT}/modules/lib.sh" + +install_core() { + step "Core Tools" + detect_os + + if [ "$PKG_MGR" = "brew" ]; then + _install_core_brew + else + _install_core_apt + fi + + _deploy_core_configs +} + +_install_core_brew() { + local tools=(lsd zsh emacs tmux htop neofetch yazi) + for t in "${tools[@]}"; do + if has_cmd "$t"; then + info "${t} already installed" + else + pkg_install "$t" + fi + done + + # tmux-mem-cpu-load + if ! has_cmd tmux-mem-cpu-load; then + info "Installing tmux-mem-cpu-load..." + brew install tmux-mem-cpu-load + fi +} + +_install_core_apt() { + sudo apt-get update -qq + + # lsd + if ! has_cmd lsd; then + info "Installing lsd..." + local lsd_ver="1.1.5" + local deb="lsd_${lsd_ver}_amd64.deb" + local url="https://github.com/lsd-rs/lsd/releases/download/v${lsd_ver}/${deb}" + local tmp + tmp=$(mktemp -d) + if curl -fsSL "$url" -o "${tmp}/${deb}"; then + sudo dpkg -i "${tmp}/${deb}" || sudo apt-get install -f -y + else + warn "lsd release download failed; trying apt..." + sudo apt-get install -y lsd || warn "lsd not available via apt" + fi + rm -rf "$tmp" + else + info "lsd already installed" + fi + + local apt_tools=(zsh emacs tmux htop neofetch) + for t in "${apt_tools[@]}"; do + if has_cmd "$t"; then + info "${t} already installed" + else + pkg_install "$t" + fi + done + + # yazi: try apt → fallback cargo + if ! has_cmd yazi; then + if apt-cache show yazi >/dev/null 2>&1; then + pkg_install yazi + elif has_cmd cargo; then + info "Installing yazi via cargo..." + cargo install --locked yazi-fm + else + warn "yazi: apt package not found and cargo unavailable; skipping" + fi + else + info "yazi already installed" + fi + + # tmux-mem-cpu-load: build from submodule + if ! has_cmd tmux-mem-cpu-load; then + local tmc_dir="${DOTFILES_ROOT}/tmux-mem-cpu-load" + if [ -d "$tmc_dir" ]; then + info "Building tmux-mem-cpu-load..." + if has_cmd cmake; then + (cd "$tmc_dir" && cmake . && make && sudo make install) + else + sudo apt-get install -y cmake + (cd "$tmc_dir" && cmake . && make && sudo make install) + fi + else + warn "tmux-mem-cpu-load source not found; skipping" + fi + else + info "tmux-mem-cpu-load already installed" + fi +} + +_deploy_core_configs() { + step "Deploying core configs" + deploy_config ".zshrc" "${HOME}/.zshrc" + deploy_config ".tmux.conf" "${HOME}/.tmux.conf" + deploy_config ".emacs.el" "${HOME}/.emacs.el" +} diff --git a/modules/dev.sh b/modules/dev.sh new file mode 100755 index 0000000..fe891a1 --- /dev/null +++ b/modules/dev.sh @@ -0,0 +1,169 @@ +#!/usr/bin/env bash +# modules/dev.sh — Dev tools: gh, ripgrep, direnv, bat, delta, fd, jq, lazygit + +# shellcheck source=modules/lib.sh +: "${DOTFILES_ROOT:=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)}" +# shellcheck disable=SC1091 +. "${DOTFILES_ROOT}/modules/lib.sh" + +install_dev() { + step "Dev Tools" + detect_os + + _install_gh + _install_ripgrep + _install_direnv + _install_bat + _install_delta + _install_fd + _install_jq + _install_lazygit + _setup_git_config + + _deploy_dev_configs +} + +_install_gh() { + if has_cmd gh; then info "gh already installed"; return; fi + info "Installing GitHub CLI..." + if [ "$PKG_MGR" = "brew" ]; then + brew install gh + else + curl -fsSL https://cli.github.com/packages/githubcli-archive-keyring.gpg \ + | sudo dd of=/usr/share/keyrings/githubcli-archive-keyring.gpg + sudo chmod go+r /usr/share/keyrings/githubcli-archive-keyring.gpg + echo "deb [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyrings/githubcli-archive-keyring.gpg] https://cli.github.com/packages stable main" \ + | sudo tee /etc/apt/sources.list.d/github-cli.list > /dev/null + sudo apt-get update -qq + sudo apt-get install -y gh + fi +} + +_install_ripgrep() { + if has_cmd rg; then info "ripgrep already installed"; return; fi + info "Installing ripgrep..." + if [ "$PKG_MGR" = "brew" ]; then + brew install ripgrep + else + sudo apt-get install -y ripgrep + fi +} + +_install_direnv() { + if has_cmd direnv; then info "direnv already installed"; return; fi + info "Installing direnv..." + if [ "$PKG_MGR" = "brew" ]; then + brew install direnv + else + sudo apt-get install -y direnv + fi +} + +_install_bat() { + if has_cmd bat || has_cmd batcat; then info "bat already installed"; return; fi + info "Installing bat..." + if [ "$PKG_MGR" = "brew" ]; then + brew install bat + else + sudo apt-get install -y bat + # On Ubuntu, binary is batcat — create symlink + if has_cmd batcat && ! has_cmd bat; then + mkdir -p "${HOME}/.local/bin" + ln -sf "$(command -v batcat)" "${HOME}/.local/bin/bat" + info "Created bat → batcat symlink in ~/.local/bin" + fi + fi +} + +_install_delta() { + if has_cmd delta; then info "delta already installed"; return; fi + info "Installing git-delta..." + if [ "$PKG_MGR" = "brew" ]; then + brew install git-delta + elif sudo apt-get install -y git-delta 2>/dev/null; then + : + else + local ver="0.17.0" + local deb="git-delta_${ver}_amd64.deb" + local tmp + tmp=$(mktemp -d) + curl -fsSL "https://github.com/dandavison/delta/releases/download/${ver}/${deb}" \ + -o "${tmp}/${deb}" && sudo dpkg -i "${tmp}/${deb}" + rm -rf "$tmp" + fi +} + +_install_fd() { + if has_cmd fd || has_cmd fdfind; then info "fd already installed"; return; fi + info "Installing fd..." + if [ "$PKG_MGR" = "brew" ]; then + brew install fd + else + sudo apt-get install -y fd-find + if has_cmd fdfind && ! has_cmd fd; then + mkdir -p "${HOME}/.local/bin" + ln -sf "$(command -v fdfind)" "${HOME}/.local/bin/fd" + info "Created fd → fdfind symlink in ~/.local/bin" + fi + fi +} + +_install_jq() { + if has_cmd jq; then info "jq already installed"; return; fi + info "Installing jq..." + if [ "$PKG_MGR" = "brew" ]; then + brew install jq + else + sudo apt-get install -y jq + fi +} + +_install_lazygit() { + if has_cmd lazygit; then info "lazygit already installed"; return; fi + info "Installing lazygit..." + if [ "$PKG_MGR" = "brew" ]; then + brew install lazygit + else + local ver + ver=$(curl -fsSL "https://api.github.com/repos/jesseduffield/lazygit/releases/latest" \ + | grep '"tag_name"' | sed -E 's/.*"v([^"]+)".*/\1/') + local tmp + tmp=$(mktemp -d) + curl -fsSL "https://github.com/jesseduffield/lazygit/releases/download/v${ver}/lazygit_${ver}_Linux_x86_64.tar.gz" \ + | tar -xz -C "$tmp" + sudo install "${tmp}/lazygit" /usr/local/bin/lazygit + rm -rf "$tmp" + fi +} + +_setup_git_config() { + step "Git configuration" + + # Commit message template + if [ -f "${DOTFILES_ROOT}/dotfiles/.gitmessage" ]; then + cp "${DOTFILES_ROOT}/dotfiles/.gitmessage" "${HOME}/.gitmessage" + git config --global commit.template "${HOME}/.gitmessage" + success "Set commit.template" + fi + + # Default branch + git config --global init.defaultBranch main + success "Set init.defaultBranch=main" + + # Delta as pager + if has_cmd delta; then + git config --global core.pager delta + git config --global interactive.diffFilter "delta --color-only" + git config --global delta.navigate true + git config --global delta.side-by-side false + git config --global delta.line-numbers true + success "Configured delta as git pager" + fi +} + +_deploy_dev_configs() { + step "Deploying dev configs" + setup_zshrc_d + deploy_zshrc_d "direnv.zsh" + deploy_zshrc_d "commit.zsh" +} diff --git a/modules/docker.sh b/modules/docker.sh new file mode 100755 index 0000000..926cb9a --- /dev/null +++ b/modules/docker.sh @@ -0,0 +1,112 @@ +#!/usr/bin/env bash +# modules/docker.sh — Docker CE + Docker Compose + +# shellcheck source=modules/lib.sh +: "${DOTFILES_ROOT:=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)}" +# shellcheck disable=SC1091 +. "${DOTFILES_ROOT}/modules/lib.sh" + +install_docker() { + step "Docker" + detect_os + + case "$OS" in + macos) + _docker_macos + ;; + wsl) + _docker_wsl + ;; + linux) + _docker_linux + ;; + esac + + _deploy_docker_config +} + +_docker_macos() { + if has_cmd docker; then + info "Docker already installed" + return + fi + warn "macOS: Please install Docker Desktop from https://www.docker.com/products/docker-desktop/" + warn "Alternatively: brew install --cask docker" + read -r -p "Open Docker Desktop download page? [y/N] " ans + case "$ans" in + [Yy]*) open "https://www.docker.com/products/docker-desktop/" ;; + esac +} + +_docker_wsl() { + # Check if Docker Desktop bridge is available + if has_cmd docker; then + info "Docker available via Docker Desktop bridge" + return + fi + warn "WSL: Docker Desktop integration recommended." + warn "Enable 'Use WSL 2 based engine' in Docker Desktop settings." + warn "Falling back to docker-ce installation..." + _docker_linux +} + +_docker_linux() { + if has_cmd docker; then + info "Docker already installed" + _ensure_compose + return + fi + info "Installing Docker CE..." + + # Remove old packages + for pkg in docker.io docker-doc docker-compose podman-docker containerd runc; do + sudo apt-get remove -y "$pkg" 2>/dev/null || true + done + + sudo apt-get update -qq + sudo apt-get install -y ca-certificates curl + + sudo install -m 0755 -d /etc/apt/keyrings + curl -fsSL https://download.docker.com/linux/ubuntu/gpg \ + | sudo gpg --dearmor -o /etc/apt/keyrings/docker.gpg + sudo chmod a+r /etc/apt/keyrings/docker.gpg + + # shellcheck disable=SC1091 + _codename=$(. /etc/os-release && echo "$VERSION_CODENAME") + echo \ + "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] \ +https://download.docker.com/linux/ubuntu \ +${_codename} stable" \ + | sudo tee /etc/apt/sources.list.d/docker.list > /dev/null + + sudo apt-get update -qq + sudo apt-get install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin + + # Add user to docker group + if ! groups "$USER" | grep -q docker; then + sudo usermod -aG docker "$USER" + warn "Added ${USER} to docker group. Log out and back in to apply." + fi + + success "Docker CE installed" + _ensure_compose +} + +_ensure_compose() { + if has_cmd docker-compose || docker compose version >/dev/null 2>&1; then + info "docker compose available" + else + info "Installing docker-compose standalone..." + if [ "$PKG_MGR" = "brew" ]; then + brew install docker-compose + else + sudo apt-get install -y docker-compose-plugin + fi + fi +} + +_deploy_docker_config() { + step "Deploying docker config" + setup_zshrc_d + deploy_zshrc_d "docker.zsh" +} diff --git a/modules/lib.sh b/modules/lib.sh new file mode 100755 index 0000000..fe6dd0e --- /dev/null +++ b/modules/lib.sh @@ -0,0 +1,134 @@ +#!/usr/bin/env bash +# modules/lib.sh — Shared helpers for all install modules + +# ── Color constants ──────────────────────────────────────────────────────────── +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +BLUE='\033[0;34m' +BOLD='\033[1m' +DIM='\033[2m' +RESET='\033[0m' + +# ── Logging ──────────────────────────────────────────────────────────────────── +info() { printf "${BLUE}[INFO]${RESET} %s\n" "$*"; } +success() { printf "${GREEN}[OK]${RESET} %s\n" "$*"; } +warn() { printf "${YELLOW}[WARN]${RESET} %s\n" "$*"; } +error() { printf "${RED}[ERROR]${RESET} %s\n" "$*" >&2; } +step() { printf "\n${BOLD}▶ %s${RESET}\n" "$*"; } + +# ── OS Detection ─────────────────────────────────────────────────────────────── +detect_os() { + if [ -f /proc/version ] && grep -qi microsoft /proc/version 2>/dev/null; then + OS="wsl" + PKG_MGR="apt" + elif [ "$(uname -s)" = "Darwin" ]; then + OS="macos" + PKG_MGR="brew" + elif [ -f /etc/debian_version ]; then + OS="linux" + PKG_MGR="apt" + else + OS="linux" + PKG_MGR="apt" + warn "Unknown OS; assuming apt-based Linux" + fi + export OS PKG_MGR +} + +# ── Command check ────────────────────────────────────────────────────────────── +has_cmd() { command -v "$1" >/dev/null 2>&1; } + +# ── Package install ──────────────────────────────────────────────────────────── +pkg_install() { + local pkg="$1" + info "Installing ${pkg}..." + if [ "$PKG_MGR" = "brew" ]; then + brew install "$pkg" + else + sudo apt-get install -y "$pkg" + fi +} + +# ── Backup ───────────────────────────────────────────────────────────────────── +BACKUP_MANIFEST="${HOME}/.dotfiles_backup_manifest" + +backup_file() { + local path="$1" + if [ -e "$path" ] && [ ! -L "$path" ]; then + local ts + ts=$(date +%Y%m%d_%H%M%S) + local bak="${path}_backup_${ts}" + mv "$path" "$bak" + echo "$bak" >> "$BACKUP_MANIFEST" + info "Backed up ${path} → ${bak}" + fi +} + +restore_latest_backup() { + local path="$1" + local latest + latest=$(find "$(dirname "$path")" -maxdepth 1 -name "$(basename "$path")_backup_*" 2>/dev/null | sort | tail -n1) + if [ -n "$latest" ]; then + mv "$latest" "$path" + success "Restored ${path} from ${latest}" + else + warn "No backup found for ${path}" + fi +} + +# ── Config deployment ────────────────────────────────────────────────────────── +# DOTFILES_ROOT must be set to the repo root before calling these + +deploy_config() { + local src_rel="$1" # relative to DOTFILES_ROOT/dotfiles/ + local dest="$2" # absolute destination path + local src="${DOTFILES_ROOT}/dotfiles/${src_rel}" + if [ ! -f "$src" ]; then + error "Source not found: ${src}" + return 1 + fi + backup_file "$dest" + mkdir -p "$(dirname "$dest")" + cp "$src" "$dest" + success "Deployed ${src_rel} → ${dest}" +} + +setup_zshrc_d() { + mkdir -p "${HOME}/.zshrc.d" +} + +deploy_zshrc_d() { + local filename="$1" + local src="${DOTFILES_ROOT}/dotfiles/.zshrc.d/${filename}" + if [ ! -f "$src" ]; then + error "Source not found: ${src}" + return 1 + fi + setup_zshrc_d + cp "$src" "${HOME}/.zshrc.d/${filename}" + success "Deployed .zshrc.d/${filename}" +} + +# ── zshrc sourcing block ─────────────────────────────────────────────────────── +ZSHRC_MARKER="# === dotfiles modular config ===" + +setup_zshrc_sourcing() { + local zshrc="${HOME}/.zshrc" + if grep -qF "$ZSHRC_MARKER" "$zshrc" 2>/dev/null; then + info ".zshrc sourcing block already present — skipping" + return 0 + fi + cat >> "$zshrc" << 'BLOCK' + +# === dotfiles modular config === +if [ -d "$HOME/.zshrc.d" ]; then + for _f in "$HOME/.zshrc.d"/*.zsh; do + [ -r "$_f" ] && . "$_f" + done + unset _f +fi +[ -f "$HOME/.zshrc.local" ] && . "$HOME/.zshrc.local" +BLOCK + success "Appended sourcing block to ${zshrc}" +} diff --git a/modules/node.sh b/modules/node.sh new file mode 100755 index 0000000..ffad3dd --- /dev/null +++ b/modules/node.sh @@ -0,0 +1,65 @@ +#!/usr/bin/env bash +# modules/node.sh — Node.js via fnm + commitizen + +# shellcheck source=modules/lib.sh +: "${DOTFILES_ROOT:=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)}" +# shellcheck disable=SC1091 +. "${DOTFILES_ROOT}/modules/lib.sh" + +install_node() { + step "Node.js (fnm + LTS)" + detect_os + + _install_fnm + _bootstrap_node + _deploy_node_config +} + +_install_fnm() { + if has_cmd fnm; then + info "fnm already installed" + return + fi + info "Installing fnm..." + if [ "$PKG_MGR" = "brew" ]; then + brew install fnm + else + curl -fsSL https://fnm.vercel.app/install | bash -s -- --install-dir "${HOME}/.local/bin" --skip-shell + fi +} + +_bootstrap_node() { + # Temporarily eval fnm so we can use it in this session + local fnm_bin + fnm_bin=$(command -v fnm 2>/dev/null || echo "${HOME}/.local/bin/fnm") + if [ ! -x "$fnm_bin" ]; then + warn "fnm not found; skipping Node install" + return + fi + + eval "$("$fnm_bin" env --use-on-cd 2>/dev/null)" || true + + info "Installing Node.js LTS..." + "$fnm_bin" install --lts + "$fnm_bin" use lts-latest || "$fnm_bin" use lts/iron + + # Optional: commitizen + if has_cmd npm; then + if ! has_cmd cz; then + info "Installing commitizen globally..." + npm install -g commitizen cz-conventional-changelog + echo '{ "path": "cz-conventional-changelog" }' > "${HOME}/.czrc" + success "commitizen installed" + else + info "commitizen already installed" + fi + else + warn "npm not available; skipping commitizen" + fi +} + +_deploy_node_config() { + step "Deploying node config" + setup_zshrc_d + deploy_zshrc_d "node.zsh" +} diff --git a/modules/shell.sh b/modules/shell.sh new file mode 100755 index 0000000..6ad3628 --- /dev/null +++ b/modules/shell.sh @@ -0,0 +1,103 @@ +#!/usr/bin/env bash +# modules/shell.sh — Shell enhancements: starship, fzf, zoxide, atuin + +# shellcheck source=modules/lib.sh +: "${DOTFILES_ROOT:=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)}" +# shellcheck disable=SC1091 +. "${DOTFILES_ROOT}/modules/lib.sh" + +install_shell() { + step "Shell Tools (starship, fzf, zoxide, atuin)" + detect_os + + _install_starship + _install_fzf + _install_zoxide + _install_atuin + + _deploy_shell_configs +} + +_install_starship() { + if has_cmd starship; then + info "starship already installed" + return + fi + info "Installing starship..." + if [ "$PKG_MGR" = "brew" ]; then + brew install starship + elif apt-cache show starship >/dev/null 2>&1; then + sudo apt-get install -y starship + else + curl -fsSL https://starship.rs/install.sh | sh -s -- --yes + fi +} + +_install_fzf() { + if has_cmd fzf; then + info "fzf already installed" + return + fi + info "Installing fzf..." + if [ "$PKG_MGR" = "brew" ]; then + brew install fzf + else + sudo apt-get install -y fzf || { + git clone --depth 1 https://github.com/junegunn/fzf.git "${HOME}/.fzf" + "${HOME}/.fzf/install" --all --no-bash --no-fish + } + fi +} + +_install_zoxide() { + if has_cmd zoxide; then + info "zoxide already installed" + return + fi + info "Installing zoxide..." + if [ "$PKG_MGR" = "brew" ]; then + brew install zoxide + elif apt-cache show zoxide >/dev/null 2>&1; then + sudo apt-get install -y zoxide + else + curl -fsSL https://raw.githubusercontent.com/ajeetdsouza/zoxide/main/install.sh | sh + fi +} + +_install_atuin() { + if has_cmd atuin; then + info "atuin already installed" + return + fi + info "Installing atuin..." + if [ "$PKG_MGR" = "brew" ]; then + brew install atuin + else + curl --proto '=https' --tlsv1.2 -LsSf https://setup.atuin.sh | sh + fi +} + +_deploy_shell_configs() { + step "Deploying shell configs" + setup_zshrc_d + deploy_zshrc_d "starship.zsh" + deploy_zshrc_d "fzf.zsh" + deploy_zshrc_d "zoxide.zsh" + deploy_zshrc_d "atuin.zsh" + + # starship config + mkdir -p "${HOME}/.config" + if [ -f "${DOTFILES_ROOT}/config/starship.toml" ]; then + backup_file "${HOME}/.config/starship.toml" + cp "${DOTFILES_ROOT}/config/starship.toml" "${HOME}/.config/starship.toml" + success "Deployed starship.toml" + fi + + # atuin config + mkdir -p "${HOME}/.config/atuin" + if [ -f "${DOTFILES_ROOT}/config/atuin/config.toml" ]; then + backup_file "${HOME}/.config/atuin/config.toml" + cp "${DOTFILES_ROOT}/config/atuin/config.toml" "${HOME}/.config/atuin/config.toml" + success "Deployed atuin/config.toml" + fi +} diff --git a/templates/node/.gitignore b/templates/node/.gitignore new file mode 100644 index 0000000..e2dec08 --- /dev/null +++ b/templates/node/.gitignore @@ -0,0 +1,15 @@ +node_modules/ +dist/ +build/ +.env +.env.local +.env.*.local +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +.DS_Store +.node_version +.nvmrc +coverage/ +.nyc_output/ diff --git a/templates/node/package.json b/templates/node/package.json new file mode 100644 index 0000000..f4bba9a --- /dev/null +++ b/templates/node/package.json @@ -0,0 +1,14 @@ +{ + "name": "PROJECT_NAME", + "version": "0.1.0", + "description": "", + "main": "src/index.js", + "scripts": { + "start": "node src/index.js", + "dev": "node --watch src/index.js", + "test": "node --test" + }, + "keywords": [], + "author": "", + "license": "MIT" +} diff --git a/templates/python/.gitignore b/templates/python/.gitignore new file mode 100644 index 0000000..b013031 --- /dev/null +++ b/templates/python/.gitignore @@ -0,0 +1,18 @@ +__pycache__/ +*.py[cod] +*$py.class +*.so +.Python +build/ +dist/ +*.egg-info/ +.eggs/ +.env +.venv +env/ +venv/ +.pytest_cache/ +.mypy_cache/ +.ruff_cache/ +.DS_Store +*.log diff --git a/templates/python/pyproject.toml b/templates/python/pyproject.toml new file mode 100644 index 0000000..75f0675 --- /dev/null +++ b/templates/python/pyproject.toml @@ -0,0 +1,30 @@ +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[project] +name = "PROJECT_NAME" +version = "0.1.0" +description = "" +readme = "README.md" +requires-python = ">=3.11" +license = { text = "MIT" } +dependencies = [] + +[project.optional-dependencies] +dev = [ + "pytest", + "ruff", + "mypy", +] + +[tool.ruff] +line-length = 88 +target-version = "py311" + +[tool.ruff.lint] +select = ["E", "F", "I", "UP"] + +[tool.mypy] +python_version = "3.11" +strict = true diff --git a/tests/run_tests.sh b/tests/run_tests.sh new file mode 100755 index 0000000..3055604 --- /dev/null +++ b/tests/run_tests.sh @@ -0,0 +1,96 @@ +#!/usr/bin/env bash +# tests/run_tests.sh — Test harness and orchestrator +# No set -e intentionally: assertions handle exit codes gracefully + +TESTS_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(dirname "$TESTS_DIR")" +export TESTS_DIR REPO_ROOT + +PASS=0 +FAIL=0 + +# ── Assertion helpers ────────────────────────────────────────────────────────── + +assert_eq() { + local desc="$1" expected="$2" actual="$3" + if [ "$expected" = "$actual" ]; then + printf " \033[32m✓\033[0m %s\n" "$desc" + PASS=$((PASS + 1)) + else + printf " \033[31m✗\033[0m %s\n expected: %s\n got: %s\n" "$desc" "$expected" "$actual" + FAIL=$((FAIL + 1)) + fi +} + +assert_contains() { + local desc="$1" haystack="$2" needle="$3" + if printf '%s' "$haystack" | grep -qF "$needle"; then + printf " \033[32m✓\033[0m %s\n" "$desc" + PASS=$((PASS + 1)) + else + printf " \033[31m✗\033[0m %s\n expected to contain: %s\n" "$desc" "$needle" + FAIL=$((FAIL + 1)) + fi +} + +assert_file_exists() { + local desc="$1" path="$2" + if [ -e "$path" ]; then + printf " \033[32m✓\033[0m %s\n" "$desc" + PASS=$((PASS + 1)) + else + printf " \033[31m✗\033[0m %s\n file not found: %s\n" "$desc" "$path" + FAIL=$((FAIL + 1)) + fi +} + +assert_executable() { + local desc="$1" path="$2" + if [ -x "$path" ]; then + printf " \033[32m✓\033[0m %s\n" "$desc" + PASS=$((PASS + 1)) + else + printf " \033[31m✗\033[0m %s\n not executable: %s\n" "$desc" "$path" + FAIL=$((FAIL + 1)) + fi +} + +assert_cmd_success() { + local desc="$1" + shift + if "$@" >/dev/null 2>&1; then + printf " \033[32m✓\033[0m %s\n" "$desc" + PASS=$((PASS + 1)) + else + printf " \033[31m✗\033[0m %s\n command failed: %s\n" "$desc" "$*" + FAIL=$((FAIL + 1)) + fi +} + +assert_cmd_fail() { + local desc="$1" + shift + if ! "$@" >/dev/null 2>&1; then + printf " \033[32m✓\033[0m %s\n" "$desc" + PASS=$((PASS + 1)) + else + printf " \033[31m✗\033[0m %s\n expected failure but succeeded: %s\n" "$desc" "$*" + FAIL=$((FAIL + 1)) + fi +} + +# ── Run each test file ───────────────────────────────────────────────────────── +for _test_file in "${TESTS_DIR}"/test_*.sh; do + printf "\n\033[1m── %s ──\033[0m\n" "$(basename "$_test_file")" + # shellcheck disable=SC1090 + . "$_test_file" + run_tests +done +unset _test_file + +# ── Summary ─────────────────────────────────────────────────────────────────── +printf "\n\033[1m═══════════════════════════════\033[0m\n" +printf " \033[32mPassed: %d\033[0m \033[31mFailed: %d\033[0m\n" "$PASS" "$FAIL" +printf "\033[1m═══════════════════════════════\033[0m\n\n" + +[ "$FAIL" -eq 0 ] || exit 1 diff --git a/tests/test_bootstrap.sh b/tests/test_bootstrap.sh new file mode 100644 index 0000000..020cd88 --- /dev/null +++ b/tests/test_bootstrap.sh @@ -0,0 +1,22 @@ +#!/usr/bin/env bash +# tests/test_bootstrap.sh — CLI validation tests for bootstrap.sh + +run_tests() { + local bootstrap="${REPO_ROOT}/bootstrap.sh" + local list_output list_rc + + # --list exits 0 + list_output=$(bash "$bootstrap" --list 2>&1) + list_rc=$? + assert_eq "--list exits 0" "0" "$list_rc" + + # --list output contains all expected module names + assert_contains "--list contains 'core'" "$list_output" "core" + assert_contains "--list contains 'shell'" "$list_output" "shell" + assert_contains "--list contains 'dev'" "$list_output" "dev" + assert_contains "--list contains 'node'" "$list_output" "node" + assert_contains "--list contains 'docker'" "$list_output" "docker" + + # Unknown flag exits non-zero + assert_cmd_fail "--unknown-flag exits non-zero" bash "$bootstrap" --unknown-flag-xyz +} diff --git a/tests/test_configs.sh b/tests/test_configs.sh new file mode 100644 index 0000000..9275dd0 --- /dev/null +++ b/tests/test_configs.sh @@ -0,0 +1,54 @@ +#!/usr/bin/env bash +# tests/test_configs.sh — File existence and content validation (no installs) + +run_tests() { + local r="$REPO_ROOT" + + # ── Executables ───────────────────────────────────────────────────────────── + assert_executable "bootstrap.sh is executable" "${r}/bootstrap.sh" + assert_executable "uninstall.sh is executable" "${r}/uninstall.sh" + + # ── Module files ──────────────────────────────────────────────────────────── + for _m in lib.sh core.sh shell.sh dev.sh node.sh docker.sh; do + assert_file_exists "modules/${_m}" "${r}/modules/${_m}" + done + + # ── .zshrc.d files ────────────────────────────────────────────────────────── + for _f in fzf.zsh zoxide.zsh starship.zsh atuin.zsh direnv.zsh node.zsh docker.zsh commit.zsh; do + assert_file_exists ".zshrc.d/${_f}" "${r}/dotfiles/.zshrc.d/${_f}" + done + + # ── Config files ──────────────────────────────────────────────────────────── + assert_file_exists "config/starship.toml" "${r}/config/starship.toml" + assert_file_exists "config/atuin/config.toml" "${r}/config/atuin/config.toml" + assert_file_exists "dotfiles/.gitmessage" "${r}/dotfiles/.gitmessage" + assert_file_exists "dotfiles/.zshrc.local.example" "${r}/dotfiles/.zshrc.local.example" + + # ── Templates ─────────────────────────────────────────────────────────────── + assert_file_exists "templates/node/package.json" "${r}/templates/node/package.json" + assert_file_exists "templates/python/pyproject.toml" "${r}/templates/python/pyproject.toml" + + # ── Every .zshrc.d file has a command -v guard ────────────────────────────── + for _f in "${r}/dotfiles/.zshrc.d/"*.zsh; do + local _fname + _fname=$(basename "$_f") + assert_cmd_success ".zshrc.d/${_fname} has 'command -v' guard" grep -q "command -v" "$_f" + done + + # ── dotfiles/.zshrc has exactly one modular config marker ─────────────────── + local _mc + _mc=$(grep -c "dotfiles modular config" "${r}/dotfiles/.zshrc" 2>/dev/null || printf "0") + assert_eq ".zshrc has exactly one modular config marker" "1" "$_mc" + + # ── .gitignore contains .zshrc.local ──────────────────────────────────────── + assert_cmd_success ".gitignore contains '.zshrc.local'" \ + grep -qF ".zshrc.local" "${r}/.gitignore" + + # ── starship.toml contains [character] ────────────────────────────────────── + assert_cmd_success "starship.toml contains '[character]'" \ + grep -qF "[character]" "${r}/config/starship.toml" + + # ── atuin/config.toml contains search_mode ────────────────────────────────── + assert_cmd_success "atuin/config.toml contains 'search_mode'" \ + grep -q "search_mode" "${r}/config/atuin/config.toml" +} diff --git a/tests/test_lib.sh b/tests/test_lib.sh new file mode 100644 index 0000000..92571cc --- /dev/null +++ b/tests/test_lib.sh @@ -0,0 +1,74 @@ +#!/usr/bin/env bash +# tests/test_lib.sh — Unit tests for modules/lib.sh (isolated HOME in temp dir) + +run_tests() { + local orig_home="$HOME" + local TMP_HOME + TMP_HOME=$(mktemp -d) + HOME="$TMP_HOME" + export HOME + + DOTFILES_ROOT="$REPO_ROOT" + export DOTFILES_ROOT + + # Source lib.sh after setting HOME so BACKUP_MANIFEST points to TMP_HOME + # shellcheck disable=SC1091 + . "${REPO_ROOT}/modules/lib.sh" + + # ── has_cmd ───────────────────────────────────────────────────────────────── + assert_cmd_success "has_cmd bash" has_cmd bash + assert_cmd_fail "has_cmd __nonexistent_xyz__" has_cmd __nonexistent_xyz__ + + # ── detect_os ─────────────────────────────────────────────────────────────── + detect_os + assert_cmd_success "detect_os sets \$OS non-empty" test -n "$OS" + assert_cmd_success "detect_os sets \$PKG_MGR non-empty" test -n "$PKG_MGR" + + # ── backup_file ───────────────────────────────────────────────────────────── + local test_file="${TMP_HOME}/testfile.txt" + printf "original content\n" > "$test_file" + backup_file "$test_file" + assert_cmd_fail "backup_file removes original" test -f "$test_file" + local bak + bak=$(find "$TMP_HOME" -maxdepth 1 -name "testfile.txt_backup_*" 2>/dev/null | head -1) + assert_cmd_success "backup_file creates backup" test -n "$bak" + + # ── backup_file on nonexistent file: no error ──────────────────────────────── + backup_file "${TMP_HOME}/nonexistent_file_xyz" 2>/dev/null + assert_eq "backup_file on nonexistent file exits 0" "0" "$?" + + # ── restore_latest_backup ─────────────────────────────────────────────────── + restore_latest_backup "$test_file" + assert_cmd_success "restore_latest_backup restores file" test -f "$test_file" + + # ── setup_zshrc_d ─────────────────────────────────────────────────────────── + setup_zshrc_d + assert_cmd_success "setup_zshrc_d creates ~/.zshrc.d" test -d "${TMP_HOME}/.zshrc.d" + + # ── deploy_config ─────────────────────────────────────────────────────────── + local deploy_dest="${TMP_HOME}/.zshrc.local.example" + deploy_config ".zshrc.local.example" "$deploy_dest" + assert_cmd_success "deploy_config copies file to destination" test -f "$deploy_dest" + + # ── deploy_zshrc_d ────────────────────────────────────────────────────────── + deploy_zshrc_d "fzf.zsh" + assert_cmd_success "deploy_zshrc_d copies file to ~/.zshrc.d/" \ + test -f "${TMP_HOME}/.zshrc.d/fzf.zsh" + + # ── setup_zshrc_sourcing ──────────────────────────────────────────────────── + touch "${TMP_HOME}/.zshrc" + setup_zshrc_sourcing + assert_cmd_success "setup_zshrc_sourcing appends block to ~/.zshrc" \ + grep -qF "dotfiles modular config" "${TMP_HOME}/.zshrc" + + # Idempotency: calling twice must produce exactly one marker + setup_zshrc_sourcing + local count + count=$(grep -c "dotfiles modular config" "${TMP_HOME}/.zshrc") + assert_eq "setup_zshrc_sourcing is idempotent (block appears once)" "1" "$count" + + # ── Cleanup ───────────────────────────────────────────────────────────────── + rm -rf "$TMP_HOME" + HOME="$orig_home" + export HOME +} diff --git a/tests/test_zshrc_d.sh b/tests/test_zshrc_d.sh new file mode 100644 index 0000000..ca3f788 --- /dev/null +++ b/tests/test_zshrc_d.sh @@ -0,0 +1,33 @@ +#!/usr/bin/env bash +# tests/test_zshrc_d.sh — Syntax validation via bash -n + +run_tests() { + local r="$REPO_ROOT" + + # ── Shell scripts ──────────────────────────────────────────────────────────── + for _f in \ + "${r}/bootstrap.sh" \ + "${r}/uninstall.sh" \ + "${r}/install_and_configure.sh" \ + "${r}/modules/lib.sh" \ + "${r}/modules/core.sh" \ + "${r}/modules/shell.sh" \ + "${r}/modules/dev.sh" \ + "${r}/modules/node.sh" \ + "${r}/modules/docker.sh" + do + local _name + _name=$(basename "$_f") + assert_cmd_success "bash -n ${_name}" bash -n "$_f" + done + + # ── .zshrc.d/*.zsh files ──────────────────────────────────────────────────── + for _f in "${r}/dotfiles/.zshrc.d/"*.zsh; do + local _name + _name=$(basename "$_f") + assert_cmd_success "bash -n .zshrc.d/${_name}" bash -n "$_f" + done + + # ── dotfiles/.zshrc ───────────────────────────────────────────────────────── + assert_cmd_success "bash -n dotfiles/.zshrc" bash -n "${r}/dotfiles/.zshrc" +} diff --git a/uninstall.sh b/uninstall.sh new file mode 100755 index 0000000..35c7067 --- /dev/null +++ b/uninstall.sh @@ -0,0 +1,133 @@ +#!/usr/bin/env bash +# uninstall.sh — Remove dotfiles configs and restore backups +set -euo pipefail + +DOTFILES_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +export DOTFILES_ROOT + +# shellcheck source=modules/lib.sh +. "${DOTFILES_ROOT}/modules/lib.sh" + +# ── Files we own in ~/.zshrc.d ───────────────────────────────────────────────── +ZSHRC_D_FILES=( + fzf.zsh + zoxide.zsh + starship.zsh + atuin.zsh + direnv.zsh + node.zsh + docker.zsh + commit.zsh +) + +# ── Config files we deployed ─────────────────────────────────────────────────── +CONFIG_FILES=( + "${HOME}/.config/starship.toml" + "${HOME}/.config/atuin/config.toml" + "${HOME}/.gitmessage" +) + +# ── Core configs that have backups ───────────────────────────────────────────── +CORE_CONFIGS=( + "${HOME}/.zshrc" + "${HOME}/.tmux.conf" + "${HOME}/.emacs.el" +) + +confirm() { + local msg="$1" + printf "${YELLOW}%s [y/N]${RESET} " "$msg" + read -r ans + case "$ans" in + [Yy]*) return 0 ;; + *) return 1 ;; + esac +} + +remove_zshrc_d() { + step "Removing ~/.zshrc.d plugin files" + for f in "${ZSHRC_D_FILES[@]}"; do + local path="${HOME}/.zshrc.d/${f}" + if [ -f "$path" ]; then + rm -f "$path" + success "Removed ${path}" + fi + done + # Remove directory if empty + if [ -d "${HOME}/.zshrc.d" ] && [ -z "$(ls -A "${HOME}/.zshrc.d")" ]; then + rmdir "${HOME}/.zshrc.d" + info "Removed empty ~/.zshrc.d" + fi +} + +restore_core_configs() { + step "Restoring core config backups" + for path in "${CORE_CONFIGS[@]}"; do + restore_latest_backup "$path" + done +} + +remove_config_files() { + step "Removing deployed config files" + for path in "${CONFIG_FILES[@]}"; do + if [ -f "$path" ]; then + rm -f "$path" + success "Removed ${path}" + fi + done +} + +remove_git_settings() { + step "Removing git config entries" + git config --global --unset commit.template 2>/dev/null && success "Removed commit.template" || true + git config --global --unset core.pager 2>/dev/null && success "Removed core.pager" || true + git config --global --unset interactive.diffFilter 2>/dev/null || true + git config --global --unset delta.navigate 2>/dev/null || true + git config --global --unset delta.side-by-side 2>/dev/null || true + git config --global --unset delta.line-numbers 2>/dev/null || true +} + +uninstall_packages() { + step "Package removal" + local extra_tools=(starship fzf zoxide atuin gh ripgrep direnv bat delta fd-find jq lazygit fnm) + warn "The following packages may have been installed by bootstrap:" + printf " %s\n" "${extra_tools[@]}" + if confirm "Remove these packages?"; then + detect_os + for pkg in "${extra_tools[@]}"; do + if has_cmd "$pkg"; then + if [ "$PKG_MGR" = "brew" ]; then + brew uninstall "$pkg" 2>/dev/null || warn "Could not remove ${pkg}" + else + sudo apt-get remove -y "$pkg" 2>/dev/null || warn "Could not remove ${pkg}" + fi + fi + done + else + info "Skipping package removal" + fi +} + +main() { + printf "\n${BOLD}╔══════════════════════════════════════════════════════════════╗${RESET}\n" + printf "${BOLD}║ dotfiles — uninstall ║${RESET}\n" + printf "${BOLD}╚══════════════════════════════════════════════════════════════╝${RESET}\n\n" + + confirm "This will remove dotfiles configs and restore backups. Continue?" || { + info "Aborted." + exit 0 + } + + remove_zshrc_d + restore_core_configs + remove_config_files + remove_git_settings + + if confirm "Remove installed packages (starship, fzf, zoxide, atuin, etc.)?"; then + uninstall_packages + fi + + success "Uninstall complete. Run 'source ~/.zshrc' or restart your shell." +} + +main "$@"