e93507a09c
Lockfile supply-chain audit / lockfile supply-chain audit (push) Has been cancelled
Windows Studio GGUF CI / GPU prebuilt resolves without Visual Studio (push) Has been cancelled
Windows Studio GGUF CI / setup.ps1 unit tests (VS 2026 / CMake guard) (push) Has been cancelled
Windows Studio GGUF CI / real-VS detection (VS 2022) (push) Has been cancelled
Windows Studio GGUF CI / real-VS detection (VS 2026) (push) Has been cancelled
Windows Studio GGUF CI / VC++ runtime detect + install round-trip (windows-2025-vs2026) (push) Has been cancelled
Windows Studio GGUF CI / VC++ runtime detect + install round-trip (windows-latest) (push) Has been cancelled
Windows Studio Update CI / Studio Updating Tests (push) Has been cancelled
Wheel CI / Wheel build + content sanity + import smoke (push) Has been cancelled
Lint CI / Source lint (Python + shell + YAML + JSON + safety nets) (push) Has been cancelled
MLX CI on Mac M1 / dispatch (push) Has been cancelled
Security audit / advisory audit (pip + npm + cargo) (push) Has been cancelled
Security audit / pip scan-packages :: extras (push) Has been cancelled
Security audit / pip scan-packages :: studio (push) Has been cancelled
Security audit / pip scan-packages :: hf-stack (push) Has been cancelled
Security audit / npm scan-packages (Studio frontend tarballs) (push) Has been cancelled
Security audit / workflow-trigger lint (pull_request_target / cache-poisoning) (push) Has been cancelled
Security audit / pytest tests/security (push) Has been cancelled
Security audit / npm provenance + new install-script diff (push) Has been cancelled
Studio API CI / Studio API & Auth Tests (push) Has been cancelled
Backend CI / (Python 3.10) (push) Has been cancelled
Backend CI / (Python 3.11) (push) Has been cancelled
Backend CI / (Python 3.12) (push) Has been cancelled
Backend CI / (Python 3.13) (push) Has been cancelled
Backend CI / Repo tests (CPU) (push) Has been cancelled
Frontend CI / Frontend build + bundle sanity (push) Has been cancelled
Studio GGUF CI / OpenAI, Anthropic API tests (push) Has been cancelled
Studio GGUF CI / Tool calling Tests (push) Has been cancelled
Studio GGUF CI / JSON, images (push) Has been cancelled
Mac Studio GGUF CI / OpenAI, Anthropic API tests (push) Has been cancelled
Mac Studio GGUF CI / Tool calling Tests (push) Has been cancelled
Mac Studio GGUF CI / JSON, images (push) Has been cancelled
Mac Studio Install Matrix CI / Install + load (macos-14) (push) Has been cancelled
Mac Studio Install Matrix CI / Install + load (macos-15) (push) Has been cancelled
Mac Studio Install Matrix CI / Install + load (macos-26) (push) Has been cancelled
Mac Studio Install Matrix CI / Install + load (macos-15-intel) (push) Has been cancelled
Mac Studio API CI / Studio API & Auth Tests (push) Has been cancelled
Mac Studio Install Matrix CI / Install + load (macos-26-intel) (push) Has been cancelled
Mac Studio UI CI / Chat UI Tests (push) Has been cancelled
Studio Tauri CI / Tauri Linux debug build (no codesign) (push) Has been cancelled
Mac Studio Update CI / Studio Updating Tests (push) Has been cancelled
Studio UI CI / Chat UI Tests (push) Has been cancelled
Windows Studio API CI / Studio API & Auth Tests (push) Has been cancelled
Windows Studio UI CI / Chat UI Tests (push) Has been cancelled
Studio Update CI / Studio Updating Tests (push) Has been cancelled
Core / Core (HF=default + TRL=default) (push) Has been cancelled
Core / Core (HF=4.57.6 + TRL<1) (push) Has been cancelled
Core / Core (HF=latest + TRL=latest) (push) Has been cancelled
Core / llama.cpp build + smoke (push) Has been cancelled
Windows Studio GGUF CI / OpenAI, Anthropic API tests (push) Has been cancelled
Windows Studio GGUF CI / Tool calling Tests (push) Has been cancelled
Windows Studio GGUF CI / JSON, images (push) Has been cancelled
Windows Studio GGUF CI / Studio install + inference without Visual Studio (push) Has been cancelled
Studio export capability / capability (macos-latest) (push) Has been cancelled
Studio export capability / capability (ubuntu-latest) (push) Has been cancelled
Studio export capability / capability (windows-latest) (push) Has been cancelled
Cross-platform parity / parity (macos-latest) (push) Has been cancelled
Cross-platform parity / parity (windows-latest) (push) Has been cancelled
Scorecard supply-chain security / Scorecard analysis (push) Has been cancelled
Studio load-orchestrator CI / test (push) Has been cancelled
131 lines
4.9 KiB
Python
131 lines
4.9 KiB
Python
# SPDX-License-Identifier: AGPL-3.0-only
|
|
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
|
|
|
|
"""Resolve a usable Node.js executable at runtime.
|
|
|
|
The installer provisions an isolated Node under ``<UNSLOTH_HOME>/node`` but only
|
|
puts it on PATH for the *setup* process, never the user's shell. So backend code
|
|
that shells out to ``node`` at runtime (the OXC validator) cannot rely on PATH.
|
|
``resolve_node_executable`` prefers a version-adequate system Node, else the
|
|
managed isolated Node (same floor the installer applies: ^20.19 || >=22.12 || >=23).
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import os
|
|
import re
|
|
import shutil
|
|
import subprocess
|
|
from pathlib import Path
|
|
|
|
from utils.subprocess_compat import windows_hidden_subprocess_kwargs
|
|
|
|
_NODE_VERSION_PROBE_TIMEOUT_SECONDS = 10
|
|
|
|
|
|
# Keep in sync with the setup scripts' Node floor: Get-NodeDecision (setup.ps1) /
|
|
# decide_node_source (setup.sh). Vite 8 needs Node ^20.19 || >=22.12 || >=23.
|
|
def _version_meets_floor(version: str) -> bool:
|
|
"""True iff a ``node -v`` string clears the installer's version bar."""
|
|
match = re.match(r"v?(\d+)\.(\d+)", version.strip())
|
|
if not match:
|
|
return False
|
|
major, minor = int(match.group(1)), int(match.group(2))
|
|
return (major == 20 and minor >= 19) or (major == 22 and minor >= 12) or major >= 23
|
|
|
|
|
|
def managed_node_dir() -> Path:
|
|
"""Isolated Node install dir. Mirrors ``_find_llama_server_binary``: shares a
|
|
parent with llama.cpp -- ``<STUDIO_HOME>`` in custom mode, else legacy ``~/.unsloth``."""
|
|
legacy_node = Path.home() / ".unsloth" / "node"
|
|
try:
|
|
# Lazy import (mirrors _find_llama_server_binary) so this module stays
|
|
# importable even if utils.paths cannot be loaded.
|
|
from utils.paths.storage_roots import studio_root
|
|
|
|
resolved = studio_root()
|
|
legacy_studio = Path.home() / ".unsloth" / "studio"
|
|
try:
|
|
is_legacy = resolved.resolve() == legacy_studio.resolve()
|
|
except (OSError, ValueError):
|
|
is_legacy = resolved == legacy_studio
|
|
return legacy_node if is_legacy else (resolved / "node")
|
|
except (ImportError, OSError, ValueError):
|
|
# Degraded env (utils.paths unavailable): still honor an explicit
|
|
# STUDIO_HOME override before the legacy default, mirroring studio_root().
|
|
override = (
|
|
os.environ.get("UNSLOTH_STUDIO_HOME") or os.environ.get("STUDIO_HOME") or ""
|
|
).strip()
|
|
if override:
|
|
try:
|
|
return Path(override).expanduser().resolve() / "node"
|
|
except (OSError, ValueError):
|
|
return Path(override).expanduser() / "node"
|
|
return legacy_node
|
|
|
|
|
|
def managed_node_binary() -> Path:
|
|
"""Node executable in the isolated install: ``<dir>/node.exe`` on Windows, ``<dir>/bin/node`` else."""
|
|
node_dir = managed_node_dir()
|
|
if os.name == "nt":
|
|
return node_dir / "node.exe"
|
|
return node_dir / "bin" / "node"
|
|
|
|
|
|
def _node_version_ok(executable: str) -> bool:
|
|
"""Run ``<executable> -v`` and check it clears the floor; False on any error."""
|
|
try:
|
|
result = subprocess.run(
|
|
[executable, "-v"],
|
|
capture_output = True,
|
|
text = True,
|
|
timeout = _NODE_VERSION_PROBE_TIMEOUT_SECONDS,
|
|
**windows_hidden_subprocess_kwargs(),
|
|
)
|
|
except (OSError, ValueError, subprocess.SubprocessError):
|
|
return False
|
|
if result.returncode != 0:
|
|
return False
|
|
return _version_meets_floor(result.stdout)
|
|
|
|
|
|
# Memoize ONLY a confirmed version-adequate executable: the installer runs in a
|
|
# separate process and may finish after the first probe here, so a negative /
|
|
# last-resort result must not be cached (it would stick until a backend restart).
|
|
_resolved_node: str | None = None
|
|
|
|
|
|
def _reset_resolved_node() -> None:
|
|
"""Clear the memoized executable (used by tests)."""
|
|
global _resolved_node
|
|
_resolved_node = None
|
|
|
|
|
|
def resolve_node_executable() -> str | None:
|
|
"""Resolve a usable node executable, or None.
|
|
|
|
Order: version-adequate system ``node`` on PATH; else the managed isolated
|
|
Node if adequate; else bare ``node`` (may be None). Only an adequate result
|
|
is memoized, so a Node installed after the first probe is picked up live.
|
|
"""
|
|
global _resolved_node
|
|
if _resolved_node is not None:
|
|
return _resolved_node
|
|
|
|
system_node = shutil.which("node")
|
|
if system_node and _node_version_ok(system_node):
|
|
_resolved_node = system_node
|
|
return _resolved_node
|
|
|
|
managed = managed_node_binary()
|
|
try:
|
|
managed_present = managed.is_file()
|
|
except OSError:
|
|
managed_present = False
|
|
if managed_present and _node_version_ok(str(managed)):
|
|
_resolved_node = str(managed)
|
|
return _resolved_node
|
|
|
|
# Last-resort system node (may be None), NOT cached so a later install is picked up.
|
|
return system_node
|