From c418ea59ae444725704e14b5e1cc605290d6f757 Mon Sep 17 00:00:00 2001 From: mateo-berri <277851410+mateo-berri@users.noreply.github.com> Date: Tue, 4 Aug 2026 21:02:26 -0700 Subject: [PATCH] fix(bootstrap): switch to the dashboard node floor via nvm or fnm The dashboard pins engines node >=24.14.1 with engine-strict, so make bootstrap dies with EBADENGINE on any shell whose default node is older. Wrap the npm install in scripts/with_dashboard_node.sh: it execs the command as-is when node already meets the floor, otherwise activates the .nvmrc version via nvm or fnm, and fails fast with install instructions when neither manager exists --- Makefile | 2 +- scripts/with_dashboard_node.sh | 52 +++++++++ .../test_litellm/test_with_dashboard_node.py | 106 ++++++++++++++++++ 3 files changed, 159 insertions(+), 1 deletion(-) create mode 100755 scripts/with_dashboard_node.sh create mode 100644 tests/test_litellm/test_with_dashboard_node.py diff --git a/Makefile b/Makefile index 0b59b2f3e95..68753605e27 100644 --- a/Makefile +++ b/Makefile @@ -75,7 +75,7 @@ install-dev: bootstrap: $(UV) sync --inexact --frozen --extra proxy --group proxy-dev --group e2e-dev $(UV_RUN) python scripts/prisma_generate_if_needed.py - cd ui/litellm-dashboard && npm install --no-audit --no-fund + cd ui/litellm-dashboard && ../../scripts/with_dashboard_node.sh npm install --no-audit --no-fund @main_root=$$(git worktree list --porcelain | head -1 | sed 's/^worktree //'); \ if [ "$$main_root" != "$$(git rev-parse --show-toplevel)" ] && [ -f "$$main_root/.env" ] && [ ! -f .env ]; then \ cp "$$main_root/.env" .env && echo "bootstrap: copied .env from $$main_root"; \ diff --git a/scripts/with_dashboard_node.sh b/scripts/with_dashboard_node.sh new file mode 100755 index 00000000000..dd2f36774a6 --- /dev/null +++ b/scripts/with_dashboard_node.sh @@ -0,0 +1,52 @@ +#!/usr/bin/env bash +set -eu + +[ $# -gt 0 ] || { echo "usage: $0 [args...]" >&2; exit 2; } + +repo_root=$(cd "$(dirname "$0")/.." && pwd) +dashboard="$repo_root/ui/litellm-dashboard" +floor=$(sed -n 's/.*"node": *">=\([0-9][0-9.]*\)".*/\1/p' "$dashboard/package.json") +pinned=$(tr -d '[:space:]' < "$dashboard/.nvmrc") +floor="${floor:-$pinned}" + +meets_floor() { + awk -v have="$1" -v need="$2" 'BEGIN { + split(have, h, "."); split(need, n, ".") + for (i = 1; i <= 3; i++) { + if (h[i] + 0 < n[i] + 0) exit 1 + if (h[i] + 0 > n[i] + 0) exit 0 + } + }' +} + +current=$(node --version 2>/dev/null | tr -d 'v' || true) +if [ -n "$current" ] && meets_floor "$current" "$floor"; then + exec "$@" +fi + +nvm_script="${NVM_DIR:-$HOME/.nvm}/nvm.sh" +if [ -r "$nvm_script" ]; then + echo "with_dashboard_node: node ${current:-missing} is below the dashboard floor $floor; switching to $pinned via nvm" >&2 + set +eu + . "$nvm_script" --no-use + nvm install "$pinned" >&2 + nvm use "$pinned" >&2 + set -eu + exec "$@" +fi + +if command -v fnm > /dev/null 2>&1; then + echo "with_dashboard_node: node ${current:-missing} is below the dashboard floor $floor; switching to $pinned via fnm" >&2 + fnm install "$pinned" >&2 + eval "$(fnm env)" + fnm use "$pinned" >&2 + exec "$@" +fi + +cat >&2 <= $floor) and neither nvm nor fnm is available to switch automatically. +Fix it with one of: + - install nvm (https://github.com/nvm-sh/nvm) and re-run; it will pick up node $pinned for you + - or install/upgrade node yourself to >= $floor (e.g. brew install node), then re-run +EOF +exit 1 diff --git a/tests/test_litellm/test_with_dashboard_node.py b/tests/test_litellm/test_with_dashboard_node.py new file mode 100644 index 00000000000..0d505983f16 --- /dev/null +++ b/tests/test_litellm/test_with_dashboard_node.py @@ -0,0 +1,106 @@ +import json +import os +import subprocess +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] +SCRIPT = ROOT / "scripts" / "with_dashboard_node.sh" + + +def _floor() -> str: + pkg = json.loads((ROOT / "ui" / "litellm-dashboard" / "package.json").read_text()) + return pkg["engines"]["node"].removeprefix(">=") + + +def _bump_major(version: str, delta: int) -> str: + major, minor, patch = version.split(".") + return f"{int(major) + delta}.{minor}.{patch}" + + +def _fake_node(bin_dir: Path, version: str) -> Path: + bin_dir.mkdir(parents=True, exist_ok=True) + node = bin_dir / "node" + node.write_text(f'#!/bin/sh\necho "v{version}"\n') + node.chmod(0o755) + return bin_dir + + +def _run(bin_dirs: list[Path], home: Path) -> subprocess.CompletedProcess[str]: + path = os.pathsep.join([*(str(b) for b in bin_dirs), "/usr/bin", "/bin"]) + home.mkdir(parents=True, exist_ok=True) + return subprocess.run( + [str(SCRIPT), "sh", "-c", "node --version"], + capture_output=True, + text=True, + env={"PATH": path, "HOME": str(home)}, + ) + + +def test_node_meeting_the_floor_runs_the_command_as_is(tmp_path): + bins = _fake_node(tmp_path / "bin", _floor()) + proc = _run([bins], tmp_path / "home") + assert proc.returncode == 0, proc.stderr + assert proc.stdout.strip() == f"v{_floor()}" + + +def test_node_above_the_floor_runs_the_command_as_is(tmp_path): + above = _bump_major(_floor(), 1) + bins = _fake_node(tmp_path / "bin", above) + proc = _run([bins], tmp_path / "home") + assert proc.returncode == 0, proc.stderr + assert proc.stdout.strip() == f"v{above}" + + +def test_old_node_without_any_manager_fails_with_instructions(tmp_path): + bins = _fake_node(tmp_path / "bin", _bump_major(_floor(), -1)) + proc = _run([bins], tmp_path / "home") + assert proc.returncode == 1 + assert "does not meet" in proc.stderr + assert _floor() in proc.stderr + assert "nvm" in proc.stderr + + +def test_missing_node_without_any_manager_fails_with_instructions(tmp_path): + proc = _run([], tmp_path / "home") + assert proc.returncode == 1 + assert "missing" in proc.stderr + + +def test_old_node_switches_via_nvm_when_present(tmp_path): + old = _fake_node(tmp_path / "old-bin", _bump_major(_floor(), -1)) + new = _fake_node(tmp_path / "new-bin", "99.0.0") + home = tmp_path / "home" + nvm_dir = home / ".nvm" + nvm_dir.mkdir(parents=True) + (nvm_dir / "nvm.sh").write_text( + f'nvm() {{ [ "$1" = use ] && PATH="{new}:$PATH"; return 0; }}\n' + ) + proc = _run([old], home) + assert proc.returncode == 0, proc.stderr + assert proc.stdout.strip() == "v99.0.0" + assert "via nvm" in proc.stderr + + +def test_old_node_switches_via_fnm_when_nvm_is_absent(tmp_path): + old = _fake_node(tmp_path / "old-bin", _bump_major(_floor(), -1)) + new = _fake_node(tmp_path / "new-bin", "99.0.0") + fnm = tmp_path / "old-bin" / "fnm" + fnm.write_text( + f'#!/bin/sh\n[ "$1" = env ] && echo \'export PATH="{new}:$PATH"\'\nexit 0\n' + ) + fnm.chmod(0o755) + proc = _run([old], tmp_path / "home") + assert proc.returncode == 0, proc.stderr + assert proc.stdout.strip() == "v99.0.0" + assert "via fnm" in proc.stderr + + +def test_no_command_is_a_usage_error(tmp_path): + proc = subprocess.run( + [str(SCRIPT)], + capture_output=True, + text=True, + env={"PATH": "/usr/bin:/bin", "HOME": str(tmp_path)}, + ) + assert proc.returncode == 2 + assert "usage" in proc.stderr