Studio: use an isolated Node.js for the frontend build instead of replacing the system Node/npm (#6533)

* Studio: use an isolated Node.js for the frontend build instead of replacing the system Node/npm

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Studio: address Node isolation review (no-Node probe crash, PATH refresh, OXC provisioning, venv python, runtime node resolver)

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Fix/adjust Node isolation for PR #6533

* Studio Node: don't cache a negative node resolution; accept Node metadata in setup.sh ownership guard

- node_runtime: memoize only a version-adequate executable so a Node installed
  by a separate-process 'studio update' is picked up without a backend restart.
- setup.sh: _studio_owned_adoptable also accepts UNSLOTH_NODE_PREBUILT_INFO.json,
  matching the setup.ps1 Node ownership guard (custom-home parity).

* Studio setup.ps1: skip OXC npm install gracefully when npm is absent

Mirror setup.sh's `command -v npm` guard so a pip-installed Studio with no
system Node skips the OXC runtime install (validator degrades at runtime) instead
of exit 1 aborting the whole setup. Tighten test_node_probe_guard.ps1's probe
regex so it only matches the two system-version probes, not this new npm guard.

* Wire test_node_probe_guard.ps1 into Windows CI for PR #6533

* Harden isolated Node install and probes for PR #6533

- install_node_prebuilt.py: keep an existing, still-usable isolated Node
  when nodejs.org's dist index is unreachable instead of aborting the
  update on a transient outage (existing_install_usable + tolerant fetch).
- install_node_prebuilt.py: pin NPM_CONFIG_PREFIX/npm_config_prefix and
  drop NODE_PATH in _run_node so any npm -g stays inside the isolated
  prefix; Windows npm otherwise writes to %APPDATA%\npm.
- install_node_prebuilt.py: resolve tar hard-link targets against the
  archive root (symlink targets stay link-parent relative).
- setup.ps1: wrap the system node/npm probes in try/catch so a present
  but broken shim degrades to the bundled Node instead of aborting setup.
- setup.ps1: run the isolated Node install with the handed-off/venv Python
  (ReusedSetupPython); the main resolver runs later and bare python may be
  a Store stub this early.
- setup.sh: log when the OXC validator runtime is skipped for missing npm,
  matching setup.ps1.
- node_runtime.py: move the version-floor comment onto _version_meets_floor.
- Tests for the offline-reuse and broken-shim paths.

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Trim verbose comments across the Studio Node installer for PR #6533

Comments-only pass: collapse the multi-line section banners to single lines,
drop comments that restate obvious code, and tighten the remaining docstrings
and "why" notes without losing intent. No code changes (verified with an AST
comment-only check on the Python files and a non-comment-diff scan on setup.sh
and setup.ps1). Net 109 fewer lines; the install, decision, and probe-guard
suites stay green.

* Harden Node install from review: validated Python, version floor, legacy home, lock race

For PR #6533, addressing the latest review pass:

- setup.ps1: run the isolated Node install with the validated reused/venv Python.
  An incompatible reused interpreter (old venv, conda, stale UNSLOTH_SETUP_PYTHON)
  is no longer used; fall back to the resolved python instead.
- setup.ps1: a STUDIO_HOME/UNSLOTH_STUDIO_HOME override equal to the legacy default
  now uses the legacy sibling node dir (~/.unsloth/node), matching the runtime
  resolver and setup.sh, so OXC can find the Node it installed.
- install_node_prebuilt.py: reject an explicit --node-version below the floor
  (^20.19 || >=22.12 || >=23) instead of installing a Node the build cannot use.
- install_node_prebuilt.py: atomically rename a stale install lock before unlinking
  so two concurrent runs without filelock cannot both acquire it.

Tests added for the version floor (parametrized + explicit-below-floor rejection).
Full install suite: 937 passed, 1 skipped; setup.ps1 parses; decision tests green.

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Address latest review: armv7l + later-fetch offline reuse for PR #6533

- install_node_prebuilt.py: reject 32-bit ARM (armv7l) up front. Node 24 LTS
  ships no linux-armv7l build, so the old path failed late with a confusing
  "no sha256"; it now fails fast with a clear unsupported-architecture error.
- install_node_prebuilt.py: extend the offline-reuse fallback to the SHASUMS and
  archive fetches. If index.json resolves a newer Node but a later download fails
  and a usable isolated Node is already on disk, keep it instead of aborting a
  non-force update.

Tests added: armv7l/armhf are unsupported; a SHASUMS failure keeps an existing
usable Node and re-raises when none is present. Full install suite: 941 passed.

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Add UNSLOTH_STUDIO_HOME node-dir tests (install side + resolver) for PR #6533

* Add regression tests pinning the reuse path read-only and isolating installer writes

Lock in the two invariants behind the isolated-Node design: reusing a good
system Node never mutates the user's Node/npm, and the installer's own npm
calls only ever write inside its install_dir.

- tests/studio/install/test_install_node_prebuilt_logic.py: assert _run_node
  redirects NPM_CONFIG_PREFIX/npm_config_prefix into install_dir and drops an
  inherited NODE_PATH; assert _ensure_npm_floor scopes the npm self-upgrade to
  install_dir (never -g against the system) and is a no-op once npm meets the floor.
- tests/sh/test_system_node_readonly.sh (new, wired into studio-backend-ci.yml):
  the setup.sh NODE_SOURCE=system arm runs no global install and sets no
  NPM_CONFIG_PREFIX, with a positive control that the bundled arm does.
- tests/studio/test_node_decision.ps1: symmetric structural guard that the prefix
  pin and the only global install (bun) live in the bundled branch, not the system arm.

---------

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
Co-authored-by: wasimysaid <wasimysdev@gmail.com>
This commit is contained in:
Daniel Han 2026-06-21 21:17:29 -07:00 committed by GitHub
commit 9f39cc2c39
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
16 changed files with 2224 additions and 139 deletions

View file

@ -222,6 +222,9 @@ jobs:
for s in \
tests/sh/test_get_torch_index_url.sh \
tests/sh/test_mac_intel_compat.sh \
tests/sh/test_node_decision.sh \
tests/sh/test_studio_home_node_dir.sh \
tests/sh/test_system_node_readonly.sh \
tests/sh/test_nvcc_meets_llama_minimum.sh \
tests/sh/test_tauri_install_exit_order.sh \
tests/sh/test_torch_constraint.sh \

View file

@ -79,6 +79,8 @@ jobs:
}
pwsh -NoProfile -File tests/studio/test_resolve_cuda_toolkit.ps1
pwsh -NoProfile -File tests/studio/test_torch_flavor.ps1
pwsh -NoProfile -File tests/studio/test_node_decision.ps1
pwsh -NoProfile -File tests/studio/test_node_probe_guard.ps1
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:

View file

@ -79,15 +79,15 @@ jobs:
# Two surgical fixes against measured Windows-only install
# waste (vs Mac/Linux on the same SHA):
#
# (1) npm. setup.ps1 line 1109-1145 requires Node 22.12+ (or
# 20.19+ / 23+) AND npm >=11 because Vite 8 needs both.
# (1) npm. setup.ps1's Get-NodeDecision requires Node 22.12+
# (or 20.19+ / 23+) AND npm >=11 because Vite 8 needs both.
# actions/setup-node@v4 with `node-version: '22'` lands
# Node 22.22.2 + the npm 10.9.7 it bundles, so the npm
# check fails and setup.ps1 falls through to the
# "winget install Node.js LTS" branch -- a ~35 s reinstall
# of Node we don't need. `npm install -g npm@^11` updates
# the bundled npm in-place in ~5 s, which makes setup.ps1
# short-circuit on the existing Node.
# Node 22.22.2 + the npm 10.9.7 it bundles, so the decision
# is "bundled" and setup.ps1 downloads an isolated Node (~30
# MB) we don't need on a runner that already has a fine Node.
# `npm install -g npm@^11` updates the runner's npm in-place
# in ~5 s, flipping the decision to "system" so setup.ps1
# reuses the existing Node with no download.
#
# (2) Defender. windows-latest's real-time scan opens / hashes
# every file Studio writes during install (Vite output =

View file

@ -2298,7 +2298,9 @@ exit 0
# ── Run studio setup ──
# setup.ps1 will handle installing Git, CMake, Visual Studio Build Tools,
# CUDA Toolkit, Node.js, and other dependencies automatically via winget.
# CUDA Toolkit, and other dependencies automatically via winget. Node.js is
# NOT installed via winget -- setup.ps1 uses an isolated Node it manages and
# never touches the system Node/npm.
Write-TauriLog "STEP" "Running studio setup"
step "setup" "running unsloth studio setup..."
$UnslothExe = Join-Path $VenvDir "Scripts\unsloth.exe"

View file

@ -14,6 +14,7 @@ from pathlib import Path
from typing import Any
from loggers import get_logger
from utils.node_runtime import resolve_node_executable
from utils.paths import ensure_dir, oxc_validator_tmp_root
logger = get_logger(__name__)
@ -231,6 +232,14 @@ def _run_oxc_batch(
"code_shape": code_shape,
"codes": code_values,
}
# Resolve a usable Node (system or the isolated install, which is not on the
# user's PATH); a bare "node" would fail for isolated-Node users.
node_executable = resolve_node_executable()
if not node_executable:
return _fallback_results(
len(code_values),
"Node.js not found (install Node >= 20.19, or re-run Studio setup to provision it).",
)
try:
tmp_dir = ensure_dir(oxc_validator_tmp_root())
env = child_env_without_native_path_secret()
@ -238,8 +247,13 @@ def _run_oxc_batch(
env["TMPDIR"] = tmp_dir_str
env["TMP"] = tmp_dir_str
env["TEMP"] = tmp_dir_str
# Resolved node's dir first on the child PATH so it finds its own npm/npx.
node_bin_dir = os.path.dirname(node_executable)
if node_bin_dir:
env["PATH"] = node_bin_dir + os.pathsep + env.get("PATH", "")
env.pop("NODE_PATH", None)
proc = subprocess.run(
["node", str(_OXC_RUNNER_PATH)],
[node_executable, str(_OXC_RUNNER_PATH)],
cwd = str(_OXC_TOOL_DIR),
input = json.dumps(payload),
text = True,

View file

@ -0,0 +1,130 @@
# 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

View file

@ -0,0 +1,765 @@
#!/usr/bin/env python3
# SPDX-License-Identifier: AGPL-3.0-only
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
"""Cross-platform Node.js prebuilt installer for Unsloth Studio.
Downloads an official Node.js archive from nodejs.org into an isolated
``<UNSLOTH_HOME>/node`` and never touches the system Node/npm. Pinning Node 24+
LTS clears the Studio frontend build floor (Vite 8: Node ^20.19 || >=22.12,
npm >= 11) with the npm it bundles.
Mirrors ``install_llama_prebuilt.py`` so the setup scripts drive it the same way.
Exit codes: 0 success, 1 error, 2 fallback, 3 busy. A re-run that already matches
logs "already matches" and returns 0 without downloading (the scripts grep it).
"""
from __future__ import annotations
import argparse
import hashlib
import json
import os
import platform
import random
import shutil
import socket
import subprocess
import sys
import tarfile
import tempfile
import time
import urllib.error
import urllib.parse
import urllib.request
import zipfile
from contextlib import contextmanager
from dataclasses import dataclass
from pathlib import Path
from typing import Iterator
try:
from filelock import FileLock, Timeout as FileLockTimeout
except ImportError:
FileLock = None
FileLockTimeout = None
EXIT_SUCCESS = 0
EXIT_ERROR = 1
EXIT_FALLBACK = 2
EXIT_BUSY = 3
# Node 24 LTS bundles npm 11, clearing Vite 8's floor (Node ^20.19 || >=22.12, npm >= 11).
NODE_MIN_LTS_MAJOR = 24
NPM_MIN_MAJOR = 11
NODE_DIST_BASE = "https://nodejs.org/dist"
NODE_DIST_INDEX = f"{NODE_DIST_BASE}/index.json"
RETRYABLE_HTTP_STATUS = {408, 429, 500, 502, 503, 504}
HTTP_FETCH_ATTEMPTS = 4
HTTP_FETCH_BASE_DELAY_SECONDS = 0.75
INSTALL_LOCK_TIMEOUT_SECONDS = 300
INSTALL_STAGING_ROOT_NAME = ".staging"
METADATA_FILENAME = "UNSLOTH_NODE_PREBUILT_INFO.json"
METADATA_SCHEMA_VERSION = 1
# PowerShell renders stderr as NativeCommandError noise; main() flips logs to stdout.
_LOG_TO_STDOUT = False
class PrebuiltFallback(RuntimeError):
"""Recoverable failure -- caller should fall back (exit code 2)."""
class BusyInstallConflict(RuntimeError):
"""Another process holds the install lock (exit code 3)."""
def log(message: str) -> None:
print(f"[node-prebuilt] {message}", file = sys.stdout if _LOG_TO_STDOUT else sys.stderr)
# ── Host detection ──
@dataclass(frozen = True)
class HostInfo:
system: str # platform.system()
machine: str # lowered platform.machine()
node_os: str # nodejs.org token: linux | darwin | win
node_arch: str # nodejs.org token: x64 | arm64 | armv7l
archive_ext: str # .tar.gz | .zip
is_windows: bool
def detect_host() -> HostInfo:
system = platform.system()
machine = platform.machine().lower()
is_windows = system == "Windows"
if system == "Linux":
node_os = "linux"
elif system == "Darwin":
node_os = "darwin"
elif is_windows:
node_os = "win"
else:
raise PrebuiltFallback(f"unsupported operating system for Node prebuilt: {system}")
if machine in {"x86_64", "amd64", "x64"}:
node_arch = "x64"
elif machine in {"arm64", "aarch64"}:
node_arch = "arm64"
else:
# 32-bit ARM (armv7l) is intentionally unsupported: Node 24 LTS ships no
# linux-armv7l build, so there is nothing at/above the floor to install.
raise PrebuiltFallback(f"unsupported CPU architecture for Node prebuilt: {machine}")
# .tar.gz (not .tar.xz) on Unix so the extractor needs no xz; .zip on Windows.
archive_ext = ".zip" if is_windows else ".tar.gz"
return HostInfo(
system = system,
machine = machine,
node_os = node_os,
node_arch = node_arch,
archive_ext = archive_ext,
is_windows = is_windows,
)
# ── URL / asset construction (pure, unit tested) ──
def node_asset_stem(version: str, host: HostInfo) -> str:
"""e.g. node-v24.4.1-linux-x64 (no extension)."""
return f"node-v{version}-{host.node_os}-{host.node_arch}"
def node_asset_name(version: str, host: HostInfo) -> str:
return f"{node_asset_stem(version, host)}{host.archive_ext}"
def node_download_url(version: str, asset_name: str) -> str:
return f"{NODE_DIST_BASE}/v{version}/{asset_name}"
def node_shasums_url(version: str) -> str:
return f"{NODE_DIST_BASE}/v{version}/SHASUMS256.txt"
def expected_sha256_for(shasums_text: str, asset_name: str) -> str | None:
"""Parse a nodejs.org SHASUMS256.txt ('<hex> <filename>' per line)."""
for line in shasums_text.splitlines():
parts = line.split()
if len(parts) == 2 and parts[1] == asset_name:
digest = parts[0].lower()
if len(digest) == 64 and all(c in "0123456789abcdef" for c in digest):
return digest
return None
def _version_tuple(value: str) -> tuple[int, ...]:
try:
return tuple(int(p) for p in value.lstrip("v").split("."))
except ValueError:
return ()
def _meets_node_floor(version: str) -> bool:
"""True iff version clears the setup floor (^20.19 || >=22.12 || >=23)."""
parts = _version_tuple(version)
if not parts:
return False
major = parts[0]
minor = parts[1] if len(parts) > 1 else 0
return (major == 20 and minor >= 19) or (major == 22 and minor >= 12) or major >= 23
def select_node_version(index: list[dict], *, channel: str, min_major: int) -> str:
"""Pick a concrete Node version from nodejs.org index.json.
channel='lts' -> newest LTS release line whose major >= min_major.
channel='latest' -> newest release overall whose major >= min_major.
Otherwise the channel is treated as an explicit version string.
"""
if channel not in {"lts", "latest"}:
return channel.lstrip("v")
best: tuple[int, ...] | None = None
best_version: str | None = None
for entry in index:
version = str(entry.get("version", "")).lstrip("v")
parsed = _version_tuple(version)
if not parsed or parsed[0] < min_major:
continue
if channel == "lts" and not entry.get("lts"):
continue
if best is None or parsed > best:
best = parsed
best_version = version
if best_version is None:
raise PrebuiltFallback(
f"no Node '{channel}' release found at or above major {min_major} in {NODE_DIST_INDEX}"
)
return best_version
# ── HTTP (retry/backoff) ──
def _auth_headers() -> dict[str, str]:
# A User-Agent keeps some proxies/CDNs happy; nodejs.org needs no auth.
return {"User-Agent": "unsloth-studio-node-prebuilt"}
def is_retryable_url_error(exc: Exception) -> bool:
if isinstance(exc, urllib.error.HTTPError):
return exc.code in RETRYABLE_HTTP_STATUS
if isinstance(exc, (urllib.error.URLError, TimeoutError, socket.timeout)):
return True
return False
def sleep_backoff(attempt: int) -> None:
delay = HTTP_FETCH_BASE_DELAY_SECONDS * (2 ** max(attempt - 1, 0))
delay += random.uniform(0.0, 0.2)
time.sleep(delay)
def download_bytes(url: str, *, timeout: int = 60) -> bytes:
last_exc: Exception | None = None
for attempt in range(1, HTTP_FETCH_ATTEMPTS + 1):
try:
request = urllib.request.Request(url, headers = _auth_headers())
with urllib.request.urlopen(request, timeout = timeout) as response:
return response.read()
except Exception as exc: # noqa: BLE001
last_exc = exc
if attempt >= HTTP_FETCH_ATTEMPTS or not is_retryable_url_error(exc):
raise
log(f"fetch failed ({attempt}/{HTTP_FETCH_ATTEMPTS}) for {url}: {exc}; retrying")
sleep_backoff(attempt)
assert last_exc is not None
raise last_exc
def fetch_json(url: str) -> object:
return json.loads(download_bytes(url, timeout = 30).decode("utf-8"))
def atomic_replace_from_tempfile(tmp_path: Path, destination: Path) -> None:
destination.parent.mkdir(parents = True, exist_ok = True)
os.replace(tmp_path, destination)
def download_file(url: str, destination: Path) -> None:
destination.parent.mkdir(parents = True, exist_ok = True)
last_exc: Exception | None = None
for attempt in range(1, HTTP_FETCH_ATTEMPTS + 1):
tmp_path: Path | None = None
try:
request = urllib.request.Request(url, headers = _auth_headers())
with tempfile.NamedTemporaryFile(
prefix = destination.name + ".tmp-",
dir = destination.parent,
delete = False,
) as handle:
tmp_path = Path(handle.name)
with urllib.request.urlopen(request, timeout = 120) as response:
while True:
chunk = response.read(1024 * 1024)
if not chunk:
break
handle.write(chunk)
handle.flush()
os.fsync(handle.fileno())
if not tmp_path.exists() or tmp_path.stat().st_size == 0:
raise RuntimeError(f"downloaded empty file from {url}")
atomic_replace_from_tempfile(tmp_path, destination)
return
except Exception as exc: # noqa: BLE001
last_exc = exc
if tmp_path is not None:
try:
tmp_path.unlink(missing_ok = True)
except Exception: # noqa: BLE001
pass
if attempt >= HTTP_FETCH_ATTEMPTS or not is_retryable_url_error(exc):
raise
log(f"download failed ({attempt}/{HTTP_FETCH_ATTEMPTS}) for {url}: {exc}; retrying")
sleep_backoff(attempt)
assert last_exc is not None
raise last_exc
def sha256_file(path: Path) -> str:
digest = hashlib.sha256()
with path.open("rb") as handle:
for chunk in iter(lambda: handle.read(1024 * 1024), b""):
digest.update(chunk)
return digest.hexdigest()
def download_file_verified(
url: str, destination: Path, *, expected_sha256: str, label: str
) -> None:
for attempt in range(1, 3):
download_file(url, destination)
actual = sha256_file(destination)
if actual == expected_sha256:
log(f"verified {label} sha256={actual}")
return
log(f"{label} checksum mismatch {attempt}/2: expected={expected_sha256} actual={actual}")
destination.unlink(missing_ok = True)
if attempt == 2:
raise PrebuiltFallback(f"{label} checksum mismatch after retry")
# ── Safe archive extraction (zip + tar.gz, traversal/symlink guarded) ──
def _safe_extract_path(base: Path, member_name: str) -> Path:
member_path = Path(member_name.replace("\\", "/"))
if member_path.is_absolute():
raise PrebuiltFallback(f"archive member used an absolute path: {member_name}")
target = (base / member_path).resolve()
try:
target.relative_to(base.resolve())
except ValueError as exc:
raise PrebuiltFallback(f"archive member escaped destination: {member_name}") from exc
return target
def _extract_zip_safely(source: Path, base: Path) -> None:
with zipfile.ZipFile(source) as archive:
for member in archive.infolist():
target = _safe_extract_path(base, member.filename)
mode = (member.external_attr >> 16) & 0o170000
if mode == 0o120000:
raise PrebuiltFallback(f"zip archive contained a symlink entry: {member.filename}")
if member.is_dir():
target.mkdir(parents = True, exist_ok = True)
continue
target.parent.mkdir(parents = True, exist_ok = True)
with archive.open(member, "r") as src, target.open("wb") as dst:
shutil.copyfileobj(src, dst)
def _extract_tar_safely(source: Path, base: Path) -> None:
# Node Unix tarballs ship bin/npm, bin/npx, bin/corepack as relative
# symlinks into lib/node_modules; defer links and resolve after files.
pending_links: list[tuple[tarfile.TarInfo, Path]] = []
with tarfile.open(source, "r:gz") as archive:
for member in archive.getmembers():
target = _safe_extract_path(base, member.name)
if member.isdir():
target.mkdir(parents = True, exist_ok = True)
continue
if member.islnk() or member.issym():
pending_links.append((member, target))
continue
if not member.isfile():
raise PrebuiltFallback(f"tar archive contained an unsupported entry: {member.name}")
target.parent.mkdir(parents = True, exist_ok = True)
extracted = archive.extractfile(member)
if extracted is None:
raise PrebuiltFallback(f"tar archive entry could not be read: {member.name}")
with extracted, target.open("wb") as dst:
shutil.copyfileobj(extracted, dst)
if member.mode & 0o111:
os.chmod(target, target.stat().st_mode | 0o111)
for member, target in pending_links:
link_name = member.linkname.replace("\\", "/")
link_path = Path(link_name)
if link_path.is_absolute() or not link_name:
raise PrebuiltFallback(
f"archive link used an unsafe target: {member.name} -> {link_name}"
)
# tar symlink names are link-parent relative; hard-link names are archive-root relative.
resolved = (target.parent / link_path if member.issym() else base / link_path).resolve()
try:
resolved.relative_to(base.resolve())
except ValueError as exc:
raise PrebuiltFallback(
f"archive link escaped destination: {member.name} -> {link_name}"
) from exc
target.parent.mkdir(parents = True, exist_ok = True)
if target.exists() or target.is_symlink():
target.unlink()
if member.issym():
target.symlink_to(link_name)
else: # hard link
shutil.copy2(resolved, target)
def extract_archive(archive_path: Path, destination: Path) -> None:
destination.mkdir(parents = True, exist_ok = True)
if archive_path.name.endswith(".zip"):
_extract_zip_safely(archive_path, destination)
elif archive_path.name.endswith(".tar.gz"):
_extract_tar_safely(archive_path, destination)
else:
raise PrebuiltFallback(f"unsupported archive format: {archive_path.name}")
# ── Install lock (concurrent setup runs share one UNSLOTH_HOME) ──
def install_lock_path(install_dir: Path) -> Path:
return install_dir.parent / f".{install_dir.name}.install.lock"
def _pid_is_alive(pid: int) -> bool:
"""Best-effort process liveness check that never signals the process on Windows."""
if pid <= 0:
return False
if sys.platform == "win32":
try:
result = subprocess.run(
["tasklist", "/FI", f"PID eq {pid}", "/FO", "CSV", "/NH"],
capture_output = True,
text = True,
timeout = 5,
**_windows_hidden_kwargs(),
)
except (OSError, ValueError, subprocess.SubprocessError):
# Be conservative if tasklist itself is unavailable.
return True
return f'"{pid}"' in result.stdout
try:
os.kill(pid, 0)
except ProcessLookupError:
return False
except PermissionError:
return True
except ValueError:
return False
return True
@contextmanager
def install_lock(lock_path: Path) -> Iterator[None]:
lock_path.parent.mkdir(parents = True, exist_ok = True)
if FileLock is None:
fd: int | None = None
deadline = time.monotonic() + INSTALL_LOCK_TIMEOUT_SECONDS
while True:
try:
fd = os.open(str(lock_path), os.O_CREAT | os.O_EXCL | os.O_RDWR)
os.write(fd, f"{os.getpid()}\n".encode())
os.fsync(fd)
break
except FileExistsError:
try:
raw = lock_path.read_text().strip()
except FileNotFoundError:
continue
stale = False
if raw:
try:
stale = not _pid_is_alive(int(raw))
except ValueError:
stale = True
if stale:
# Atomically rename before unlinking so only one racer removes
# the stale lock; a process recreating it loses the rename and waits.
try:
stale_path = lock_path.with_name(f"{lock_path.name}.stale.{os.getpid()}")
os.replace(str(lock_path), str(stale_path))
stale_path.unlink(missing_ok = True)
except (OSError, ValueError):
pass
continue
if time.monotonic() >= deadline:
raise BusyInstallConflict(
f"timed out after {INSTALL_LOCK_TIMEOUT_SECONDS}s waiting for install lock: {lock_path}"
)
time.sleep(0.5)
try:
yield
finally:
if fd is not None:
os.close(fd)
lock_path.unlink(missing_ok = True)
return
try:
with FileLock(str(lock_path), timeout = INSTALL_LOCK_TIMEOUT_SECONDS):
yield
except FileLockTimeout as exc:
raise BusyInstallConflict(
f"timed out after {INSTALL_LOCK_TIMEOUT_SECONDS}s waiting for install lock: {lock_path}"
) from exc
# ── Install layout / metadata / health ──
def node_binary_path(install_dir: Path, host: HostInfo) -> Path:
return install_dir / "node.exe" if host.is_windows else install_dir / "bin" / "node"
def npm_cli_path(install_dir: Path, host: HostInfo) -> Path:
# Windows ships npm at <root>\node_modules\npm; Unix at <root>/lib/node_modules/npm.
if host.is_windows:
return install_dir / "node_modules" / "npm" / "bin" / "npm-cli.js"
return install_dir / "lib" / "node_modules" / "npm" / "bin" / "npm-cli.js"
def _windows_hidden_kwargs() -> dict[str, object]:
if sys.platform != "win32":
return {}
kwargs: dict[str, object] = {}
flag = getattr(subprocess, "CREATE_NO_WINDOW", 0)
if flag:
kwargs["creationflags"] = flag
return kwargs
def _run_node(
install_dir: Path,
host: HostInfo,
args: list[str],
*,
timeout: int = 120,
) -> str:
node_bin = node_binary_path(install_dir, host)
env = os.environ.copy()
# Keep any `npm -g` writes inside the isolated prefix; Windows npm otherwise
# defaults its global prefix to %APPDATA%\npm and touches the system install.
env["NPM_CONFIG_PREFIX"] = str(install_dir)
env["npm_config_prefix"] = str(install_dir)
env.pop("NODE_PATH", None)
result = subprocess.run(
[str(node_bin), *args],
capture_output = True,
text = True,
timeout = timeout,
env = env,
**_windows_hidden_kwargs(),
)
if result.returncode != 0:
raise RuntimeError(
f"node {' '.join(args)} failed: {result.stderr.strip() or result.stdout.strip()}"
)
return result.stdout.strip()
def installed_node_version(install_dir: Path, host: HostInfo) -> str | None:
node_bin = node_binary_path(install_dir, host)
if not node_bin.exists():
return None
try:
return _run_node(install_dir, host, ["-v"], timeout = 30).lstrip("v")
except Exception: # noqa: BLE001
return None
def installed_npm_major(install_dir: Path, host: HostInfo) -> int | None:
cli = npm_cli_path(install_dir, host)
if not cli.exists():
return None
try:
out = _run_node(install_dir, host, [str(cli), "--version"], timeout = 60)
return _version_tuple(out)[0]
except Exception: # noqa: BLE001
return None
def metadata_path(install_dir: Path) -> Path:
return install_dir / METADATA_FILENAME
def write_metadata(install_dir: Path, *, version: str, asset: str, sha256: str) -> None:
payload = {
"schema_version": METADATA_SCHEMA_VERSION,
"kind": "node",
"version": version,
"asset": asset,
"sha256": sha256,
}
metadata_path(install_dir).write_text(json.dumps(payload, indent = 2) + "\n")
def load_metadata(install_dir: Path) -> dict | None:
path = metadata_path(install_dir)
if not path.exists():
return None
try:
data = json.loads(path.read_text())
except (json.JSONDecodeError, OSError):
return None
return data if isinstance(data, dict) else None
def existing_install_matches(install_dir: Path, host: HostInfo, *, version: str) -> bool:
"""True iff the on-disk install is exactly this version and runs."""
meta = load_metadata(install_dir)
if not meta or meta.get("version") != version:
return False
if installed_node_version(install_dir, host) != version:
return False
npm_major = installed_npm_major(install_dir, host)
return npm_major is not None and npm_major >= NPM_MIN_MAJOR
def existing_install_usable(install_dir: Path, host: HostInfo) -> bool:
"""True iff the on-disk install runs and clears the npm floor, ignoring version."""
if not load_metadata(install_dir):
return False
if installed_node_version(install_dir, host) is None:
return False
npm_major = installed_npm_major(install_dir, host)
return npm_major is not None and npm_major >= NPM_MIN_MAJOR
def _swap_into_place(extracted_root: Path, install_dir: Path) -> None:
"""Atomically replace install_dir with extracted_root (same filesystem)."""
install_dir.parent.mkdir(parents = True, exist_ok = True)
backup: Path | None = None
if install_dir.exists():
backup = install_dir.parent / f".{install_dir.name}.old-{os.getpid()}"
os.replace(install_dir, backup)
try:
os.replace(extracted_root, install_dir)
except OSError:
if backup is not None and not install_dir.exists():
os.replace(backup, install_dir)
raise
if backup is not None:
shutil.rmtree(backup, ignore_errors = True)
def _ensure_npm_floor(install_dir: Path, host: HostInfo) -> None:
"""Self-upgrade npm inside the isolated prefix if a pinned build ships npm < 11 (no-op on Node 24+)."""
npm_major = installed_npm_major(install_dir, host)
if npm_major is not None and npm_major >= NPM_MIN_MAJOR:
return
log(f"bundled npm {npm_major} below {NPM_MIN_MAJOR}; upgrading npm inside the isolated prefix")
cli = npm_cli_path(install_dir, host)
_run_node(install_dir, host, [str(cli), "install", "-g", f"npm@^{NPM_MIN_MAJOR}"], timeout = 300)
# ── Orchestration ──
def install_prebuilt(install_dir: Path, *, channel: str, min_major: int, force: bool) -> int:
host = detect_host()
if channel in {"lts", "latest"}:
try:
index = fetch_json(NODE_DIST_INDEX)
except Exception as exc: # noqa: BLE001
# nodejs.org unreachable: keep a working isolated Node instead of aborting.
if not force and existing_install_usable(install_dir, host):
log(f"Node dist index unreachable ({exc}); keeping existing isolated Node")
return EXIT_SUCCESS
raise
if not isinstance(index, list):
raise PrebuiltFallback(f"unexpected index.json payload from {NODE_DIST_INDEX}")
version = select_node_version(index, channel = channel, min_major = min_major)
else:
version = channel.lstrip("v")
# Explicit version bypasses min_major; reject anything Vite/OXC cannot use.
if not _meets_node_floor(version):
raise PrebuiltFallback(
f"requested Node v{version} is below the floor (^20.19 || >=22.12 || >=23)"
)
asset = node_asset_name(version, host)
log(f"target Node v{version} ({asset})")
if not force and existing_install_matches(install_dir, host, version = version):
log(f"existing Node install already matches v{version}; nothing to do")
return EXIT_SUCCESS
with install_lock(install_lock_path(install_dir)):
# Re-check under the lock: a concurrent run may have just finished.
if not force and existing_install_matches(install_dir, host, version = version):
log(f"existing Node install already matches v{version}; nothing to do")
return EXIT_SUCCESS
try:
shasums = download_bytes(node_shasums_url(version), timeout = 30).decode("utf-8")
expected_sha = expected_sha256_for(shasums, asset)
if not expected_sha:
raise PrebuiltFallback(f"no sha256 for {asset} in SHASUMS256.txt (v{version})")
staging_root = install_dir.parent / INSTALL_STAGING_ROOT_NAME
staging_root.mkdir(parents = True, exist_ok = True)
staging = Path(
tempfile.mkdtemp(prefix = f"{install_dir.name}.staging-", dir = staging_root)
)
try:
archive_path = staging / asset
log(f"downloading {node_download_url(version, asset)}")
download_file_verified(
node_download_url(version, asset),
archive_path,
expected_sha256 = expected_sha,
label = asset,
)
extract_dir = staging / "extracted"
extract_archive(archive_path, extract_dir)
roots = [p for p in extract_dir.iterdir() if p.is_dir()]
if len(roots) != 1:
raise PrebuiltFallback(f"unexpected archive layout: {[p.name for p in roots]}")
extracted_root = roots[0]
_ensure_npm_floor(extracted_root, host)
write_metadata(extracted_root, version = version, asset = asset, sha256 = expected_sha)
_swap_into_place(extracted_root, install_dir)
finally:
shutil.rmtree(staging, ignore_errors = True)
try:
staging_root.rmdir()
except OSError:
pass
except Exception as exc: # noqa: BLE001
# A newer Node exists upstream but the shasums/archive fetch failed;
# keep an existing usable Node rather than aborting the update.
if not force and existing_install_usable(install_dir, host):
log(f"Node download failed ({exc}); keeping existing isolated Node")
return EXIT_SUCCESS
raise
final_version = installed_node_version(install_dir, host)
npm_major = installed_npm_major(install_dir, host)
if final_version != version or npm_major is None or npm_major < NPM_MIN_MAJOR:
raise PrebuiltFallback(
f"post-install verification failed: node={final_version} npm_major={npm_major}"
)
log(f"installed isolated Node v{final_version} (npm {npm_major}.x) at {install_dir}")
return EXIT_SUCCESS
def main(argv: list[str] | None = None) -> int:
global _LOG_TO_STDOUT
_LOG_TO_STDOUT = True
parser = argparse.ArgumentParser(description = "Install an isolated Node.js for Unsloth Studio")
parser.add_argument(
"--install-dir", required = True, help = "isolated Node directory, e.g. <UNSLOTH_HOME>/node"
)
parser.add_argument(
"--node-version",
default = os.environ.get("UNSLOTH_NODE_VERSION", "lts"),
help = "'lts' (default), 'latest', or an explicit version like 24.4.1",
)
parser.add_argument("--min-major", type = int, default = NODE_MIN_LTS_MAJOR)
parser.add_argument(
"--force", action = "store_true", help = "reinstall even if the version matches"
)
args = parser.parse_args(argv)
install_dir = Path(args.install_dir).expanduser().resolve()
try:
return install_prebuilt(
install_dir,
channel = args.node_version,
min_major = args.min_major,
force = args.force,
)
except BusyInstallConflict as exc:
log(str(exc))
return EXIT_BUSY
except PrebuiltFallback as exc:
log(f"prebuilt unavailable: {exc}")
return EXIT_FALLBACK
except Exception as exc: # noqa: BLE001
log(f"unexpected error: {exc}")
return EXIT_ERROR
if __name__ == "__main__":
raise SystemExit(main())

View file

@ -5,9 +5,10 @@
.SYNOPSIS
Full environment setup for Unsloth Studio on Windows (bundled version).
.DESCRIPTION
Always installs Node.js if needed. When running from pip install:
skips frontend build (already bundled). When running from git repo:
full setup including frontend build.
Uses an isolated, Unsloth-managed Node.js for the frontend build when the
system Node/npm do not meet requirements (never modifies the system Node).
When running from pip install: skips frontend build (already bundled). When
running from git repo: full setup including frontend build.
Supports NVIDIA GPU (full training + inference) and CPU-only (GGUF chat mode).
.NOTES
Default output is minimal (step/substep), aligned with studio/setup.sh.
@ -1491,71 +1492,92 @@ if ($HasROCm) {
# ============================================
# 1f. Node.js / npm (skip if pip-installed or Tauri -- only needed for frontend build)
# ============================================
# Frontend and OXC share this Node floor. The helper returns:
# system | bundled | skip.
function Get-NodeDecision {
param(
[string]$NodeVersion, # `node -v` output, e.g. v22.17.1 (or empty)
[string]$NpmVersion, # `npm -v` output, e.g. 10.9.2 (or empty)
[string]$SkipInstall # "1" => never auto-install
)
$node = ($NodeVersion -replace '^v', '').Trim()
$npm = "$NpmVersion".Trim()
if ($node -match '^\d+\.\d+' -and $npm -match '^\d+') {
$nodeMajor = [int]($node.Split('.')[0])
$nodeMinor = [int]($node.Split('.')[1])
$npmMajor = [int]($npm.Split('.')[0])
$nodeOk = ($nodeMajor -eq 20 -and $nodeMinor -ge 19) -or
($nodeMajor -eq 22 -and $nodeMinor -ge 12) -or
($nodeMajor -ge 23)
if ($nodeOk -and $npmMajor -ge 11) { return "system" }
}
if ($SkipInstall -eq "1") { return "skip" }
return "bundled"
}
$SkipFrontend = ($env:SKIP_STUDIO_FRONTEND -eq "1")
$NodeOverride = $null
$NodeParent = $null
$NodeDir = $null
$SysNodeVersion = ""
$SysNpmVersion = ""
$NodeSource = $null
if (-not $IsPipInstall) {
# Put Node beside the Studio root. OXC can still need npm when the
# frontend build is skipped.
if (-not [string]::IsNullOrWhiteSpace($env:UNSLOTH_STUDIO_HOME)) { $NodeOverride = $env:UNSLOTH_STUDIO_HOME.Trim() }
elseif (-not [string]::IsNullOrWhiteSpace($env:STUDIO_HOME)) { $NodeOverride = $env:STUDIO_HOME.Trim() }
if ($NodeOverride) {
if ($NodeOverride -eq "~") {
$NodeOverride = $env:USERPROFILE
} elseif ($NodeOverride -like "~/*" -or $NodeOverride -like "~\*") {
$NodeOverride = (Join-Path $env:USERPROFILE $NodeOverride.Substring(1).TrimStart('/', '\'))
}
if (-not (Test-Path -LiteralPath $NodeOverride -PathType Container)) {
Write-Host "ERROR: UNSLOTH_STUDIO_HOME/STUDIO_HOME=$NodeOverride does not exist." -ForegroundColor Red
Write-Host " Run install.ps1 to create the install root before 'unsloth studio update'." -ForegroundColor Red
exit 1
}
$NodeParent = (Resolve-Path -LiteralPath $NodeOverride).Path
# An override pointing at the legacy default maps to the legacy sibling
# ~/.unsloth/node (what the runtime resolver and setup.sh use), not <root>/node.
$_legacyStudio = Join-Path $env:USERPROFILE ".unsloth\studio"
if (Test-Path -LiteralPath $_legacyStudio -PathType Container) {
$_legacyStudio = (Resolve-Path -LiteralPath $_legacyStudio).Path
}
if ($NodeParent -eq $_legacyStudio) {
$NodeParent = Join-Path $env:USERPROFILE ".unsloth"
$NodeOverride = $null
}
} else {
$NodeParent = Join-Path $env:USERPROFILE ".unsloth"
}
$NodeDir = Join-Path $NodeParent "node"
# Probe system node/npm without letting a missing/broken command abort setup.
# Under $ErrorActionPreference = "Stop" a bare `node -v` for an absent node
# throws a terminating error `2>$null` cannot swallow, and a present-but-broken
# shim throws too. Guard with Get-Command (node/npm independently) + try/catch;
# empty version => Get-NodeDecision returns "bundled".
$SysNodeVersion = try { if (Get-Command node -ErrorAction SilentlyContinue) { (node -v 2>$null) } else { "" } } catch { "" }
$SysNpmVersion = try { if (Get-Command npm -ErrorAction SilentlyContinue) { (npm -v 2>$null) } else { "" } } catch { "" }
$NodeSource = Get-NodeDecision -NodeVersion "$SysNodeVersion" -NpmVersion "$SysNpmVersion" -SkipInstall "$($env:UNSLOTH_SKIP_NODE_INSTALL)"
}
if ($IsPipInstall) {
step "frontend" "bundled (pip install)"
} elseif ($SkipFrontend) {
step "frontend" "bundled (Tauri)"
} else {
# setup.sh installs Node LTS (v22) via nvm. We enforce the same range here:
# Vite 8 requires Node ^20.19.0 || >=22.12.0, npm >= 11.
$NeedNode = $true
try {
$NodeVersion = (node -v 2>$null)
$NpmVersion = (npm -v 2>$null)
if ($NodeVersion -and $NpmVersion) {
$NodeParts = ($NodeVersion -replace 'v','').Split('.')
$NodeMajor = [int]$NodeParts[0]
$NodeMinor = [int]$NodeParts[1]
$NpmMajor = [int]$NpmVersion.Split('.')[0]
# Vite 8: ^20.19.0 || >=22.12.0
$NodeOk = ($NodeMajor -eq 20 -and $NodeMinor -ge 19) -or
($NodeMajor -eq 22 -and $NodeMinor -ge 12) -or
($NodeMajor -ge 23)
if ($NodeOk -and $NpmMajor -ge 11) {
substep "Node $NodeVersion and npm $NpmVersion already meet requirements."
$NeedNode = $false
} else {
substep "Node $NodeVersion / npm $NpmVersion too old." "Yellow"
}
}
} catch {
substep "Node/npm not found." "Yellow"
}
if ($NeedNode) {
substep "installing Node.js LTS via winget..."
try {
winget install OpenJS.NodeJS.LTS --source winget --accept-package-agreements --accept-source-agreements
Refresh-Environment
} catch {
Write-Host "[ERROR] Could not install Node.js automatically." -ForegroundColor Red
Write-Host "Please install Node.js >= 20 from https://nodejs.org/" -ForegroundColor Red
exit 1
}
}
step "node" "$(node -v) | npm $(npm -v)"
# ── bun (optional, faster package installs) ──
# Installed via npm — Node is already guaranteed above. Works on all platforms.
if (-not (Get-Command bun -ErrorAction SilentlyContinue)) {
substep "installing bun (faster frontend package installs)..."
$prevEAP_bun = $ErrorActionPreference
$ErrorActionPreference = "Continue"
# --allow-scripts=bun: npm >=11.16 gates install scripts and bun's
# postinstall fetches its binary; without it the install is a broken stub.
Invoke-SetupCommand { npm install -g bun --allow-scripts=bun } | Out-Null
$ErrorActionPreference = $prevEAP_bun
Refresh-Environment
if (Get-Command bun -ErrorAction SilentlyContinue) {
substep "bun installed ($(bun --version))"
} else {
substep "bun install skipped (npm will be used instead)"
}
# Stale npm used to trigger system Node changes. Keep this process-local
# and provision only when the build or OXC needs Node.
if ($NodeSource -eq "system") {
substep "Node $SysNodeVersion and npm $SysNpmVersion already meet requirements (system)."
} elseif ($NodeSource -eq "bundled") {
substep "Node='$SysNodeVersion' npm='$SysNpmVersion' unsuitable; will use an isolated Node (system left untouched)."
} else {
substep "bun already installed ($(bun --version))"
substep "Node='$SysNodeVersion' npm='$SysNpmVersion' unsuitable and UNSLOTH_SKIP_NODE_INSTALL set; frontend build will be skipped." "Yellow"
}
}
@ -1630,12 +1652,14 @@ function Add-PythonDirToProcessPath {
}
# Reuse the install.ps1 / venv interpreter before any system probe.
$ValidatedSetupPython = $null
if ($ReusedSetupPython) {
$_reusedVer = Get-CompatiblePythonVersion $ReusedSetupPython
if ($_reusedVer -and -not (Test-IsConda $ReusedSetupPython)) {
$DetectedPyVer = $_reusedVer
Add-PythonDirToProcessPath $ReusedSetupPython
$PythonOk = $true
$ValidatedSetupPython = $ReusedSetupPython
}
}
@ -1766,6 +1790,86 @@ if ($IsPipInstall) {
substep "Frontend source changed since last build -- rebuilding..." "Yellow"
}
}
# Provision Node when the frontend build OR the OXC runtime install needs it (the
# OXC `npm install` runs whenever its dir exists, regardless of dist staleness);
# never eagerly. System Node is used read-only; the isolated one is ours.
$NeedNodeForSetup = (-not $IsPipInstall) -and ($NeedFrontendBuild -or (Test-Path $OxcValidatorDir))
if ($NeedNodeForSetup) {
if ($NodeSource -eq "skip") {
if ($NeedFrontendBuild) {
step "frontend" "skipped (no suitable Node; system left untouched)" "Yellow"
}
$NeedFrontendBuild = $false
substep "found Node='$SysNodeVersion' npm='$SysNpmVersion'; Studio needs Node >=20.19/22.12/23 and npm >= 11" "Yellow"
substep "install a suitable Node + npm, or unset UNSLOTH_SKIP_NODE_INSTALL to let Unsloth manage an isolated Node" "Yellow"
} elseif ($NodeSource -eq "bundled") {
New-Item -ItemType Directory -Force -Path $NodeParent -ErrorAction SilentlyContinue | Out-Null
# Minimal ownership guard for a custom-home dir (the full Studio-owned
# helpers are defined later); never os.replace over a user-owned dir.
if ($NodeOverride -and (Test-Path -LiteralPath $NodeDir -PathType Container)) {
$nodeOwnedMarker = Join-Path $NodeDir ".unsloth-studio-owned"
$nodeMeta = Join-Path $NodeDir "UNSLOTH_NODE_PREBUILT_INFO.json"
if (-not (Test-Path -LiteralPath $nodeOwnedMarker) -and -not (Test-Path -LiteralPath $nodeMeta)) {
Write-Host "[ERROR] $NodeDir already exists and is not a Studio-owned Node install." -ForegroundColor Red
Write-Host " Move it aside or choose an empty UNSLOTH_STUDIO_HOME before re-running." -ForegroundColor Yellow
exit 1
}
}
substep "installing isolated Node (system Node/npm left untouched)..."
# The main Python resolver runs later; bare `python` may be a Store stub or
# absent this early, so prefer the validated handed-off/venv Python.
$NodeInstallPython = if ($ValidatedSetupPython) { $ValidatedSetupPython } else { "python" }
$nodeOut = & $NodeInstallPython "$PSScriptRoot\install_node_prebuilt.py" --install-dir $NodeDir 2>&1 | Out-String
$nodeExit = $LASTEXITCODE
if ($nodeExit -eq 3) {
Write-Host $nodeOut -ForegroundColor DarkGray
step "node" "install blocked by another active Studio install" "Red"
exit 3
} elseif ($nodeExit -ne 0) {
Write-Host $nodeOut -ForegroundColor DarkGray
Write-Host "[ERROR] Could not install an isolated Node automatically." -ForegroundColor Red
Write-Host " Install Node >= 20.19 (with npm >= 11) from https://nodejs.org/ and re-run, or check your network." -ForegroundColor Yellow
exit 1
}
if ($NodeOverride -and (Test-Path -LiteralPath $NodeDir -PathType Container)) {
New-Item -ItemType File -Force -Path (Join-Path $NodeDir ".unsloth-studio-owned") -ErrorAction SilentlyContinue | Out-Null
}
# Windows Node zip ships node.exe + npm.cmd at the root; prepend it (this
# process only) so node/npm/bun resolve here for the build.
$env:PATH = "$NodeDir;" + $env:PATH
# Keep npm and module resolution inside the isolated Node.
$env:NPM_CONFIG_PREFIX = $NodeDir
$env:npm_config_prefix = $NodeDir
Remove-Item Env:NODE_PATH -ErrorAction SilentlyContinue
step "node" "$(node -v) | npm $(npm -v) (isolated)"
# bun (optional, faster installs); npm -g stays in the isolated prefix.
if (-not (Get-Command bun -ErrorAction SilentlyContinue)) {
substep "installing bun (faster frontend package installs)..."
$prevEAP_bun = $ErrorActionPreference
$ErrorActionPreference = "Continue"
Invoke-SetupCommand { npm install -g bun --allow-scripts=bun } | Out-Null
$ErrorActionPreference = $prevEAP_bun
Refresh-Environment
# Refresh-Environment rebuilds PATH (Machine;User;current), demoting the
# isolated-Node prepend; re-prepend so it wins for the build and OXC step.
$env:PATH = "$NodeDir;" + $env:PATH
$env:NPM_CONFIG_PREFIX = $NodeDir
$env:npm_config_prefix = $NodeDir
Remove-Item Env:NODE_PATH -ErrorAction SilentlyContinue
if (Get-Command bun -ErrorAction SilentlyContinue) {
substep "bun installed ($(bun --version))"
} else {
substep "bun install skipped (npm will be used instead)"
}
}
} else {
# system Node already satisfies requirements; use it as-is. We do NOT
# install global packages (bun) here -- the build falls back to npm.
step "node" "$SysNodeVersion | npm $SysNpmVersion (system)"
}
}
if ($NeedFrontendBuild -and -not $IsPipInstall) {
Write-Host ""
substep "building frontend..."
@ -1876,7 +1980,7 @@ if ($NeedFrontendBuild -and -not $IsPipInstall) {
}
}
if (Test-Path $OxcValidatorDir) {
if ((Test-Path $OxcValidatorDir) -and $NodeSource -ne "skip" -and (Get-Command npm -ErrorAction SilentlyContinue)) {
substep "installing OXC validator runtime..."
$prevEAP_oxc = $ErrorActionPreference
$ErrorActionPreference = "Continue"
@ -1891,6 +1995,10 @@ if (Test-Path $OxcValidatorDir) {
Pop-Location
$ErrorActionPreference = $prevEAP_oxc
step "oxc runtime" "installed"
} elseif ((Test-Path $OxcValidatorDir) -and $NodeSource -ne "skip") {
# No npm on PATH (e.g. a pip install with no system Node and no isolated Node
# provisioned). Skip rather than abort; the runtime resolver degrades. Mirrors setup.sh.
substep "OXC validator runtime skipped (no npm found); code validation degrades until Node is available" "Yellow"
}
# ==========================================================================

View file

@ -439,14 +439,13 @@ _STUDIO_HOME_IS_CUSTOM=false
if [ "$_studio_home_canon" != "$_LEGACY_STUDIO_HOME" ]; then
_STUDIO_HOME_IS_CUSTOM=true
fi
# Directory-local evidence that Studio created "$1", used to adopt a custom-home
# llama.cpp predating the .unsloth-studio-owned marker without weakening the guard.
# Only UNSLOTH_PREBUILT_INFO.json counts (written exclusively by the prebuilt
# installer). A top-level llama-quantize symlink is NOT trusted: a user may have
# their own build with one, and this runs right before a destructive rm -rf, so we
# match Windows and keep markerless source builds strict.
# Directory-local evidence Studio created "$1": only prebuilt-installer metadata
# counts (UNSLOTH_PREBUILT_INFO.json for llama.cpp, UNSLOTH_NODE_PREBUILT_INFO.json
# for Node), both written only by our installers. Mirrors the setup.ps1 Node guard.
# A markerless source build stays strict since this runs right before an rm -rf.
_studio_owned_adoptable() {
[ -f "$1/UNSLOTH_PREBUILT_INFO.json" ] && return 0
[ -f "$1/UNSLOTH_NODE_PREBUILT_INFO.json" ] && return 0
return 1
}
_assert_studio_owned_or_absent() {
@ -485,84 +484,141 @@ if [ -d "$SCRIPT_DIR/frontend/dist" ]; then
fi
fi # end SKIP_STUDIO_FRONTEND guard
if [ "$_NEED_FRONTEND_BUILD" = false ]; then
# OXC validator runtime (below) needs node/npm whenever its dir exists, regardless
# of dist staleness; provision Node when the frontend builds OR the OXC dir exists.
_OXC_DIR="$SCRIPT_DIR/backend/core/data_recipe/oxc-validator"
if [ "$_NEED_FRONTEND_BUILD" = false ] && [ ! -d "$_OXC_DIR" ]; then
step "frontend" "up to date"
verbose_substep "frontend dist is newer than source inputs"
else
# ── Node ──
NEED_NODE=true
if command -v node &>/dev/null && command -v npm &>/dev/null; then
NODE_MAJOR=$(node -v | sed 's/v//' | cut -d. -f1)
NODE_MINOR=$(node -v | sed 's/v//' | cut -d. -f2)
NPM_MAJOR=$(npm -v | cut -d. -f1)
# Vite 8 requires Node ^20.19.0 || >=22.12.0
NODE_OK=false
if [ "$NODE_MAJOR" -eq 20 ] && [ "$NODE_MINOR" -ge 19 ]; then NODE_OK=true; fi
if [ "$NODE_MAJOR" -eq 22 ] && [ "$NODE_MINOR" -ge 12 ]; then NODE_OK=true; fi
if [ "$NODE_MAJOR" -ge 23 ]; then NODE_OK=true; fi
if [ "$NODE_OK" = true ] && [ "$NPM_MAJOR" -ge 11 ]; then
NEED_NODE=false
else
if [ "$IS_COLAB" = true ] && [ "$NODE_OK" = true ]; then
# In Colab, just upgrade npm directly - nvm doesn't work well
if [ "$NPM_MAJOR" -lt 11 ]; then
substep "upgrading npm..."
run_maybe_quiet npm install -g npm@latest
fi
NEED_NODE=false
# ── Node (isolated; never touches the system Node/npm) ──
# Studio's frontend (Vite 8) needs Node ^20.19 || >=22.12 || >=23 and npm >= 11.
# Three sources:
# system -- system Node + npm already satisfy both; used read-only.
# bundled -- install a pinned isolated Node under $UNSLOTH_HOME/node, build-only.
# skip -- UNSLOTH_SKIP_NODE_INSTALL=1 and system unsuitable; print manual fix.
# decide_node_source(node_v, npm_v, skip_flag) -> system | bundled | skip
# (pure; unit-tested in tests/sh/test_node_decision.sh).
decide_node_source() {
_dns_node="${1#v}"
_dns_npm="$2"
_dns_skip="$3"
# Treat empty or non-numeric versions as "missing".
case "$_dns_node" in ''|*[!0-9.]*) _dns_node='' ;; esac
case "$_dns_npm" in ''|*[!0-9.]*) _dns_npm='' ;; esac
if [ -n "$_dns_node" ] && [ -n "$_dns_npm" ]; then
_dns_nmaj="${_dns_node%%.*}"
case "$_dns_node" in
*.*) _dns_rest="${_dns_node#*.}"; _dns_nmin="${_dns_rest%%.*}" ;;
*) _dns_nmin=0 ;;
esac
case "$_dns_nmin" in ''|*[!0-9]*) _dns_nmin=0 ;; esac
_dns_pmaj="${_dns_npm%%.*}"
_dns_ok=false
if [ "$_dns_nmaj" -eq 20 ] && [ "$_dns_nmin" -ge 19 ]; then _dns_ok=true; fi
if [ "$_dns_nmaj" -eq 22 ] && [ "$_dns_nmin" -ge 12 ]; then _dns_ok=true; fi
if [ "$_dns_nmaj" -ge 23 ]; then _dns_ok=true; fi
if [ "$_dns_ok" = true ] && [ "$_dns_pmaj" -ge 11 ]; then
echo system
return 0
fi
fi
if [ "$_dns_skip" = "1" ]; then
echo skip
return 0
fi
echo bundled
}
# Mirror the llama.cpp UNSLOTH_HOME derivation; the frontend build runs first.
if [ "$_STUDIO_HOME_IS_CUSTOM" = true ]; then
_NODE_PARENT="$STUDIO_HOME"
else
_NODE_PARENT="$HOME/.unsloth"
fi
NODE_DIR="$_NODE_PARENT/node"
if [ "$NEED_NODE" = true ]; then
substep "installing nvm..."
export NODE_OPTIONS=--dns-result-order=ipv4first
if _is_verbose; then
curl -so- https://raw.githubusercontent.com/nvm-sh/nvm/v0.40.1/install.sh | bash
_SYS_NODE_VER="$(node -v 2>/dev/null || true)"
_SYS_NPM_VER="$(npm -v 2>/dev/null || true)"
NODE_SOURCE="$(decide_node_source "$_SYS_NODE_VER" "$_SYS_NPM_VER" "${UNSLOTH_SKIP_NODE_INSTALL:-0}")"
_FRONTEND_SKIP=false
if [ "$NODE_SOURCE" = system ]; then
step "node" "$(node -v) | npm $(npm -v) (system)"
elif [ "$NODE_SOURCE" = bundled ]; then
mkdir -p "$_NODE_PARENT"
# install_node_prebuilt.py uses os.replace(); guard a custom-home dir so we
# never displace a user-owned $UNSLOTH_STUDIO_HOME/node.
if [ "$_STUDIO_HOME_IS_CUSTOM" = true ]; then
_assert_studio_owned_or_absent "$NODE_DIR" "Node install"
fi
substep "installing isolated Node (system Node/npm left untouched)..."
# Runs before the venv is activated, so bare `python` may be absent; resolve
# venv python, then python3, then python.
if [ -x "$VENV_DIR/bin/python" ]; then
_NODE_PY="$VENV_DIR/bin/python"
elif command -v python3 >/dev/null 2>&1; then
_NODE_PY="python3"
else
curl -so- https://raw.githubusercontent.com/nvm-sh/nvm/v0.40.1/install.sh | bash > /dev/null 2>&1
_NODE_PY="python"
fi
export NVM_DIR="$HOME/.nvm"
set +u
[ -s "$NVM_DIR/nvm.sh" ] && \. "$NVM_DIR/nvm.sh"
if [ -f "$HOME/.npmrc" ]; then
if grep -qE '^\s*(prefix|globalconfig)\s*=' "$HOME/.npmrc"; then
sed -i.bak '/^\s*\(prefix\|globalconfig\)\s*=/d' "$HOME/.npmrc"
fi
fi
substep "installing Node LTS..."
run_quiet "nvm install" nvm install --lts
_NODE_LOG="$(mktemp)"
set +e
if _is_verbose; then
nvm use --lts
"$_NODE_PY" "$SCRIPT_DIR/install_node_prebuilt.py" --install-dir "$NODE_DIR" 2>&1 | tee "$_NODE_LOG"
_NODE_STATUS=${PIPESTATUS[0]}
else
nvm use --lts > /dev/null 2>&1
"$_NODE_PY" "$SCRIPT_DIR/install_node_prebuilt.py" --install-dir "$NODE_DIR" >"$_NODE_LOG" 2>&1
_NODE_STATUS=$?
fi
set -u
NODE_MAJOR=$(node -v | sed 's/v//' | cut -d. -f1)
NPM_MAJOR=$(npm -v | cut -d. -f1)
if [ "$NODE_MAJOR" -lt 20 ]; then
step "node" "FAILED -- version must be >= 20 (got $(node -v))" "$C_ERR"
set -e
if [ "$_NODE_STATUS" -eq 3 ]; then
step "node" "install blocked by another active Studio install" "$C_ERR"
sed 's/^/ | /' "$_NODE_LOG" >&2; rm -f "$_NODE_LOG"
substep "close other Studio installs and retry"
exit 3
elif [ "$_NODE_STATUS" -ne 0 ]; then
step "node" "isolated Node install failed" "$C_ERR"
sed 's/^/ | /' "$_NODE_LOG" >&2; rm -f "$_NODE_LOG"
substep "install Node >= 20.19 (with npm >= 11) yourself and re-run, or check your network"
exit 1
fi
if [ "$NPM_MAJOR" -lt 11 ]; then
substep "upgrading npm..."
run_quiet "npm update" npm install -g npm@latest
grep -Fq "already matches" "$_NODE_LOG" && verbose_substep "isolated Node already up to date"
rm -f "$_NODE_LOG"
if [ "$_STUDIO_HOME_IS_CUSTOM" = true ] && [ -d "$NODE_DIR" ]; then
: > "$NODE_DIR/$_STUDIO_OWNED_MARKER" 2>/dev/null || true
fi
# Prepend the isolated bin (this process only) so node/npm/bun resolve here.
export PATH="$NODE_DIR/bin:$PATH"
# Keep npm and module resolution inside the isolated Node.
export NPM_CONFIG_PREFIX="$NODE_DIR"
export npm_config_prefix="$NODE_DIR"
unset NODE_PATH
hash -r 2>/dev/null || true
step "node" "$(node -v) | npm $(npm -v) (isolated)"
else
_FRONTEND_SKIP=true
step "frontend" "skipped (no suitable Node; system left untouched)" "$C_WARN"
substep "found Node='${_SYS_NODE_VER:-none}' npm='${_SYS_NPM_VER:-none}'; Studio needs Node >=20.19/22.12/23 and npm >= 11"
substep "install a suitable Node + npm, or unset UNSLOTH_SKIP_NODE_INSTALL to let Unsloth manage an isolated Node"
fi
verbose_substep "node source: $NODE_SOURCE (sys node=${_SYS_NODE_VER:-none} npm=${_SYS_NPM_VER:-none}) dir=$NODE_DIR"
step "node" "$(node -v) | npm $(npm -v)"
verbose_substep "node check: NEED_NODE=$NEED_NODE NODE_OK=${NODE_OK:-unknown} NPM_MAJOR=${NPM_MAJOR:-unknown}"
if [ "$_FRONTEND_SKIP" = true ]; then
: # no suitable Node (skip source): message already shown above; nothing to build
elif [ "$_NEED_FRONTEND_BUILD" = false ]; then
# Node was provisioned only for the OXC runtime; the dist is already current.
step "frontend" "up to date"
verbose_substep "frontend dist is newer than source inputs"
else
# ── Install bun (optional, faster package installs) ──
# Uses npm to install bun globally -- Node is already guaranteed above,
# avoids platform-specific installers, PATH issues, and admin requirements.
if ! command -v bun &>/dev/null; then
# Install bun via npm only when we manage the isolated Node (npm -g lands in the
# isolated prefix); on a system Node we install nothing global. Build falls back to npm.
if command -v bun &>/dev/null; then
substep "bun already installed ($(bun --version))"
elif [ "$NODE_SOURCE" = bundled ]; then
substep "installing bun..."
# --allow-scripts=bun: npm >=11.16 gates install scripts and bun's
# postinstall fetches its binary; without it the install is a broken stub.
@ -572,7 +628,7 @@ if ! command -v bun &>/dev/null; then
substep "bun install skipped (npm will be used instead)"
fi
else
substep "bun already installed ($(bun --version))"
verbose_substep "skipping global bun install on system Node (npm will be used)"
fi
# ── Build frontend ──
@ -669,17 +725,25 @@ fi
cd "$SCRIPT_DIR"
fi # end _FRONTEND_SKIP guard (Node available: system or isolated)
fi # end frontend build check
# ── oxc-validator runtime ──
if [ -d "$SCRIPT_DIR/backend/core/data_recipe/oxc-validator" ] && command -v npm &>/dev/null; then
cd "$SCRIPT_DIR/backend/core/data_recipe/oxc-validator"
# Skip when the user opted out of Node (NODE_SOURCE=skip): there is no suitable
# Node, so do not run npm install against an unsuitable/absent system Node.
if [ -d "$_OXC_DIR" ] && [ "${NODE_SOURCE:-}" != skip ] && command -v npm &>/dev/null; then
cd "$_OXC_DIR"
run_quiet_no_exit "npm install (oxc validator runtime)" npm install --no-fund --no-audit --loglevel=error
_oxc_install_rc=$?
if [ "$_oxc_install_rc" -ne 0 ]; then
exit "$_oxc_install_rc"
fi
cd "$SCRIPT_DIR"
elif [ -d "$_OXC_DIR" ] && [ "${NODE_SOURCE:-}" != skip ]; then
# No npm on PATH: skip rather than abort; the backend Node resolver degrades
# the validator gracefully. Mirrors setup.ps1's elseif on this block.
substep "OXC validator runtime skipped (no npm found); code validation degrades until Node is available" "$C_WARN"
fi
# ── Python venv + deps ──

View file

@ -0,0 +1,63 @@
#!/bin/bash
# Unit tests for decide_node_source() from studio/setup.sh.
# Slices the pure function out of setup.sh and exercises the three outcomes:
# system -- system Node + npm already satisfy Vite 8 (^20.19/22.12/>=23) + npm>=11
# bundled -- otherwise install an isolated Node (the Discord-reported npm-only case)
# skip -- UNSLOTH_SKIP_NODE_INSTALL=1 and the system is unsuitable
set -e
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
SETUP_SH="$SCRIPT_DIR/../../studio/setup.sh"
PASS=0
FAIL=0
_FUNC_FILE=$(mktemp)
sed -n '/^decide_node_source()/,/^}/p' "$SETUP_SH" > "$_FUNC_FILE"
if [ ! -s "$_FUNC_FILE" ]; then
echo "FAIL: could not extract decide_node_source from $SETUP_SH"
exit 1
fi
# shellcheck disable=SC1090
. "$_FUNC_FILE"
assert_decision() {
_label="$1"; _node="$2"; _npm="$3"; _skip="$4"; _expected="$5"
_actual="$(decide_node_source "$_node" "$_npm" "$_skip")"
if [ "$_actual" = "$_expected" ]; then
echo " PASS: $_label (node='$_node' npm='$_npm' skip='$_skip' -> $_actual)"
PASS=$((PASS + 1))
else
echo " FAIL: $_label (node='$_node' npm='$_npm' skip='$_skip' expected '$_expected', got '$_actual')"
FAIL=$((FAIL + 1))
fi
}
echo "decide_node_source"
# system: both satisfy
assert_decision "node22 + npm11" "v22.17.1" "11.13.0" "0" system
assert_decision "node20.19 + npm11" "v20.19.0" "11.0.0" "0" system
assert_decision "node24 + npm11" "v24.17.0" "11.13.0" "0" system
assert_decision "node23 + npm11" "v23.5.0" "11.0.0" "0" system
# bundled: the reported bug -- fine Node, stale npm
assert_decision "node22 + npm10 (bug)" "v22.17.1" "10.9.2" "0" bundled
# bundled: node too old / wrong line
assert_decision "node18" "v18.20.0" "11.0.0" "0" bundled
assert_decision "node22.11 (<22.12)" "v22.11.0" "11.0.0" "0" bundled
assert_decision "node20.18 (<20.19)" "v20.18.0" "11.0.0" "0" bundled
assert_decision "node21 (odd)" "v21.7.0" "11.0.0" "0" bundled
# bundled: missing entirely
assert_decision "no node/npm" "" "" "0" bundled
# bundled: garbage versions
assert_decision "garbage versions" "vfoo" "bar" "0" bundled
# skip: unsuitable + skip flag
assert_decision "npm10 + skip" "v22.17.1" "10.9.2" "1" skip
assert_decision "missing + skip" "" "" "1" skip
# skip flag does NOT override an already-good system
assert_decision "good system + skip" "v22.17.1" "11.13.0" "1" system
rm -f "$_FUNC_FILE"
echo ""
echo "Passed: $PASS Failed: $FAIL"
[ "$FAIL" -eq 0 ] || exit 1

View file

@ -0,0 +1,58 @@
#!/usr/bin/env bash
# Regression test: setup.sh installs the isolated Node under <UNSLOTH_STUDIO_HOME>
# (or the STUDIO_HOME alias), matching node_runtime.managed_node_dir(). Extracts
# the real STUDIO_HOME + NODE_DIR logic from setup.sh by content anchors (not line
# numbers) and runs it against a hermetic fake HOME for each override case.
set -u
HERE="$(CDPATH= cd -P -- "$(dirname "$0")" && pwd -P)"
SETUP="$HERE/../../studio/setup.sh"
fails=0
check() { # name expected actual
if [ "$2" = "$3" ]; then printf ' PASS %s\n' "$1"
else printf ' FAIL %s : expected [%s] got [%s]\n' "$1" "$2" "$3"; fails=$((fails+1)); fi
}
# Block A: studio override -> STUDIO_HOME -> _STUDIO_HOME_IS_CUSTOM.
blockA="$(awk '
/^_studio_override_var=""/ {grab=1}
grab {print}
/_STUDIO_HOME_IS_CUSTOM=true/ {seen=1}
seen && /^fi$/ {exit}
' "$SETUP")"
# Block B: _STUDIO_HOME_IS_CUSTOM -> _NODE_PARENT -> NODE_DIR.
blockB="$(awk '
/^if \[ "\$_STUDIO_HOME_IS_CUSTOM" = true \]; then/ {grab=1}
grab {print}
/^NODE_DIR="\$_NODE_PARENT\/node"/ {exit}
' "$SETUP")"
SNIP="$blockA"$'\n'"$blockB"$'\n''echo "$NODE_DIR"'
# Self-validate the extraction so a future setup.sh refactor fails loudly here.
case "$blockA" in *"_STUDIO_HOME_IS_CUSTOM=true"*) : ;; *) echo "FAIL: blockA extraction broke"; exit 1 ;; esac
case "$blockB" in *'NODE_DIR="$_NODE_PARENT/node"'*) : ;; *) echo "FAIL: blockB extraction broke"; exit 1 ;; esac
node_dir_for() { # HOME UNSLOTH_STUDIO_HOME STUDIO_HOME
env -i HOME="$1" UNSLOTH_STUDIO_HOME="$2" STUDIO_HOME="$3" PATH="$PATH" \
bash -c "$SNIP" 2>/dev/null | tail -1
}
T="$(mktemp -d)"
trap 'rm -rf "$T"' EXIT
mkdir -p "$T/custom" "$T/fakehome/.unsloth/studio"
CUSTOM="$(CDPATH= cd -P -- "$T/custom" && pwd -P)"
FAKEHOME="$(CDPATH= cd -P -- "$T/fakehome" && pwd -P)"
LEGACY="$FAKEHOME/.unsloth/studio"
# 1. UNSLOTH_STUDIO_HOME = custom dir -> <custom>/node
check "UNSLOTH_STUDIO_HOME=<custom> -> <custom>/node" "$CUSTOM/node" "$(node_dir_for "$FAKEHOME" "$CUSTOM" "")"
# 2. STUDIO_HOME alias = custom dir -> <custom>/node
check "STUDIO_HOME alias -> <custom>/node" "$CUSTOM/node" "$(node_dir_for "$FAKEHOME" "" "$CUSTOM")"
# 3. UNSLOTH_STUDIO_HOME wins over STUDIO_HOME
check "UNSLOTH_STUDIO_HOME wins over STUDIO_HOME" "$CUSTOM/node" "$(node_dir_for "$FAKEHOME" "$CUSTOM" "$T/fakehome")"
# 4. Override = legacy default -> sibling ~/.unsloth/node
check "legacy-valued override -> ~/.unsloth/node sibling" "$FAKEHOME/.unsloth/node" "$(node_dir_for "$FAKEHOME" "$LEGACY" "")"
# 5. No override -> ~/.unsloth/node
check "no override -> ~/.unsloth/node" "$FAKEHOME/.unsloth/node" "$(node_dir_for "$FAKEHOME" "" "")"
if [ "$fails" -ne 0 ]; then echo "$fails check(s) failed"; exit 1; fi
echo "All checks passed"

View file

@ -0,0 +1,58 @@
#!/usr/bin/env bash
# Regression test: setup.sh's reuse (NODE_SOURCE=system) path is strictly
# read-only. It runs no global npm install and sets no NPM_CONFIG_PREFIX, so
# reusing a good system Node never mutates the user's Node/npm/NVM. Only the
# isolated (bundled) path redirects npm into its own prefix and installs
# anything global (and even then -g lands in the isolated prefix). Extraction is
# anchored on setup.sh content, not line numbers, and self-validates so a
# refactor fails loudly here.
set -u
HERE="$(CDPATH= cd -P -- "$(dirname "$0")" && pwd -P)"
SETUP="$HERE/../../studio/setup.sh"
fails=0
fail() { printf ' FAIL %s\n' "$1"; fails=$((fails+1)); }
pass() { printf ' PASS %s\n' "$1"; }
# Arm 1: the NODE_SOURCE=system branch body (reuse a good system Node).
system_arm="$(awk '
/^if \[ "\$NODE_SOURCE" = system \]; then/ {grab=1; next}
/^elif \[ "\$NODE_SOURCE" = bundled \]; then/ {grab=0}
grab {print}
' "$SETUP")"
# Arm 2: the NODE_SOURCE=bundled branch body (provision the isolated Node).
bundled_arm="$(awk '
/^elif \[ "\$NODE_SOURCE" = bundled \]; then/ {grab=1; next}
grab && /^else$/ {grab=0}
grab {print}
' "$SETUP")"
# The optional-bun block (the only global install, gated on the bundled path).
bun_block="$(awk '
/^if command -v bun &>\/dev\/null; then/ {grab=1}
grab {print}
grab && /^fi$/ {exit}
' "$SETUP")"
# Self-validate extraction so a setup.sh refactor cannot silently void the test.
[ -n "$system_arm" ] || { echo "FAIL: system arm extraction broke"; exit 1; }
case "$bundled_arm" in *'NPM_CONFIG_PREFIX="$NODE_DIR"'*) : ;; *) echo "FAIL: bundled arm extraction broke"; exit 1 ;; esac
case "$bun_block" in *'npm install -g bun'*) : ;; *) echo "FAIL: bun block extraction broke"; exit 1 ;; esac
# 1. system (reuse) arm performs no global npm install.
case "$system_arm" in *"npm install -g"*) fail "system arm runs no 'npm install -g'" ;; *) pass "system arm runs no 'npm install -g'" ;; esac
# 2. system (reuse) arm sets no npm prefix redirect (either casing of the var).
case "$system_arm" in *NPM_CONFIG_PREFIX*|*npm_config_prefix*) fail "system arm sets no NPM_CONFIG_PREFIX" ;; *) pass "system arm sets no NPM_CONFIG_PREFIX" ;; esac
# 3. system (reuse) arm does not rewrite PATH toward a managed Node dir.
case "$system_arm" in *"export PATH="*) fail "system arm does not rewrite PATH" ;; *) pass "system arm does not rewrite PATH" ;; esac
# 4. positive control: the bundled arm DOES pin the prefix (so 1-3 aren't vacuous).
case "$bundled_arm" in *'NPM_CONFIG_PREFIX="$NODE_DIR"'*) pass "bundled arm pins NPM_CONFIG_PREFIX to the isolated dir" ;; *) fail "bundled arm pins NPM_CONFIG_PREFIX to the isolated dir" ;; esac
# 5. the only global install (bun) is gated behind NODE_SOURCE=bundled.
guard_at=$(printf '%s\n' "$bun_block" | grep -n 'elif \[ "\$NODE_SOURCE" = bundled \]; then' | head -1 | cut -d: -f1)
bun_at=$(printf '%s\n' "$bun_block" | grep -n 'npm install -g bun' | head -1 | cut -d: -f1)
if [ -n "$guard_at" ] && [ -n "$bun_at" ] && [ "$guard_at" -lt "$bun_at" ]; then
pass "global bun install gated behind NODE_SOURCE=bundled"
else
fail "global bun install gated behind NODE_SOURCE=bundled"
fi
if [ "$fails" -ne 0 ]; then echo "$fails check(s) failed"; exit 1; fi
echo "All checks passed"

View file

@ -0,0 +1,477 @@
# SPDX-License-Identifier: AGPL-3.0-only
# Logic tests for studio/install_node_prebuilt.py -- the isolated Node installer.
# No network/GPU: downloads are monkeypatched and archives are built in-memory.
import importlib.util
import io
import json
import os
import sys
import tarfile
import types
import zipfile
from pathlib import Path
import pytest
PACKAGE_ROOT = Path(__file__).resolve().parents[3]
MODULE_PATH = PACKAGE_ROOT / "studio" / "install_node_prebuilt.py"
SPEC = importlib.util.spec_from_file_location("studio_install_node_prebuilt", MODULE_PATH)
assert SPEC is not None and SPEC.loader is not None
M = importlib.util.module_from_spec(SPEC)
sys.modules[SPEC.name] = M
SPEC.loader.exec_module(M)
HostInfo = M.HostInfo
PrebuiltFallback = M.PrebuiltFallback
def _host(node_os: str, node_arch: str) -> HostInfo:
ext = ".zip" if node_os == "win" else ".tar.gz"
return HostInfo(
system = {"linux": "Linux", "darwin": "Darwin", "win": "Windows"}[node_os],
machine = node_arch,
node_os = node_os,
node_arch = node_arch,
archive_ext = ext,
is_windows = node_os == "win",
)
# ── Host detection (per OS/arch) ──
@pytest.mark.parametrize(
"system,machine,exp_os,exp_arch,exp_ext",
[
("Linux", "x86_64", "linux", "x64", ".tar.gz"),
("Linux", "aarch64", "linux", "arm64", ".tar.gz"),
("Darwin", "x86_64", "darwin", "x64", ".tar.gz"),
("Darwin", "arm64", "darwin", "arm64", ".tar.gz"),
("Windows", "AMD64", "win", "x64", ".zip"),
("Windows", "ARM64", "win", "arm64", ".zip"),
],
)
def test_detect_host(monkeypatch, system, machine, exp_os, exp_arch, exp_ext):
monkeypatch.setattr(M.platform, "system", lambda: system)
monkeypatch.setattr(M.platform, "machine", lambda: machine)
host = M.detect_host()
assert (host.node_os, host.node_arch, host.archive_ext) == (exp_os, exp_arch, exp_ext)
assert host.is_windows == (exp_os == "win")
@pytest.mark.parametrize(
"system,machine",
[("Plan9", "x86_64"), ("Linux", "sparc64"), ("Linux", "armv7l"), ("Linux", "armhf")],
)
def test_detect_host_unsupported(monkeypatch, system, machine):
monkeypatch.setattr(M.platform, "system", lambda: system)
monkeypatch.setattr(M.platform, "machine", lambda: machine)
with pytest.raises(PrebuiltFallback):
M.detect_host()
# ── URL / asset construction (pure) ──
def test_asset_and_url_linux():
host = _host("linux", "x64")
assert M.node_asset_name("24.17.0", host) == "node-v24.17.0-linux-x64.tar.gz"
assert (
M.node_download_url("24.17.0", M.node_asset_name("24.17.0", host))
== "https://nodejs.org/dist/v24.17.0/node-v24.17.0-linux-x64.tar.gz"
)
def test_asset_windows_is_zip():
host = _host("win", "x64")
assert M.node_asset_name("24.17.0", host) == "node-v24.17.0-win-x64.zip"
def test_shasums_url():
assert M.node_shasums_url("24.17.0") == "https://nodejs.org/dist/v24.17.0/SHASUMS256.txt"
def test_binary_layout_is_host_aware():
# Windows ships node.exe + node_modules\npm at the root; Unix uses bin/ + lib/.
win = _host("win", "x64")
nix = _host("linux", "x64")
assert M.node_binary_path(Path("/n"), win) == Path("/n/node.exe")
assert M.node_binary_path(Path("/n"), nix) == Path("/n/bin/node")
assert M.npm_cli_path(Path("/n"), win) == Path("/n/node_modules/npm/bin/npm-cli.js")
assert M.npm_cli_path(Path("/n"), nix) == Path("/n/lib/node_modules/npm/bin/npm-cli.js")
# ── SHASUMS256.txt parsing ──
def test_expected_sha256_for():
asset = "node-v24.17.0-linux-x64.tar.gz"
good = "a" * 64
text = (
f"{'b' * 64} node-v24.17.0-linux-arm64.tar.gz\n"
f"{good} {asset}\n"
f"{'c' * 64} node-v24.17.0-win-x64.zip\n"
)
assert M.expected_sha256_for(text, asset) == good
assert M.expected_sha256_for(text, "node-v24.17.0-darwin-x64.tar.gz") is None
def test_expected_sha256_rejects_malformed():
asset = "node-v24.17.0-linux-x64.tar.gz"
assert M.expected_sha256_for(f"notahex {asset}\n", asset) is None
# ── Version selection from index.json ──
INDEX = [
{"version": "v26.3.1", "lts": False},
{"version": "v24.17.0", "lts": "Krypton"},
{"version": "v24.9.0", "lts": "Krypton"},
{"version": "v22.20.0", "lts": "Jod"},
{"version": "v20.19.0", "lts": "Iron"},
]
def test_select_lts_respects_min_major():
# Newest LTS at/above 24 -> 24.17.0 (22.x LTS is below the floor).
assert M.select_node_version(INDEX, channel = "lts", min_major = 24) == "24.17.0"
def test_select_latest_overall():
assert M.select_node_version(INDEX, channel = "latest", min_major = 24) == "26.3.1"
def test_select_explicit_passthrough():
assert M.select_node_version(INDEX, channel = "v24.5.0", min_major = 24) == "24.5.0"
def test_select_no_candidate_raises():
with pytest.raises(PrebuiltFallback):
M.select_node_version(INDEX, channel = "lts", min_major = 99)
# ── Archive extraction (zip + tar.gz with the npm-style symlink), traversal guard ──
def _add_file(
tar: tarfile.TarFile,
name: str,
data: bytes,
mode: int = 0o644,
):
info = tarfile.TarInfo(name)
info.size = len(data)
info.mode = mode
tar.addfile(info, io.BytesIO(data))
def _add_symlink(tar: tarfile.TarFile, name: str, target: str):
info = tarfile.TarInfo(name)
info.type = tarfile.SYMTYPE
info.linkname = target
tar.addfile(info)
@pytest.mark.skipif(
os.name == "nt",
reason = "Node ships a .zip (no symlinks) on Windows; the tar+symlink path is Unix-only",
)
def test_extract_tar_gz_with_npm_symlink(tmp_path: Path):
# Mirrors the real Node tarball: bin/npm -> ../lib/node_modules/npm/bin/npm-cli.js
archive = tmp_path / "node.tar.gz"
with tarfile.open(archive, "w:gz") as tar:
_add_file(tar, "node-v24/bin/node", b"#!/bin/sh\necho v24.17.0\n", mode = 0o755)
_add_file(tar, "node-v24/lib/node_modules/npm/bin/npm-cli.js", b"// npm")
_add_symlink(tar, "node-v24/bin/npm", "../lib/node_modules/npm/bin/npm-cli.js")
dest = tmp_path / "out"
M.extract_archive(archive, dest)
npm_link = dest / "node-v24" / "bin" / "npm"
assert npm_link.is_symlink()
assert (dest / "node-v24" / "bin" / "node").exists()
# executable bit preserved
assert (dest / "node-v24" / "bin" / "node").stat().st_mode & 0o111
def test_extract_zip(tmp_path: Path):
archive = tmp_path / "node.zip"
with zipfile.ZipFile(archive, "w") as zf:
zf.writestr("node-v24-win-x64/node.exe", b"MZ")
zf.writestr("node-v24-win-x64/npm.cmd", b"@echo off")
dest = tmp_path / "out"
M.extract_archive(archive, dest)
assert (dest / "node-v24-win-x64" / "node.exe").exists()
def test_extract_rejects_path_traversal(tmp_path: Path):
archive = tmp_path / "evil.tar.gz"
with tarfile.open(archive, "w:gz") as tar:
_add_file(tar, "../escape.txt", b"pwn")
with pytest.raises(PrebuiltFallback):
M.extract_archive(archive, tmp_path / "out")
# ── Checksum-verified download (accept + reject) ──
def test_download_file_verified_accepts_match(tmp_path: Path, monkeypatch):
payload = b"real-node-archive"
sha = M.hashlib.sha256(payload).hexdigest()
def fake_download(url: str, destination: Path):
destination.write_bytes(payload)
monkeypatch.setattr(M, "download_file", fake_download)
dest = tmp_path / "a.tar.gz"
M.download_file_verified("http://x/a.tar.gz", dest, expected_sha256 = sha, label = "a")
assert dest.read_bytes() == payload
def test_download_file_verified_rejects_mismatch(tmp_path: Path, monkeypatch):
def fake_download(url: str, destination: Path):
destination.write_bytes(b"tampered")
monkeypatch.setattr(M, "download_file", fake_download)
with pytest.raises(PrebuiltFallback):
M.download_file_verified("http://x/a", tmp_path / "a", expected_sha256 = "0" * 64, label = "a")
# ── Lock liveness probe (Windows must not use os.kill(pid, 0)) ──
def test_pid_is_alive_windows_uses_tasklist_not_os_kill(monkeypatch):
monkeypatch.setattr(M.sys, "platform", "win32")
def fail_kill(pid, sig):
raise AssertionError("Windows liveness must not call os.kill(pid, 0)")
def fake_run(cmd, **kwargs):
assert cmd[:2] == ["tasklist", "/FI"]
assert "PID eq 1234" in cmd
return types.SimpleNamespace(stdout = '"node.exe","1234","Console","1","12,345 K"\n')
monkeypatch.setattr(M.os, "kill", fail_kill)
monkeypatch.setattr(M.subprocess, "run", fake_run)
assert M._pid_is_alive(1234) is True
def test_pid_is_alive_windows_false_when_tasklist_omits_pid(monkeypatch):
monkeypatch.setattr(M.sys, "platform", "win32")
monkeypatch.setattr(
M.subprocess,
"run",
lambda *a, **k: types.SimpleNamespace(
stdout = "INFO: No tasks are running which match the specified criteria.\n"
),
)
assert M._pid_is_alive(1234) is False
def test_pid_is_alive_windows_assumes_alive_when_tasklist_fails(monkeypatch):
monkeypatch.setattr(M.sys, "platform", "win32")
def boom(*args, **kwargs):
raise OSError("tasklist unavailable")
monkeypatch.setattr(M.subprocess, "run", boom)
assert M._pid_is_alive(1234) is True
def test_pid_is_alive_posix_signal_zero(monkeypatch):
monkeypatch.setattr(M.sys, "platform", "linux")
calls = []
def fake_kill(pid, sig):
calls.append((pid, sig))
if pid == 9999:
raise ProcessLookupError
monkeypatch.setattr(M.os, "kill", fake_kill)
assert M._pid_is_alive(1234) is True
assert M._pid_is_alive(9999) is False
assert calls == [(1234, 0), (9999, 0)]
# ── existing_install_matches + install_prebuilt short-circuit ──
def test_existing_install_matches_false_without_metadata(tmp_path: Path):
host = _host("linux", "x64")
assert M.existing_install_matches(tmp_path, host, version = "24.17.0") is False
def test_existing_install_matches_true_when_version_and_runtime_ok(tmp_path: Path, monkeypatch):
host = _host("linux", "x64")
M.write_metadata(tmp_path, version = "24.17.0", asset = "x", sha256 = "y")
monkeypatch.setattr(M, "installed_node_version", lambda d, h: "24.17.0")
monkeypatch.setattr(M, "installed_npm_major", lambda d, h: 11)
assert M.existing_install_matches(tmp_path, host, version = "24.17.0") is True
# npm too old -> not a match
monkeypatch.setattr(M, "installed_npm_major", lambda d, h: 10)
assert M.existing_install_matches(tmp_path, host, version = "24.17.0") is False
def test_install_prebuilt_short_circuits_when_version_matches(tmp_path: Path, monkeypatch):
install_dir = tmp_path / "node"
install_dir.mkdir()
M.write_metadata(install_dir, version = "24.17.0", asset = "x", sha256 = "y")
monkeypatch.setattr(M, "detect_host", lambda: _host("linux", "x64"))
monkeypatch.setattr(M, "fetch_json", lambda url: INDEX)
monkeypatch.setattr(M, "installed_node_version", lambda d, h: "24.17.0")
monkeypatch.setattr(M, "installed_npm_major", lambda d, h: 11)
def boom(*a, **k):
raise AssertionError("must not download when the install already matches")
monkeypatch.setattr(M, "download_file", boom)
monkeypatch.setattr(M, "download_bytes", boom)
rc = M.install_prebuilt(install_dir, channel = "lts", min_major = 24, force = False)
assert rc == M.EXIT_SUCCESS
def test_existing_install_usable_is_version_agnostic(tmp_path: Path, monkeypatch):
host = _host("linux", "x64")
assert M.existing_install_usable(tmp_path, host) is False # no metadata
M.write_metadata(tmp_path, version = "24.17.0", asset = "x", sha256 = "y")
monkeypatch.setattr(M, "installed_node_version", lambda d, h: "24.17.0")
monkeypatch.setattr(M, "installed_npm_major", lambda d, h: 11)
assert M.existing_install_usable(tmp_path, host) is True
monkeypatch.setattr(M, "installed_npm_major", lambda d, h: 10)
assert M.existing_install_usable(tmp_path, host) is False # npm below floor
monkeypatch.setattr(M, "installed_npm_major", lambda d, h: 11)
monkeypatch.setattr(M, "installed_node_version", lambda d, h: None)
assert M.existing_install_usable(tmp_path, host) is False # node does not run
def _offline(*a, **k):
raise OSError("nodejs.org unreachable")
def test_install_prebuilt_keeps_existing_when_index_unreachable(tmp_path: Path, monkeypatch):
install_dir = tmp_path / "node"
install_dir.mkdir()
M.write_metadata(install_dir, version = "24.17.0", asset = "x", sha256 = "y")
monkeypatch.setattr(M, "detect_host", lambda: _host("linux", "x64"))
monkeypatch.setattr(M, "installed_node_version", lambda d, h: "24.17.0")
monkeypatch.setattr(M, "installed_npm_major", lambda d, h: 11)
monkeypatch.setattr(M, "fetch_json", _offline)
def boom(*a, **k):
raise AssertionError("must not download when keeping the existing install")
monkeypatch.setattr(M, "download_file", boom)
monkeypatch.setattr(M, "download_bytes", boom)
rc = M.install_prebuilt(install_dir, channel = "lts", min_major = 24, force = False)
assert rc == M.EXIT_SUCCESS
def test_install_prebuilt_reraises_when_index_unreachable_and_no_install(
tmp_path: Path, monkeypatch
):
install_dir = tmp_path / "node" # nothing on disk to fall back to
monkeypatch.setattr(M, "detect_host", lambda: _host("linux", "x64"))
monkeypatch.setattr(M, "fetch_json", _offline)
with pytest.raises(OSError):
M.install_prebuilt(install_dir, channel = "lts", min_major = 24, force = False)
def test_install_prebuilt_force_does_not_keep_existing_offline(tmp_path: Path, monkeypatch):
install_dir = tmp_path / "node"
install_dir.mkdir()
M.write_metadata(install_dir, version = "24.17.0", asset = "x", sha256 = "y")
monkeypatch.setattr(M, "detect_host", lambda: _host("linux", "x64"))
monkeypatch.setattr(M, "installed_node_version", lambda d, h: "24.17.0")
monkeypatch.setattr(M, "installed_npm_major", lambda d, h: 11)
monkeypatch.setattr(M, "fetch_json", _offline)
with pytest.raises(OSError):
M.install_prebuilt(install_dir, channel = "lts", min_major = 24, force = True)
@pytest.mark.parametrize(
"ver,ok",
[
("20.19.0", True),
("20.18.9", False),
("22.12.0", True),
("22.11.5", False),
("23.0.0", True),
("24.4.1", True),
("21.7.3", False),
("24", True),
("20", False),
],
)
def test_meets_node_floor(ver, ok):
assert M._meets_node_floor(ver) is ok
def test_install_prebuilt_rejects_explicit_below_floor(tmp_path: Path, monkeypatch):
install_dir = tmp_path / "node"
monkeypatch.setattr(M, "detect_host", lambda: _host("linux", "x64"))
def boom(*a, **k):
raise AssertionError("must not download a below-floor Node")
monkeypatch.setattr(M, "download_file", boom)
monkeypatch.setattr(M, "download_bytes", boom)
with pytest.raises(PrebuiltFallback):
M.install_prebuilt(install_dir, channel = "20.18.0", min_major = 24, force = False)
def test_install_prebuilt_keeps_existing_when_shasums_fetch_fails(tmp_path: Path, monkeypatch):
# index.json resolves a newer version, but the later SHASUMS fetch fails and a
# usable older isolated Node is on disk -> keep it instead of aborting.
install_dir = tmp_path / "node"
install_dir.mkdir()
M.write_metadata(install_dir, version = "24.9.0", asset = "x", sha256 = "y")
monkeypatch.setattr(M, "detect_host", lambda: _host("linux", "x64"))
monkeypatch.setattr(M, "fetch_json", lambda url: INDEX) # newest LTS = 24.17.0
monkeypatch.setattr(M, "installed_node_version", lambda d, h: "24.9.0")
monkeypatch.setattr(M, "installed_npm_major", lambda d, h: 11)
monkeypatch.setattr(M, "download_bytes", _offline) # SHASUMS fetch fails
rc = M.install_prebuilt(install_dir, channel = "lts", min_major = 24, force = False)
assert rc == M.EXIT_SUCCESS
def test_install_prebuilt_reraises_shasums_failure_without_existing(tmp_path: Path, monkeypatch):
install_dir = tmp_path / "node" # nothing usable on disk
monkeypatch.setattr(M, "detect_host", lambda: _host("linux", "x64"))
monkeypatch.setattr(M, "fetch_json", lambda url: INDEX)
monkeypatch.setattr(M, "download_bytes", _offline)
with pytest.raises(OSError):
M.install_prebuilt(install_dir, channel = "lts", min_major = 24, force = False)
# ── Isolation invariant: the installer only writes inside its own install_dir ──
def test_run_node_pins_npm_prefix_to_install_dir(tmp_path: Path, monkeypatch):
# Every node/npm call the installer makes redirects npm's global prefix into
# the isolated install_dir and drops an inherited NODE_PATH, so a stray `npm
# -g` can never write to the user's system Node/npm.
install_dir = tmp_path / "node"
monkeypatch.setenv("NPM_CONFIG_PREFIX", "/usr/local") # user's own global prefix
monkeypatch.setenv("NODE_PATH", "/usr/lib/node_modules")
captured = {}
def fake_run(cmd, **kw):
captured["env"] = kw["env"]
return types.SimpleNamespace(returncode = 0, stdout = "v24.17.0\n", stderr = "")
monkeypatch.setattr(M.subprocess, "run", fake_run)
assert M._run_node(install_dir, _host("linux", "x64"), ["-v"]) == "v24.17.0"
env = captured["env"]
assert env["NPM_CONFIG_PREFIX"] == str(install_dir)
assert env["npm_config_prefix"] == str(install_dir)
assert "NODE_PATH" not in env # inherited NODE_PATH is dropped, not leaked in
def test_ensure_npm_floor_scopes_upgrade_to_install_dir(tmp_path: Path, monkeypatch):
# A pinned build shipping npm < 11 self-upgrades, but only inside the isolated
# prefix: it goes through _run_node against install_dir, never the system.
install_dir = tmp_path / "node"
monkeypatch.setattr(M, "installed_npm_major", lambda d, h: 10)
calls = []
monkeypatch.setattr(M, "_run_node", lambda d, h, args, **kw: calls.append((d, args)) or "")
M._ensure_npm_floor(install_dir, _host("linux", "x64"))
assert len(calls) == 1
target_dir, args = calls[0]
assert target_dir == install_dir # upgrade scoped to the isolated dir
assert args[-3:] == ["install", "-g", f"npm@^{M.NPM_MIN_MAJOR}"]
def test_ensure_npm_floor_noop_when_npm_meets_bar(tmp_path: Path, monkeypatch):
monkeypatch.setattr(M, "installed_npm_major", lambda d, h: M.NPM_MIN_MAJOR)
def boom(*a, **k):
raise AssertionError("must not run an npm upgrade when npm already meets the floor")
monkeypatch.setattr(M, "_run_node", boom)
M._ensure_npm_floor(tmp_path / "node", _host("linux", "x64"))

View file

@ -0,0 +1,185 @@
# SPDX-License-Identifier: AGPL-3.0-only
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
"""Tests for the runtime managed-Node resolver (studio/backend/utils/node_runtime.py).
The Studio frontend installer may provision an isolated Node under
``<UNSLOTH_HOME>/node`` that is never added to the user's PATH. The backend OXC
validator must still find a usable Node at runtime: a version-adequate system
Node, else the managed isolated one. These tests pin that resolution and the
version floor (kept in sync with the setup scripts' Node decision).
"""
from __future__ import annotations
import importlib
import os
import sys
from pathlib import Path
import pytest
# node_runtime imports sibling backend packages by top-level name, so put
# studio/backend on sys.path before importing it.
_BACKEND = Path(__file__).resolve().parents[3] / "studio" / "backend"
if str(_BACKEND) not in sys.path:
sys.path.insert(0, str(_BACKEND))
nr = importlib.import_module("utils.node_runtime")
@pytest.fixture(autouse = True)
def _clear_resolver_cache():
nr._reset_resolved_node()
yield
nr._reset_resolved_node()
@pytest.mark.parametrize(
"version,expected",
[
("v20.19.0", True),
("v20.18.9", False),
("v21.7.0", False), # Node 21 (odd, non-LTS) is below the bar
("v22.12.0", True),
("v22.11.0", False),
("v23.0.0", True),
("v24.17.0", True),
("v18.20.0", False),
("not-a-version", False),
("", False),
],
)
def test_version_floor_matches_setup_bar(version, expected):
assert nr._version_meets_floor(version) is expected
def test_managed_binary_layout_is_host_aware(monkeypatch, tmp_path):
monkeypatch.setenv("UNSLOTH_STUDIO_HOME", str(tmp_path))
binary = nr.managed_node_binary()
if os.name == "nt":
assert binary == tmp_path / "node" / "node.exe"
else:
assert binary == tmp_path / "node" / "bin" / "node"
def test_managed_dir_uses_legacy_sibling_by_default(monkeypatch):
# No env override -> ~/.unsloth/node (sibling of ~/.unsloth/studio).
monkeypatch.delenv("UNSLOTH_STUDIO_HOME", raising = False)
monkeypatch.delenv("STUDIO_HOME", raising = False)
assert nr.managed_node_dir() == Path.home() / ".unsloth" / "node"
def _raise_oserror():
raise OSError("simulated degraded import environment")
def test_managed_dir_fallback_honors_override(monkeypatch, tmp_path):
# If utils.paths cannot be loaded / studio_root() fails, the resolver must
# still honor an explicit STUDIO_HOME override (not silently use legacy).
import utils.paths.storage_roots as sr
monkeypatch.setattr(sr, "studio_root", _raise_oserror)
monkeypatch.setenv("UNSLOTH_STUDIO_HOME", str(tmp_path))
assert nr.managed_node_dir() == tmp_path / "node"
def test_managed_dir_fallback_legacy_without_override(monkeypatch):
import utils.paths.storage_roots as sr
monkeypatch.setattr(sr, "studio_root", _raise_oserror)
monkeypatch.delenv("UNSLOTH_STUDIO_HOME", raising = False)
monkeypatch.delenv("STUDIO_HOME", raising = False)
assert nr.managed_node_dir() == Path.home() / ".unsloth" / "node"
def test_managed_dir_honors_studio_home_alias(monkeypatch, tmp_path):
monkeypatch.delenv("UNSLOTH_STUDIO_HOME", raising = False)
monkeypatch.setenv("STUDIO_HOME", str(tmp_path))
assert nr.managed_node_dir() == tmp_path / "node"
def test_managed_dir_unsloth_studio_home_wins_over_alias(monkeypatch, tmp_path):
monkeypatch.setenv("UNSLOTH_STUDIO_HOME", str(tmp_path))
monkeypatch.setenv("STUDIO_HOME", str(tmp_path / "other"))
assert nr.managed_node_dir() == tmp_path / "node"
def test_managed_dir_legacy_valued_override_uses_sibling(monkeypatch):
# An override set explicitly to the legacy default maps to the sibling
# ~/.unsloth/node (matching setup.sh / setup.ps1), not ~/.unsloth/studio/node.
legacy = Path.home() / ".unsloth" / "studio"
monkeypatch.setenv("UNSLOTH_STUDIO_HOME", str(legacy))
assert nr.managed_node_dir() == Path.home() / ".unsloth" / "node"
def test_resolve_prefers_adequate_system_node(monkeypatch):
monkeypatch.setattr(
nr.shutil, "which", lambda name: "/usr/bin/node" if name == "node" else None
)
monkeypatch.setattr(nr, "_node_version_ok", lambda exe: exe == "/usr/bin/node")
assert nr.resolve_node_executable() == "/usr/bin/node"
def test_resolve_falls_back_to_managed_when_no_system(monkeypatch, tmp_path):
monkeypatch.setenv("UNSLOTH_STUDIO_HOME", str(tmp_path))
managed = nr.managed_node_binary()
managed.parent.mkdir(parents = True, exist_ok = True)
managed.write_text("#!/bin/sh\necho v24.17.0\n")
monkeypatch.setattr(nr.shutil, "which", lambda name: None)
monkeypatch.setattr(nr, "_node_version_ok", lambda exe: str(exe) == str(managed))
assert nr.resolve_node_executable() == str(managed)
def test_resolve_prefers_managed_over_unsuitable_system(monkeypatch, tmp_path):
# System node present but too old; managed isolated Node is adequate.
monkeypatch.setenv("UNSLOTH_STUDIO_HOME", str(tmp_path))
managed = nr.managed_node_binary()
managed.parent.mkdir(parents = True, exist_ok = True)
managed.write_text("fake")
monkeypatch.setattr(nr.shutil, "which", lambda name: "/old/node")
monkeypatch.setattr(nr, "_node_version_ok", lambda exe: str(exe) == str(managed))
assert nr.resolve_node_executable() == str(managed)
def test_resolve_returns_old_system_as_last_resort(monkeypatch, tmp_path):
# Old system node, no managed install -> preserve pre-isolation behaviour.
monkeypatch.setenv("UNSLOTH_STUDIO_HOME", str(tmp_path))
monkeypatch.setattr(nr.shutil, "which", lambda name: "/old/node")
monkeypatch.setattr(nr, "_node_version_ok", lambda exe: False)
assert nr.resolve_node_executable() == "/old/node"
def test_resolve_returns_none_when_nothing_available(monkeypatch, tmp_path):
monkeypatch.setenv("UNSLOTH_STUDIO_HOME", str(tmp_path)) # managed dir is empty
monkeypatch.setattr(nr.shutil, "which", lambda name: None)
monkeypatch.setattr(nr, "_node_version_ok", lambda exe: False)
assert nr.resolve_node_executable() is None
def test_negative_result_is_not_cached(monkeypatch, tmp_path):
# A Node that appears after the first (empty) probe must be picked up without
# a restart, so None must not be memoized.
monkeypatch.setenv("UNSLOTH_STUDIO_HOME", str(tmp_path))
monkeypatch.setattr(nr.shutil, "which", lambda name: None)
monkeypatch.setattr(nr, "_node_version_ok", lambda exe: False)
assert nr.resolve_node_executable() is None
managed = nr.managed_node_binary()
managed.parent.mkdir(parents = True, exist_ok = True)
managed.write_text("now-installed")
monkeypatch.setattr(nr, "_node_version_ok", lambda exe: str(exe) == str(managed))
assert nr.resolve_node_executable() == str(managed)
def test_positive_result_is_cached(monkeypatch):
monkeypatch.setattr(nr.shutil, "which", lambda name: "/usr/bin/node")
monkeypatch.setattr(nr, "_node_version_ok", lambda exe: True)
assert nr.resolve_node_executable() == "/usr/bin/node"
# A cached positive result must not re-probe (shutil.which would now raise).
def _boom(name):
raise AssertionError("resolver re-probed despite a cached positive result")
monkeypatch.setattr(nr.shutil, "which", _boom)
assert nr.resolve_node_executable() == "/usr/bin/node"

View file

@ -0,0 +1,83 @@
#!/usr/bin/env pwsh
# Unit test for setup.ps1's Get-NodeDecision (the isolated-Node source picker:
# system | bundled | skip). Pure helper, AST-extracted and run in-process -- no
# Node/npm/network needed. Also serves as a setup.ps1 parse/syntax gate.
# Run: pwsh -NoProfile -File tests/studio/test_node_decision.ps1
$ErrorActionPreference = "Stop"
$setupPath = [System.IO.Path]::Combine($PSScriptRoot, "..", "..", "studio", "setup.ps1")
$setupPath = (Resolve-Path $setupPath).Path
$source = Get-Content -Raw -Path $setupPath
$tokens = $null; $errors = $null
$ast = [System.Management.Automation.Language.Parser]::ParseFile($setupPath, [ref]$tokens, [ref]$errors)
if ($errors) { $errors | ForEach-Object { $_.ToString() }; throw "setup.ps1 has parse errors" }
$fn = $ast.FindAll({ param($n)
$n -is [System.Management.Automation.Language.FunctionDefinitionAst] -and $n.Name -eq "Get-NodeDecision"
}, $true)
if ($fn.Count -ne 1) { throw "expected exactly one Get-NodeDecision in setup.ps1, found $($fn.Count)" }
Invoke-Expression $fn[0].Extent.Text
$failures = 0
function Check($name, $cond) {
if ($cond) { Write-Host " PASS $name" }
else { Write-Host " FAIL $name" -ForegroundColor Red; $script:failures++ }
}
function D($node, $npm, $skip) { Get-NodeDecision -NodeVersion $node -NpmVersion $npm -SkipInstall $skip }
Write-Host "Get-NodeDecision"
# system
Check "node22 + npm11 -> system" ((D "v22.17.1" "11.13.0" "0") -eq "system")
Check "node20.19 + npm11 -> system" ((D "v20.19.0" "11.0.0" "0") -eq "system")
Check "node24 + npm11 -> system" ((D "v24.17.0" "11.13.0" "0") -eq "system")
Check "node23 + npm11 -> system" ((D "v23.5.0" "11.0.0" "0") -eq "system")
# bundled (the reported bug: fine Node, stale npm)
Check "node22 + npm10 -> bundled" ((D "v22.17.1" "10.9.2" "0") -eq "bundled")
Check "node18 -> bundled" ((D "v18.20.0" "11.0.0" "0") -eq "bundled")
Check "node22.11 -> bundled" ((D "v22.11.0" "11.0.0" "0") -eq "bundled")
Check "node20.18 -> bundled" ((D "v20.18.0" "11.0.0" "0") -eq "bundled")
Check "node21 (odd) -> bundled" ((D "v21.7.0" "11.0.0" "0") -eq "bundled")
Check "missing -> bundled" ((D "" "" "0") -eq "bundled")
# skip flag
Check "npm10 + skip -> skip" ((D "v22.17.1" "10.9.2" "1") -eq "skip")
Check "missing + skip -> skip" ((D "" "" "1") -eq "skip")
Check "good + skip -> system" ((D "v22.17.1" "11.13.0" "1") -eq "system")
# Structural guards: OXC can need Node when frontend is skipped, custom roots
# must exist before NodeParent creation, bundled Node must isolate npm, and the
# reuse (system) arm must touch nothing -- no prefix pin, no global install.
$nodeSourceOffset = $source.IndexOf('$NodeSource = Get-NodeDecision')
$skipFrontendBranchOffset = $source.IndexOf('} elseif ($SkipFrontend) {')
$customHomeErrorOffset = $source.IndexOf('UNSLOTH_STUDIO_HOME/STUDIO_HOME=$NodeOverride does not exist')
$nodeParentMkdirOffset = $source.IndexOf('New-Item -ItemType Directory -Force -Path $NodeParent')
$npmPrefixOffset = $source.IndexOf('$env:NPM_CONFIG_PREFIX = $NodeDir')
$nodePathClearOffset = $source.IndexOf('Remove-Item Env:NODE_PATH')
$bundledBranchOffset = $source.IndexOf('} elseif ($NodeSource -eq "bundled") {')
$systemArmOffset = $source.IndexOf('$SysNodeVersion | npm $SysNpmVersion (system)')
$globalBunOffset = $source.IndexOf('npm install -g bun')
Check "NodeSource initialized before SKIP_STUDIO_FRONTEND branch" (
$nodeSourceOffset -ge 0 -and $skipFrontendBranchOffset -ge 0 -and $nodeSourceOffset -lt $skipFrontendBranchOffset
)
Check "custom Studio home validated before Node parent creation" (
$customHomeErrorOffset -ge 0 -and $nodeParentMkdirOffset -ge 0 -and $customHomeErrorOffset -lt $nodeParentMkdirOffset
)
Check "bundled Node pins npm prefix and clears NODE_PATH" (
$npmPrefixOffset -ge 0 -and $nodePathClearOffset -ge 0 -and $npmPrefixOffset -lt $nodePathClearOffset
)
# Symmetric to tests/sh/test_system_node_readonly.sh: the prefix pin and the only
# global install (bun) sit between the bundled-branch marker and the system arm,
# i.e. inside bundled, so reusing a good system Node mutates nothing.
Check "npm prefix pin lives in the bundled branch, not the system arm" (
$bundledBranchOffset -ge 0 -and $systemArmOffset -ge 0 -and
$bundledBranchOffset -lt $npmPrefixOffset -and $npmPrefixOffset -lt $systemArmOffset
)
Check "global bun install lives in the bundled branch, not the system arm" (
$bundledBranchOffset -ge 0 -and $systemArmOffset -ge 0 -and
$bundledBranchOffset -lt $globalBunOffset -and $globalBunOffset -lt $systemArmOffset
)
Write-Host ""
if ($failures -gt 0) { Write-Host "$failures check(s) FAILED" -ForegroundColor Red; exit 1 }
Write-Host "All checks passed" -ForegroundColor Green

View file

@ -0,0 +1,73 @@
# Regression test for the setup.ps1 system-node/npm probes. Under "Stop", a bare
# `node -v` for an absent/broken node throws a terminating error `2>$null` cannot
# swallow, which used to abort setup before the bundled-Node decision. The probes
# are now guarded (Get-Command + try/catch); this runs the real probe lines with
# node/npm absent or throwing and asserts setup would NOT terminate.
$ErrorActionPreference = "Stop"
$script:failures = 0
function Check($name, $cond) {
if ($cond) { Write-Host " PASS $name" }
else { Write-Host " FAIL $name" -ForegroundColor Red; $script:failures++ }
}
$setupPath = (Resolve-Path ([System.IO.Path]::Combine($PSScriptRoot, "..", "..", "studio", "setup.ps1"))).Path
# Match specifically the two system-version probe assignments (not every
# Get-Command node/npm in the file, e.g. the OXC-runtime npm guard).
$probeLines = (Get-Content $setupPath) | Where-Object {
$_ -match '\$Sys(Node|Npm)Version = try \{ if \(Get-Command (node|npm) -ErrorAction SilentlyContinue'
}
Check "setup.ps1 guards both node and npm probes with Get-Command" ($probeLines.Count -eq 2)
# Resolve pwsh by absolute path BEFORE scrubbing PATH, so we can launch a child
# whose PATH has no node/npm while still invoking the interpreter.
$pwshExe = (Get-Command pwsh -ErrorAction SilentlyContinue).Source
if (-not $pwshExe) { $pwshExe = (Get-Command powershell).Source }
$emptyDir = Join-Path ([System.IO.Path]::GetTempPath()) ("uns_probe_" + [guid]::NewGuid().ToString("N"))
New-Item -ItemType Directory -Force -Path $emptyDir | Out-Null
function Invoke-WithoutNode([string]$body) {
$script = "`$ErrorActionPreference = 'Stop'`n$body"
$file = Join-Path $emptyDir ("probe_" + [guid]::NewGuid().ToString("N") + ".ps1")
Set-Content -Path $file -Value $script -Encoding utf8
$saved = $env:PATH
try {
$env:PATH = $emptyDir # node/npm guaranteed absent for the child
$out = & $pwshExe -NoProfile -File $file 2>&1 | Out-String
$code = $LASTEXITCODE
} finally {
$env:PATH = $saved
}
return [pscustomobject]@{ ExitCode = $code; Output = $out }
}
# 1. The real guarded probes must NOT terminate, and must yield empty versions
# (which Get-NodeDecision then maps to "bundled").
$guarded = ($probeLines -join "`n") + "`nWrite-Output ""RESULT node=[`$SysNodeVersion] npm=[`$SysNpmVersion]"""
$r = Invoke-WithoutNode $guarded
Check "guarded probes do not terminate when node is absent (exit 0)" ($r.ExitCode -eq 0)
Check "guarded probes yield empty node/npm versions" ($r.Output -match 'RESULT node=\[\] npm=\[\]')
# 2. Negative control: the OLD unguarded form DOES terminate -- proves this test
# can actually distinguish the bug from the fix.
$unguarded = "`$SysNodeVersion = (node -v 2>`$null)`nWrite-Output ""REACHED"""
$n = Invoke-WithoutNode $unguarded
Check "unguarded bare probe terminates under Stop (negative control)" ($n.ExitCode -ne 0 -and $n.Output -notmatch 'REACHED')
# 3. Present-but-broken shim: Get-Command finds it but invoking it throws (corrupt
# Node / blocked npm.ps1). The try/catch must still degrade to empty, not abort.
$throwShims = "function node { throw 'boom' }`nfunction npm { throw 'boom' }`n"
$broken = $throwShims + ($probeLines -join "`n") + "`nWrite-Output ""RESULT node=[`$SysNodeVersion] npm=[`$SysNpmVersion]"""
$b = Invoke-WithoutNode $broken
Check "guarded probes do not terminate when a present shim throws (exit 0)" ($b.ExitCode -eq 0)
Check "guarded probes yield empty versions when a present shim throws" ($b.Output -match 'RESULT node=\[\] npm=\[\]')
# 4. Negative control: the if-guard WITHOUT try/catch terminates when a present
# command throws -- proves the try/catch (not just Get-Command) is load-bearing.
$brokenUnguarded = "function node { throw 'boom' }`n`$SysNodeVersion = if (Get-Command node -ErrorAction SilentlyContinue) { (node -v 2>`$null) } else { '' }`nWrite-Output ""REACHED"""
$bn = Invoke-WithoutNode $brokenUnguarded
Check "if-guard without try/catch terminates on a throwing present command (negative control)" ($bn.ExitCode -ne 0 -and $bn.Output -notmatch 'REACHED')
Remove-Item -Recurse -Force $emptyDir -ErrorAction SilentlyContinue
if ($script:failures -gt 0) { Write-Host "$($script:failures) check(s) failed" -ForegroundColor Red; exit 1 }
Write-Host "All checks passed"