From 5feb6b95f49be3ee820b935b5bfc4a44cdd18f79 Mon Sep 17 00:00:00 2001 From: Sebastian Mendel Date: Sat, 20 Jun 2026 17:14:32 +0200 Subject: [PATCH 01/14] fix(catalog): use --version flag for yq version detection yq's -v flag means --verbose, not --version. The generic version probe tries -v first, so it captured a DEBUG log line and parsed the timestamp millis (e.g. 19.169) as the version. Pin version_flag to --version so detection reads 'yq ... version v4.53.3'. Signed-off-by: Sebastian Mendel --- catalog/yq.json | 1 + 1 file changed, 1 insertion(+) 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", From 4eb5066c0386cefdff6926bf33049b936f128514 Mon Sep 17 00:00:00 2001 From: Sebastian Mendel Date: Sat, 20 Jun 2026 17:14:32 +0200 Subject: [PATCH 02/14] fix(catalog): correct google-workspace-cli release asset name The release asset is google-workspace-cli--unknown-linux-gnu.tar.gz, not gws--... The wrong template 404'd ('Download failed'). The binary inside the archive is still 'gws'. Signed-off-by: Sebastian Mendel --- catalog/google-workspace-cli.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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" From 5ee895c37d0601efa7a67f6f887cef26c90508ff Mon Sep 17 00:00:00 2001 From: Sebastian Mendel Date: Sat, 20 Jun 2026 17:14:32 +0200 Subject: [PATCH 03/14] fix(scripts): detect version output sent to stderr in github_release_binary gh-aw prints 'gh aw version vX.Y.Z' to stderr, but the before/after probes redirected stderr to /dev/null, so both showed . Add a shared detect_version_string helper that prefers stdout and falls back to stderr. Signed-off-by: Sebastian Mendel --- scripts/installers/github_release_binary.sh | 46 ++++++++++----------- 1 file changed, 22 insertions(+), 24 deletions(-) diff --git a/scripts/installers/github_release_binary.sh b/scripts/installers/github_release_binary.sh index 07f3e29..a383e50 100755 --- a/scripts/installers/github_release_binary.sh +++ b/scripts/installers/github_release_binary.sh @@ -42,22 +42,31 @@ 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 +# 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. Echoes empty if not detectable. +detect_version_string() { + command -v "$BINARY_NAME" >/dev/null 2>&1 || return 0 + local out="" if [ -n "$VERSION_COMMAND" ]; then - # Use catalog-specified shell command - before="$(timeout 2 bash -c "$VERSION_COMMAND" 2>/dev/null || true)" + out="$(timeout 3 bash -c "$VERSION_COMMAND" 2>/dev/null | head -1 || true)" + [ -z "$out" ] && out="$(timeout 3 bash -c "$VERSION_COMMAND" 2>&1 >/dev/null | head -1 || true)" elif [ -n "$VERSION_FLAG" ]; then - # Use catalog-specified version flag/subcommand - before="$(timeout 2 "$BINARY_NAME" $VERSION_FLAG /dev/null | head -1 || true)" + out="$(timeout 3 "$BINARY_NAME" $VERSION_FLAG /dev/null | head -1 || true)" + [ -z "$out" ] && out="$(timeout 3 "$BINARY_NAME" $VERSION_FLAG &1 >/dev/null | head -1 || 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 "$BINARY_NAME" --version /dev/null | head -1 || true)" + [ -z "$out" ] && out="$(timeout 3 "$BINARY_NAME" version --client /dev/null | head -1 || true)" + [ -z "$out" ] && out="$(timeout 3 "$BINARY_NAME" version /dev/null | head -1 || true)" + # Last resort: capture stderr (tools like gh-aw print --version there) + [ -z "$out" ] && out="$(timeout 3 "$BINARY_NAME" --version &1 >/dev/null | head -1 || true)" + [ -z "$out" ] && out="$(timeout 3 "$BINARY_NAME" version &1 >/dev/null | head -1 || true)" fi -fi + printf '%s' "$out" +} + +# Get current version +before="$(detect_version_string)" # Detect OS and architecture OS="linux" @@ -276,18 +285,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:-}")" From b6d22242dc2651cafc42382a1f5ab9a3bf804009 Mon Sep 17 00:00:00 2001 From: Sebastian Mendel Date: Sat, 20 Jun 2026 17:14:32 +0200 Subject: [PATCH 04/14] fix(scripts): locate binaries by resolving symlinks and npm's global prefix Three PATH-trust bugs surfaced during make upgrade: - prefers_nvm_node compared the unresolved symlink path, so a ~/.local/bin/node shim into ~/.nvm was misread as a manual install and triggered the apt removal path. Resolve symlinks first. - ensure_nvm checked 'nvm' before sourcing nvm.sh, re-running the nvm web installer every run (which hijacked NODE_VERSION, printing 'Failed to install Node.js '). Source nvm first; clear NODE_VERSION on bootstrap. - npm installs globals into 'npm prefix -g'/bin, which is not always on PATH; command -v then reported freshly-installed tools as or 'binary not found'. Add resolve_global_bin/warn_if_bin_off_path and use them in npm_global.sh and reconcile.sh. Signed-off-by: Sebastian Mendel --- scripts/install_node.sh | 12 ++++++++-- scripts/installers/npm_global.sh | 40 ++++++++++++++++++++------------ scripts/lib/common.sh | 38 ++++++++++++++++++++++++++++++ scripts/lib/reconcile.sh | 9 +++++-- 4 files changed, 80 insertions(+), 19 deletions(-) diff --git a/scripts/install_node.sh b/scripts/install_node.sh index 97625cb..c913668 100755 --- a/scripts/install_node.sh +++ b/scripts/install_node.sh @@ -17,10 +17,18 @@ 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 fi - ensure_nvm_loaded } # Get version of a specific Node.js major version (e.g., "24" -> "v24.13.0") diff --git a/scripts/installers/npm_global.sh b/scripts/installers/npm_global.sh index 35db14c..ae05b86 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,29 @@ 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() { + local bin_path="$1" + local bin_dir="" + [ -n "$bin_path" ] && bin_dir="$(dirname "$bin_path")" if [ -n "$VERSION_COMMAND" ]; then - timeout 2 bash -c "$VERSION_COMMAND" 2>/dev/null || true + # Prepend the resolved bin dir so a version_command that calls the bare + # binary name still resolves when that dir is not on PATH. + PATH="${bin_dir:+$bin_dir:}$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 + [ -z "$bin_path" ] && return + 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 +111,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..8604d94 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,41 @@ prefers_rbenv_ruby() { is_path_under "$p" "$HOME/.rbenv" || return 1 } +# 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" +} + +# 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")" + case ":$PATH:" in + *":$d:"*) return 0 ;; + esac + log "[$label] warning: $d is not on PATH; your shell will not find '$(basename "$bin_path")' until that directory is on PATH (e.g. 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..404649e 100755 --- a/scripts/lib/reconcile.sh +++ b/scripts/lib/reconcile.sh @@ -344,8 +344,12 @@ reconcile_tool() { return 1 fi - # Verify installation - if command -v "$binary_name" >/dev/null 2>&1; then + # 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 local new_method new_method="$(detect_install_method "$tool" "$binary_name")" if [ "$current_method" = "$best_method" ]; then @@ -353,6 +357,7 @@ reconcile_tool() { 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 From 639bb336795d904eb209f9e1abb9e4c757fccff5 Mon Sep 17 00:00:00 2001 From: Sebastian Mendel Date: Sat, 20 Jun 2026 17:14:32 +0200 Subject: [PATCH 05/14] test: regression tests for make upgrade version-reporting fixes Covers yq version_flag, google-workspace-cli asset URL, github_release stderr fallback, prefers_nvm_node symlink resolution, and off-PATH npm global resolution. Signed-off-by: Sebastian Mendel --- tests/test_update_fixes.py | 169 +++++++++++++++++++++++++++++++++++++ 1 file changed, 169 insertions(+) diff --git a/tests/test_update_fixes.py b/tests/test_update_fixes.py index fbddb76..1cce295 100644 --- a/tests/test_update_fixes.py +++ b/tests/test_update_fixes.py @@ -930,3 +930,172 @@ 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" + + +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: extracts millis from the debug timestamp, not the version + assert extract_version_number(line) != "4.53.3" + + 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 From 5ea9be6d20e112eb812e77d29e7a756cf7adbe09 Mon Sep 17 00:00:00 2001 From: Sebastian Mendel Date: Sat, 20 Jun 2026 17:23:11 +0200 Subject: [PATCH 06/14] fix(scripts): refresh local state in upgrade guide to avoid stale installed display The guide rendered 'installed:' from a possibly-stale snapshot while the installers detect the live 'before:'. When the snapshot lagged reality the two contradicted each other (e.g. 'installed: not installed' directly above 'before: 10.5.0', or 'installed: 11.12.1' above 'before: 11.17.0'). Refresh local installation state (network-free 'audit.py --update-local') at guide startup so the displayed status matches what the installers see. Upstream 'target' versions still come from the cached baseline, so the guide stays offline. Signed-off-by: Sebastian Mendel --- Makefile.d/user.mk | 2 +- scripts/guide.sh | 8 +++++++- tests/test_update_fixes.py | 21 +++++++++++++++++++++ 3 files changed, 29 insertions(+), 2 deletions(-) diff --git a/Makefile.d/user.mk b/Makefile.d/user.mk index d4d07b0..0ec9022 100644 --- a/Makefile.d/user.mk +++ b/Makefile.d/user.mk @@ -20,7 +20,7 @@ update: ## Collect fresh version data with network calls and update snapshot (~1 @$(MAKE) check-python-managers 2>/dev/null || true @$(MAKE) check-node-managers 2>/dev/null || true -upgrade: scripts-perms ## Run interactive upgrade guide (uses snapshot, no network calls) +upgrade: scripts-perms ## Run interactive upgrade guide (refreshes local state first, no network calls) @bash scripts/guide.sh cleanup: scripts-perms ## Interactive removal of installed tools diff --git a/scripts/guide.sh b/scripts/guide.sh index d49b463..be6120e 100755 --- a/scripts/guide.sh +++ b/scripts/guide.sh @@ -96,7 +96,13 @@ reload_audit_json() { fi } -echo "Gathering current tool status from snapshot..." +# Refresh local installation state first (network-free). The installers detect +# the live "before:" version, so rendering "installed:" from a stale snapshot +# can contradict it (e.g. "installed: not installed" right above +# "before: 10.5.0"). Refreshing here keeps both in agreement. Upstream "target" +# versions still come from the cached baseline, so this stays offline. +echo "Refreshing local tool status (no network)..." +(cd "$ROOT" && "$CLI" audit.py --update-local >/dev/null 2>&1) || true AUDIT_OUTPUT="$(cd "$ROOT" && CLI_AUDIT_RENDER=1 CLI_AUDIT_LINKS=0 CLI_AUDIT_EMOJI=0 "$CLI" audit.py || true)" AUDIT_JSON="$(cd "$ROOT" && CLI_AUDIT_JSON=1 CLI_AUDIT_RENDER=1 "$CLI" audit.py || true)" diff --git a/tests/test_update_fixes.py b/tests/test_update_fixes.py index 1cce295..e02f522 100644 --- a/tests/test_update_fixes.py +++ b/tests/test_update_fixes.py @@ -1099,3 +1099,24 @@ def test_warn_if_bin_off_path_silent_when_on_path(self): capture_output=True, text=True, timeout=10, cwd=str(PROJECT_ROOT), ) assert "not on PATH" not in result.stdout + + +@skip_on_windows +class TestGuideRefreshesLocalState: + """guide.sh must refresh local installed state before rendering, so the + displayed 'installed:' agrees with the installers' live 'before:' instead of + showing stale-snapshot conflicts (e.g. 'not installed' above 'before: X').""" + + def _content(self) -> str: + return (SCRIPTS_DIR / "guide.sh").read_text() + + def test_guide_refreshes_local_state(self): + assert "audit.py --update-local" in self._content(), ( + "guide.sh must refresh local installed state before display" + ) + + def test_refresh_runs_before_render(self): + content = self._content() + refresh_pos = content.index("audit.py --update-local") + render_pos = content.index('AUDIT_JSON="$(cd "$ROOT" && CLI_AUDIT_JSON=1 CLI_AUDIT_RENDER=1') + assert refresh_pos < render_pos, "local-state refresh must precede the render" From 86b265a9015878cbcf1b90fe5491528c3cb4b515 Mon Sep 17 00:00:00 2001 From: Sebastian Mendel Date: Sat, 20 Jun 2026 17:31:35 +0200 Subject: [PATCH 07/14] fix(scripts): harden version detection per review feedback - github_release_binary: gate the stderr version fallback on a version-like token so a stderr banner/warning is not surfaced as the version. - npm_global: only prepend the npm-global bin dir to PATH for a version_command when it is genuinely off PATH, so a stray or hostile npm prefix cannot shadow normal PATH lookups in the common (on-PATH) case. - common: generalize the off-PATH warning message (it is used for non-nvm tools too). - tests: pin the yq verbose-misparse regression to the exact misparsed value instead of a weak inequality. Signed-off-by: Sebastian Mendel --- scripts/installers/github_release_binary.sh | 12 +++++++----- scripts/installers/npm_global.sh | 16 +++++++++++++--- scripts/lib/common.sh | 2 +- tests/test_update_fixes.py | 6 ++++-- 4 files changed, 25 insertions(+), 11 deletions(-) diff --git a/scripts/installers/github_release_binary.sh b/scripts/installers/github_release_binary.sh index a383e50..f666018 100755 --- a/scripts/installers/github_release_binary.sh +++ b/scripts/installers/github_release_binary.sh @@ -44,23 +44,25 @@ VERSION_FLAG="$(jq -r '.version_flag // empty' "$CATALOG_FILE")" # 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. Echoes empty if not detectable. +# 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() { command -v "$BINARY_NAME" >/dev/null 2>&1 || return 0 local out="" if [ -n "$VERSION_COMMAND" ]; then out="$(timeout 3 bash -c "$VERSION_COMMAND" 2>/dev/null | head -1 || true)" - [ -z "$out" ] && out="$(timeout 3 bash -c "$VERSION_COMMAND" 2>&1 >/dev/null | head -1 || true)" + [ -z "$out" ] && out="$(timeout 3 bash -c "$VERSION_COMMAND" 2>&1 >/dev/null | grep -m1 -E '[0-9]+\.[0-9]+' || true)" elif [ -n "$VERSION_FLAG" ]; then out="$(timeout 3 "$BINARY_NAME" $VERSION_FLAG /dev/null | head -1 || true)" - [ -z "$out" ] && out="$(timeout 3 "$BINARY_NAME" $VERSION_FLAG &1 >/dev/null | head -1 || true)" + [ -z "$out" ] && out="$(timeout 3 "$BINARY_NAME" $VERSION_FLAG &1 >/dev/null | grep -m1 -E '[0-9]+\.[0-9]+' || true)" else out="$(timeout 3 "$BINARY_NAME" --version /dev/null | head -1 || true)" [ -z "$out" ] && out="$(timeout 3 "$BINARY_NAME" version --client /dev/null | head -1 || true)" [ -z "$out" ] && out="$(timeout 3 "$BINARY_NAME" version /dev/null | head -1 || true)" # Last resort: capture stderr (tools like gh-aw print --version there) - [ -z "$out" ] && out="$(timeout 3 "$BINARY_NAME" --version &1 >/dev/null | head -1 || true)" - [ -z "$out" ] && out="$(timeout 3 "$BINARY_NAME" version &1 >/dev/null | head -1 || true)" + [ -z "$out" ] && out="$(timeout 3 "$BINARY_NAME" --version &1 >/dev/null | grep -m1 -E '[0-9]+\.[0-9]+' || true)" + [ -z "$out" ] && out="$(timeout 3 "$BINARY_NAME" version &1 >/dev/null | grep -m1 -E '[0-9]+\.[0-9]+' || true)" fi printf '%s' "$out" } diff --git a/scripts/installers/npm_global.sh b/scripts/installers/npm_global.sh index ae05b86..afebf1f 100755 --- a/scripts/installers/npm_global.sh +++ b/scripts/installers/npm_global.sh @@ -66,9 +66,19 @@ get_npm_tool_version() { local bin_dir="" [ -n "$bin_path" ] && bin_dir="$(dirname "$bin_path")" if [ -n "$VERSION_COMMAND" ]; then - # Prepend the resolved bin dir so a version_command that calls the bare - # binary name still resolves when that dir is not on PATH. - PATH="${bin_dir:+$bin_dir:}$PATH" timeout 8 bash -c "$VERSION_COMMAND" 2>/dev/null | head -1 || true + # version_command runs even when bin_path is empty (the catalog command may + # locate the tool itself). 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="" + if [ -n "$bin_dir" ]; then + case ":$PATH:" in + *":$bin_dir:"*) : ;; + *) pfx="$bin_dir:" ;; + esac + fi + PATH="${pfx}$PATH" timeout 8 bash -c "$VERSION_COMMAND" 2>/dev/null | head -1 || true return fi [ -z "$bin_path" ] && return diff --git a/scripts/lib/common.sh b/scripts/lib/common.sh index 8604d94..e147dd0 100755 --- a/scripts/lib/common.sh +++ b/scripts/lib/common.sh @@ -127,7 +127,7 @@ warn_if_bin_off_path() { case ":$PATH:" in *":$d:"*) return 0 ;; esac - log "[$label] warning: $d is not on PATH; your shell will not find '$(basename "$bin_path")' until that directory is on PATH (e.g. make it your nvm default, then run 'hash -r')" + 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 diff --git a/tests/test_update_fixes.py b/tests/test_update_fixes.py index e02f522..41e22f0 100644 --- a/tests/test_update_fixes.py +++ b/tests/test_update_fixes.py @@ -989,8 +989,10 @@ def test_without_flag_misparses_verbose_output(self, tmp_path): 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: extracts millis from the debug timestamp, not the version - assert extract_version_number(line) != "4.53.3" + # 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 1af971a64a69c53f4252dec3c2090fae933e3873 Mon Sep 17 00:00:00 2001 From: Sebastian Mendel Date: Sat, 20 Jun 2026 17:32:48 +0200 Subject: [PATCH 08/14] test: skip shell-based version-line test on Windows TestVersionLineRespectsFlag executes a #!/bin/sh fake binary via get_version_line, which cannot run on the Windows CI runner. Mark it @skip_on_windows like the other shell-based test classes; the JSON-only catalog tests still run on all platforms. Signed-off-by: Sebastian Mendel --- tests/test_update_fixes.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/test_update_fixes.py b/tests/test_update_fixes.py index 41e22f0..81f3adc 100644 --- a/tests/test_update_fixes.py +++ b/tests/test_update_fixes.py @@ -965,6 +965,7 @@ def test_download_url_uses_correct_asset_prefix(self): 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.""" From 60537af096d286f3699e76fcb367fc44db5d66ef Mon Sep 17 00:00:00 2001 From: Sebastian Mendel Date: Sat, 20 Jun 2026 18:01:23 +0200 Subject: [PATCH 09/14] fix(scripts): drop ineffective guide local-state refresh The startup 'audit.py --update-local' added to the guide writes local_state.json, but the guide's render reads tools_snapshot.json (load_snapshot), so the refresh never updated the displayed 'installed:' status -- it only added ~5s latency. Remove it. Installed-vs-snapshot consistency is a separate data-model concern (tools_snapshot.json vs local_state.json vs the upstream baseline). Signed-off-by: Sebastian Mendel --- Makefile.d/user.mk | 2 +- scripts/guide.sh | 8 +------- 2 files changed, 2 insertions(+), 8 deletions(-) diff --git a/Makefile.d/user.mk b/Makefile.d/user.mk index 0ec9022..d4d07b0 100644 --- a/Makefile.d/user.mk +++ b/Makefile.d/user.mk @@ -20,7 +20,7 @@ update: ## Collect fresh version data with network calls and update snapshot (~1 @$(MAKE) check-python-managers 2>/dev/null || true @$(MAKE) check-node-managers 2>/dev/null || true -upgrade: scripts-perms ## Run interactive upgrade guide (refreshes local state first, no network calls) +upgrade: scripts-perms ## Run interactive upgrade guide (uses snapshot, no network calls) @bash scripts/guide.sh cleanup: scripts-perms ## Interactive removal of installed tools diff --git a/scripts/guide.sh b/scripts/guide.sh index be6120e..d49b463 100755 --- a/scripts/guide.sh +++ b/scripts/guide.sh @@ -96,13 +96,7 @@ reload_audit_json() { fi } -# Refresh local installation state first (network-free). The installers detect -# the live "before:" version, so rendering "installed:" from a stale snapshot -# can contradict it (e.g. "installed: not installed" right above -# "before: 10.5.0"). Refreshing here keeps both in agreement. Upstream "target" -# versions still come from the cached baseline, so this stays offline. -echo "Refreshing local tool status (no network)..." -(cd "$ROOT" && "$CLI" audit.py --update-local >/dev/null 2>&1) || true +echo "Gathering current tool status from snapshot..." AUDIT_OUTPUT="$(cd "$ROOT" && CLI_AUDIT_RENDER=1 CLI_AUDIT_LINKS=0 CLI_AUDIT_EMOJI=0 "$CLI" audit.py || true)" AUDIT_JSON="$(cd "$ROOT" && CLI_AUDIT_JSON=1 CLI_AUDIT_RENDER=1 "$CLI" audit.py || true)" From 3e3f9e405a293c97c7c47f158ebedb24238ef0cf Mon Sep 17 00:00:00 2001 From: Sebastian Mendel Date: Sat, 20 Jun 2026 18:01:23 +0200 Subject: [PATCH 10/14] fix(scripts): harden version detection per multi-perspective review - github_release_binary: extract the version-token regex to a constant (Sonar S1192), use [[ ]] (S7688), and broaden the stderr gate to also accept 8-digit dates so a dotless/CalVer version is not dropped (matches normalize_version_output). - npm_global / common: use [[ ]]; add path_contains_dir helper and reuse it; only prepend the npm-global bin dir to PATH when it is genuinely off PATH. - common: npm_global_bin_dir now always returns 0 (safe to call bare). - install_node: guard ensure_nvm against an endless re-bootstrap when ~/.nvm/nvm.sh exists but is corrupt. - tests: add behavioral tests for the stderr/banner gate and the npm PATH-prepend gating; strengthen the yq verbose-misparse assertion; drop the obsolete guide test. Signed-off-by: Sebastian Mendel --- scripts/install_node.sh | 7 ++ scripts/installers/github_release_binary.sh | 21 +++-- scripts/installers/npm_global.sh | 17 ++-- scripts/lib/common.sh | 21 +++-- tests/test_update_fixes.py | 94 +++++++++++++++++---- 5 files changed, 120 insertions(+), 40 deletions(-) diff --git a/scripts/install_node.sh b/scripts/install_node.sh index c913668..b6626de 100755 --- a/scripts/install_node.sh +++ b/scripts/install_node.sh @@ -28,6 +28,13 @@ ensure_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 } diff --git a/scripts/installers/github_release_binary.sh b/scripts/installers/github_release_binary.sh index f666018..22be52a 100755 --- a/scripts/installers/github_release_binary.sh +++ b/scripts/installers/github_release_binary.sh @@ -42,6 +42,11 @@ 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")" +# 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 @@ -50,19 +55,19 @@ VERSION_FLAG="$(jq -r '.version_flag // empty' "$CATALOG_FILE")" detect_version_string() { command -v "$BINARY_NAME" >/dev/null 2>&1 || return 0 local out="" - if [ -n "$VERSION_COMMAND" ]; then + if [[ -n "$VERSION_COMMAND" ]]; then out="$(timeout 3 bash -c "$VERSION_COMMAND" 2>/dev/null | head -1 || true)" - [ -z "$out" ] && out="$(timeout 3 bash -c "$VERSION_COMMAND" 2>&1 >/dev/null | grep -m1 -E '[0-9]+\.[0-9]+' || true)" - elif [ -n "$VERSION_FLAG" ]; then + [[ -z "$out" ]] && out="$(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 "$BINARY_NAME" $VERSION_FLAG /dev/null | head -1 || true)" - [ -z "$out" ] && out="$(timeout 3 "$BINARY_NAME" $VERSION_FLAG &1 >/dev/null | grep -m1 -E '[0-9]+\.[0-9]+' || true)" + [[ -z "$out" ]] && out="$(timeout 3 "$BINARY_NAME" $VERSION_FLAG &1 >/dev/null | grep -m1 -E "$GRB_VERSION_RE" || true)" else out="$(timeout 3 "$BINARY_NAME" --version /dev/null | head -1 || true)" - [ -z "$out" ] && out="$(timeout 3 "$BINARY_NAME" version --client /dev/null | head -1 || true)" - [ -z "$out" ] && out="$(timeout 3 "$BINARY_NAME" version /dev/null | head -1 || true)" + [[ -z "$out" ]] && out="$(timeout 3 "$BINARY_NAME" version --client /dev/null | head -1 || true)" + [[ -z "$out" ]] && out="$(timeout 3 "$BINARY_NAME" version /dev/null | head -1 || true)" # Last resort: capture stderr (tools like gh-aw print --version there) - [ -z "$out" ] && out="$(timeout 3 "$BINARY_NAME" --version &1 >/dev/null | grep -m1 -E '[0-9]+\.[0-9]+' || true)" - [ -z "$out" ] && out="$(timeout 3 "$BINARY_NAME" version &1 >/dev/null | grep -m1 -E '[0-9]+\.[0-9]+' || true)" + [[ -z "$out" ]] && out="$(timeout 3 "$BINARY_NAME" --version &1 >/dev/null | grep -m1 -E "$GRB_VERSION_RE" || true)" + [[ -z "$out" ]] && out="$(timeout 3 "$BINARY_NAME" version &1 >/dev/null | grep -m1 -E "$GRB_VERSION_RE" || true)" fi printf '%s' "$out" } diff --git a/scripts/installers/npm_global.sh b/scripts/installers/npm_global.sh index afebf1f..79f310f 100755 --- a/scripts/installers/npm_global.sh +++ b/scripts/installers/npm_global.sh @@ -64,25 +64,22 @@ fi get_npm_tool_version() { local bin_path="$1" local bin_dir="" - [ -n "$bin_path" ] && bin_dir="$(dirname "$bin_path")" - if [ -n "$VERSION_COMMAND" ]; then + [[ -n "$bin_path" ]] && bin_dir="$(dirname "$bin_path")" + if [[ -n "$VERSION_COMMAND" ]]; then # version_command runs even when bin_path is empty (the catalog command may # locate the tool itself). 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="" - if [ -n "$bin_dir" ]; then - case ":$PATH:" in - *":$bin_dir:"*) : ;; - *) pfx="$bin_dir:" ;; - esac + if [[ -n "$bin_dir" ]] && ! path_contains_dir "$bin_dir"; then + pfx="$bin_dir:" fi PATH="${pfx}$PATH" timeout 8 bash -c "$VERSION_COMMAND" 2>/dev/null | head -1 || true return fi - [ -z "$bin_path" ] && return - if [ -n "$VERSION_FLAG" ]; then + [[ -z "$bin_path" ]] && return + 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 @@ -125,7 +122,7 @@ 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 +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. diff --git a/scripts/lib/common.sh b/scripts/lib/common.sh index e147dd0..d38cf16 100755 --- a/scripts/lib/common.sh +++ b/scripts/lib/common.sh @@ -95,6 +95,14 @@ 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 @@ -103,17 +111,18 @@ 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" + [[ -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 + if [[ -z "$p" ]]; then local gdir gdir="$(npm_global_bin_dir)" - [ -n "$gdir" ] && [ -x "$gdir/$bin" ] && p="$gdir/$bin" + [[ -n "$gdir" ]] && [[ -x "$gdir/$bin" ]] && p="$gdir/$bin" fi printf '%s' "$p" } @@ -121,12 +130,10 @@ resolve_global_bin() { # 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 + [[ -z "$bin_path" ]] && return 0 local d d="$(dirname "$bin_path")" - case ":$PATH:" in - *":$d:"*) return 0 ;; - esac + 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')" } diff --git a/tests/test_update_fixes.py b/tests/test_update_fixes.py index 81f3adc..082f76b 100644 --- a/tests/test_update_fixes.py +++ b/tests/test_update_fixes.py @@ -1104,22 +1104,86 @@ def test_warn_if_bin_off_path_silent_when_on_path(self): assert "not on PATH" not in result.stdout -@skip_on_windows -class TestGuideRefreshesLocalState: - """guide.sh must refresh local installed state before rendering, so the - displayed 'installed:' agrees with the installers' live 'before:' instead of - showing stale-snapshot conflicts (e.g. 'not installed' above 'before: X').""" +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) - def _content(self) -> str: - return (SCRIPTS_DIR / "guide.sh").read_text() - def test_guide_refreshes_local_state(self): - assert "audit.py --update-local" in self._content(), ( - "guide.sh must refresh local installed state before display" +@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{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_refresh_runs_before_render(self): - content = self._content() - refresh_pos = content.index("audit.py --update-local") - render_pos = content.index('AUDIT_JSON="$(cd "$ROOT" && CLI_AUDIT_JSON=1 CLI_AUDIT_RENDER=1') - assert refresh_pos < render_pos, "local-state refresh must precede the render" + 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" + 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"') From 0576770ce2543c8f764ff15e759893fb67b059aa Mon Sep 17 00:00:00 2001 From: Sebastian Mendel Date: Sat, 20 Jun 2026 18:04:57 +0200 Subject: [PATCH 11/14] test: shim timeout in portable behavioral tests detect_version_string and get_npm_tool_version use 'timeout', which BSD/macOS lacks, so the extracted-function tests returned empty on the macOS runner. Add a passthrough timeout() shim when the command is absent so the tests run on macOS. Signed-off-by: Sebastian Mendel --- tests/test_update_fixes.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/tests/test_update_fixes.py b/tests/test_update_fixes.py index 082f76b..adca73b 100644 --- a/tests/test_update_fixes.py +++ b/tests/test_update_fixes.py @@ -1134,7 +1134,10 @@ def _run(self, tmp_path, tool_body: str) -> str: ) func = _extract_shell_func("installers/github_release_binary.sh", "detect_version_string") script = ( - f'export PATH="{tmp_path}:$PATH"\n{grb}\n{func}\n' + 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( @@ -1163,6 +1166,8 @@ 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' ) From ac855a29c00e6e0962a269e030436eedb81760e7 Mon Sep 17 00:00:00 2001 From: Sebastian Mendel Date: Sat, 20 Jun 2026 18:29:02 +0200 Subject: [PATCH 12/14] fix(audit): directional status so make update and make upgrade agree The render (make upgrade) showed 53 'outdated' while make update showed 3. Two root causes, both fixed: - Status was computed by EQUALITY (installed != latest => OUTDATED), so a tool AHEAD of the (possibly stale) committed upstream baseline was falsely flagged OUTDATED (e.g. ansible-core 2.21.1 vs baseline 2.20.1). Add compute_status() with DIRECTIONAL comparison (installed >= latest => UP-TO-DATE; missing latest => UNKNOWN, never a false OUTDATED) and use it at all three sites (audit_tool, multi-version, cmd_update_local). False 'outdated' dropped from ~41 to 0. - make update's per-category summary omitted multi-version cycles while make upgrade's Readiness line counts them. Emit the same Readiness summary at the end of make update so both report identical totals. Verified: make update and make upgrade now print the identical Readiness line. Signed-off-by: Sebastian Mendel --- audit.py | 50 ++++++++++++++++++++++++-------------- tests/test_update_fixes.py | 31 +++++++++++++++++++++++ 2 files changed, 63 insertions(+), 18 deletions(-) diff --git a/audit.py b/audit.py index d05a1fe..8b96283 100755 --- a/audit.py +++ b/audit.py @@ -103,6 +103,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 "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 +204,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}" @@ -271,10 +284,9 @@ def audit_tool(tool: Tool, offline_cache: dict[str, tuple[str, str]] | None = No elif version_line == "X" or not installed: 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: @@ -732,6 +744,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,12 +830,9 @@ 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" else: diff --git a/tests/test_update_fixes.py b/tests/test_update_fixes.py index adca73b..2b9653b 100644 --- a/tests/test_update_fixes.py +++ b/tests/test_update_fixes.py @@ -1192,3 +1192,34 @@ def test_does_not_prepend_when_already_on_path(self, tmp_path): 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" From 5e870bf4d4ede8c676a70ef917b4d5357ae47c11 Mon Sep 17 00:00:00 2001 From: Sebastian Mendel Date: Sat, 20 Jun 2026 18:33:28 +0200 Subject: [PATCH 13/14] refactor(audit): extract STATUS_NOT_INSTALLED constant compute_status pushed the 'NOT INSTALLED' literal to 6 occurrences, which Sonar flagged (S1192). Name it once as STATUS_NOT_INSTALLED. Behavior unchanged (the string value is identical, so JSON/status filtering still match). Signed-off-by: Sebastian Mendel --- audit.py | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/audit.py b/audit.py index 8b96283..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. @@ -113,7 +116,7 @@ def compute_status(installed: str, latest: str) -> str: installed -> NOT INSTALLED; missing latest -> UNKNOWN. """ if not installed: - return "NOT INSTALLED" + return STATUS_NOT_INSTALLED if not latest: return "UNKNOWN" from cli_audit.upgrade import compare_versions @@ -282,7 +285,7 @@ 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: # Directional: installed >= latest is UP-TO-DATE (also handles being # ahead of a stale baseline and "7.28.00" vs "7.28.0"). @@ -316,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 "", } @@ -684,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}") @@ -834,7 +837,7 @@ def cmd_update_local(args: argparse.Namespace) -> int: installation.installed_version, cached.latest_version ) elif not installation.installed_version: - installation.status = "NOT INSTALLED" + installation.status = STATUS_NOT_INSTALLED else: installation.status = "UNKNOWN" @@ -920,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, {})) From 50f331b937f38916586c23c5234cd3c89361c929 Mon Sep 17 00:00:00 2001 From: Sebastian Mendel Date: Sat, 20 Jun 2026 18:45:33 +0200 Subject: [PATCH 14/14] fix(scripts): resolve off-PATH binaries + skip empty probes (review) Address gemini-code-assist review feedback: - detect_version_string (github_release_binary): when command -v misses the binary (install dir not yet on PATH on a first-time install), fall back to the install dir from get_install_dir and run the version probe on that path. - get_npm_tool_version (npm_global): return early when the binary was not found, instead of running a version_command that cannot resolve it. - reconcile verify: prepend the resolved binary's dir to PATH so detect_install_method (which uses command -v) can classify an off-PATH install instead of returning 'none'. Signed-off-by: Sebastian Mendel --- scripts/installers/github_release_binary.sh | 31 ++++++++++++++------- scripts/installers/npm_global.sh | 21 +++++++------- scripts/lib/reconcile.sh | 7 +++-- 3 files changed, 36 insertions(+), 23 deletions(-) diff --git a/scripts/installers/github_release_binary.sh b/scripts/installers/github_release_binary.sh index 22be52a..e897dc7 100755 --- a/scripts/installers/github_release_binary.sh +++ b/scripts/installers/github_release_binary.sh @@ -53,21 +53,32 @@ GRB_VERSION_RE='[0-9]+\.[0-9]+|[0-9]{8}' # 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() { - command -v "$BINARY_NAME" >/dev/null 2>&1 || return 0 + # 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="$(timeout 3 bash -c "$VERSION_COMMAND" 2>/dev/null | head -1 || true)" - [[ -z "$out" ]] && out="$(timeout 3 bash -c "$VERSION_COMMAND" 2>&1 >/dev/null | grep -m1 -E "$GRB_VERSION_RE" || true)" + 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 "$BINARY_NAME" $VERSION_FLAG /dev/null | head -1 || true)" - [[ -z "$out" ]] && out="$(timeout 3 "$BINARY_NAME" $VERSION_FLAG &1 >/dev/null | grep -m1 -E "$GRB_VERSION_RE" || true)" + 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 - out="$(timeout 3 "$BINARY_NAME" --version /dev/null | head -1 || true)" - [[ -z "$out" ]] && out="$(timeout 3 "$BINARY_NAME" version --client /dev/null | head -1 || true)" - [[ -z "$out" ]] && out="$(timeout 3 "$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 "$BINARY_NAME" --version &1 >/dev/null | grep -m1 -E "$GRB_VERSION_RE" || true)" - [[ -z "$out" ]] && out="$(timeout 3 "$BINARY_NAME" 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)" + [[ -z "$out" ]] && out="$(timeout 3 "$bin_path" version &1 >/dev/null | grep -m1 -E "$GRB_VERSION_RE" || true)" fi printf '%s' "$out" } diff --git a/scripts/installers/npm_global.sh b/scripts/installers/npm_global.sh index 79f310f..8a348bf 100755 --- a/scripts/installers/npm_global.sh +++ b/scripts/installers/npm_global.sh @@ -63,22 +63,21 @@ fi # when that dir is off PATH) so detection works regardless of PATH state. get_npm_tool_version() { local bin_path="$1" - local bin_dir="" - [[ -n "$bin_path" ]] && bin_dir="$(dirname "$bin_path")" + # 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 - # version_command runs even when bin_path is empty (the catalog command may - # locate the tool itself). 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. + # 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="" - if [[ -n "$bin_dir" ]] && ! path_contains_dir "$bin_dir"; then - pfx="$bin_dir:" - fi + 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 - [[ -z "$bin_path" ]] && return if [[ -n "$VERSION_FLAG" ]]; then timeout 8 "$bin_path" $VERSION_FLAG /dev/null | head -1 || true else diff --git a/scripts/lib/reconcile.sh b/scripts/lib/reconcile.sh index 404649e..1d4aa29 100755 --- a/scripts/lib/reconcile.sh +++ b/scripts/lib/reconcile.sh @@ -350,8 +350,11 @@ reconcile_tool() { local resolved_bin resolved_bin="$(resolve_global_bin "$binary_name")" if [ -n "$resolved_bin" ]; then - local new_method - new_method="$(detect_install_method "$tool" "$binary_name")" + # 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