diff --git a/home-manager/modules/npm-globals/install-npm-globals.sh b/home-manager/modules/npm-globals/install-npm-globals.sh index 95e505672..5e43a45e6 100755 --- a/home-manager/modules/npm-globals/install-npm-globals.sh +++ b/home-manager/modules/npm-globals/install-npm-globals.sh @@ -87,20 +87,17 @@ x86_64 | amd64) PLATFORM_CPU="x64" ;; *) PLATFORM_CPU="" ;; esac -# Returns 0 when a package declares a platform-native optionalDependency for the -# current OS/CPU but that dependency is not installed. Many CLIs ship their real -# binary this way; a version-only skip would otherwise leave such a package -# "installed" yet non-functional (e.g. after an `omit=optional` install). -missing_native_optional_dep() { - local dep="$1" - local pj="${GLOBAL_MODULES}/${dep}/package.json" - [ -f "$pj" ] || return 1 - [ -n "$PLATFORM_OS" ] && [ -n "$PLATFORM_CPU" ] || return 1 - - local opt_deps name matched=0 present=0 +# Prints the name of a platform-native optionalDependency declared in the given +# package.json that is NOT installed, or nothing if all present / none declared. +# Emits " " so callers can install the exact +# binary that bun dropped, at the version that matches its declaring wrapper. +missing_native_from_pkg() { + local pj="$1" + [ -f "$pj" ] || return 0 + local opt_deps name decl_ver opt_deps=$(jq -r '.optionalDependencies // {} | keys[]' "$pj" 2>/dev/null || true) - [ -n "$opt_deps" ] || return 1 - + [ -n "$opt_deps" ] || return 0 + decl_ver=$(jq -r '.version // empty' "$pj" 2>/dev/null || true) while IFS= read -r name; do [ -z "$name" ] && continue # Only weigh native deps targeting this platform. @@ -108,11 +105,65 @@ missing_native_optional_dep() { *"$PLATFORM_OS"*"$PLATFORM_CPU"* | *"$PLATFORM_CPU"*"$PLATFORM_OS"*) ;; *) continue ;; esac - matched=1 - [ -d "${GLOBAL_MODULES}/${name}" ] && present=1 + [ -d "${GLOBAL_MODULES}/${name}" ] && continue + printf '%s %s\n' "$name" "$decl_ver" + return 0 done <<<"$opt_deps" +} - [ "$matched" -eq 1 ] && [ "$present" -eq 0 ] +# Candidate package.json paths that may declare a package's real native binary: +# the package itself, plus its direct dependencies (thin wrappers hide the binary +# one level down, e.g. tokscale -> @tokscale/cli -> @tokscale/cli-darwin-arm64). +native_candidate_pkgs() { + local dep="$1" + local pj="${GLOBAL_MODULES}/${dep}/package.json" + printf '%s\n' "$pj" + local child + while IFS= read -r child; do + [ -z "$child" ] && continue + printf '%s\n' "${GLOBAL_MODULES}/${child}/package.json" + done < <(jq -r '.dependencies // {} | keys[]' "$pj" 2>/dev/null || true) +} + +# Returns 0 when a package (or its immediate wrapper dependency) declares a +# platform-native optionalDependency for the current OS/CPU that is not +# installed. Many CLIs ship their real binary this way; a version-only skip would +# otherwise leave such a package "installed" yet non-functional (bun silently +# drops these transitive optional deps during global installs). +missing_native_optional_dep() { + local dep="$1" + [ -f "${GLOBAL_MODULES}/${dep}/package.json" ] || return 1 + [ -n "$PLATFORM_OS" ] && [ -n "$PLATFORM_CPU" ] || return 1 + + local pj + while IFS= read -r pj; do + [ -n "$(missing_native_from_pkg "$pj")" ] && return 0 + done < <(native_candidate_pkgs "$dep") + return 1 +} + +# Directly install the platform-native binary package that bun dropped for a +# wrapper dep, at the version its declaring package pins, so wrapper and binary +# always match. Returns 0 when the binary is present afterwards. Preferred over +# reinstalling the wrapper, which just re-triggers the same bun drop. +repair_native_optional_dep() { + local dep="$1" + local pj found native decl_ver spec + while IFS= read -r pj; do + found=$(missing_native_from_pkg "$pj") + [ -n "$found" ] && break + done < <(native_candidate_pkgs "$dep") + [ -n "$found" ] || return 1 + + native="${found%% *}" + decl_ver="${found##* }" + spec="$native" + [ -n "$decl_ver" ] && spec="${native}@${decl_ver}" + echo "Installing missing native binary: $spec" + timeout 600 bun add --global "$spec" --minimum-release-age 0 2>/dev/null || + echo "Install failed: $spec" >&2 + purge_bun_npm_shim + [ -d "${GLOBAL_MODULES}/${native}" ] } # Remove the npm "bun" wrapper package from global node_modules. @@ -221,7 +272,14 @@ if [ -n "$DEPS" ]; then # Version matches, but only skip if the native binary is actually # present. Drop a broken install so the reinstall below refetches it. if missing_native_optional_dep "$dep"; then - echo "$dep@$installed_ver installed but native binary missing, reinstalling" + echo "$dep@$installed_ver installed but native binary missing" + # Install the dropped binary directly; reinstalling the wrapper just + # re-triggers the same bun transitive-optional drop. + if repair_native_optional_dep "$dep"; then + echo "$dep native binary repaired in place" + continue + fi + echo "$dep native binary repair failed, reinstalling wrapper" rm -rf "${GLOBAL_MODULES:?}/${dep}" MISSING+=("$dep") continue @@ -246,6 +304,11 @@ if [ "${#MISSING[@]}" -gt 0 ]; then timeout 600 bun add --global "$dep" 2>/dev/null || echo "Install failed: $dep" purge_bun_npm_shim run_postinstall_if_needed "$dep" + # Heal in the same run: bun drops transitive platform binaries on fresh + # global installs, so repair immediately instead of waiting for next activation. + if missing_native_optional_dep "$dep"; then + repair_native_optional_dep "$dep" || echo "Native binary repair failed: $dep" >&2 + fi done else echo "All npm global packages already installed" diff --git a/spec/npm_globals_spec.sh b/spec/npm_globals_spec.sh index 49c2248ad..326e61b16 100644 --- a/spec/npm_globals_spec.sh +++ b/spec/npm_globals_spec.sh @@ -264,9 +264,19 @@ When run bash -c "grep 'optionalDependencies' '$SCRIPT'" The output should include 'optionalDependencies' End -It 'reinstalls a version-matched package whose native binary is missing' +It 'detects a version-matched package whose native binary is missing' When run bash -c "grep 'installed but native binary missing' '$SCRIPT'" -The output should include 'reinstalling' +The output should include 'installed but native binary missing' +End + +It 'repairs the dropped native binary in place before reinstalling the wrapper' +When run bash -c "grep 'repair_native_optional_dep' '$SCRIPT'" +The output should include 'repair_native_optional_dep' +End + +It 'follows one level of wrapper indirection to find the native binary' +When run bash -c "grep 'native_candidate_pkgs' '$SCRIPT'" +The output should include 'native_candidate_pkgs' End End @@ -337,6 +347,77 @@ The output should include 'bun add --global nativecli' End End +Describe 'wrapper-indirection native binary self-heal (tokscale pattern)' +setup() { + TEMP_HOME=$(mktemp -d) + MOCK_BIN=$(mktemp -d) + MOCK_LOG="$TEMP_HOME/mock.log" + REAL_BIN_DIR="$(dirname "$(command -v jq)")" + REAL_SYSTEM_BIN_DIR="$(dirname "$(command -v mv)")" + : >"$MOCK_LOG" + + GM="$TEMP_HOME/.bun/install/global/node_modules" + mkdir -p "$TEMP_HOME/dotfiles" "$TEMP_HOME/.bun/install/global" "$TEMP_HOME/.bun/bin" + + os_tok=$(uname -s | tr '[:upper:]' '[:lower:]') + [ "$os_tok" = "darwin" ] || os_tok="linux" + cpu_tok=$(uname -m) + case "$cpu_tok" in arm64 | aarch64) cpu_tok=arm64 ;; *) cpu_tok=x64 ;; esac + NATIVE_DEP="@wrap/cli-${os_tok}-${cpu_tok}" + EXPECT_INSTALL="bun add --global ${NATIVE_DEP}@1.0.0" + + cat >"$TEMP_HOME/dotfiles/package.json" <<'EOF' +{ + "dependencies": { "wrapcli": "^1.0.0" } +} +EOF + + # Thin wrapper: no optionalDependencies of its own; the native binary is + # declared one level down on @wrap/cli, which bun dropped. + mkdir -p "$GM/wrapcli" "$GM/@wrap/cli" + cat >"$GM/wrapcli/package.json" <<'EOF' +{ "name": "wrapcli", "version": "1.0.0", "dependencies": { "@wrap/cli": "1.0.0" } } +EOF + cat >"$GM/@wrap/cli/package.json" <"$MOCK_BIN/timeout" <<'EOF' +#!/usr/bin/env bash +set -euo pipefail +shift +if [ "${1:-}" = "bash" ] && [ "${2:-}" = "-c" ] && [ "${3:-}" = "exec 3<>/dev/tcp/1.1.1.1/53" ]; then + exit 0 +fi +exec "$@" +EOF + chmod +x "$MOCK_BIN/timeout" + + cat >"$MOCK_BIN/bun" <<'EOF' +#!/usr/bin/env bash +set -euo pipefail +printf 'bun %s\n' "$*" >>"$MOCK_LOG" +EOF + chmod +x "$MOCK_BIN/bun" +} + +cleanup() { + rm -rf "$TEMP_HOME" "$MOCK_BIN" +} + +Before 'setup' +After 'cleanup' + +It 'installs the nested native binary directly instead of reinstalling the wrapper' +When run bash -c "HOME='$TEMP_HOME' MOCK_LOG='$MOCK_LOG' PATH='$MOCK_BIN:$REAL_BIN_DIR:$REAL_SYSTEM_BIN_DIR:/usr/bin:/bin' bash '$SCRIPT' >/dev/null 2>&1; cat '$MOCK_LOG'" +The output should include "$EXPECT_INSTALL" +End +End + Describe 'stale global package pruning' setup() { TEMP_HOME=$(mktemp -d)