diff --git a/CLAUDE.md b/CLAUDE.md index 174a74f8..07df7174 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -15,11 +15,11 @@ The system combines: ## Prerequisites Before working on this codebase, ensure: +- **Bash 3.2+** — All hook scripts and installer are compatible with the stock `/bin/bash` on macOS (3.2). `BASH_REMATCH` and `set -euo pipefail` are available since Bash 3.0/2.0 respectively. - **jq 1.6+** — Required for all hook scripts and validation. Install: `brew install jq` - **Node.js 20+ + npm** — Required only for dashboard development (`infra/gcp/dashboard/`) -- **Bash 4+** — All scripts use `set -euo pipefail` and POSIX-compatible tools - **Git** — Version control and release tagging -- **Tested platforms** — macOS and Linux. CI runs on both via `ci.yml`. WSL supported via installer detection. +- **Tested platforms** — macOS 10.15+ and Linux. CI runs on both via `ci.yml`. WSL supported via installer detection. ## Development Environment Setup @@ -853,7 +853,7 @@ Add tests for any new feature or bug fix. ## Version Compatibility **Minimum requirements:** -- Bash 4.0+ (macOS ships with Bash 3.2 — install via `brew install bash` if needed) +- Bash 3.2+ (works with macOS's stock `/bin/bash`) - jq 1.6+ (released 2018; widely available) - Git 2.0+ - Python 3.12 (for Cloud Functions only; hooks are pure Bash) diff --git a/README.md b/README.md index f5f12973..666057cc 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,7 @@ [![Pure Bash](https://img.shields.io/badge/Pure%20Bash-Yes-green?style=flat-square)](https://github.com/renatobardi/hapai) [![CI/CD](https://img.shields.io/github/actions/workflow/status/renatobardi/hapai/ci.yml?style=flat-square&branch=main)](https://github.com/renatobardi/hapai/actions/workflows/ci.yml) -[![Tests](https://img.shields.io/badge/Tests-133%2F133-green?style=flat-square)](tests/run-tests.sh) +[![Tests](https://img.shields.io/badge/Tests-135%2F135-green?style=flat-square)](tests/run-tests.sh) [![License](https://img.shields.io/github/license/renatobardi/hapai?style=flat-square)](LICENSE) [![GitHub Release](https://img.shields.io/github/v/release/renatobardi/hapai?style=flat-square)](https://github.com/renatobardi/hapai/releases) @@ -374,10 +374,10 @@ Pure bash assertions (no test framework). ~200 assertions covering: ## Requirements -- **bash** (macOS default bash 3.2+ works fine) -- **jq** (JSON parser) +- **bash 3.2+** (works with macOS's stock `/bin/bash`) +- **jq 1.6+** (JSON parser) - **git** (for guard scripts) -- **Node.js 24+** (for GitHub Actions workflows only) +- **Node.js 20+** (for dashboard development only; not required for hooks) For cloud logging (optional): - **gcloud CLI** (Cloud Storage, Cloud Functions, BigQuery) diff --git a/install.sh b/install.sh index 79603b60..f7a6e89c 100755 --- a/install.sh +++ b/install.sh @@ -38,11 +38,36 @@ setup_path() { local dir="$1" [[ ":$PATH:" == *":$dir:"* ]] && return 0 - # ~/.profile is sourced by bash, zsh, sh, and dash as a login shell — no - # shell-specific config needed - local profile="$HOME/.profile" + # Detect shell and choose appropriate RC file for PATH setup + # For zsh: ~/.zprofile (login shell), or ~/.zshrc (interactive shell) + # For bash: ~/.bash_profile, then ~/.profile, then ~/.bashrc + # Fallback: ~/.profile (POSIX standard) + local profile + + if [[ "$SHELL" == *"zsh"* ]]; then + # zsh users: prefer ~/.zprofile (login shell) over ~/.profile + if [[ -f "$HOME/.zprofile" ]]; then + profile="$HOME/.zprofile" + else + profile="$HOME/.zshrc" + fi + elif [[ "$SHELL" == *"bash"* ]]; then + # bash users: check ~/.bash_profile (login shell), fallback to ~/.bashrc + if [[ -f "$HOME/.bash_profile" ]]; then + profile="$HOME/.bash_profile" + else + profile="$HOME/.bashrc" + fi + else + # Other shells: use POSIX ~/.profile + profile="$HOME/.profile" + fi + if ! grep -qF "$dir" "$profile" 2>/dev/null; then - printf '\n# hapai — added by installer\nexport PATH="%s:$PATH"\n' "$dir" >> "$profile" + if ! printf '\n# hapai — added by installer\nexport PATH="%s:$PATH"\n' "$dir" >> "$profile" 2>/dev/null; then + log_warn "Could not update $profile (check file permissions)" + return 0 + fi log_ok "Added $dir to PATH in $profile" fi } @@ -86,7 +111,7 @@ check_deps() { exit 1 fi - log_ok "Dependencies: bash, git, jq — OK" + log_ok "Dependencies: git, jq — OK" } # ─── Resolve version ───────────────────────────────────────────────────────── @@ -123,9 +148,18 @@ install_from_source() { cp -r "$src_dir/templates" "$HAPAI_HOME/" cp -r "$src_dir/exporters" "$HAPAI_HOME/" 2>/dev/null || true - # Install binary - mkdir -p "$INSTALL_DIR" - cp "$src_dir/bin/hapai" "$INSTALL_DIR/hapai" + # Install binary with fallback on permission error + if ! mkdir -p "$INSTALL_DIR" 2>/dev/null || ! cp "$src_dir/bin/hapai" "$INSTALL_DIR/hapai" 2>/dev/null; then + log_warn "Cannot write to $INSTALL_DIR (permission denied)" + + # Fallback: use ~/.local/bin + INSTALL_DIR="$HOME/.local/bin" + log_info "Falling back to $INSTALL_DIR" + + mkdir -p "$INSTALL_DIR" || die "Cannot create $INSTALL_DIR either" + cp "$src_dir/bin/hapai" "$INSTALL_DIR/hapai" || die "Failed to copy binary to $INSTALL_DIR" + fi + chmod +x "$INSTALL_DIR/hapai" setup_path "$INSTALL_DIR" } @@ -188,9 +222,18 @@ install_from_github() { cp -r "$extracted_dir/templates" "$HAPAI_HOME/" cp -r "$extracted_dir/exporters" "$HAPAI_HOME/" 2>/dev/null || true - # Install binary - mkdir -p "$INSTALL_DIR" - cp "$extracted_dir/bin/hapai" "$INSTALL_DIR/hapai" + # Install binary with fallback on permission error + if ! mkdir -p "$INSTALL_DIR" 2>/dev/null || ! cp "$extracted_dir/bin/hapai" "$INSTALL_DIR/hapai" 2>/dev/null; then + log_warn "Cannot write to $INSTALL_DIR (permission denied)" + + # Fallback: use ~/.local/bin + INSTALL_DIR="$HOME/.local/bin" + log_info "Falling back to $INSTALL_DIR" + + mkdir -p "$INSTALL_DIR" || die "Cannot create $INSTALL_DIR either" + cp "$extracted_dir/bin/hapai" "$INSTALL_DIR/hapai" || die "Failed to copy binary to $INSTALL_DIR" + fi + chmod +x "$INSTALL_DIR/hapai" setup_path "$INSTALL_DIR" } diff --git a/tests/run-tests.sh b/tests/run-tests.sh index a5686b34..3bc3bf80 100755 --- a/tests/run-tests.sh +++ b/tests/run-tests.sh @@ -1347,6 +1347,69 @@ assert_allowed "$output" "Allows all when git_workflow disabled (default)" rm -f "$WORKFLOW_CONFIG" cd "$MOCK_REPO" && git checkout main -q 2>/dev/null || true +# ═══════════════════════════════════════════════════════════════════════════ +# Installer Tests (install.sh) +# ═══════════════════════════════════════════════════════════════════════════ + +echo "" +echo -e "${BOLD}Testing installer (install.sh)${NC}" + +# Helper: build a sourceable file containing the real setup_path() from install.sh +# Stubs out log_ok/log_warn so their output can be captured cleanly. +_installer_func_file() { + local out="$1" + { + echo 'log_ok() { echo "$*"; }' + echo 'log_warn() { echo "WARN: $*"; }' + awk '/^setup_path\(\) \{/{p=1} p{print} /^\}/{if(p){p=0;exit}}' "$HAPAI_ROOT/install.sh" + } > "$out" +} + +# Test: setup_path() detects zsh and uses .zprofile when it exists +test_zsh_path_detection() { + local temp_home install_dir func_file output + temp_home="$(mktemp -d)" + install_dir="$(mktemp -d)" # unique dir guaranteed not to be in PATH + func_file="$(mktemp)" + _installer_func_file "$func_file" + + touch "$temp_home/.zprofile" + + output="$(HOME="$temp_home" SHELL="/bin/zsh" \ + bash -c "source '$func_file'; setup_path '$install_dir'" 2>/dev/null)" + + assert_contains "$output" ".zprofile" "setup_path() detects zsh and uses .zprofile" + rm -rf "$temp_home" "$install_dir" "$func_file" +} + +# Test: setup_path() uses .bash_profile for bash when it exists, falls back to .bashrc +test_bash_path_detection() { + local temp_home install_dir func_file output_with output_without + temp_home="$(mktemp -d)" + func_file="$(mktemp)" + _installer_func_file "$func_file" + + # Branch 1: .bash_profile present — should use it + install_dir="$(mktemp -d)" + touch "$temp_home/.bash_profile" + output_with="$(HOME="$temp_home" SHELL="/bin/bash" \ + bash -c "source '$func_file'; setup_path '$install_dir'" 2>/dev/null)" + assert_contains "$output_with" ".bash_profile" "setup_path() uses .bash_profile for bash when present" + + # Branch 2: no .bash_profile — should fall back to .bashrc + install_dir="$(mktemp -d)" + rm -f "$temp_home/.bash_profile" + output_without="$(HOME="$temp_home" SHELL="/bin/bash" \ + bash -c "source '$func_file'; setup_path '$install_dir'" 2>/dev/null)" + assert_contains "$output_without" ".bashrc" "setup_path() falls back to .bashrc when .bash_profile absent" + + rm -rf "$temp_home" "$install_dir" "$func_file" +} + +# Run installer tests +test_zsh_path_detection +test_bash_path_detection + # ═══════════════════════════════════════════════════════════════════════════ # Summary # ═══════════════════════════════════════════════════════════════════════════