Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
63 changes: 40 additions & 23 deletions audit.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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.

Expand Down Expand Up @@ -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}"
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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 "",
}


Expand Down Expand Up @@ -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}")
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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"

Expand Down Expand Up @@ -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, {}))
Expand Down
2 changes: 1 addition & 1 deletion catalog/google-workspace-cli.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
1 change: 1 addition & 0 deletions catalog/yq.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
19 changes: 17 additions & 2 deletions scripts/install_node.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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 <ver>" 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")
Expand Down
68 changes: 42 additions & 26 deletions scripts/installers/github_release_binary.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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 2>/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 2>/dev/null | head -1 || true)"
[[ -z "$out" ]] && out="$(timeout 3 "$bin_path" $VERSION_FLAG </dev/null 2>&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 2>/dev/null || \
timeout 2 "$BINARY_NAME" version --client </dev/null 2>/dev/null | head -1 || \
timeout 2 "$BINARY_NAME" version </dev/null 2>/dev/null | head -1 || true)"
out="$(timeout 3 "$bin_path" --version </dev/null 2>/dev/null | head -1 || true)"
[[ -z "$out" ]] && out="$(timeout 3 "$bin_path" version --client </dev/null 2>/dev/null | head -1 || true)"
[[ -z "$out" ]] && out="$(timeout 3 "$bin_path" version </dev/null 2>/dev/null | head -1 || true)"
# Last resort: capture stderr (tools like gh-aw print --version there)
[[ -z "$out" ]] && out="$(timeout 3 "$bin_path" --version </dev/null 2>&1 >/dev/null | grep -m1 -E "$GRB_VERSION_RE" || true)"
[[ -z "$out" ]] && out="$(timeout 3 "$bin_path" version </dev/null 2>&1 >/dev/null | grep -m1 -E "$GRB_VERSION_RE" || true)"
fi
fi
printf '%s' "$out"
}
Comment thread
CybotTM marked this conversation as resolved.

# Get current version
before="$(detect_version_string)"

# Detect OS and architecture
OS="linux"
Expand Down Expand Up @@ -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 2>/dev/null | head -1 || true)"
else
after="$(timeout 2 "$BINARY_NAME" --version </dev/null 2>/dev/null || \
timeout 2 "$BINARY_NAME" version --client </dev/null 2>/dev/null | head -1 || \
timeout 2 "$BINARY_NAME" version </dev/null 2>/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:-}")"
Expand Down
48 changes: 32 additions & 16 deletions scripts/installers/npm_global.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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 2>/dev/null | head -1 || true
else
timeout 2 "$bin" --version </dev/null 2>/dev/null | head -1 || true
fi
if [[ -n "$VERSION_FLAG" ]]; then
timeout 8 "$bin_path" $VERSION_FLAG </dev/null 2>/dev/null | head -1 || true
else
timeout 8 "$bin_path" --version </dev/null 2>/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
Expand Down Expand Up @@ -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:-<none>}"
printf "[%s] after: %s\n" "$TOOL" "${after:-<none>}"
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"
45 changes: 45 additions & 0 deletions scripts/lib/common.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

Expand All @@ -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 <none> 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
Expand Down
Loading
Loading