diff --git a/.agents/scripts/tool-version-check.sh b/.agents/scripts/tool-version-check.sh index 79b4fbbc9..c7c6106ff 100755 --- a/.agents/scripts/tool-version-check.sh +++ b/.agents/scripts/tool-version-check.sh @@ -10,6 +10,7 @@ # # Categories: npm, brew, pip, all (default) +# shellcheck disable=SC1091 SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" || exit source "${SCRIPT_DIR}/shared-constants.sh" @@ -197,6 +198,17 @@ get_installed_version() { # Timeout for external package manager queries (seconds) readonly PKG_QUERY_TIMEOUT=30 +get_public_release_tag() { + local repo="$1" + local tag="" + tag=$(timeout_sec "$PKG_QUERY_TIMEOUT" curl -fsSL "https://api.github.com/repos/${repo}/releases/latest" 2>/dev/null | + grep -oE '"tag_name"[[:space:]]*:[[:space:]]*"v?[^"]+"' | + head -1 | + sed -E 's/.*"v?([^"]+)"/\1/' || true) + echo "$tag" + return 0 +} + # Get latest npm version get_npm_latest() { local pkg="$1" @@ -208,8 +220,12 @@ get_npm_latest() { # Get latest brew version get_brew_latest() { local pkg="$1" - if command -v brew &>/dev/null; then - timeout_sec "$PKG_QUERY_TIMEOUT" brew info "$pkg" 2>/dev/null | head -1 | grep -oE '[0-9]+\.[0-9]+\.[0-9]+' | head -1 || echo "unknown" + local brew_bin="" + brew_bin=$(command -v brew 2>/dev/null || true) + if [[ -n "$brew_bin" && -x "$brew_bin" ]]; then + timeout_sec "$PKG_QUERY_TIMEOUT" "$brew_bin" info "$pkg" 2>/dev/null | head -1 | grep -oE '[0-9]+\.[0-9]+\.[0-9]+' | head -1 || echo "unknown" + elif [[ "$pkg" == "gh" ]]; then + get_public_release_tag "cli/cli" else echo "unknown" fi @@ -299,7 +315,7 @@ check_tool() { json_name="${json_name//\"/\\\"}" local json_update="${update_cmd//\\/\\\\}" json_update="${json_update//\"/\\\"}" - JSON_RESULTS+=("{\"name\":\"$json_name\",\"category\":\"$category\",\"installed\":\"$installed\",\"latest\":\"$latest\",\"status\":\"$status\",\"update_cmd\":\"$json_update\"}") + JSON_RESULTS+=("{\"name\": \"$json_name\", \"category\": \"$category\", \"installed\": \"$installed\", \"latest\": \"$latest\", \"status\": \"$status\", \"update_cmd\": \"$json_update\"}") else # Console output if [[ "$QUIET" == "true" && "$status" != "outdated" && "$status" != "timeout" ]]; then diff --git a/aidevops.sh b/aidevops.sh index 45456a71c..05f17f3fd 100755 --- a/aidevops.sh +++ b/aidevops.sh @@ -74,6 +74,19 @@ get_remote_version() { curl -fsSL "https://raw.githubusercontent.com/marcusquinn/aidevops/main/VERSION" 2>/dev/null || echo "unknown" } +get_public_release_tag() { + local repo="$1" + local tag="" + + tag=$(_timeout_cmd 15 curl -fsSL "https://api.github.com/repos/${repo}/releases/latest" 2>/dev/null | + grep -oE '"tag_name"[[:space:]]*:[[:space:]]*"v?[^"]+"' | + head -1 | + sed -E 's/.*"v?([^"]+)"/\1/' || true) + + printf '%s\n' "$tag" + return 0 +} + # Check if a command exists check_cmd() { command -v "$1" >/dev/null 2>&1 @@ -924,11 +937,14 @@ cmd_update() { # Get latest version (npm, brew, or GitHub API) — timeout prevents hangs on slow registries if [[ "$pkg_ref" == brew:* ]]; then local brew_pkg="${pkg_ref#brew:}" - if command -v brew &>/dev/null; then - latest=$(_timeout_cmd 30 brew info --json=v2 "$brew_pkg" | jq -r '.formulae[0].versions.stable // empty' || true) + local brew_bin="" + brew_bin=$(command -v brew 2>/dev/null || true) + if [[ -n "$brew_bin" && -x "$brew_bin" ]]; then + latest=$(_timeout_cmd 30 "$brew_bin" info --json=v2 "$brew_pkg" | jq -r '.formulae[0].versions.stable // empty' || true) elif [[ "$brew_pkg" == "gh" ]] && command -v gh &>/dev/null; then - # Fallback: get latest gh version from GitHub API (works without brew) - latest=$(_timeout_cmd 15 gh api repos/cli/cli/releases/latest --jq '.tag_name' 2>/dev/null | sed 's/^v//' || true) + # Fallback: use the public GitHub API so update still works when brew + # is unavailable or gh auth refresh is unhealthy. + latest=$(get_public_release_tag "cli/cli") fi else latest=$(_timeout_cmd 30 npm view "$pkg_ref" version || true) diff --git a/tests/test-update-fallbacks.sh b/tests/test-update-fallbacks.sh new file mode 100644 index 000000000..1ed2fc76c --- /dev/null +++ b/tests/test-update-fallbacks.sh @@ -0,0 +1,133 @@ +#!/usr/bin/env bash + +set -euo pipefail + +REPO_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +CLI_SCRIPT="$REPO_DIR/aidevops.sh" +TOOL_CHECK_SCRIPT="$REPO_DIR/.agents/scripts/tool-version-check.sh" + +PASS_COUNT=0 +FAIL_COUNT=0 +TOTAL_COUNT=0 + +pass() { + PASS_COUNT=$((PASS_COUNT + 1)) + TOTAL_COUNT=$((TOTAL_COUNT + 1)) + printf "\033[0;32mPASS\033[0m %s\n" "$1" + return 0 +} + +fail() { + FAIL_COUNT=$((FAIL_COUNT + 1)) + TOTAL_COUNT=$((TOTAL_COUNT + 1)) + printf "\033[0;31mFAIL\033[0m %s\n" "$1" + if [[ -n "${2:-}" ]]; then + printf " %s\n" "$2" + fi + return 0 +} + +assert_grep() { + local pattern="$1" + local file="$2" + local name="$3" + if grep -qE "$pattern" "$file"; then + pass "$name" + else + fail "$name" "Pattern not found: $pattern" + fi + return 0 +} + +assert_not_grep() { + local pattern="$1" + local file="$2" + local name="$3" + if grep -qE "$pattern" "$file"; then + fail "$name" "Unexpected pattern found: $pattern" + else + pass "$name" + fi + return 0 +} + +assert_contains() { + local haystack="$1" + local needle="$2" + local name="$3" + if [[ "$haystack" == *"$needle"* ]]; then + pass "$name" + else + fail "$name" "Missing substring: $needle" + fi + return 0 +} + +TMP_DIR="$(mktemp -d)" +trap 'rm -rf "$TMP_DIR"' EXIT + +FAKE_BIN="$TMP_DIR/bin" +mkdir -p "$FAKE_BIN" + +cat >"$FAKE_BIN/gh" <<'EOF' +#!/usr/bin/env bash +if [[ "$1" == "--version" ]]; then + printf 'gh version 2.0.0\n' + exit 0 +fi +printf 'Error: Token refresh failed: 500\n' >&2 +exit 1 +EOF +chmod +x "$FAKE_BIN/gh" + +cat >"$FAKE_BIN/curl" <<'EOF' +#!/usr/bin/env bash +printf '{"tag_name":"v2.1.0"}\n' +EOF +chmod +x "$FAKE_BIN/curl" + +cat >"$FAKE_BIN/timeout" <<'EOF' +#!/usr/bin/env bash +shift +exec "$@" +EOF +chmod +x "$FAKE_BIN/timeout" + +PATH="$FAKE_BIN:/usr/bin:/bin" TOOL_OUTPUT="$(bash "$TOOL_CHECK_SCRIPT" --category brew --json 2>&1 || true)" + +# shellcheck disable=SC2016 +BREW_BIN_PATTERN='\[\[ -n "\$brew_bin" && -x "\$brew_bin" \]\]' +GH_LATEST_PATTERN='"latest": "2.1.0"' +GH_INSTALLED_PATTERN='"installed": "2.0.0"' + +assert_grep 'brew_bin=.*command -v brew' "$CLI_SCRIPT" 'CLI resolves brew path before timeout use' +assert_grep "$BREW_BIN_PATTERN" "$CLI_SCRIPT" 'CLI requires brew to be executable before invoking timeout' +assert_grep 'get_public_release_tag "cli/cli"' "$CLI_SCRIPT" 'CLI uses public GitHub API fallback for gh latest version' +assert_not_grep 'gh api repos/cli/cli/releases/latest' "$CLI_SCRIPT" 'CLI no longer depends on gh auth for gh latest version checks' + +assert_grep 'brew_bin=.*command -v brew' "$TOOL_CHECK_SCRIPT" 'Tool checker resolves brew path before timeout use' +assert_grep "$BREW_BIN_PATTERN" "$TOOL_CHECK_SCRIPT" 'Tool checker requires brew to be executable before invoking timeout' +assert_grep 'get_public_release_tag "cli/cli"' "$TOOL_CHECK_SCRIPT" 'Tool checker uses public GitHub API fallback for gh latest version' + +assert_contains "$TOOL_OUTPUT" "$GH_LATEST_PATTERN" 'Tool checker reports gh latest version from public API fallback' +assert_contains "$TOOL_OUTPUT" "$GH_INSTALLED_PATTERN" 'Tool checker keeps the installed gh version when brew is unavailable' + +if [[ "$TOOL_OUTPUT" == *'timeout: failed to run command'* ]]; then + fail 'Tool checker avoids timeout/brew ENOENT when brew is unavailable' "$TOOL_OUTPUT" +else + pass 'Tool checker avoids timeout/brew ENOENT when brew is unavailable' +fi + +if [[ "$TOOL_OUTPUT" == *'Token refresh failed: 500'* ]]; then + fail 'Tool checker avoids gh auth refresh failures for public latest checks' "$TOOL_OUTPUT" +else + pass 'Tool checker avoids gh auth refresh failures for public latest checks' +fi + +printf "\nRan %d tests, %d failed.\n" "$TOTAL_COUNT" "$FAIL_COUNT" + +if [[ "$FAIL_COUNT" -ne 0 ]]; then + exit 1 +fi + +exit 0