diff --git a/audit.py b/audit.py index d05a1fe..de3b154 100755 --- a/audit.py +++ b/audit.py @@ -70,6 +70,9 @@ def _sanitize(s: str) -> str: UPDATE_BASELINE_ONLY = os.environ.get("CLI_AUDIT_UPDATE_BASELINE", "0") == "1" USE_SPLIT_FILES = os.environ.get("CLI_AUDIT_SPLIT_FILES", "0") == "1" +# Audit status value (named to avoid duplicating the literal across call sites) +STATUS_NOT_INSTALLED = "NOT INSTALLED" + def normalize_version(version: str) -> str: """Normalize version string for comparison. @@ -103,6 +106,25 @@ def normalize_version(version: str) -> str: return '.'.join(normalized_parts) +def compute_status(installed: str, latest: str) -> str: + """Determine audit status from installed vs latest using DIRECTIONAL version + comparison. + + ``installed >= latest`` is UP-TO-DATE -- a tool that is *ahead* of the known + latest (e.g. ahead of a stale committed baseline) needs no upgrade and must + not be reported as OUTDATED. Only ``installed < latest`` is OUTDATED. Missing + installed -> NOT INSTALLED; missing latest -> UNKNOWN. + """ + if not installed: + return STATUS_NOT_INSTALLED + if not latest: + return "UNKNOWN" + from cli_audit.upgrade import compare_versions + if compare_versions(normalize_version(installed), normalize_version(latest)) < 0: + return "OUTDATED" + return "UP-TO-DATE" + + def collect_latest_version(tool: Tool, offline_cache: dict[str, tuple[str, str]] | None = None) -> tuple[str, str]: """Collect latest version for a tool. @@ -185,14 +207,8 @@ def audit_multi_version_tool( method = version_info.get("install_method", "") status_lifecycle = version_info.get("status", "unknown") - # Determine audit status - if installed: - if installed == latest: - status = "UP-TO-DATE" - else: - status = "OUTDATED" - else: - status = "NOT INSTALLED" + # Determine audit status (directional: installed >= latest is current) + status = compute_status(installed, latest) # Build versioned tool name versioned_name = f"{tool_name}@{cycle}" @@ -269,12 +285,11 @@ def audit_tool(tool: Tool, offline_cache: dict[str, tuple[str, str]] | None = No if version_line and version_line.startswith("CONFLICT:"): status = "CONFLICT" elif version_line == "X" or not installed: - status = "NOT INSTALLED" + status = STATUS_NOT_INSTALLED elif version_num and latest_num: - # Normalize versions for comparison (handles "7.28.00" vs "7.28.0") - normalized_installed = normalize_version(version_num) - normalized_latest = normalize_version(latest_num) - status = "UP-TO-DATE" if normalized_installed == normalized_latest else "OUTDATED" + # Directional: installed >= latest is UP-TO-DATE (also handles being + # ahead of a stale baseline and "7.28.00" vs "7.28.0"). + status = compute_status(version_num, latest_num) elif version_num and not latest_num: status = "UNKNOWN" else: @@ -304,7 +319,7 @@ def audit_tool(tool: Tool, offline_cache: dict[str, tuple[str, str]] | None = No "status": status, "tool_url": tool_url, "latest_url": latest_url, - "hint": tool.hint if status in ("NOT INSTALLED", "OUTDATED", "CONFLICT") else "", + "hint": tool.hint if status in (STATUS_NOT_INSTALLED, "OUTDATED", "CONFLICT") else "", } @@ -672,7 +687,7 @@ def cmd_update(args: argparse.Namespace) -> int: parts.append(f"{GREEN}{counts['UP-TO-DATE']} current{RESET}") if counts["OUTDATED"]: parts.append(f"{YELLOW}{counts['OUTDATED']} outdated{RESET}") - if counts["NOT INSTALLED"]: + if counts[STATUS_NOT_INSTALLED]: parts.append(f"{BLUE}{counts['NOT INSTALLED']} missing{RESET}") if counts["CONFLICT"]: parts.append(f"{YELLOW}{counts['CONFLICT']} conflict{RESET}") @@ -732,6 +747,11 @@ def cmd_update(args: argparse.Namespace) -> int: print(f"✓ Snapshot updated: {get_snapshot_path()}", file=sys.stderr) print(f"✓ Collected {meta['count']} tools", file=sys.stderr) + # Print the same Readiness summary the render (make upgrade) shows, so + # make update and make upgrade report identical totals. The per-category + # summary above omits multi-version cycles; this grand total includes them. + print_summary({"__meta__": {"count": len(results), "offline": OFFLINE_MODE}}, results) + # Report GitHub rate limit status rate_limit = get_github_rate_limit() if rate_limit: @@ -813,14 +833,11 @@ def cmd_update_local(args: argparse.Namespace) -> int: # Determine status using cached upstream cached = upstream_cache.versions.get(tool.name) if cached and installation.installed_version: - norm_inst = normalize_version(installation.installed_version) - norm_latest = normalize_version(cached.latest_version) - if norm_inst == norm_latest: - installation.status = "UP-TO-DATE" - else: - installation.status = "OUTDATED" + installation.status = compute_status( + installation.installed_version, cached.latest_version + ) elif not installation.installed_version: - installation.status = "NOT INSTALLED" + installation.status = STATUS_NOT_INSTALLED else: installation.status = "UNKNOWN" @@ -906,7 +923,7 @@ def cmd_update_local(args: argparse.Namespace) -> int: elif installed_v: status_v = "OUTDATED" else: - status_v = "NOT INSTALLED" + status_v = STATUS_NOT_INSTALLED method = info.get("install_method") versioned = f"{tool.name}@{cycle}" entry = dict(tools_by_name.get(versioned, {})) diff --git a/catalog/google-workspace-cli.json b/catalog/google-workspace-cli.json index 0e633ea..2133f46 100644 --- a/catalog/google-workspace-cli.json +++ b/catalog/google-workspace-cli.json @@ -6,7 +6,7 @@ "homepage": "https://github.com/googleworkspace/cli", "github_repo": "googleworkspace/cli", "binary_name": "gws", - "download_url_template": "https://github.com/googleworkspace/cli/releases/download/{version}/gws-{arch}-unknown-linux-gnu.tar.gz", + "download_url_template": "https://github.com/googleworkspace/cli/releases/download/{version}/google-workspace-cli-{arch}-unknown-linux-gnu.tar.gz", "arch_map": { "x86_64": "x86_64", "aarch64": "aarch64" diff --git a/catalog/yq.json b/catalog/yq.json index fed9d1e..236e402 100644 --- a/catalog/yq.json +++ b/catalog/yq.json @@ -6,6 +6,7 @@ "homepage": "https://github.com/mikefarah/yq", "github_repo": "mikefarah/yq", "binary_name": "yq", + "version_flag": "--version", "download_url_template": "https://github.com/mikefarah/yq/releases/download/{version}/yq_linux_{arch}", "arch_map": { "x86_64": "amd64", diff --git a/scripts/install_node.sh b/scripts/install_node.sh index 97625cb..b6626de 100755 --- a/scripts/install_node.sh +++ b/scripts/install_node.sh @@ -17,10 +17,25 @@ if [ -n "${NODE_VERSION:-}" ]; then fi ensure_nvm() { + # Load nvm first. nvm is a shell function, so `have nvm` is false until + # nvm.sh is sourced. Checking before loading made this re-run the web + # installer on every invocation -- and that installer reads NODE_VERSION and + # tries to install a matching node, producing confusing "Failed to install + # Node.js " noise during ordinary upgrades. + ensure_nvm_loaded if ! have nvm; then - curl -fsSL https://raw.githubusercontent.com/nvm-sh/nvm/v0.39.7/install.sh | bash + # nvm genuinely missing -- bootstrap it. Clear NODE_VERSION so the nvm + # installer does not additionally install a node for our channel variable. + curl -fsSL https://raw.githubusercontent.com/nvm-sh/nvm/v0.39.7/install.sh | NODE_VERSION="" bash + ensure_nvm_loaded + # Guard against an endless re-bootstrap loop: if nvm.sh exists but sourcing + # it still does not define `nvm` (e.g. a truncated/corrupt install), stop + # rather than silently re-downloading on every invocation. + if ! have nvm; then + log "[node] Error: nvm bootstrap did not yield a usable 'nvm' (corrupt ~/.nvm/nvm.sh?)" + return 1 + fi fi - ensure_nvm_loaded } # Get version of a specific Node.js major version (e.g., "24" -> "v24.13.0") diff --git a/scripts/installers/github_release_binary.sh b/scripts/installers/github_release_binary.sh index 07f3e29..e897dc7 100755 --- a/scripts/installers/github_release_binary.sh +++ b/scripts/installers/github_release_binary.sh @@ -42,22 +42,49 @@ PRESERVE_DIR="$(jq -r '.preserve_directory // empty' "$CATALOG_FILE")" VERSION_COMMAND="$(jq -r '.version_command // empty' "$CATALOG_FILE")" VERSION_FLAG="$(jq -r '.version_flag // empty' "$CATALOG_FILE")" -# Get current version -before="" -if command -v "$BINARY_NAME" >/dev/null 2>&1; then - if [ -n "$VERSION_COMMAND" ]; then - # Use catalog-specified shell command - before="$(timeout 2 bash -c "$VERSION_COMMAND" 2>/dev/null || true)" - elif [ -n "$VERSION_FLAG" ]; then - # Use catalog-specified version flag/subcommand - before="$(timeout 2 "$BINARY_NAME" $VERSION_FLAG /dev/null | head -1 || true)" +# A version-like token: a dotted version (1.2.3) or an 8-digit date (20240101), +# matching what normalize_version_output accepts. Used to gate the stderr +# fallback so a banner/warning line is not surfaced as the version. +GRB_VERSION_RE='[0-9]+\.[0-9]+|[0-9]{8}' + +# Detect the installed tool's version string. +# Prefers stdout but falls back to stderr, because some tools (e.g. gh-aw) +# print their --version output to stderr. The stderr fallback only accepts a +# line containing a version-like token so a stderr banner/warning is not +# surfaced as the version. Echoes empty if not detectable. +detect_version_string() { + # Resolve the binary: prefer PATH, fall back to the install dir. On a + # first-time install that dir may not be on PATH yet, so command -v alone + # would miss a binary we just placed there. + local bin_path bin_dir + bin_path="$(command -v "$BINARY_NAME" 2>/dev/null || true)" + if [[ -z "$bin_path" ]]; then + local target_dir + target_dir="$(get_install_dir "$BINARY_NAME" 2>/dev/null || true)" + [[ -n "$target_dir" ]] && [[ -x "$target_dir/$BINARY_NAME" ]] && bin_path="$target_dir/$BINARY_NAME" + fi + [[ -z "$bin_path" ]] && return 0 + bin_dir="$(dirname "$bin_path")" + local out="" + if [[ -n "$VERSION_COMMAND" ]]; then + out="$(PATH="$bin_dir:$PATH" timeout 3 bash -c "$VERSION_COMMAND" 2>/dev/null | head -1 || true)" + [[ -z "$out" ]] && out="$(PATH="$bin_dir:$PATH" timeout 3 bash -c "$VERSION_COMMAND" 2>&1 >/dev/null | grep -m1 -E "$GRB_VERSION_RE" || true)" + elif [[ -n "$VERSION_FLAG" ]]; then + out="$(timeout 3 "$bin_path" $VERSION_FLAG /dev/null | head -1 || true)" + [[ -z "$out" ]] && out="$(timeout 3 "$bin_path" $VERSION_FLAG &1 >/dev/null | grep -m1 -E "$GRB_VERSION_RE" || true)" else - # Fallback: try multiple version command formats - before="$(timeout 2 "$BINARY_NAME" --version /dev/null || \ - timeout 2 "$BINARY_NAME" version --client /dev/null | head -1 || \ - timeout 2 "$BINARY_NAME" version /dev/null | head -1 || true)" + out="$(timeout 3 "$bin_path" --version /dev/null | head -1 || true)" + [[ -z "$out" ]] && out="$(timeout 3 "$bin_path" version --client /dev/null | head -1 || true)" + [[ -z "$out" ]] && out="$(timeout 3 "$bin_path" version /dev/null | head -1 || true)" + # Last resort: capture stderr (tools like gh-aw print --version there) + [[ -z "$out" ]] && out="$(timeout 3 "$bin_path" --version &1 >/dev/null | grep -m1 -E "$GRB_VERSION_RE" || true)" + [[ -z "$out" ]] && out="$(timeout 3 "$bin_path" version &1 >/dev/null | grep -m1 -E "$GRB_VERSION_RE" || true)" fi -fi + printf '%s' "$out" +} + +# Get current version +before="$(detect_version_string)" # Detect OS and architecture OS="linux" @@ -276,18 +303,7 @@ if [ -n "$EXTRACT_DIR" ] && [ -d "$EXTRACT_DIR" ]; then fi # Report -after="" -if command -v "$BINARY_NAME" >/dev/null 2>&1; then - if [ -n "$VERSION_COMMAND" ]; then - after="$(timeout 2 bash -c "$VERSION_COMMAND" 2>/dev/null || true)" - elif [ -n "$VERSION_FLAG" ]; then - after="$(timeout 2 "$BINARY_NAME" $VERSION_FLAG /dev/null | head -1 || true)" - else - after="$(timeout 2 "$BINARY_NAME" --version /dev/null || \ - timeout 2 "$BINARY_NAME" version --client /dev/null | head -1 || \ - timeout 2 "$BINARY_NAME" version /dev/null | head -1 || true)" - fi -fi +after="$(detect_version_string)" # Normalize verbose version output before="$(normalize_version_output "${before:-}")" after="$(normalize_version_output "${after:-}")" diff --git a/scripts/installers/npm_global.sh b/scripts/installers/npm_global.sh index 35db14c..8a348bf 100755 --- a/scripts/installers/npm_global.sh +++ b/scripts/installers/npm_global.sh @@ -3,6 +3,7 @@ set -euo pipefail DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +. "$DIR/lib/common.sh" . "$DIR/lib/install_strategy.sh" # Load nvm if available (needed for node-based package managers) @@ -57,24 +58,35 @@ if [ -z "$PKG_MANAGER" ]; then exit 1 fi -# Version detection helper (uses catalog version_command/version_flag if available) +# Version detection helper (uses catalog version_command/version_flag if available). +# Takes the resolved binary PATH (which may live in npm's global bin dir even +# when that dir is off PATH) so detection works regardless of PATH state. get_npm_tool_version() { - if [ -n "$VERSION_COMMAND" ]; then - timeout 2 bash -c "$VERSION_COMMAND" 2>/dev/null || true + local bin_path="$1" + # Nothing to probe if the binary was not found on PATH or in npm's global bin. + # (A bare-name version_command could not resolve it either, so skip the run.) + [[ -z "$bin_path" ]] && return + local bin_dir + bin_dir="$(dirname "$bin_path")" + if [[ -n "$VERSION_COMMAND" ]]; then + # Only extend PATH when the resolved bin dir is genuinely off PATH: this lets + # a bare-name version_command resolve an off-PATH npm-global install, without + # letting that dir shadow normal PATH lookups (or a hostile npm prefix plant a + # sibling binary) in the common case. + local pfx="" + path_contains_dir "$bin_dir" || pfx="$bin_dir:" + PATH="${pfx}$PATH" timeout 8 bash -c "$VERSION_COMMAND" 2>/dev/null | head -1 || true return fi - local bin="$1" - if command -v "$bin" >/dev/null 2>&1; then - if [ -n "$VERSION_FLAG" ]; then - timeout 2 "$bin" $VERSION_FLAG /dev/null | head -1 || true - else - timeout 2 "$bin" --version /dev/null | head -1 || true - fi + if [[ -n "$VERSION_FLAG" ]]; then + timeout 8 "$bin_path" $VERSION_FLAG /dev/null | head -1 || true + else + timeout 8 "$bin_path" --version /dev/null | head -1 || true fi } -# Get current version -before="$(get_npm_tool_version "$BINARY_NAME")" +# Get current version (resolve_global_bin also checks npm's global bin dir) +before="$(get_npm_tool_version "$(resolve_global_bin "$BINARY_NAME")")" # Install or upgrade globally echo "[$TOOL] Installing package globally via $PKG_MANAGER: $PACKAGE_NAME" >&2 @@ -105,12 +117,16 @@ case "$PKG_MANAGER" in esac # Report -after="$(get_npm_tool_version "$BINARY_NAME")" - -path="$(command -v "$BINARY_NAME" 2>/dev/null || true)" +path="$(resolve_global_bin "$BINARY_NAME")" +after="$(get_npm_tool_version "$path")" printf "[%s] before: %s\n" "$TOOL" "${before:-}" printf "[%s] after: %s\n" "$TOOL" "${after:-}" -if [ -n "$path" ]; then printf "[%s] path: %s\n" "$TOOL" "$path"; fi +if [[ -n "$path" ]]; then + printf "[%s] path: %s\n" "$TOOL" "$path" + # Surface the real reason a freshly-installed tool can be "missing": npm put + # it in a global bin dir that is not on PATH. + warn_if_bin_off_path "$TOOL" "$path" +fi # Refresh snapshot after successful installation refresh_snapshot "$TOOL" diff --git a/scripts/lib/common.sh b/scripts/lib/common.sh index f30f7ef..d38cf16 100755 --- a/scripts/lib/common.sh +++ b/scripts/lib/common.sh @@ -66,6 +66,9 @@ is_path_under() { case "$1" in "$2"*) return 0 ;; *) return 1 ;; esac } prefers_nvm_node() { local p p="$(command -v node || true)" + # Resolve symlinks: a ~/.local/bin/node shim often points into ~/.nvm, in + # which case node is still nvm-managed and must not trigger the apt path. + [ -n "$p" ] && p="$(readlink -f "$p" 2>/dev/null || echo "$p")" is_path_under "$p" "$HOME/.nvm" || return 1 } @@ -92,6 +95,48 @@ prefers_rbenv_ruby() { is_path_under "$p" "$HOME/.rbenv" || return 1 } +# True if directory $1 is an exact member of PATH. +path_contains_dir() { + case ":$PATH:" in + *":$1:"*) return 0 ;; + *) return 1 ;; + esac +} + +# npm installs global packages into `npm prefix -g`/bin, which is NOT always on +# PATH (e.g. when a stale node shim hijacks the prefix). These helpers locate a +# globally-installed binary even when it landed off PATH, so a successful +# install is not misreported as or "binary not found". +npm_global_bin_dir() { + command -v npm >/dev/null 2>&1 || return 0 + local p + p="$(npm prefix -g 2>/dev/null || true)" + [[ -n "$p" ]] && printf '%s/bin' "$p" + return 0 +} + +# Resolve a global CLI binary path: prefer PATH, fall back to npm's global bin. +resolve_global_bin() { + local bin="$1" p + p="$(command -v "$bin" 2>/dev/null || true)" + if [[ -z "$p" ]]; then + local gdir + gdir="$(npm_global_bin_dir)" + [[ -n "$gdir" ]] && [[ -x "$gdir/$bin" ]] && p="$gdir/$bin" + fi + printf '%s' "$p" +} + +# Warn (to stderr) when a binary's directory is not on PATH. +warn_if_bin_off_path() { + local label="$1" bin_path="$2" + [[ -z "$bin_path" ]] && return 0 + local d + d="$(dirname "$bin_path")" + path_contains_dir "$d" && return 0 + log "[$label] warning: $d is not on PATH; your shell will not find '$(basename "$bin_path")' until you add that directory to PATH (for an nvm global bin, make it your nvm default, then run 'hash -r')" +} + # Ensure uv is available, offer to install if missing ensure_uv() { have uv && return 0 diff --git a/scripts/lib/reconcile.sh b/scripts/lib/reconcile.sh index 91dfd6c..1d4aa29 100755 --- a/scripts/lib/reconcile.sh +++ b/scripts/lib/reconcile.sh @@ -344,15 +344,23 @@ reconcile_tool() { return 1 fi - # Verify installation - if command -v "$binary_name" >/dev/null 2>&1; then - local new_method - new_method="$(detect_install_method "$tool" "$binary_name")" + # Verify installation. Resolve via PATH first, then npm's global bin dir -- + # an npm install can land in a prefix that is not on PATH, which previously + # made a successful install look like "binary not found". + local resolved_bin + resolved_bin="$(resolve_global_bin "$binary_name")" + if [ -n "$resolved_bin" ]; then + # Prepend the resolved binary's dir so detect_install_method (which uses + # command -v) can classify a binary that landed off PATH. + local bin_dir new_method + bin_dir="$(dirname "$resolved_bin")" + new_method="$(PATH="${bin_dir:+$bin_dir:}$PATH" detect_install_method "$tool" "$binary_name")" if [ "$current_method" = "$best_method" ]; then echo "[$tool] ✓ Upgrade complete (via $new_method)" >&2 else echo "[$tool] ✓ Reconciliation complete: now installed via $new_method" >&2 fi + warn_if_bin_off_path "$tool" "$resolved_bin" return 0 else echo "[$tool] Error: Installation via $best_method completed but binary not found" >&2 diff --git a/tests/test_update_fixes.py b/tests/test_update_fixes.py index fbddb76..2b9653b 100644 --- a/tests/test_update_fixes.py +++ b/tests/test_update_fixes.py @@ -930,3 +930,296 @@ def test_remove_installation_github_release_writability_check(self): assert "sudo rm" in content, ( "reconcile.sh must use 'sudo rm' as fallback for non-writable dirs" ) + + +# =========================================================================== +# make upgrade version-reporting fixes +# (yq verbose flag, gh-aw stderr, gws URL, node symlink, npm off-PATH) +# =========================================================================== + + +class TestCatalogYq: + """yq.json detection fix: -v means --verbose for mikefarah yq, so the + generic probe captured a DEBUG-log timestamp instead of the version.""" + + def test_yq_has_explicit_version_flag(self): + with open(CATALOG_DIR / "yq.json") as f: + data = json.load(f) + assert data.get("version_flag") == "--version", ( + "yq needs version_flag=--version; its -v flag is --verbose and emits " + "a debug log whose timestamp the version regex misparses" + ) + + +class TestCatalogGoogleWorkspaceCli: + """google-workspace-cli download URL fix: asset is named + google-workspace-cli--..., not gws--...""" + + def test_download_url_uses_correct_asset_prefix(self): + with open(CATALOG_DIR / "google-workspace-cli.json") as f: + data = json.load(f) + tmpl = data["download_url_template"] + assert "google-workspace-cli-{arch}-unknown-linux-gnu.tar.gz" in tmpl, ( + "asset prefix must be 'google-workspace-cli-', the real release asset name" + ) + assert "/gws-{arch}-" not in tmpl, "the gws- asset prefix does not exist upstream" + + +@skip_on_windows +class TestVersionLineRespectsFlag: + """get_version_line must use the catalog version_flag instead of the generic + flag probe, which tries -v first and can capture verbose/log output.""" + + def _fake_yq(self, tmp_path: Path) -> str: + # Mimics mikefarah yq: -v is verbose (debug log to stderr with a + # timestamp), --version prints the real version. + script = tmp_path / "fakeyq" + script.write_text( + "#!/bin/sh\n" + 'case "$1" in\n' + ' -v) echo \'time=2026-06-20T16:17:44.047+02:00 level=DEBUG ' + 'msg="processed args: []"\' >&2 ;;\n' + " --version) echo 'fakeyq (https://example/) version v4.53.3' ;;\n" + "esac\n" + ) + script.chmod(0o755) + return str(script) + + def test_without_flag_misparses_verbose_output(self, tmp_path): + """Regression: the generic probe (-v first) grabs the log timestamp.""" + from cli_audit.detection import extract_version_number, get_version_line + path = self._fake_yq(tmp_path) + line = get_version_line(path, "fakeyq") + # The bug: -v emits a DEBUG line whose timestamp (…44.047…) is misparsed + # as the version. Pin the exact misparse so this fails loudly if the + # generic probe ever stops exercising the -v-first behavior the fix guards. + assert extract_version_number(line) == "44.047" + + def test_with_version_flag_returns_clean_version(self, tmp_path): + """Fix: catalog version_flag=--version yields the real version.""" + from cli_audit.detection import extract_version_number, get_version_line + path = self._fake_yq(tmp_path) + line = get_version_line(path, "fakeyq", version_flag="--version") + assert extract_version_number(line) == "4.53.3" + + +@skip_on_windows +class TestGithubReleaseStderrVersion: + """github_release_binary.sh must detect versions printed to stderr (gh-aw + prints `gh aw version vX.Y.Z` to stderr).""" + + def _content(self) -> str: + return (SCRIPTS_DIR / "installers" / "github_release_binary.sh").read_text() + + def test_detect_version_helper_exists(self): + assert "detect_version_string()" in self._content() + + def test_detect_version_falls_back_to_stderr(self): + # `2>&1 >/dev/null` captures stderr while discarding stdout + assert "2>&1 >/dev/null" in self._content(), ( + "version detection must fall back to stderr for tools like gh-aw" + ) + + def test_before_and_after_use_helper(self): + content = self._content() + assert 'before="$(detect_version_string)"' in content + assert 'after="$(detect_version_string)"' in content + + +@skip_on_windows +class TestPrefersNvmNodeSymlink: + """prefers_nvm_node must resolve symlinks: a ~/.local/bin/node shim pointing + into ~/.nvm is still nvm-managed and must not trigger the apt removal path.""" + + def test_symlinked_node_is_detected_as_nvm(self, tmp_path): + nvm_bin = tmp_path / ".nvm" / "versions" / "node" / "v26.0.0" / "bin" + nvm_bin.mkdir(parents=True) + real_node = nvm_bin / "node" + real_node.write_text("#!/bin/sh\necho v26.0.0") + real_node.chmod(0o755) + local_bin = tmp_path / ".local" / "bin" + local_bin.mkdir(parents=True) + (local_bin / "node").symlink_to(real_node) + + result = subprocess.run( + ["bash", "-c", f""" + export HOME="{tmp_path}" + source scripts/lib/common.sh + export PATH="{local_bin}:$PATH" + if prefers_nvm_node; then echo NVM; else echo NOTNVM; fi + """], + capture_output=True, text=True, timeout=10, cwd=str(PROJECT_ROOT), + ) + assert "NVM" in result.stdout and "NOTNVM" not in result.stdout + + +@skip_on_windows +class TestResolveGlobalBin: + """resolve_global_bin must find a binary in npm's global bin dir even when + that dir is not on PATH (off-PATH npm prefix → eslint/gemini/pnpm ).""" + + def test_finds_binary_in_npm_global_bin_off_path(self, tmp_path): + prefix_bin = tmp_path / "prefix" / "bin" + prefix_bin.mkdir(parents=True) + tool = prefix_bin / "faketool" + tool.write_text("#!/bin/sh\necho 1.2.3") + tool.chmod(0o755) + fake_bin = tmp_path / "fakebin" + fake_bin.mkdir() + npm = fake_bin / "npm" + # `npm prefix -g` -> our prefix; faketool itself is NOT on PATH + npm.write_text(f'#!/bin/sh\n[ "$1" = "prefix" ] && echo "{tmp_path}/prefix"\nexit 0\n') + npm.chmod(0o755) + + result = subprocess.run( + ["bash", "-c", f""" + source scripts/lib/common.sh + export PATH="{fake_bin}:/usr/bin:/bin" + resolve_global_bin faketool + """], + capture_output=True, text=True, timeout=10, cwd=str(PROJECT_ROOT), + ) + assert str(tool) in result.stdout + + def test_warn_if_bin_off_path_warns_when_off_path(self): + result = subprocess.run( + ["bash", "-c", """ + source scripts/lib/common.sh + export PATH="/usr/bin:/bin" + warn_if_bin_off_path mytool /opt/nowhere/bin/mytool 2>&1 + """], + capture_output=True, text=True, timeout=10, cwd=str(PROJECT_ROOT), + ) + assert "not on PATH" in result.stdout + + def test_warn_if_bin_off_path_silent_when_on_path(self): + result = subprocess.run( + ["bash", "-c", """ + source scripts/lib/common.sh + export PATH="/usr/bin:/bin" + warn_if_bin_off_path mytool /bin/mytool 2>&1 + """], + capture_output=True, text=True, timeout=10, cwd=str(PROJECT_ROOT), + ) + assert "not on PATH" not in result.stdout + + +def _extract_shell_func(rel_path: str, func_name: str) -> str: + """Extract a top-level shell function (up to its column-0 closing brace) so + the REAL function can be eval'd and tested in isolation.""" + out: list[str] = [] + capturing = False + for ln in (SCRIPTS_DIR / rel_path).read_text().splitlines(): + if ln.startswith(f"{func_name}() {{"): + capturing = True + if capturing: + out.append(ln) + if ln == "}": + break + return "\n".join(out) + + +@skip_on_windows +class TestDetectVersionStringBehavior: + """Behavioral tests for github_release_binary.sh::detect_version_string — + the stderr fallback (gh-aw-style) and the version-token gate (banner reject).""" + + def _run(self, tmp_path, tool_body: str) -> str: + tool = tmp_path / "faketool" + tool.write_text("#!/bin/sh\n" + tool_body) + tool.chmod(0o755) + grb = next( + ln for ln in (SCRIPTS_DIR / "installers/github_release_binary.sh") + .read_text().splitlines() if ln.startswith("GRB_VERSION_RE=") + ) + func = _extract_shell_func("installers/github_release_binary.sh", "detect_version_string") + script = ( + f'export PATH="{tmp_path}:$PATH"\n' + # macOS/BSD has no `timeout`; shim it as a passthrough so the test is portable + 'if ! command -v timeout >/dev/null 2>&1; then timeout() { shift; "$@"; }; fi\n' + f'{grb}\n{func}\n' + "BINARY_NAME=faketool VERSION_COMMAND='' VERSION_FLAG='' detect_version_string\n" + ) + return subprocess.run( + ["bash", "-c", script], capture_output=True, text=True, + timeout=10, cwd=str(PROJECT_ROOT), + ).stdout + + def test_stderr_only_version_is_detected(self, tmp_path): + # gh-aw prints its --version to stderr + assert "1.2.3" in self._run(tmp_path, 'echo "faketool version v1.2.3" >&2\n') + + def test_stderr_banner_is_rejected(self, tmp_path): + # a no-version banner on stderr must NOT be surfaced as the version + assert self._run(tmp_path, 'echo "Welcome to faketool, see the docs" >&2\n').strip() == "" + + def test_stdout_version_wins(self, tmp_path): + assert "1.2.3" in self._run(tmp_path, 'echo "1.2.3"\n') + + +@skip_on_windows +class TestNpmGlobalVersionDetection: + """npm_global.sh::get_npm_tool_version only prepends an off-PATH bin dir to + PATH for a version_command, and never shadows an already-on-PATH lookup.""" + + def _run(self, bin_path: str, version_command: str, extra_path: str = "") -> str: + func = _extract_shell_func("installers/npm_global.sh", "get_npm_tool_version") + script = ( + "source scripts/lib/common.sh\n" + # macOS/BSD has no `timeout`; shim it as a passthrough so the test is portable + 'if ! command -v timeout >/dev/null 2>&1; then timeout() { shift; "$@"; }; fi\n' + f"{extra_path}\n{func}\n" + f'VERSION_COMMAND="{version_command}" VERSION_FLAG="" get_npm_tool_version "{bin_path}"\n' + ) + return subprocess.run( + ["bash", "-c", script], capture_output=True, text=True, + timeout=10, cwd=str(PROJECT_ROOT), + ).stdout + + def test_prepends_offpath_bindir_for_version_command(self, tmp_path): + off = tmp_path / "off" / "bin" + off.mkdir(parents=True) + tool = off / "faketool" + tool.write_text("#!/bin/sh\necho 9.9.9\n") + tool.chmod(0o755) + # off/bin is NOT on PATH; the bare-name version_command must still resolve it + assert "9.9.9" in self._run(str(tool), "faketool") + + def test_does_not_prepend_when_already_on_path(self, tmp_path): + onp = tmp_path / "onp" + onp.mkdir() + tool = onp / "faketool" + tool.write_text("#!/bin/sh\necho 1.0.0\n") + tool.chmod(0o755) + assert "1.0.0" in self._run(str(tool), "faketool", extra_path=f'export PATH="{onp}:$PATH"') + + +class TestComputeStatusDirection: + """audit.compute_status uses DIRECTIONAL comparison: a tool ahead of the + (possibly stale) baseline is UP-TO-DATE, not OUTDATED. Regression for the + 'make update (3 outdated) vs make upgrade (53 outdated)' data-model bug.""" + + def test_installed_ahead_of_stale_baseline_is_up_to_date(self): + import audit + # ansible-core ahead of a stale committed baseline must NOT be OUTDATED + assert audit.compute_status("2.21.1", "2.20.1") == "UP-TO-DATE" + assert audit.compute_status("0.141.0", "0.101.0") == "UP-TO-DATE" + + def test_installed_behind_is_outdated(self): + import audit + assert audit.compute_status("0.9.0", "0.10.0") == "OUTDATED" + + def test_equal_is_up_to_date(self): + import audit + assert audit.compute_status("1.2.3", "1.2.3") == "UP-TO-DATE" + # trailing-zero normalization + assert audit.compute_status("7.28.00", "7.28.0") == "UP-TO-DATE" + + def test_missing_installed_is_not_installed(self): + import audit + assert audit.compute_status("", "1.0.0") == "NOT INSTALLED" + + def test_missing_latest_is_unknown(self): + import audit + # no known latest -> UNKNOWN, never a false OUTDATED + assert audit.compute_status("1.0.0", "") == "UNKNOWN"