From 257dc0a45ac1ee4363d1f0e043ddceab66cfc86c Mon Sep 17 00:00:00 2001 From: Shun Kakinoki Date: Sun, 12 Apr 2026 20:16:06 +0800 Subject: [PATCH] fix(npm-globals): Prune stale Bun globals Remove packages from Bun global state when they are no longer declared in dotfiles/package.json instead of only installing missing packages. Also clean dangling shims in ~/.bun/bin and cover the stale @beads/bd case with shell specs so the local repo binary remains the only bd on PATH. Co-authored-by: Codex --- .../npm-globals/install-npm-globals.sh | 39 +++++++++- spec/npm_globals_spec.sh | 74 +++++++++++++++++++ 2 files changed, 112 insertions(+), 1 deletion(-) diff --git a/home-manager/modules/npm-globals/install-npm-globals.sh b/home-manager/modules/npm-globals/install-npm-globals.sh index e590932cd..7c0c1fad6 100755 --- a/home-manager/modules/npm-globals/install-npm-globals.sh +++ b/home-manager/modules/npm-globals/install-npm-globals.sh @@ -48,6 +48,33 @@ if [ -n "$TRUSTED_DEPS" ]; then done fi +# Remove packages that are no longer declared in dotfiles/package.json +GLOBAL_PKG="${HOME}/.bun/install/global/package.json" +STALE=() +if [ -f "$GLOBAL_PKG" ]; then + GLOBAL_DEPS=$(jq -r '.dependencies | keys[]?' "$GLOBAL_PKG" 2>/dev/null || true) + if [ -n "$GLOBAL_DEPS" ]; then + while IFS= read -r dep; do + [ -z "$dep" ] && continue + if ! jq -e --arg dep "$dep" '.dependencies | has($dep)' "$PACKAGE_JSON" >/dev/null 2>&1; then + STALE+=("$dep") + fi + done <<<"$GLOBAL_DEPS" + fi +fi + +if [ "${#STALE[@]}" -gt 0 ]; then + echo "Removing ${#STALE[@]} stale packages..." + for dep in "${STALE[@]}"; do + timeout 600 bun remove --global "$dep" 2>/dev/null || echo "Remove failed: $dep" + if [ -f "$GLOBAL_PKG" ] && jq -e --arg dep "$dep" '.dependencies | has($dep)' "$GLOBAL_PKG" >/dev/null 2>&1; then + jq --arg dep "$dep" 'del(.dependencies[$dep])' "$GLOBAL_PKG" >"${GLOBAL_PKG}.tmp" && + mv "${GLOBAL_PKG}.tmp" "$GLOBAL_PKG" + fi + rm -rf "${HOME}/.bun/install/global/node_modules/$dep" + done +fi + # Build list of packages that need installing or updating GLOBAL_MODULES="${HOME}/.bun/install/global/node_modules" DEPS=$(jq -r '.dependencies | to_entries[] | "\(.key)=\(.value)"' "$PACKAGE_JSON" 2>/dev/null || true) @@ -89,11 +116,21 @@ else echo "All npm global packages already installed" fi +# Remove stale shims left behind by prior Bun global installs +BUN_BIN="${HOME}/.bun/bin" +if [ -d "$BUN_BIN" ]; then + find "$BUN_BIN" -mindepth 1 -maxdepth 1 -type l 2>/dev/null | while read -r shim; do + if [ ! -e "$shim" ]; then + rm -f "$shim" + echo "Removed dangling bun shim: $(basename "$shim")" + fi + done +fi + # Apply dependency overrides to the global install # Bun's flat hoisting can resolve incompatible versions (e.g. pino@10 vs pino-http@10.5) OVERRIDES=$(jq -c '.overrides // empty' "$PACKAGE_JSON" 2>/dev/null || true) if [ -n "$OVERRIDES" ]; then - GLOBAL_PKG="${HOME}/.bun/install/global/package.json" if [ -f "$GLOBAL_PKG" ]; then jq --argjson overrides "$OVERRIDES" '.overrides = $overrides' "$GLOBAL_PKG" >"${GLOBAL_PKG}.tmp" && mv "${GLOBAL_PKG}.tmp" "$GLOBAL_PKG" diff --git a/spec/npm_globals_spec.sh b/spec/npm_globals_spec.sh index bfe54a505..d4919aae5 100644 --- a/spec/npm_globals_spec.sh +++ b/spec/npm_globals_spec.sh @@ -186,4 +186,78 @@ When run bash -c "grep 'find.*GLOBAL_MODULES' '$SCRIPT'" The output should include 'find' End End + +Describe 'stale global package pruning' +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" + + mkdir -p "$TEMP_HOME/dotfiles" "$TEMP_HOME/.bun/install/global" "$TEMP_HOME/.bun/bin" + + cat >"$TEMP_HOME/dotfiles/package.json" <<'EOF' +{ + "dependencies": {} +} +EOF + + cat >"$TEMP_HOME/.bun/install/global/package.json" <<'EOF' +{ + "dependencies": { + "@beads/bd": "^0.63.3" + } +} +EOF + + ln -sf ../install/global/node_modules/@beads/bd/bin/bd.js "$TEMP_HOME/.bun/bin/bd" + + cat >"$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" +if [ "${1:-}" = "remove" ] && [ "${2:-}" = "--global" ] && [ "${3:-}" = "@beads/bd" ]; then + jq 'del(.dependencies["@beads/bd"])' "$HOME/.bun/install/global/package.json" >"$HOME/.bun/install/global/package.json.tmp" + mv "$HOME/.bun/install/global/package.json.tmp" "$HOME/.bun/install/global/package.json" + rm -f "$HOME/.bun/bin/bd" +fi +EOF + chmod +x "$MOCK_BIN/bun" +} + +cleanup() { + rm -rf "$TEMP_HOME" "$MOCK_BIN" +} + +Before 'setup' +After 'cleanup' + +It 'removes packages no longer declared in dotfiles package.json' +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 'bun remove --global @beads/bd' +End + +It 'removes stale package entries from bun global package.json' +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; jq -r '.dependencies[\"@beads/bd\"] // \"missing\"' '$TEMP_HOME/.bun/install/global/package.json'" +The output should eq 'missing' +End + +It 'removes dangling bun shims for stale packages' +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; test -e '$TEMP_HOME/.bun/bin/bd' && echo present || echo missing" +The output should eq 'missing' +End +End End