diff --git a/.github/workflows/studio-backend-ci.yml b/.github/workflows/studio-backend-ci.yml index b394b308e4..6e53a290cf 100644 --- a/.github/workflows/studio-backend-ci.yml +++ b/.github/workflows/studio-backend-ci.yml @@ -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 \ diff --git a/.github/workflows/studio-windows-inference-smoke.yml b/.github/workflows/studio-windows-inference-smoke.yml index a6f1401067..ceae8e049d 100644 --- a/.github/workflows/studio-windows-inference-smoke.yml +++ b/.github/workflows/studio-windows-inference-smoke.yml @@ -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: diff --git a/.github/workflows/studio-windows-update-smoke.yml b/.github/workflows/studio-windows-update-smoke.yml index 4a4806cfb1..1a2a7df493 100644 --- a/.github/workflows/studio-windows-update-smoke.yml +++ b/.github/workflows/studio-windows-update-smoke.yml @@ -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 = diff --git a/install.ps1 b/install.ps1 index c241a04166..d48c9fa4f3 100644 --- a/install.ps1 +++ b/install.ps1 @@ -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" diff --git a/studio/backend/core/data_recipe/local_callable_validators.py b/studio/backend/core/data_recipe/local_callable_validators.py index fbe847f9ce..ebb1d39dfb 100644 --- a/studio/backend/core/data_recipe/local_callable_validators.py +++ b/studio/backend/core/data_recipe/local_callable_validators.py @@ -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, diff --git a/studio/backend/utils/node_runtime.py b/studio/backend/utils/node_runtime.py new file mode 100644 index 0000000000..fef2430708 --- /dev/null +++ b/studio/backend/utils/node_runtime.py @@ -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 ``/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 -- ```` 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: ``/node.exe`` on Windows, ``/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 `` -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 diff --git a/studio/install_node_prebuilt.py b/studio/install_node_prebuilt.py new file mode 100644 index 0000000000..bc67189e2b --- /dev/null +++ b/studio/install_node_prebuilt.py @@ -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 +``/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 (' ' 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 \node_modules\npm; Unix at /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. /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()) diff --git a/studio/setup.ps1 b/studio/setup.ps1 index 7af532e7af..28941d91a6 100644 --- a/studio/setup.ps1 +++ b/studio/setup.ps1 @@ -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 /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" } # ========================================================================== diff --git a/studio/setup.sh b/studio/setup.sh index 75a7fce31e..ac82416f9b 100755 --- a/studio/setup.sh +++ b/studio/setup.sh @@ -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 ── diff --git a/tests/sh/test_node_decision.sh b/tests/sh/test_node_decision.sh new file mode 100644 index 0000000000..8d2903ba69 --- /dev/null +++ b/tests/sh/test_node_decision.sh @@ -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 diff --git a/tests/sh/test_studio_home_node_dir.sh b/tests/sh/test_studio_home_node_dir.sh new file mode 100755 index 0000000000..e7a55b442e --- /dev/null +++ b/tests/sh/test_studio_home_node_dir.sh @@ -0,0 +1,58 @@ +#!/usr/bin/env bash +# Regression test: setup.sh installs the isolated Node under +# (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 -> /node +check "UNSLOTH_STUDIO_HOME= -> /node" "$CUSTOM/node" "$(node_dir_for "$FAKEHOME" "$CUSTOM" "")" +# 2. STUDIO_HOME alias = custom dir -> /node +check "STUDIO_HOME alias -> /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" diff --git a/tests/sh/test_system_node_readonly.sh b/tests/sh/test_system_node_readonly.sh new file mode 100755 index 0000000000..1416fc3187 --- /dev/null +++ b/tests/sh/test_system_node_readonly.sh @@ -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" diff --git a/tests/studio/install/test_install_node_prebuilt_logic.py b/tests/studio/install/test_install_node_prebuilt_logic.py new file mode 100644 index 0000000000..0c6da87655 --- /dev/null +++ b/tests/studio/install/test_install_node_prebuilt_logic.py @@ -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")) diff --git a/tests/studio/install/test_managed_node_runtime.py b/tests/studio/install/test_managed_node_runtime.py new file mode 100644 index 0000000000..0dbc6788ca --- /dev/null +++ b/tests/studio/install/test_managed_node_runtime.py @@ -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 +``/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" diff --git a/tests/studio/test_node_decision.ps1 b/tests/studio/test_node_decision.ps1 new file mode 100644 index 0000000000..bd5d5c8677 --- /dev/null +++ b/tests/studio/test_node_decision.ps1 @@ -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 diff --git a/tests/studio/test_node_probe_guard.ps1 b/tests/studio/test_node_probe_guard.ps1 new file mode 100644 index 0000000000..9198ba0cc2 --- /dev/null +++ b/tests/studio/test_node_probe_guard.ps1 @@ -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"