Skip to content

Commit 4334a67

Browse files
authored
feat: add comprehensive environment detection for better Node.js installation guidance (#12)
1 parent 4f244be commit 4334a67

File tree

7 files changed

+1689
-46
lines changed

7 files changed

+1689
-46
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: 360 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,360 @@
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_wsl: bool = False # Windows Subsystem for Linux
29+
is_ci: bool = False
30+
ci_platform: Optional[str] = None # "github", "gitlab", "circleci", "jenkins", etc.
31+
is_venv: bool = False
32+
is_conda: bool = False
33+
has_sudo: bool = False # Best guess if user has sudo access
34+
35+
36+
def _detect_linux_distro() -> tuple[Optional[str], Optional[str]]:
37+
"""
38+
Detect Linux distribution and version.
39+
40+
Returns:
41+
Tuple of (distro_id, version) where distro_id is normalized
42+
(e.g., "ubuntu", "debian", "rhel", "alpine", "arch")
43+
"""
44+
# Define known distros for normalization
45+
known_base_distros = {"ubuntu", "debian", "alpine", "arch", "fedora"}
46+
rhel_family = {"rhel", "centos", "rocky", "almalinux", "ol", "amzn"}
47+
suse_family = {"opensuse", "opensuse-leap", "opensuse-tumbleweed", "sles"}
48+
49+
# Try /etc/os-release first, then /usr/lib/os-release (per freedesktop spec)
50+
for os_release_path in [Path("/etc/os-release"), Path("/usr/lib/os-release")]:
51+
if os_release_path.exists():
52+
try:
53+
with open(os_release_path) as f:
54+
os_release = {}
55+
for line in f:
56+
line = line.strip()
57+
if not line or line.startswith("#"):
58+
continue
59+
if "=" in line:
60+
key, _, value = line.partition("=")
61+
# Remove quotes
62+
value = value.strip('"').strip("'")
63+
os_release[key] = value
64+
65+
distro_id = os_release.get("ID", "").lower()
66+
version = os_release.get("VERSION_ID", "")
67+
id_like = os_release.get("ID_LIKE", "").lower().split()
68+
69+
# Normalize distro IDs
70+
if distro_id in known_base_distros:
71+
return distro_id, version
72+
elif distro_id in rhel_family:
73+
# Oracle Linux (ol), Amazon Linux (amzn)
74+
return "rhel", version
75+
elif distro_id in suse_family:
76+
return "suse", version
77+
78+
# Check ID_LIKE for derivative distributions (e.g., Pop!_OS, Raspbian, Mint)
79+
if id_like:
80+
for parent in id_like:
81+
if parent in known_base_distros:
82+
return parent, version
83+
elif parent in rhel_family:
84+
return "rhel", version
85+
elif parent in suse_family:
86+
return "suse", version
87+
88+
# Return the raw distro_id if we couldn't normalize it
89+
return distro_id, version
90+
except OSError:
91+
pass
92+
93+
# Fallback: check for specific files
94+
if Path("/etc/debian_version").exists():
95+
return "debian", None
96+
elif Path("/etc/redhat-release").exists():
97+
return "rhel", None
98+
elif Path("/etc/alpine-release").exists():
99+
return "alpine", None
100+
elif Path("/etc/arch-release").exists():
101+
return "arch", None
102+
103+
return None, None
104+
105+
106+
def _detect_cloud_provider() -> Optional[str]:
107+
"""
108+
Detect if running on a cloud provider.
109+
110+
Returns:
111+
One of "aws", "gcp", "azure", or None
112+
"""
113+
# AWS detection
114+
# Check for EC2 metadata
115+
if Path("/sys/hypervisor/uuid").exists():
116+
try:
117+
with open("/sys/hypervisor/uuid") as f:
118+
uuid = f.read().strip()
119+
if uuid.startswith("ec2") or uuid.startswith("EC2"):
120+
return "aws"
121+
except OSError:
122+
pass
123+
124+
# Check AWS environment variables
125+
if os.getenv("AWS_EXECUTION_ENV") or os.getenv("AWS_REGION"):
126+
return "aws"
127+
128+
# GCP detection
129+
# Check for GCP metadata
130+
if Path("/sys/class/dmi/id/product_name").exists():
131+
try:
132+
with open("/sys/class/dmi/id/product_name") as f:
133+
product = f.read().strip()
134+
if "Google" in product or "GCE" in product:
135+
return "gcp"
136+
except OSError:
137+
pass
138+
139+
# Check GCP environment variables
140+
if os.getenv("GOOGLE_CLOUD_PROJECT") or os.getenv("GCP_PROJECT"):
141+
return "gcp"
142+
143+
# Azure detection
144+
if Path("/sys/class/dmi/id/sys_vendor").exists():
145+
try:
146+
with open("/sys/class/dmi/id/sys_vendor") as f:
147+
vendor = f.read().strip()
148+
# Could be Azure or Hyper-V, check for Azure-specific
149+
if "Microsoft Corporation" in vendor and Path("/var/lib/waagent").exists():
150+
return "azure"
151+
except OSError:
152+
pass
153+
154+
# Check Azure environment variables
155+
if os.getenv("AZURE_SUBSCRIPTION_ID") or os.getenv("WEBSITE_INSTANCE_ID"):
156+
return "azure"
157+
158+
return None
159+
160+
161+
def _detect_container() -> tuple[bool, bool]:
162+
"""
163+
Detect if running in a container.
164+
165+
Returns:
166+
Tuple of (is_docker, is_kubernetes)
167+
"""
168+
is_docker = False
169+
is_kubernetes = False
170+
171+
# Docker detection
172+
if Path("/.dockerenv").exists():
173+
is_docker = True
174+
175+
# Also check cgroup
176+
if Path("/proc/1/cgroup").exists():
177+
try:
178+
with open("/proc/1/cgroup") as f:
179+
cgroup_content = f.read()
180+
if "docker" in cgroup_content or "containerd" in cgroup_content:
181+
is_docker = True
182+
except OSError:
183+
pass
184+
185+
# Kubernetes detection
186+
if os.getenv("KUBERNETES_SERVICE_HOST"):
187+
is_kubernetes = True
188+
189+
return is_docker, is_kubernetes
190+
191+
192+
def _detect_wsl() -> bool:
193+
"""
194+
Detect if running in Windows Subsystem for Linux (WSL).
195+
196+
Returns:
197+
True if running in WSL, False otherwise
198+
"""
199+
# Check for WSL environment variable
200+
if os.getenv("WSL_DISTRO_NAME") or os.getenv("WSL_INTEROP"):
201+
return True
202+
203+
# Check /proc/version for Microsoft/WSL signatures
204+
if Path("/proc/version").exists():
205+
try:
206+
with open("/proc/version") as f:
207+
version_info = f.read().lower()
208+
if "microsoft" in version_info or "wsl" in version_info:
209+
return True
210+
except OSError:
211+
pass
212+
213+
# Check for Windows filesystem mounts (WSL mounts Windows drives at /mnt/)
214+
# This is less reliable but can catch WSL 1
215+
return Path("/mnt/c").exists() and Path("/proc/version").exists()
216+
217+
218+
def _detect_ci() -> tuple[bool, Optional[str]]:
219+
"""
220+
Detect if running in a CI/CD environment.
221+
222+
Returns:
223+
Tuple of (is_ci, ci_platform)
224+
"""
225+
ci_env_vars = {
226+
"GITHUB_ACTIONS": "github",
227+
"GITLAB_CI": "gitlab",
228+
"CIRCLECI": "circleci",
229+
"JENKINS_HOME": "jenkins",
230+
"TRAVIS": "travis",
231+
"BUILDKITE": "buildkite",
232+
"DRONE": "drone",
233+
"BITBUCKET_BUILD_NUMBER": "bitbucket",
234+
"TEAMCITY_VERSION": "teamcity",
235+
"TF_BUILD": "azure-devops",
236+
}
237+
238+
for env_var, platform in ci_env_vars.items():
239+
if os.getenv(env_var):
240+
return True, platform
241+
242+
# Generic CI detection
243+
if os.getenv("CI"):
244+
return True, None
245+
246+
return False, None
247+
248+
249+
def _detect_python_env() -> tuple[bool, bool]:
250+
"""
251+
Detect Python virtual environment.
252+
253+
Returns:
254+
Tuple of (is_venv, is_conda)
255+
"""
256+
# venv/virtualenv detection
257+
is_venv = hasattr(sys, "real_prefix") or (hasattr(sys, "base_prefix") and sys.base_prefix != sys.prefix)
258+
259+
# Conda detection
260+
is_conda = "CONDA_DEFAULT_ENV" in os.environ or "CONDA_PREFIX" in os.environ
261+
262+
return is_venv, is_conda
263+
264+
265+
def _has_sudo_access() -> bool:
266+
"""
267+
Best-effort check if user likely has sudo access.
268+
269+
Returns:
270+
True if user is root or likely has sudo, False otherwise
271+
"""
272+
# Unix-like systems
273+
if hasattr(os, "geteuid"):
274+
# Root user
275+
if os.geteuid() == 0:
276+
return True
277+
278+
# Check if sudo command exists
279+
import shutil
280+
281+
return shutil.which("sudo") is not None
282+
283+
# Windows - check if admin (requires elevation detection)
284+
if sys.platform == "win32":
285+
try:
286+
import ctypes
287+
288+
return ctypes.windll.shell32.IsUserAnAdmin() != 0
289+
except Exception:
290+
return False
291+
292+
return False
293+
294+
295+
def detect_environment() -> Environment:
296+
"""
297+
Detect the current execution environment.
298+
299+
Returns:
300+
Environment object with detected platform information
301+
"""
302+
os_type = sys.platform
303+
if os_type.startswith("linux"):
304+
os_type = "linux"
305+
elif os_type == "darwin":
306+
os_type = "darwin"
307+
elif os_type == "win32":
308+
os_type = "windows"
309+
310+
# Linux-specific detection
311+
linux_distro = None
312+
linux_distro_version = None
313+
if os_type == "linux":
314+
linux_distro, linux_distro_version = _detect_linux_distro()
315+
316+
# Cloud provider detection
317+
cloud_provider = _detect_cloud_provider()
318+
319+
# Lambda and Cloud Functions detection
320+
is_lambda = os.getenv("AWS_LAMBDA_FUNCTION_NAME") is not None
321+
is_cloud_function = (
322+
os.getenv("FUNCTION_NAME") is not None # GCP Cloud Functions
323+
or os.getenv("FUNCTIONS_WORKER_RUNTIME") is not None # Azure Functions
324+
)
325+
326+
# Container detection
327+
is_docker, is_kubernetes = False, False
328+
if os_type == "linux":
329+
is_docker, is_kubernetes = _detect_container()
330+
331+
# WSL detection
332+
is_wsl = False
333+
if os_type == "linux":
334+
is_wsl = _detect_wsl()
335+
336+
# CI detection
337+
is_ci, ci_platform = _detect_ci()
338+
339+
# Python environment detection
340+
is_venv, is_conda = _detect_python_env()
341+
342+
# Sudo detection
343+
has_sudo = _has_sudo_access()
344+
345+
return Environment(
346+
os_type=os_type,
347+
linux_distro=linux_distro,
348+
linux_distro_version=linux_distro_version,
349+
cloud_provider=cloud_provider,
350+
is_lambda=is_lambda,
351+
is_cloud_function=is_cloud_function,
352+
is_docker=is_docker,
353+
is_kubernetes=is_kubernetes,
354+
is_wsl=is_wsl,
355+
is_ci=is_ci,
356+
ci_platform=ci_platform,
357+
is_venv=is_venv,
358+
is_conda=is_conda,
359+
has_sudo=has_sudo,
360+
)

0 commit comments

Comments
 (0)