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
39 changes: 38 additions & 1 deletion home-manager/modules/npm-globals/install-npm-globals.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

To robustly parse the output of the find command and avoid issues with filenames that might contain leading or trailing whitespace, you should use IFS= read -r. This aligns with the general rule for robustly parsing command output and maintains consistency with the while loop used earlier in the script (line 57).

Suggested change
find "$BUN_BIN" -mindepth 1 -maxdepth 1 -type l 2>/dev/null | while read -r shim; do
find "$BUN_BIN" -mindepth 1 -maxdepth 1 -type l 2>/dev/null | while IFS= read -r shim; do
References
  1. To robustly parse command output in shell scripts, use a unique delimiter (e.g., tab) in the format string and read with a matching IFS. This is safer than splitting by spaces with cut, especially when data fields might contain spaces.

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"
Expand Down
74 changes: 74 additions & 0 deletions spec/npm_globals_spec.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
Loading