From 100d33aad943c273a722591ea8f31b4587c0c764 Mon Sep 17 00:00:00 2001 From: MickLesk <47820557+MickLesk@users.noreply.github.com> Date: Sun, 15 Nov 2026 15:54:13 +0100 Subject: [PATCH] setup_nodejs: probe a module's binaries by its bin field The PATH check took the package name for the binary, so single-file-cli (binary single-file) was reinstalled and reported as not callable on every run, and scoped packages were never checked. --- lib/runtime.func | 51 ++++++++++++++------- tests/nodejs-module-bins.sh | 90 +++++++++++++++++++++++++++++++++++++ 2 files changed, 125 insertions(+), 16 deletions(-) create mode 100644 tests/nodejs-module-bins.sh diff --git a/lib/runtime.func b/lib/runtime.func index a0793ab..85218a1 100644 --- a/lib/runtime.func +++ b/lib/runtime.func @@ -1794,25 +1794,44 @@ setup_nodejs() { # removed without a reinstall behind it, a half-finished global install -- # each leaves a module npm still lists while `command -v` finds nothing, and # the caller is about to run exactly that binary. So ask the question the - # caller cares about, and put back whatever cannot answer it. - local mod_bin + # caller cares about, and put back whatever cannot answer it. The binaries + # come from the package's own bin field: single-file-cli installs + # `single-file`, @vue/cli installs `vue`. + local mod_pkg mod_bin mod_bins missing still npm_root + npm_root="$(npm root -g 2>/dev/null)" for mod in "${MODULES[@]}"; do - # Scoped packages do not name their binary (@vue/cli installs `vue`), so - # there is nothing reliable to probe for. - [[ "$mod" == @*/* ]] && continue - mod_bin="${mod%@*}" - [[ -z "$mod_bin" ]] && continue - command -v "$mod_bin" >/dev/null 2>&1 && continue - - msg_warn "$mod_bin is installed but not on PATH - reinstalling" - if { $STD npm install -g --force "$mod" 2>/dev/null || - $STD npm install -g --force "${mod_bin}@latest" 2>/dev/null; } && - command -v "$mod_bin" >/dev/null 2>&1; then - msg_ok "Restored $mod_bin" + if [[ "$mod" == @* ]]; then + mod_pkg="${mod#@}" + mod_pkg="@${mod_pkg%@*}" else - msg_error "$mod_bin is not callable and could not be restored" - [[ " ${failed_modules[*]:-} " == *" $mod_bin "* ]] || failed_modules+=("$mod_bin") + mod_pkg="${mod%@*}" fi + [[ -n "$mod_pkg" ]] || continue + mod_bins="" + [[ -n "$npm_root" && -f "$npm_root/$mod_pkg/package.json" ]] && + mod_bins="$(node -e 'const p=require(process.argv[1]);const b=p.bin;if(typeof b==="string")console.log(p.name.replace(/^@[^/]+\//,""));else if(b)console.log(Object.keys(b).join(" "))' "$npm_root/$mod_pkg/package.json" 2>/dev/null)" + [[ -z "$mod_bins" && "$mod_pkg" != @*/* ]] && mod_bins="$mod_pkg" + missing="" + for mod_bin in $mod_bins; do + command -v "$mod_bin" >/dev/null 2>&1 || missing+=" $mod_bin" + done + [[ -z "$missing" ]] && continue + + msg_warn "${missing# } is installed but not on PATH - reinstalling" + still="$missing" + if $STD npm install -g --force "$mod" 2>/dev/null || + $STD npm install -g --force "${mod_pkg}@latest" 2>/dev/null; then + still="" + for mod_bin in $missing; do + command -v "$mod_bin" >/dev/null 2>&1 || still+=" $mod_bin" + done + fi + if [[ -z "$still" ]]; then + msg_ok "Restored $mod_pkg" + continue + fi + msg_error "${still# } is not callable and could not be restored" + [[ " ${failed_modules[*]:-} " == *" $mod_pkg "* ]] || failed_modules+=("$mod_pkg") done if ((${#failed_modules[@]} > 0)); then diff --git a/tests/nodejs-module-bins.sh b/tests/nodejs-module-bins.sh new file mode 100644 index 0000000..48a6fee --- /dev/null +++ b/tests/nodejs-module-bins.sh @@ -0,0 +1,90 @@ +#!/usr/bin/env bash +set -uo pipefail + +CORE_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)" +eval "$(awk '/^setup_nodejs[(][)] [{]/,/^}/' "$CORE_DIR/lib/runtime.func")" + +TEST_DIR="$(mktemp -d)" +trap 'rm -rf -- "$TEST_DIR"' EXIT +CALLS="$TEST_DIR/calls" +LOG="$TEST_DIR/log" +passed=0 failed=0 + +export STD="" +msg_info() { echo "info: $*" >>"$LOG"; } +msg_ok() { echo "ok: $*" >>"$LOG"; } +msg_warn() { echo "warn: $*" >>"$LOG"; } +msg_error() { echo "error: $*" >>"$LOG"; } + +HAVE="node npm jq pnpm" +command() { + if [[ "${1:-}" == "-v" ]]; then + [[ " $HAVE " == *" $2 "* ]] && { echo "/usr/bin/$2"; return 0; } + return 1 + fi + builtin command "$@" +} + +is_tool_installed() { echo 22; } +cleanup_legacy_install() { :; } +ensure_apt_working() { :; } +download_gpg_key() { :; } +cache_installed_version() { :; } +_cs_drop_pm_shim() { :; } +_setup_npm() { + # shellcheck disable=SC2034 # read by the extracted setup_nodejs + NPM_SETUP_VERSION="12.2.1" +} +apt() { :; } +apt-get() { :; } +NPM_ROOT="$TEST_DIR/node_modules" +command mkdir -p "$NPM_ROOT/single-file-cli" "$NPM_ROOT/@postlight/parser" +touch "$NPM_ROOT/single-file-cli/package.json" "$NPM_ROOT/@postlight/parser/package.json" +echo "single-file" >"$NPM_ROOT/single-file-cli/bins" +echo "postlight-parser" >"$NPM_ROOT/@postlight/parser/bins" + +node() { + case "$1" in + -v) echo v22.23.4 ;; + -e) cat "${3%package.json}bins" ;; + esac + return 0 +} +corepack() { :; } +npm() { + case "${1:-}" in + -v) echo "12.2.1" ;; + root) echo "$NPM_ROOT" ;; + list) echo "/usr/lib"; echo "\`-- single-file-cli@2.0.0"; echo "\`-- @postlight/parser@2.2.3" ;; + install) echo "npm $*" >>"$CALLS" ;; + esac + return 0 +} +mkdir() { :; } +cd() { [[ "$1" == "/opt" ]] || builtin cd "$@" || return; } + +run() { + : >"$CALLS" + : >"$LOG" + NODE_VERSION=22 NODE_MODULE="$1" setup_nodejs +} +expect() { + if [[ "$2" == "$3" ]]; then + echo "PASS $1" + passed=$((passed + 1)) + else + echo "FAIL $1: expected <$2>, got <$3>" >&2 + failed=$((failed + 1)) + fi +} +HAVE="node npm jq single-file postlight-parser" +run "single-file-cli@latest,@postlight/parser@latest" +expect "a binary named apart from its package is found" "" "$(grep -E '^(warn|error):' "$LOG" | grep -i 'path\|callable')" + +HAVE="node npm jq postlight-parser" +run "single-file-cli@latest" +expect "a missing binary reinstalls its package" "1" "$(grep -c 'npm install -g --force single-file-cli@latest' "$CALLS")" +expect "the error names the binary and the package" "1" "$(grep -c 'error: single-file is not callable' "$LOG")" + +echo "${passed} passed, ${failed} failed" +((failed == 0))