Skip to content

Commit 9872af3

Browse files
mldangeloclaude
andcommitted
feat: add comprehensive environment detection for better Node.js installation guidance
Add platform-aware error messages that detect the user's environment and provide tailored Node.js installation instructions. Features: - Detects OS (Linux, macOS, Windows), Linux distributions (Ubuntu, Debian, RHEL, Amazon Linux, Alpine, Arch, SUSE), cloud providers (AWS, GCP, Azure), containers (Docker, Kubernetes), CI/CD platforms (GitHub Actions, GitLab CI, CircleCI, etc.), Python environments (venv, conda), and sudo access - Generates platform-specific installation instructions including package manager commands, NodeSource setup, nvm installation, Docker examples, CI/CD configuration, and alternatives for restricted environments - Provides sudo vs no-sudo alternatives where applicable - Includes nodeenv option for Python virtual environment users - Always suggests direct npx usage as a fallback - Comprehensive test coverage with 53 new tests This improves user experience when Node.js is not installed by providing actionable, context-aware guidance instead of generic error messages. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
1 parent 4f244be commit 9872af3

File tree

6 files changed

+1422
-45
lines changed

6 files changed

+1422
-45
lines changed

src/promptfoo/cli.py

Lines changed: 7 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -26,16 +26,13 @@ def check_npx_installed() -> bool:
2626

2727

2828
def print_installation_help() -> None:
29-
"""Print helpful installation instructions for Node.js."""
30-
print("ERROR: promptfoo requires Node.js to be installed.", file=sys.stderr)
31-
print("", file=sys.stderr)
32-
print("Please install Node.js:", file=sys.stderr)
33-
print(" - macOS: brew install node", file=sys.stderr)
34-
print(" - Ubuntu/Debian: sudo apt install nodejs npm", file=sys.stderr)
35-
print(" - Windows: Download from https://nodejs.org/", file=sys.stderr)
36-
print("", file=sys.stderr)
37-
print("Or use nvm (Node Version Manager):", file=sys.stderr)
38-
print(" https://github.com/nvm-sh/nvm", file=sys.stderr)
29+
"""Print contextual installation instructions for Node.js based on the environment."""
30+
from .environment import detect_environment
31+
from .instructions import get_installation_instructions
32+
33+
env = detect_environment()
34+
instructions = get_installation_instructions(env)
35+
print(instructions, file=sys.stderr)
3936

4037

4138
def _normalize_path(path: str) -> str:

src/promptfoo/environment.py

Lines changed: 310 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,310 @@
1+
"""
2+
Environment detection for providing contextual Node.js installation instructions.
3+
4+
This module detects the operating system, Linux distribution, cloud provider,
5+
container environment, CI/CD platform, and Python environment to provide
6+
tailored installation instructions for Node.js.
7+
"""
8+
9+
import os
10+
import sys
11+
from dataclasses import dataclass
12+
from pathlib import Path
13+
from typing import Optional
14+
15+
16+
@dataclass
17+
class Environment:
18+
"""Information about the current execution environment."""
19+
20+
os_type: str # "linux", "darwin", "windows"
21+
linux_distro: Optional[str] = None # "ubuntu", "debian", "rhel", "fedora", "alpine", "arch", etc.
22+
linux_distro_version: Optional[str] = None # e.g., "22.04", "11", "9"
23+
cloud_provider: Optional[str] = None # "aws", "gcp", "azure"
24+
is_lambda: bool = False # AWS Lambda
25+
is_cloud_function: bool = False # GCP Cloud Functions or Azure Functions
26+
is_docker: bool = False
27+
is_kubernetes: bool = False
28+
is_ci: bool = False
29+
ci_platform: Optional[str] = None # "github", "gitlab", "circleci", "jenkins", etc.
30+
is_venv: bool = False
31+
is_conda: bool = False
32+
has_sudo: bool = False # Best guess if user has sudo access
33+
34+
35+
def _detect_linux_distro() -> tuple[Optional[str], Optional[str]]:
36+
"""
37+
Detect Linux distribution and version.
38+
39+
Returns:
40+
Tuple of (distro_id, version) where distro_id is normalized
41+
(e.g., "ubuntu", "debian", "rhel", "alpine", "arch")
42+
"""
43+
# Try /etc/os-release first (most modern systems)
44+
os_release_path = Path("/etc/os-release")
45+
if os_release_path.exists():
46+
try:
47+
with open(os_release_path) as f:
48+
os_release = {}
49+
for line in f:
50+
line = line.strip()
51+
if not line or line.startswith("#"):
52+
continue
53+
if "=" in line:
54+
key, _, value = line.partition("=")
55+
# Remove quotes
56+
value = value.strip('"').strip("'")
57+
os_release[key] = value
58+
59+
distro_id = os_release.get("ID", "").lower()
60+
version = os_release.get("VERSION_ID", "")
61+
62+
# Normalize some distro IDs
63+
if distro_id in ("ubuntu", "debian", "alpine", "arch", "fedora"):
64+
return distro_id, version
65+
elif distro_id in ("rhel", "centos", "rocky", "almalinux", "ol", "amzn"):
66+
# Oracle Linux (ol), Amazon Linux (amzn)
67+
return "rhel", version
68+
elif distro_id in ("opensuse", "opensuse-leap", "opensuse-tumbleweed", "sles"):
69+
return "suse", version
70+
71+
return distro_id, version
72+
except OSError:
73+
pass
74+
75+
# Fallback: check for specific files
76+
if Path("/etc/debian_version").exists():
77+
return "debian", None
78+
elif Path("/etc/redhat-release").exists():
79+
return "rhel", None
80+
elif Path("/etc/alpine-release").exists():
81+
return "alpine", None
82+
elif Path("/etc/arch-release").exists():
83+
return "arch", None
84+
85+
return None, None
86+
87+
88+
def _detect_cloud_provider() -> Optional[str]:
89+
"""
90+
Detect if running on a cloud provider.
91+
92+
Returns:
93+
One of "aws", "gcp", "azure", or None
94+
"""
95+
# AWS detection
96+
# Check for EC2 metadata
97+
if Path("/sys/hypervisor/uuid").exists():
98+
try:
99+
with open("/sys/hypervisor/uuid") as f:
100+
uuid = f.read().strip()
101+
if uuid.startswith("ec2") or uuid.startswith("EC2"):
102+
return "aws"
103+
except OSError:
104+
pass
105+
106+
# Check AWS environment variables
107+
if os.getenv("AWS_EXECUTION_ENV") or os.getenv("AWS_REGION"):
108+
return "aws"
109+
110+
# GCP detection
111+
# Check for GCP metadata
112+
if Path("/sys/class/dmi/id/product_name").exists():
113+
try:
114+
with open("/sys/class/dmi/id/product_name") as f:
115+
product = f.read().strip()
116+
if "Google" in product or "GCE" in product:
117+
return "gcp"
118+
except OSError:
119+
pass
120+
121+
# Check GCP environment variables
122+
if os.getenv("GOOGLE_CLOUD_PROJECT") or os.getenv("GCP_PROJECT"):
123+
return "gcp"
124+
125+
# Azure detection
126+
if Path("/sys/class/dmi/id/sys_vendor").exists():
127+
try:
128+
with open("/sys/class/dmi/id/sys_vendor") as f:
129+
vendor = f.read().strip()
130+
# Could be Azure or Hyper-V, check for Azure-specific
131+
if "Microsoft Corporation" in vendor and Path("/var/lib/waagent").exists():
132+
return "azure"
133+
except OSError:
134+
pass
135+
136+
# Check Azure environment variables
137+
if os.getenv("AZURE_SUBSCRIPTION_ID") or os.getenv("WEBSITE_INSTANCE_ID"):
138+
return "azure"
139+
140+
return None
141+
142+
143+
def _detect_container() -> tuple[bool, bool]:
144+
"""
145+
Detect if running in a container.
146+
147+
Returns:
148+
Tuple of (is_docker, is_kubernetes)
149+
"""
150+
is_docker = False
151+
is_kubernetes = False
152+
153+
# Docker detection
154+
if Path("/.dockerenv").exists():
155+
is_docker = True
156+
157+
# Also check cgroup
158+
if Path("/proc/1/cgroup").exists():
159+
try:
160+
with open("/proc/1/cgroup") as f:
161+
cgroup_content = f.read()
162+
if "docker" in cgroup_content or "containerd" in cgroup_content:
163+
is_docker = True
164+
except OSError:
165+
pass
166+
167+
# Kubernetes detection
168+
if os.getenv("KUBERNETES_SERVICE_HOST"):
169+
is_kubernetes = True
170+
171+
return is_docker, is_kubernetes
172+
173+
174+
def _detect_ci() -> tuple[bool, Optional[str]]:
175+
"""
176+
Detect if running in a CI/CD environment.
177+
178+
Returns:
179+
Tuple of (is_ci, ci_platform)
180+
"""
181+
ci_env_vars = {
182+
"GITHUB_ACTIONS": "github",
183+
"GITLAB_CI": "gitlab",
184+
"CIRCLECI": "circleci",
185+
"JENKINS_HOME": "jenkins",
186+
"TRAVIS": "travis",
187+
"BUILDKITE": "buildkite",
188+
"DRONE": "drone",
189+
"BITBUCKET_BUILD_NUMBER": "bitbucket",
190+
"TEAMCITY_VERSION": "teamcity",
191+
"TF_BUILD": "azure-devops",
192+
}
193+
194+
for env_var, platform in ci_env_vars.items():
195+
if os.getenv(env_var):
196+
return True, platform
197+
198+
# Generic CI detection
199+
if os.getenv("CI"):
200+
return True, None
201+
202+
return False, None
203+
204+
205+
def _detect_python_env() -> tuple[bool, bool]:
206+
"""
207+
Detect Python virtual environment.
208+
209+
Returns:
210+
Tuple of (is_venv, is_conda)
211+
"""
212+
# venv/virtualenv detection
213+
is_venv = hasattr(sys, "real_prefix") or (hasattr(sys, "base_prefix") and sys.base_prefix != sys.prefix)
214+
215+
# Conda detection
216+
is_conda = "CONDA_DEFAULT_ENV" in os.environ or "CONDA_PREFIX" in os.environ
217+
218+
return is_venv, is_conda
219+
220+
221+
def _has_sudo_access() -> bool:
222+
"""
223+
Best-effort check if user likely has sudo access.
224+
225+
Returns:
226+
True if user is root or likely has sudo, False otherwise
227+
"""
228+
# Unix-like systems
229+
if hasattr(os, "geteuid"):
230+
# Root user
231+
if os.geteuid() == 0:
232+
return True
233+
234+
# Check if sudo command exists
235+
import shutil
236+
237+
return shutil.which("sudo") is not None
238+
239+
# Windows - check if admin (requires elevation detection)
240+
if sys.platform == "win32":
241+
try:
242+
import ctypes
243+
244+
return ctypes.windll.shell32.IsUserAnAdmin() != 0
245+
except Exception:
246+
return False
247+
248+
return False
249+
250+
251+
def detect_environment() -> Environment:
252+
"""
253+
Detect the current execution environment.
254+
255+
Returns:
256+
Environment object with detected platform information
257+
"""
258+
os_type = sys.platform
259+
if os_type.startswith("linux"):
260+
os_type = "linux"
261+
elif os_type == "darwin":
262+
os_type = "darwin"
263+
elif os_type == "win32":
264+
os_type = "windows"
265+
266+
# Linux-specific detection
267+
linux_distro = None
268+
linux_distro_version = None
269+
if os_type == "linux":
270+
linux_distro, linux_distro_version = _detect_linux_distro()
271+
272+
# Cloud provider detection
273+
cloud_provider = _detect_cloud_provider()
274+
275+
# Lambda and Cloud Functions detection
276+
is_lambda = os.getenv("AWS_LAMBDA_FUNCTION_NAME") is not None
277+
is_cloud_function = (
278+
os.getenv("FUNCTION_NAME") is not None # GCP Cloud Functions
279+
or os.getenv("FUNCTIONS_WORKER_RUNTIME") is not None # Azure Functions
280+
)
281+
282+
# Container detection
283+
is_docker, is_kubernetes = False, False
284+
if os_type == "linux":
285+
is_docker, is_kubernetes = _detect_container()
286+
287+
# CI detection
288+
is_ci, ci_platform = _detect_ci()
289+
290+
# Python environment detection
291+
is_venv, is_conda = _detect_python_env()
292+
293+
# Sudo detection
294+
has_sudo = _has_sudo_access()
295+
296+
return Environment(
297+
os_type=os_type,
298+
linux_distro=linux_distro,
299+
linux_distro_version=linux_distro_version,
300+
cloud_provider=cloud_provider,
301+
is_lambda=is_lambda,
302+
is_cloud_function=is_cloud_function,
303+
is_docker=is_docker,
304+
is_kubernetes=is_kubernetes,
305+
is_ci=is_ci,
306+
ci_platform=ci_platform,
307+
is_venv=is_venv,
308+
is_conda=is_conda,
309+
has_sudo=has_sudo,
310+
)

0 commit comments

Comments
 (0)