Studio: use an isolated Node.js for the frontend build instead of replacing the system Node/npm (#6533)
* Studio: use an isolated Node.js for the frontend build instead of replacing the system Node/npm * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Studio: address Node isolation review (no-Node probe crash, PATH refresh, OXC provisioning, venv python, runtime node resolver) * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Fix/adjust Node isolation for PR #6533 * Studio Node: don't cache a negative node resolution; accept Node metadata in setup.sh ownership guard - node_runtime: memoize only a version-adequate executable so a Node installed by a separate-process 'studio update' is picked up without a backend restart. - setup.sh: _studio_owned_adoptable also accepts UNSLOTH_NODE_PREBUILT_INFO.json, matching the setup.ps1 Node ownership guard (custom-home parity). * Studio setup.ps1: skip OXC npm install gracefully when npm is absent Mirror setup.sh's `command -v npm` guard so a pip-installed Studio with no system Node skips the OXC runtime install (validator degrades at runtime) instead of exit 1 aborting the whole setup. Tighten test_node_probe_guard.ps1's probe regex so it only matches the two system-version probes, not this new npm guard. * Wire test_node_probe_guard.ps1 into Windows CI for PR #6533 * Harden isolated Node install and probes for PR #6533 - install_node_prebuilt.py: keep an existing, still-usable isolated Node when nodejs.org's dist index is unreachable instead of aborting the update on a transient outage (existing_install_usable + tolerant fetch). - install_node_prebuilt.py: pin NPM_CONFIG_PREFIX/npm_config_prefix and drop NODE_PATH in _run_node so any npm -g stays inside the isolated prefix; Windows npm otherwise writes to %APPDATA%\npm. - install_node_prebuilt.py: resolve tar hard-link targets against the archive root (symlink targets stay link-parent relative). - setup.ps1: wrap the system node/npm probes in try/catch so a present but broken shim degrades to the bundled Node instead of aborting setup. - setup.ps1: run the isolated Node install with the handed-off/venv Python (ReusedSetupPython); the main resolver runs later and bare python may be a Store stub this early. - setup.sh: log when the OXC validator runtime is skipped for missing npm, matching setup.ps1. - node_runtime.py: move the version-floor comment onto _version_meets_floor. - Tests for the offline-reuse and broken-shim paths. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Trim verbose comments across the Studio Node installer for PR #6533 Comments-only pass: collapse the multi-line section banners to single lines, drop comments that restate obvious code, and tighten the remaining docstrings and "why" notes without losing intent. No code changes (verified with an AST comment-only check on the Python files and a non-comment-diff scan on setup.sh and setup.ps1). Net 109 fewer lines; the install, decision, and probe-guard suites stay green. * Harden Node install from review: validated Python, version floor, legacy home, lock race For PR #6533, addressing the latest review pass: - setup.ps1: run the isolated Node install with the validated reused/venv Python. An incompatible reused interpreter (old venv, conda, stale UNSLOTH_SETUP_PYTHON) is no longer used; fall back to the resolved python instead. - setup.ps1: a STUDIO_HOME/UNSLOTH_STUDIO_HOME override equal to the legacy default now uses the legacy sibling node dir (~/.unsloth/node), matching the runtime resolver and setup.sh, so OXC can find the Node it installed. - install_node_prebuilt.py: reject an explicit --node-version below the floor (^20.19 || >=22.12 || >=23) instead of installing a Node the build cannot use. - install_node_prebuilt.py: atomically rename a stale install lock before unlinking so two concurrent runs without filelock cannot both acquire it. Tests added for the version floor (parametrized + explicit-below-floor rejection). Full install suite: 937 passed, 1 skipped; setup.ps1 parses; decision tests green. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Address latest review: armv7l + later-fetch offline reuse for PR #6533 - install_node_prebuilt.py: reject 32-bit ARM (armv7l) up front. Node 24 LTS ships no linux-armv7l build, so the old path failed late with a confusing "no sha256"; it now fails fast with a clear unsupported-architecture error. - install_node_prebuilt.py: extend the offline-reuse fallback to the SHASUMS and archive fetches. If index.json resolves a newer Node but a later download fails and a usable isolated Node is already on disk, keep it instead of aborting a non-force update. Tests added: armv7l/armhf are unsupported; a SHASUMS failure keeps an existing usable Node and re-raises when none is present. Full install suite: 941 passed. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Add UNSLOTH_STUDIO_HOME node-dir tests (install side + resolver) for PR #6533 * Add regression tests pinning the reuse path read-only and isolating installer writes Lock in the two invariants behind the isolated-Node design: reusing a good system Node never mutates the user's Node/npm, and the installer's own npm calls only ever write inside its install_dir. - tests/studio/install/test_install_node_prebuilt_logic.py: assert _run_node redirects NPM_CONFIG_PREFIX/npm_config_prefix into install_dir and drops an inherited NODE_PATH; assert _ensure_npm_floor scopes the npm self-upgrade to install_dir (never -g against the system) and is a no-op once npm meets the floor. - tests/sh/test_system_node_readonly.sh (new, wired into studio-backend-ci.yml): the setup.sh NODE_SOURCE=system arm runs no global install and sets no NPM_CONFIG_PREFIX, with a positive control that the bundled arm does. - tests/studio/test_node_decision.ps1: symmetric structural guard that the prefix pin and the only global install (bun) live in the bundled branch, not the system arm. --------- Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> Co-authored-by: wasimysaid <wasimysdev@gmail.com>
This commit is contained in:
parent
e6b4480832
commit
9f39cc2c39
16 changed files with 2224 additions and 139 deletions
|
|
@ -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,
|
||||
|
|
|
|||
130
studio/backend/utils/node_runtime.py
Normal file
130
studio/backend/utils/node_runtime.py
Normal file
|
|
@ -0,0 +1,130 @@
|
|||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
|
||||
|
||||
"""Resolve a usable Node.js executable at runtime.
|
||||
|
||||
The installer provisions an isolated Node under ``<UNSLOTH_HOME>/node`` but only
|
||||
puts it on PATH for the *setup* process, never the user's shell. So backend code
|
||||
that shells out to ``node`` at runtime (the OXC validator) cannot rely on PATH.
|
||||
``resolve_node_executable`` prefers a version-adequate system Node, else the
|
||||
managed isolated Node (same floor the installer applies: ^20.19 || >=22.12 || >=23).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
from utils.subprocess_compat import windows_hidden_subprocess_kwargs
|
||||
|
||||
_NODE_VERSION_PROBE_TIMEOUT_SECONDS = 10
|
||||
|
||||
|
||||
# Keep in sync with the setup scripts' Node floor: Get-NodeDecision (setup.ps1) /
|
||||
# decide_node_source (setup.sh). Vite 8 needs Node ^20.19 || >=22.12 || >=23.
|
||||
def _version_meets_floor(version: str) -> bool:
|
||||
"""True iff a ``node -v`` string clears the installer's version bar."""
|
||||
match = re.match(r"v?(\d+)\.(\d+)", version.strip())
|
||||
if not match:
|
||||
return False
|
||||
major, minor = int(match.group(1)), int(match.group(2))
|
||||
return (major == 20 and minor >= 19) or (major == 22 and minor >= 12) or major >= 23
|
||||
|
||||
|
||||
def managed_node_dir() -> Path:
|
||||
"""Isolated Node install dir. Mirrors ``_find_llama_server_binary``: shares a
|
||||
parent with llama.cpp -- ``<STUDIO_HOME>`` in custom mode, else legacy ``~/.unsloth``."""
|
||||
legacy_node = Path.home() / ".unsloth" / "node"
|
||||
try:
|
||||
# Lazy import (mirrors _find_llama_server_binary) so this module stays
|
||||
# importable even if utils.paths cannot be loaded.
|
||||
from utils.paths.storage_roots import studio_root
|
||||
|
||||
resolved = studio_root()
|
||||
legacy_studio = Path.home() / ".unsloth" / "studio"
|
||||
try:
|
||||
is_legacy = resolved.resolve() == legacy_studio.resolve()
|
||||
except (OSError, ValueError):
|
||||
is_legacy = resolved == legacy_studio
|
||||
return legacy_node if is_legacy else (resolved / "node")
|
||||
except (ImportError, OSError, ValueError):
|
||||
# Degraded env (utils.paths unavailable): still honor an explicit
|
||||
# STUDIO_HOME override before the legacy default, mirroring studio_root().
|
||||
override = (
|
||||
os.environ.get("UNSLOTH_STUDIO_HOME") or os.environ.get("STUDIO_HOME") or ""
|
||||
).strip()
|
||||
if override:
|
||||
try:
|
||||
return Path(override).expanduser().resolve() / "node"
|
||||
except (OSError, ValueError):
|
||||
return Path(override).expanduser() / "node"
|
||||
return legacy_node
|
||||
|
||||
|
||||
def managed_node_binary() -> Path:
|
||||
"""Node executable in the isolated install: ``<dir>/node.exe`` on Windows, ``<dir>/bin/node`` else."""
|
||||
node_dir = managed_node_dir()
|
||||
if os.name == "nt":
|
||||
return node_dir / "node.exe"
|
||||
return node_dir / "bin" / "node"
|
||||
|
||||
|
||||
def _node_version_ok(executable: str) -> bool:
|
||||
"""Run ``<executable> -v`` and check it clears the floor; False on any error."""
|
||||
try:
|
||||
result = subprocess.run(
|
||||
[executable, "-v"],
|
||||
capture_output = True,
|
||||
text = True,
|
||||
timeout = _NODE_VERSION_PROBE_TIMEOUT_SECONDS,
|
||||
**windows_hidden_subprocess_kwargs(),
|
||||
)
|
||||
except (OSError, ValueError, subprocess.SubprocessError):
|
||||
return False
|
||||
if result.returncode != 0:
|
||||
return False
|
||||
return _version_meets_floor(result.stdout)
|
||||
|
||||
|
||||
# Memoize ONLY a confirmed version-adequate executable: the installer runs in a
|
||||
# separate process and may finish after the first probe here, so a negative /
|
||||
# last-resort result must not be cached (it would stick until a backend restart).
|
||||
_resolved_node: str | None = None
|
||||
|
||||
|
||||
def _reset_resolved_node() -> None:
|
||||
"""Clear the memoized executable (used by tests)."""
|
||||
global _resolved_node
|
||||
_resolved_node = None
|
||||
|
||||
|
||||
def resolve_node_executable() -> str | None:
|
||||
"""Resolve a usable node executable, or None.
|
||||
|
||||
Order: version-adequate system ``node`` on PATH; else the managed isolated
|
||||
Node if adequate; else bare ``node`` (may be None). Only an adequate result
|
||||
is memoized, so a Node installed after the first probe is picked up live.
|
||||
"""
|
||||
global _resolved_node
|
||||
if _resolved_node is not None:
|
||||
return _resolved_node
|
||||
|
||||
system_node = shutil.which("node")
|
||||
if system_node and _node_version_ok(system_node):
|
||||
_resolved_node = system_node
|
||||
return _resolved_node
|
||||
|
||||
managed = managed_node_binary()
|
||||
try:
|
||||
managed_present = managed.is_file()
|
||||
except OSError:
|
||||
managed_present = False
|
||||
if managed_present and _node_version_ok(str(managed)):
|
||||
_resolved_node = str(managed)
|
||||
return _resolved_node
|
||||
|
||||
# Last-resort system node (may be None), NOT cached so a later install is picked up.
|
||||
return system_node
|
||||
765
studio/install_node_prebuilt.py
Normal file
765
studio/install_node_prebuilt.py
Normal file
|
|
@ -0,0 +1,765 @@
|
|||
#!/usr/bin/env python3
|
||||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
|
||||
|
||||
"""Cross-platform Node.js prebuilt installer for Unsloth Studio.
|
||||
|
||||
Downloads an official Node.js archive from nodejs.org into an isolated
|
||||
``<UNSLOTH_HOME>/node`` and never touches the system Node/npm. Pinning Node 24+
|
||||
LTS clears the Studio frontend build floor (Vite 8: Node ^20.19 || >=22.12,
|
||||
npm >= 11) with the npm it bundles.
|
||||
|
||||
Mirrors ``install_llama_prebuilt.py`` so the setup scripts drive it the same way.
|
||||
Exit codes: 0 success, 1 error, 2 fallback, 3 busy. A re-run that already matches
|
||||
logs "already matches" and returns 0 without downloading (the scripts grep it).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import platform
|
||||
import random
|
||||
import shutil
|
||||
import socket
|
||||
import subprocess
|
||||
import sys
|
||||
import tarfile
|
||||
import tempfile
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
import zipfile
|
||||
from contextlib import contextmanager
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Iterator
|
||||
|
||||
try:
|
||||
from filelock import FileLock, Timeout as FileLockTimeout
|
||||
except ImportError:
|
||||
FileLock = None
|
||||
FileLockTimeout = None
|
||||
|
||||
|
||||
EXIT_SUCCESS = 0
|
||||
EXIT_ERROR = 1
|
||||
EXIT_FALLBACK = 2
|
||||
EXIT_BUSY = 3
|
||||
|
||||
# Node 24 LTS bundles npm 11, clearing Vite 8's floor (Node ^20.19 || >=22.12, npm >= 11).
|
||||
NODE_MIN_LTS_MAJOR = 24
|
||||
NPM_MIN_MAJOR = 11
|
||||
|
||||
NODE_DIST_BASE = "https://nodejs.org/dist"
|
||||
NODE_DIST_INDEX = f"{NODE_DIST_BASE}/index.json"
|
||||
|
||||
RETRYABLE_HTTP_STATUS = {408, 429, 500, 502, 503, 504}
|
||||
HTTP_FETCH_ATTEMPTS = 4
|
||||
HTTP_FETCH_BASE_DELAY_SECONDS = 0.75
|
||||
INSTALL_LOCK_TIMEOUT_SECONDS = 300
|
||||
INSTALL_STAGING_ROOT_NAME = ".staging"
|
||||
METADATA_FILENAME = "UNSLOTH_NODE_PREBUILT_INFO.json"
|
||||
METADATA_SCHEMA_VERSION = 1
|
||||
|
||||
# PowerShell renders stderr as NativeCommandError noise; main() flips logs to stdout.
|
||||
_LOG_TO_STDOUT = False
|
||||
|
||||
|
||||
class PrebuiltFallback(RuntimeError):
|
||||
"""Recoverable failure -- caller should fall back (exit code 2)."""
|
||||
|
||||
|
||||
class BusyInstallConflict(RuntimeError):
|
||||
"""Another process holds the install lock (exit code 3)."""
|
||||
|
||||
|
||||
def log(message: str) -> None:
|
||||
print(f"[node-prebuilt] {message}", file = sys.stdout if _LOG_TO_STDOUT else sys.stderr)
|
||||
|
||||
|
||||
# ── Host detection ──
|
||||
@dataclass(frozen = True)
|
||||
class HostInfo:
|
||||
system: str # platform.system()
|
||||
machine: str # lowered platform.machine()
|
||||
node_os: str # nodejs.org token: linux | darwin | win
|
||||
node_arch: str # nodejs.org token: x64 | arm64 | armv7l
|
||||
archive_ext: str # .tar.gz | .zip
|
||||
is_windows: bool
|
||||
|
||||
|
||||
def detect_host() -> HostInfo:
|
||||
system = platform.system()
|
||||
machine = platform.machine().lower()
|
||||
is_windows = system == "Windows"
|
||||
|
||||
if system == "Linux":
|
||||
node_os = "linux"
|
||||
elif system == "Darwin":
|
||||
node_os = "darwin"
|
||||
elif is_windows:
|
||||
node_os = "win"
|
||||
else:
|
||||
raise PrebuiltFallback(f"unsupported operating system for Node prebuilt: {system}")
|
||||
|
||||
if machine in {"x86_64", "amd64", "x64"}:
|
||||
node_arch = "x64"
|
||||
elif machine in {"arm64", "aarch64"}:
|
||||
node_arch = "arm64"
|
||||
else:
|
||||
# 32-bit ARM (armv7l) is intentionally unsupported: Node 24 LTS ships no
|
||||
# linux-armv7l build, so there is nothing at/above the floor to install.
|
||||
raise PrebuiltFallback(f"unsupported CPU architecture for Node prebuilt: {machine}")
|
||||
|
||||
# .tar.gz (not .tar.xz) on Unix so the extractor needs no xz; .zip on Windows.
|
||||
archive_ext = ".zip" if is_windows else ".tar.gz"
|
||||
return HostInfo(
|
||||
system = system,
|
||||
machine = machine,
|
||||
node_os = node_os,
|
||||
node_arch = node_arch,
|
||||
archive_ext = archive_ext,
|
||||
is_windows = is_windows,
|
||||
)
|
||||
|
||||
|
||||
# ── URL / asset construction (pure, unit tested) ──
|
||||
def node_asset_stem(version: str, host: HostInfo) -> str:
|
||||
"""e.g. node-v24.4.1-linux-x64 (no extension)."""
|
||||
return f"node-v{version}-{host.node_os}-{host.node_arch}"
|
||||
|
||||
|
||||
def node_asset_name(version: str, host: HostInfo) -> str:
|
||||
return f"{node_asset_stem(version, host)}{host.archive_ext}"
|
||||
|
||||
|
||||
def node_download_url(version: str, asset_name: str) -> str:
|
||||
return f"{NODE_DIST_BASE}/v{version}/{asset_name}"
|
||||
|
||||
|
||||
def node_shasums_url(version: str) -> str:
|
||||
return f"{NODE_DIST_BASE}/v{version}/SHASUMS256.txt"
|
||||
|
||||
|
||||
def expected_sha256_for(shasums_text: str, asset_name: str) -> str | None:
|
||||
"""Parse a nodejs.org SHASUMS256.txt ('<hex> <filename>' per line)."""
|
||||
for line in shasums_text.splitlines():
|
||||
parts = line.split()
|
||||
if len(parts) == 2 and parts[1] == asset_name:
|
||||
digest = parts[0].lower()
|
||||
if len(digest) == 64 and all(c in "0123456789abcdef" for c in digest):
|
||||
return digest
|
||||
return None
|
||||
|
||||
|
||||
def _version_tuple(value: str) -> tuple[int, ...]:
|
||||
try:
|
||||
return tuple(int(p) for p in value.lstrip("v").split("."))
|
||||
except ValueError:
|
||||
return ()
|
||||
|
||||
|
||||
def _meets_node_floor(version: str) -> bool:
|
||||
"""True iff version clears the setup floor (^20.19 || >=22.12 || >=23)."""
|
||||
parts = _version_tuple(version)
|
||||
if not parts:
|
||||
return False
|
||||
major = parts[0]
|
||||
minor = parts[1] if len(parts) > 1 else 0
|
||||
return (major == 20 and minor >= 19) or (major == 22 and minor >= 12) or major >= 23
|
||||
|
||||
|
||||
def select_node_version(index: list[dict], *, channel: str, min_major: int) -> str:
|
||||
"""Pick a concrete Node version from nodejs.org index.json.
|
||||
|
||||
channel='lts' -> newest LTS release line whose major >= min_major.
|
||||
channel='latest' -> newest release overall whose major >= min_major.
|
||||
Otherwise the channel is treated as an explicit version string.
|
||||
"""
|
||||
if channel not in {"lts", "latest"}:
|
||||
return channel.lstrip("v")
|
||||
|
||||
best: tuple[int, ...] | None = None
|
||||
best_version: str | None = None
|
||||
for entry in index:
|
||||
version = str(entry.get("version", "")).lstrip("v")
|
||||
parsed = _version_tuple(version)
|
||||
if not parsed or parsed[0] < min_major:
|
||||
continue
|
||||
if channel == "lts" and not entry.get("lts"):
|
||||
continue
|
||||
if best is None or parsed > best:
|
||||
best = parsed
|
||||
best_version = version
|
||||
if best_version is None:
|
||||
raise PrebuiltFallback(
|
||||
f"no Node '{channel}' release found at or above major {min_major} in {NODE_DIST_INDEX}"
|
||||
)
|
||||
return best_version
|
||||
|
||||
|
||||
# ── HTTP (retry/backoff) ──
|
||||
def _auth_headers() -> dict[str, str]:
|
||||
# A User-Agent keeps some proxies/CDNs happy; nodejs.org needs no auth.
|
||||
return {"User-Agent": "unsloth-studio-node-prebuilt"}
|
||||
|
||||
|
||||
def is_retryable_url_error(exc: Exception) -> bool:
|
||||
if isinstance(exc, urllib.error.HTTPError):
|
||||
return exc.code in RETRYABLE_HTTP_STATUS
|
||||
if isinstance(exc, (urllib.error.URLError, TimeoutError, socket.timeout)):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def sleep_backoff(attempt: int) -> None:
|
||||
delay = HTTP_FETCH_BASE_DELAY_SECONDS * (2 ** max(attempt - 1, 0))
|
||||
delay += random.uniform(0.0, 0.2)
|
||||
time.sleep(delay)
|
||||
|
||||
|
||||
def download_bytes(url: str, *, timeout: int = 60) -> bytes:
|
||||
last_exc: Exception | None = None
|
||||
for attempt in range(1, HTTP_FETCH_ATTEMPTS + 1):
|
||||
try:
|
||||
request = urllib.request.Request(url, headers = _auth_headers())
|
||||
with urllib.request.urlopen(request, timeout = timeout) as response:
|
||||
return response.read()
|
||||
except Exception as exc: # noqa: BLE001
|
||||
last_exc = exc
|
||||
if attempt >= HTTP_FETCH_ATTEMPTS or not is_retryable_url_error(exc):
|
||||
raise
|
||||
log(f"fetch failed ({attempt}/{HTTP_FETCH_ATTEMPTS}) for {url}: {exc}; retrying")
|
||||
sleep_backoff(attempt)
|
||||
assert last_exc is not None
|
||||
raise last_exc
|
||||
|
||||
|
||||
def fetch_json(url: str) -> object:
|
||||
return json.loads(download_bytes(url, timeout = 30).decode("utf-8"))
|
||||
|
||||
|
||||
def atomic_replace_from_tempfile(tmp_path: Path, destination: Path) -> None:
|
||||
destination.parent.mkdir(parents = True, exist_ok = True)
|
||||
os.replace(tmp_path, destination)
|
||||
|
||||
|
||||
def download_file(url: str, destination: Path) -> None:
|
||||
destination.parent.mkdir(parents = True, exist_ok = True)
|
||||
last_exc: Exception | None = None
|
||||
for attempt in range(1, HTTP_FETCH_ATTEMPTS + 1):
|
||||
tmp_path: Path | None = None
|
||||
try:
|
||||
request = urllib.request.Request(url, headers = _auth_headers())
|
||||
with tempfile.NamedTemporaryFile(
|
||||
prefix = destination.name + ".tmp-",
|
||||
dir = destination.parent,
|
||||
delete = False,
|
||||
) as handle:
|
||||
tmp_path = Path(handle.name)
|
||||
with urllib.request.urlopen(request, timeout = 120) as response:
|
||||
while True:
|
||||
chunk = response.read(1024 * 1024)
|
||||
if not chunk:
|
||||
break
|
||||
handle.write(chunk)
|
||||
handle.flush()
|
||||
os.fsync(handle.fileno())
|
||||
if not tmp_path.exists() or tmp_path.stat().st_size == 0:
|
||||
raise RuntimeError(f"downloaded empty file from {url}")
|
||||
atomic_replace_from_tempfile(tmp_path, destination)
|
||||
return
|
||||
except Exception as exc: # noqa: BLE001
|
||||
last_exc = exc
|
||||
if tmp_path is not None:
|
||||
try:
|
||||
tmp_path.unlink(missing_ok = True)
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
if attempt >= HTTP_FETCH_ATTEMPTS or not is_retryable_url_error(exc):
|
||||
raise
|
||||
log(f"download failed ({attempt}/{HTTP_FETCH_ATTEMPTS}) for {url}: {exc}; retrying")
|
||||
sleep_backoff(attempt)
|
||||
assert last_exc is not None
|
||||
raise last_exc
|
||||
|
||||
|
||||
def sha256_file(path: Path) -> str:
|
||||
digest = hashlib.sha256()
|
||||
with path.open("rb") as handle:
|
||||
for chunk in iter(lambda: handle.read(1024 * 1024), b""):
|
||||
digest.update(chunk)
|
||||
return digest.hexdigest()
|
||||
|
||||
|
||||
def download_file_verified(
|
||||
url: str, destination: Path, *, expected_sha256: str, label: str
|
||||
) -> None:
|
||||
for attempt in range(1, 3):
|
||||
download_file(url, destination)
|
||||
actual = sha256_file(destination)
|
||||
if actual == expected_sha256:
|
||||
log(f"verified {label} sha256={actual}")
|
||||
return
|
||||
log(f"{label} checksum mismatch {attempt}/2: expected={expected_sha256} actual={actual}")
|
||||
destination.unlink(missing_ok = True)
|
||||
if attempt == 2:
|
||||
raise PrebuiltFallback(f"{label} checksum mismatch after retry")
|
||||
|
||||
|
||||
# ── Safe archive extraction (zip + tar.gz, traversal/symlink guarded) ──
|
||||
def _safe_extract_path(base: Path, member_name: str) -> Path:
|
||||
member_path = Path(member_name.replace("\\", "/"))
|
||||
if member_path.is_absolute():
|
||||
raise PrebuiltFallback(f"archive member used an absolute path: {member_name}")
|
||||
target = (base / member_path).resolve()
|
||||
try:
|
||||
target.relative_to(base.resolve())
|
||||
except ValueError as exc:
|
||||
raise PrebuiltFallback(f"archive member escaped destination: {member_name}") from exc
|
||||
return target
|
||||
|
||||
|
||||
def _extract_zip_safely(source: Path, base: Path) -> None:
|
||||
with zipfile.ZipFile(source) as archive:
|
||||
for member in archive.infolist():
|
||||
target = _safe_extract_path(base, member.filename)
|
||||
mode = (member.external_attr >> 16) & 0o170000
|
||||
if mode == 0o120000:
|
||||
raise PrebuiltFallback(f"zip archive contained a symlink entry: {member.filename}")
|
||||
if member.is_dir():
|
||||
target.mkdir(parents = True, exist_ok = True)
|
||||
continue
|
||||
target.parent.mkdir(parents = True, exist_ok = True)
|
||||
with archive.open(member, "r") as src, target.open("wb") as dst:
|
||||
shutil.copyfileobj(src, dst)
|
||||
|
||||
|
||||
def _extract_tar_safely(source: Path, base: Path) -> None:
|
||||
# Node Unix tarballs ship bin/npm, bin/npx, bin/corepack as relative
|
||||
# symlinks into lib/node_modules; defer links and resolve after files.
|
||||
pending_links: list[tuple[tarfile.TarInfo, Path]] = []
|
||||
with tarfile.open(source, "r:gz") as archive:
|
||||
for member in archive.getmembers():
|
||||
target = _safe_extract_path(base, member.name)
|
||||
if member.isdir():
|
||||
target.mkdir(parents = True, exist_ok = True)
|
||||
continue
|
||||
if member.islnk() or member.issym():
|
||||
pending_links.append((member, target))
|
||||
continue
|
||||
if not member.isfile():
|
||||
raise PrebuiltFallback(f"tar archive contained an unsupported entry: {member.name}")
|
||||
target.parent.mkdir(parents = True, exist_ok = True)
|
||||
extracted = archive.extractfile(member)
|
||||
if extracted is None:
|
||||
raise PrebuiltFallback(f"tar archive entry could not be read: {member.name}")
|
||||
with extracted, target.open("wb") as dst:
|
||||
shutil.copyfileobj(extracted, dst)
|
||||
if member.mode & 0o111:
|
||||
os.chmod(target, target.stat().st_mode | 0o111)
|
||||
|
||||
for member, target in pending_links:
|
||||
link_name = member.linkname.replace("\\", "/")
|
||||
link_path = Path(link_name)
|
||||
if link_path.is_absolute() or not link_name:
|
||||
raise PrebuiltFallback(
|
||||
f"archive link used an unsafe target: {member.name} -> {link_name}"
|
||||
)
|
||||
# tar symlink names are link-parent relative; hard-link names are archive-root relative.
|
||||
resolved = (target.parent / link_path if member.issym() else base / link_path).resolve()
|
||||
try:
|
||||
resolved.relative_to(base.resolve())
|
||||
except ValueError as exc:
|
||||
raise PrebuiltFallback(
|
||||
f"archive link escaped destination: {member.name} -> {link_name}"
|
||||
) from exc
|
||||
target.parent.mkdir(parents = True, exist_ok = True)
|
||||
if target.exists() or target.is_symlink():
|
||||
target.unlink()
|
||||
if member.issym():
|
||||
target.symlink_to(link_name)
|
||||
else: # hard link
|
||||
shutil.copy2(resolved, target)
|
||||
|
||||
|
||||
def extract_archive(archive_path: Path, destination: Path) -> None:
|
||||
destination.mkdir(parents = True, exist_ok = True)
|
||||
if archive_path.name.endswith(".zip"):
|
||||
_extract_zip_safely(archive_path, destination)
|
||||
elif archive_path.name.endswith(".tar.gz"):
|
||||
_extract_tar_safely(archive_path, destination)
|
||||
else:
|
||||
raise PrebuiltFallback(f"unsupported archive format: {archive_path.name}")
|
||||
|
||||
|
||||
# ── Install lock (concurrent setup runs share one UNSLOTH_HOME) ──
|
||||
def install_lock_path(install_dir: Path) -> Path:
|
||||
return install_dir.parent / f".{install_dir.name}.install.lock"
|
||||
|
||||
|
||||
def _pid_is_alive(pid: int) -> bool:
|
||||
"""Best-effort process liveness check that never signals the process on Windows."""
|
||||
if pid <= 0:
|
||||
return False
|
||||
if sys.platform == "win32":
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["tasklist", "/FI", f"PID eq {pid}", "/FO", "CSV", "/NH"],
|
||||
capture_output = True,
|
||||
text = True,
|
||||
timeout = 5,
|
||||
**_windows_hidden_kwargs(),
|
||||
)
|
||||
except (OSError, ValueError, subprocess.SubprocessError):
|
||||
# Be conservative if tasklist itself is unavailable.
|
||||
return True
|
||||
return f'"{pid}"' in result.stdout
|
||||
try:
|
||||
os.kill(pid, 0)
|
||||
except ProcessLookupError:
|
||||
return False
|
||||
except PermissionError:
|
||||
return True
|
||||
except ValueError:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
@contextmanager
|
||||
def install_lock(lock_path: Path) -> Iterator[None]:
|
||||
lock_path.parent.mkdir(parents = True, exist_ok = True)
|
||||
if FileLock is None:
|
||||
fd: int | None = None
|
||||
deadline = time.monotonic() + INSTALL_LOCK_TIMEOUT_SECONDS
|
||||
while True:
|
||||
try:
|
||||
fd = os.open(str(lock_path), os.O_CREAT | os.O_EXCL | os.O_RDWR)
|
||||
os.write(fd, f"{os.getpid()}\n".encode())
|
||||
os.fsync(fd)
|
||||
break
|
||||
except FileExistsError:
|
||||
try:
|
||||
raw = lock_path.read_text().strip()
|
||||
except FileNotFoundError:
|
||||
continue
|
||||
stale = False
|
||||
if raw:
|
||||
try:
|
||||
stale = not _pid_is_alive(int(raw))
|
||||
except ValueError:
|
||||
stale = True
|
||||
if stale:
|
||||
# Atomically rename before unlinking so only one racer removes
|
||||
# the stale lock; a process recreating it loses the rename and waits.
|
||||
try:
|
||||
stale_path = lock_path.with_name(f"{lock_path.name}.stale.{os.getpid()}")
|
||||
os.replace(str(lock_path), str(stale_path))
|
||||
stale_path.unlink(missing_ok = True)
|
||||
except (OSError, ValueError):
|
||||
pass
|
||||
continue
|
||||
if time.monotonic() >= deadline:
|
||||
raise BusyInstallConflict(
|
||||
f"timed out after {INSTALL_LOCK_TIMEOUT_SECONDS}s waiting for install lock: {lock_path}"
|
||||
)
|
||||
time.sleep(0.5)
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
if fd is not None:
|
||||
os.close(fd)
|
||||
lock_path.unlink(missing_ok = True)
|
||||
return
|
||||
|
||||
try:
|
||||
with FileLock(str(lock_path), timeout = INSTALL_LOCK_TIMEOUT_SECONDS):
|
||||
yield
|
||||
except FileLockTimeout as exc:
|
||||
raise BusyInstallConflict(
|
||||
f"timed out after {INSTALL_LOCK_TIMEOUT_SECONDS}s waiting for install lock: {lock_path}"
|
||||
) from exc
|
||||
|
||||
|
||||
# ── Install layout / metadata / health ──
|
||||
def node_binary_path(install_dir: Path, host: HostInfo) -> Path:
|
||||
return install_dir / "node.exe" if host.is_windows else install_dir / "bin" / "node"
|
||||
|
||||
|
||||
def npm_cli_path(install_dir: Path, host: HostInfo) -> Path:
|
||||
# Windows ships npm at <root>\node_modules\npm; Unix at <root>/lib/node_modules/npm.
|
||||
if host.is_windows:
|
||||
return install_dir / "node_modules" / "npm" / "bin" / "npm-cli.js"
|
||||
return install_dir / "lib" / "node_modules" / "npm" / "bin" / "npm-cli.js"
|
||||
|
||||
|
||||
def _windows_hidden_kwargs() -> dict[str, object]:
|
||||
if sys.platform != "win32":
|
||||
return {}
|
||||
kwargs: dict[str, object] = {}
|
||||
flag = getattr(subprocess, "CREATE_NO_WINDOW", 0)
|
||||
if flag:
|
||||
kwargs["creationflags"] = flag
|
||||
return kwargs
|
||||
|
||||
|
||||
def _run_node(
|
||||
install_dir: Path,
|
||||
host: HostInfo,
|
||||
args: list[str],
|
||||
*,
|
||||
timeout: int = 120,
|
||||
) -> str:
|
||||
node_bin = node_binary_path(install_dir, host)
|
||||
env = os.environ.copy()
|
||||
# Keep any `npm -g` writes inside the isolated prefix; Windows npm otherwise
|
||||
# defaults its global prefix to %APPDATA%\npm and touches the system install.
|
||||
env["NPM_CONFIG_PREFIX"] = str(install_dir)
|
||||
env["npm_config_prefix"] = str(install_dir)
|
||||
env.pop("NODE_PATH", None)
|
||||
result = subprocess.run(
|
||||
[str(node_bin), *args],
|
||||
capture_output = True,
|
||||
text = True,
|
||||
timeout = timeout,
|
||||
env = env,
|
||||
**_windows_hidden_kwargs(),
|
||||
)
|
||||
if result.returncode != 0:
|
||||
raise RuntimeError(
|
||||
f"node {' '.join(args)} failed: {result.stderr.strip() or result.stdout.strip()}"
|
||||
)
|
||||
return result.stdout.strip()
|
||||
|
||||
|
||||
def installed_node_version(install_dir: Path, host: HostInfo) -> str | None:
|
||||
node_bin = node_binary_path(install_dir, host)
|
||||
if not node_bin.exists():
|
||||
return None
|
||||
try:
|
||||
return _run_node(install_dir, host, ["-v"], timeout = 30).lstrip("v")
|
||||
except Exception: # noqa: BLE001
|
||||
return None
|
||||
|
||||
|
||||
def installed_npm_major(install_dir: Path, host: HostInfo) -> int | None:
|
||||
cli = npm_cli_path(install_dir, host)
|
||||
if not cli.exists():
|
||||
return None
|
||||
try:
|
||||
out = _run_node(install_dir, host, [str(cli), "--version"], timeout = 60)
|
||||
return _version_tuple(out)[0]
|
||||
except Exception: # noqa: BLE001
|
||||
return None
|
||||
|
||||
|
||||
def metadata_path(install_dir: Path) -> Path:
|
||||
return install_dir / METADATA_FILENAME
|
||||
|
||||
|
||||
def write_metadata(install_dir: Path, *, version: str, asset: str, sha256: str) -> None:
|
||||
payload = {
|
||||
"schema_version": METADATA_SCHEMA_VERSION,
|
||||
"kind": "node",
|
||||
"version": version,
|
||||
"asset": asset,
|
||||
"sha256": sha256,
|
||||
}
|
||||
metadata_path(install_dir).write_text(json.dumps(payload, indent = 2) + "\n")
|
||||
|
||||
|
||||
def load_metadata(install_dir: Path) -> dict | None:
|
||||
path = metadata_path(install_dir)
|
||||
if not path.exists():
|
||||
return None
|
||||
try:
|
||||
data = json.loads(path.read_text())
|
||||
except (json.JSONDecodeError, OSError):
|
||||
return None
|
||||
return data if isinstance(data, dict) else None
|
||||
|
||||
|
||||
def existing_install_matches(install_dir: Path, host: HostInfo, *, version: str) -> bool:
|
||||
"""True iff the on-disk install is exactly this version and runs."""
|
||||
meta = load_metadata(install_dir)
|
||||
if not meta or meta.get("version") != version:
|
||||
return False
|
||||
if installed_node_version(install_dir, host) != version:
|
||||
return False
|
||||
npm_major = installed_npm_major(install_dir, host)
|
||||
return npm_major is not None and npm_major >= NPM_MIN_MAJOR
|
||||
|
||||
|
||||
def existing_install_usable(install_dir: Path, host: HostInfo) -> bool:
|
||||
"""True iff the on-disk install runs and clears the npm floor, ignoring version."""
|
||||
if not load_metadata(install_dir):
|
||||
return False
|
||||
if installed_node_version(install_dir, host) is None:
|
||||
return False
|
||||
npm_major = installed_npm_major(install_dir, host)
|
||||
return npm_major is not None and npm_major >= NPM_MIN_MAJOR
|
||||
|
||||
|
||||
def _swap_into_place(extracted_root: Path, install_dir: Path) -> None:
|
||||
"""Atomically replace install_dir with extracted_root (same filesystem)."""
|
||||
install_dir.parent.mkdir(parents = True, exist_ok = True)
|
||||
backup: Path | None = None
|
||||
if install_dir.exists():
|
||||
backup = install_dir.parent / f".{install_dir.name}.old-{os.getpid()}"
|
||||
os.replace(install_dir, backup)
|
||||
try:
|
||||
os.replace(extracted_root, install_dir)
|
||||
except OSError:
|
||||
if backup is not None and not install_dir.exists():
|
||||
os.replace(backup, install_dir)
|
||||
raise
|
||||
if backup is not None:
|
||||
shutil.rmtree(backup, ignore_errors = True)
|
||||
|
||||
|
||||
def _ensure_npm_floor(install_dir: Path, host: HostInfo) -> None:
|
||||
"""Self-upgrade npm inside the isolated prefix if a pinned build ships npm < 11 (no-op on Node 24+)."""
|
||||
npm_major = installed_npm_major(install_dir, host)
|
||||
if npm_major is not None and npm_major >= NPM_MIN_MAJOR:
|
||||
return
|
||||
log(f"bundled npm {npm_major} below {NPM_MIN_MAJOR}; upgrading npm inside the isolated prefix")
|
||||
cli = npm_cli_path(install_dir, host)
|
||||
_run_node(install_dir, host, [str(cli), "install", "-g", f"npm@^{NPM_MIN_MAJOR}"], timeout = 300)
|
||||
|
||||
|
||||
# ── Orchestration ──
|
||||
def install_prebuilt(install_dir: Path, *, channel: str, min_major: int, force: bool) -> int:
|
||||
host = detect_host()
|
||||
|
||||
if channel in {"lts", "latest"}:
|
||||
try:
|
||||
index = fetch_json(NODE_DIST_INDEX)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
# nodejs.org unreachable: keep a working isolated Node instead of aborting.
|
||||
if not force and existing_install_usable(install_dir, host):
|
||||
log(f"Node dist index unreachable ({exc}); keeping existing isolated Node")
|
||||
return EXIT_SUCCESS
|
||||
raise
|
||||
if not isinstance(index, list):
|
||||
raise PrebuiltFallback(f"unexpected index.json payload from {NODE_DIST_INDEX}")
|
||||
version = select_node_version(index, channel = channel, min_major = min_major)
|
||||
else:
|
||||
version = channel.lstrip("v")
|
||||
# Explicit version bypasses min_major; reject anything Vite/OXC cannot use.
|
||||
if not _meets_node_floor(version):
|
||||
raise PrebuiltFallback(
|
||||
f"requested Node v{version} is below the floor (^20.19 || >=22.12 || >=23)"
|
||||
)
|
||||
|
||||
asset = node_asset_name(version, host)
|
||||
log(f"target Node v{version} ({asset})")
|
||||
|
||||
if not force and existing_install_matches(install_dir, host, version = version):
|
||||
log(f"existing Node install already matches v{version}; nothing to do")
|
||||
return EXIT_SUCCESS
|
||||
|
||||
with install_lock(install_lock_path(install_dir)):
|
||||
# Re-check under the lock: a concurrent run may have just finished.
|
||||
if not force and existing_install_matches(install_dir, host, version = version):
|
||||
log(f"existing Node install already matches v{version}; nothing to do")
|
||||
return EXIT_SUCCESS
|
||||
|
||||
try:
|
||||
shasums = download_bytes(node_shasums_url(version), timeout = 30).decode("utf-8")
|
||||
expected_sha = expected_sha256_for(shasums, asset)
|
||||
if not expected_sha:
|
||||
raise PrebuiltFallback(f"no sha256 for {asset} in SHASUMS256.txt (v{version})")
|
||||
|
||||
staging_root = install_dir.parent / INSTALL_STAGING_ROOT_NAME
|
||||
staging_root.mkdir(parents = True, exist_ok = True)
|
||||
staging = Path(
|
||||
tempfile.mkdtemp(prefix = f"{install_dir.name}.staging-", dir = staging_root)
|
||||
)
|
||||
try:
|
||||
archive_path = staging / asset
|
||||
log(f"downloading {node_download_url(version, asset)}")
|
||||
download_file_verified(
|
||||
node_download_url(version, asset),
|
||||
archive_path,
|
||||
expected_sha256 = expected_sha,
|
||||
label = asset,
|
||||
)
|
||||
extract_dir = staging / "extracted"
|
||||
extract_archive(archive_path, extract_dir)
|
||||
|
||||
roots = [p for p in extract_dir.iterdir() if p.is_dir()]
|
||||
if len(roots) != 1:
|
||||
raise PrebuiltFallback(f"unexpected archive layout: {[p.name for p in roots]}")
|
||||
extracted_root = roots[0]
|
||||
|
||||
_ensure_npm_floor(extracted_root, host)
|
||||
write_metadata(extracted_root, version = version, asset = asset, sha256 = expected_sha)
|
||||
_swap_into_place(extracted_root, install_dir)
|
||||
finally:
|
||||
shutil.rmtree(staging, ignore_errors = True)
|
||||
try:
|
||||
staging_root.rmdir()
|
||||
except OSError:
|
||||
pass
|
||||
except Exception as exc: # noqa: BLE001
|
||||
# A newer Node exists upstream but the shasums/archive fetch failed;
|
||||
# keep an existing usable Node rather than aborting the update.
|
||||
if not force and existing_install_usable(install_dir, host):
|
||||
log(f"Node download failed ({exc}); keeping existing isolated Node")
|
||||
return EXIT_SUCCESS
|
||||
raise
|
||||
|
||||
final_version = installed_node_version(install_dir, host)
|
||||
npm_major = installed_npm_major(install_dir, host)
|
||||
if final_version != version or npm_major is None or npm_major < NPM_MIN_MAJOR:
|
||||
raise PrebuiltFallback(
|
||||
f"post-install verification failed: node={final_version} npm_major={npm_major}"
|
||||
)
|
||||
log(f"installed isolated Node v{final_version} (npm {npm_major}.x) at {install_dir}")
|
||||
return EXIT_SUCCESS
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
global _LOG_TO_STDOUT
|
||||
_LOG_TO_STDOUT = True
|
||||
|
||||
parser = argparse.ArgumentParser(description = "Install an isolated Node.js for Unsloth Studio")
|
||||
parser.add_argument(
|
||||
"--install-dir", required = True, help = "isolated Node directory, e.g. <UNSLOTH_HOME>/node"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--node-version",
|
||||
default = os.environ.get("UNSLOTH_NODE_VERSION", "lts"),
|
||||
help = "'lts' (default), 'latest', or an explicit version like 24.4.1",
|
||||
)
|
||||
parser.add_argument("--min-major", type = int, default = NODE_MIN_LTS_MAJOR)
|
||||
parser.add_argument(
|
||||
"--force", action = "store_true", help = "reinstall even if the version matches"
|
||||
)
|
||||
args = parser.parse_args(argv)
|
||||
|
||||
install_dir = Path(args.install_dir).expanduser().resolve()
|
||||
try:
|
||||
return install_prebuilt(
|
||||
install_dir,
|
||||
channel = args.node_version,
|
||||
min_major = args.min_major,
|
||||
force = args.force,
|
||||
)
|
||||
except BusyInstallConflict as exc:
|
||||
log(str(exc))
|
||||
return EXIT_BUSY
|
||||
except PrebuiltFallback as exc:
|
||||
log(f"prebuilt unavailable: {exc}")
|
||||
return EXIT_FALLBACK
|
||||
except Exception as exc: # noqa: BLE001
|
||||
log(f"unexpected error: {exc}")
|
||||
return EXIT_ERROR
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
232
studio/setup.ps1
232
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 <root>/node.
|
||||
$_legacyStudio = Join-Path $env:USERPROFILE ".unsloth\studio"
|
||||
if (Test-Path -LiteralPath $_legacyStudio -PathType Container) {
|
||||
$_legacyStudio = (Resolve-Path -LiteralPath $_legacyStudio).Path
|
||||
}
|
||||
if ($NodeParent -eq $_legacyStudio) {
|
||||
$NodeParent = Join-Path $env:USERPROFILE ".unsloth"
|
||||
$NodeOverride = $null
|
||||
}
|
||||
} else {
|
||||
$NodeParent = Join-Path $env:USERPROFILE ".unsloth"
|
||||
}
|
||||
$NodeDir = Join-Path $NodeParent "node"
|
||||
|
||||
# Probe system node/npm without letting a missing/broken command abort setup.
|
||||
# Under $ErrorActionPreference = "Stop" a bare `node -v` for an absent node
|
||||
# throws a terminating error `2>$null` cannot swallow, and a present-but-broken
|
||||
# shim throws too. Guard with Get-Command (node/npm independently) + try/catch;
|
||||
# empty version => Get-NodeDecision returns "bundled".
|
||||
$SysNodeVersion = try { if (Get-Command node -ErrorAction SilentlyContinue) { (node -v 2>$null) } else { "" } } catch { "" }
|
||||
$SysNpmVersion = try { if (Get-Command npm -ErrorAction SilentlyContinue) { (npm -v 2>$null) } else { "" } } catch { "" }
|
||||
$NodeSource = Get-NodeDecision -NodeVersion "$SysNodeVersion" -NpmVersion "$SysNpmVersion" -SkipInstall "$($env:UNSLOTH_SKIP_NODE_INSTALL)"
|
||||
}
|
||||
|
||||
if ($IsPipInstall) {
|
||||
step "frontend" "bundled (pip install)"
|
||||
} elseif ($SkipFrontend) {
|
||||
step "frontend" "bundled (Tauri)"
|
||||
} else {
|
||||
# setup.sh installs Node LTS (v22) via nvm. We enforce the same range here:
|
||||
# Vite 8 requires Node ^20.19.0 || >=22.12.0, npm >= 11.
|
||||
$NeedNode = $true
|
||||
try {
|
||||
$NodeVersion = (node -v 2>$null)
|
||||
$NpmVersion = (npm -v 2>$null)
|
||||
if ($NodeVersion -and $NpmVersion) {
|
||||
$NodeParts = ($NodeVersion -replace 'v','').Split('.')
|
||||
$NodeMajor = [int]$NodeParts[0]
|
||||
$NodeMinor = [int]$NodeParts[1]
|
||||
$NpmMajor = [int]$NpmVersion.Split('.')[0]
|
||||
|
||||
# Vite 8: ^20.19.0 || >=22.12.0
|
||||
$NodeOk = ($NodeMajor -eq 20 -and $NodeMinor -ge 19) -or
|
||||
($NodeMajor -eq 22 -and $NodeMinor -ge 12) -or
|
||||
($NodeMajor -ge 23)
|
||||
if ($NodeOk -and $NpmMajor -ge 11) {
|
||||
substep "Node $NodeVersion and npm $NpmVersion already meet requirements."
|
||||
$NeedNode = $false
|
||||
} else {
|
||||
substep "Node $NodeVersion / npm $NpmVersion too old." "Yellow"
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
substep "Node/npm not found." "Yellow"
|
||||
}
|
||||
|
||||
if ($NeedNode) {
|
||||
substep "installing Node.js LTS via winget..."
|
||||
try {
|
||||
winget install OpenJS.NodeJS.LTS --source winget --accept-package-agreements --accept-source-agreements
|
||||
Refresh-Environment
|
||||
} catch {
|
||||
Write-Host "[ERROR] Could not install Node.js automatically." -ForegroundColor Red
|
||||
Write-Host "Please install Node.js >= 20 from https://nodejs.org/" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
}
|
||||
|
||||
step "node" "$(node -v) | npm $(npm -v)"
|
||||
|
||||
# ── bun (optional, faster package installs) ──
|
||||
# Installed via npm — Node is already guaranteed above. Works on all platforms.
|
||||
if (-not (Get-Command bun -ErrorAction SilentlyContinue)) {
|
||||
substep "installing bun (faster frontend package installs)..."
|
||||
$prevEAP_bun = $ErrorActionPreference
|
||||
$ErrorActionPreference = "Continue"
|
||||
# --allow-scripts=bun: npm >=11.16 gates install scripts and bun's
|
||||
# postinstall fetches its binary; without it the install is a broken stub.
|
||||
Invoke-SetupCommand { npm install -g bun --allow-scripts=bun } | Out-Null
|
||||
$ErrorActionPreference = $prevEAP_bun
|
||||
Refresh-Environment
|
||||
if (Get-Command bun -ErrorAction SilentlyContinue) {
|
||||
substep "bun installed ($(bun --version))"
|
||||
} else {
|
||||
substep "bun install skipped (npm will be used instead)"
|
||||
}
|
||||
# Stale npm used to trigger system Node changes. Keep this process-local
|
||||
# and provision only when the build or OXC needs Node.
|
||||
if ($NodeSource -eq "system") {
|
||||
substep "Node $SysNodeVersion and npm $SysNpmVersion already meet requirements (system)."
|
||||
} elseif ($NodeSource -eq "bundled") {
|
||||
substep "Node='$SysNodeVersion' npm='$SysNpmVersion' unsuitable; will use an isolated Node (system left untouched)."
|
||||
} else {
|
||||
substep "bun already installed ($(bun --version))"
|
||||
substep "Node='$SysNodeVersion' npm='$SysNpmVersion' unsuitable and UNSLOTH_SKIP_NODE_INSTALL set; frontend build will be skipped." "Yellow"
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1630,12 +1652,14 @@ function Add-PythonDirToProcessPath {
|
|||
}
|
||||
|
||||
# Reuse the install.ps1 / venv interpreter before any system probe.
|
||||
$ValidatedSetupPython = $null
|
||||
if ($ReusedSetupPython) {
|
||||
$_reusedVer = Get-CompatiblePythonVersion $ReusedSetupPython
|
||||
if ($_reusedVer -and -not (Test-IsConda $ReusedSetupPython)) {
|
||||
$DetectedPyVer = $_reusedVer
|
||||
Add-PythonDirToProcessPath $ReusedSetupPython
|
||||
$PythonOk = $true
|
||||
$ValidatedSetupPython = $ReusedSetupPython
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1766,6 +1790,86 @@ if ($IsPipInstall) {
|
|||
substep "Frontend source changed since last build -- rebuilding..." "Yellow"
|
||||
}
|
||||
}
|
||||
|
||||
# Provision Node when the frontend build OR the OXC runtime install needs it (the
|
||||
# OXC `npm install` runs whenever its dir exists, regardless of dist staleness);
|
||||
# never eagerly. System Node is used read-only; the isolated one is ours.
|
||||
$NeedNodeForSetup = (-not $IsPipInstall) -and ($NeedFrontendBuild -or (Test-Path $OxcValidatorDir))
|
||||
if ($NeedNodeForSetup) {
|
||||
if ($NodeSource -eq "skip") {
|
||||
if ($NeedFrontendBuild) {
|
||||
step "frontend" "skipped (no suitable Node; system left untouched)" "Yellow"
|
||||
}
|
||||
$NeedFrontendBuild = $false
|
||||
substep "found Node='$SysNodeVersion' npm='$SysNpmVersion'; Studio needs Node >=20.19/22.12/23 and npm >= 11" "Yellow"
|
||||
substep "install a suitable Node + npm, or unset UNSLOTH_SKIP_NODE_INSTALL to let Unsloth manage an isolated Node" "Yellow"
|
||||
} elseif ($NodeSource -eq "bundled") {
|
||||
New-Item -ItemType Directory -Force -Path $NodeParent -ErrorAction SilentlyContinue | Out-Null
|
||||
# Minimal ownership guard for a custom-home dir (the full Studio-owned
|
||||
# helpers are defined later); never os.replace over a user-owned dir.
|
||||
if ($NodeOverride -and (Test-Path -LiteralPath $NodeDir -PathType Container)) {
|
||||
$nodeOwnedMarker = Join-Path $NodeDir ".unsloth-studio-owned"
|
||||
$nodeMeta = Join-Path $NodeDir "UNSLOTH_NODE_PREBUILT_INFO.json"
|
||||
if (-not (Test-Path -LiteralPath $nodeOwnedMarker) -and -not (Test-Path -LiteralPath $nodeMeta)) {
|
||||
Write-Host "[ERROR] $NodeDir already exists and is not a Studio-owned Node install." -ForegroundColor Red
|
||||
Write-Host " Move it aside or choose an empty UNSLOTH_STUDIO_HOME before re-running." -ForegroundColor Yellow
|
||||
exit 1
|
||||
}
|
||||
}
|
||||
substep "installing isolated Node (system Node/npm left untouched)..."
|
||||
# The main Python resolver runs later; bare `python` may be a Store stub or
|
||||
# absent this early, so prefer the validated handed-off/venv Python.
|
||||
$NodeInstallPython = if ($ValidatedSetupPython) { $ValidatedSetupPython } else { "python" }
|
||||
$nodeOut = & $NodeInstallPython "$PSScriptRoot\install_node_prebuilt.py" --install-dir $NodeDir 2>&1 | Out-String
|
||||
$nodeExit = $LASTEXITCODE
|
||||
if ($nodeExit -eq 3) {
|
||||
Write-Host $nodeOut -ForegroundColor DarkGray
|
||||
step "node" "install blocked by another active Studio install" "Red"
|
||||
exit 3
|
||||
} elseif ($nodeExit -ne 0) {
|
||||
Write-Host $nodeOut -ForegroundColor DarkGray
|
||||
Write-Host "[ERROR] Could not install an isolated Node automatically." -ForegroundColor Red
|
||||
Write-Host " Install Node >= 20.19 (with npm >= 11) from https://nodejs.org/ and re-run, or check your network." -ForegroundColor Yellow
|
||||
exit 1
|
||||
}
|
||||
if ($NodeOverride -and (Test-Path -LiteralPath $NodeDir -PathType Container)) {
|
||||
New-Item -ItemType File -Force -Path (Join-Path $NodeDir ".unsloth-studio-owned") -ErrorAction SilentlyContinue | Out-Null
|
||||
}
|
||||
# Windows Node zip ships node.exe + npm.cmd at the root; prepend it (this
|
||||
# process only) so node/npm/bun resolve here for the build.
|
||||
$env:PATH = "$NodeDir;" + $env:PATH
|
||||
# Keep npm and module resolution inside the isolated Node.
|
||||
$env:NPM_CONFIG_PREFIX = $NodeDir
|
||||
$env:npm_config_prefix = $NodeDir
|
||||
Remove-Item Env:NODE_PATH -ErrorAction SilentlyContinue
|
||||
step "node" "$(node -v) | npm $(npm -v) (isolated)"
|
||||
|
||||
# bun (optional, faster installs); npm -g stays in the isolated prefix.
|
||||
if (-not (Get-Command bun -ErrorAction SilentlyContinue)) {
|
||||
substep "installing bun (faster frontend package installs)..."
|
||||
$prevEAP_bun = $ErrorActionPreference
|
||||
$ErrorActionPreference = "Continue"
|
||||
Invoke-SetupCommand { npm install -g bun --allow-scripts=bun } | Out-Null
|
||||
$ErrorActionPreference = $prevEAP_bun
|
||||
Refresh-Environment
|
||||
# Refresh-Environment rebuilds PATH (Machine;User;current), demoting the
|
||||
# isolated-Node prepend; re-prepend so it wins for the build and OXC step.
|
||||
$env:PATH = "$NodeDir;" + $env:PATH
|
||||
$env:NPM_CONFIG_PREFIX = $NodeDir
|
||||
$env:npm_config_prefix = $NodeDir
|
||||
Remove-Item Env:NODE_PATH -ErrorAction SilentlyContinue
|
||||
if (Get-Command bun -ErrorAction SilentlyContinue) {
|
||||
substep "bun installed ($(bun --version))"
|
||||
} else {
|
||||
substep "bun install skipped (npm will be used instead)"
|
||||
}
|
||||
}
|
||||
} else {
|
||||
# system Node already satisfies requirements; use it as-is. We do NOT
|
||||
# install global packages (bun) here -- the build falls back to npm.
|
||||
step "node" "$SysNodeVersion | npm $SysNpmVersion (system)"
|
||||
}
|
||||
}
|
||||
if ($NeedFrontendBuild -and -not $IsPipInstall) {
|
||||
Write-Host ""
|
||||
substep "building frontend..."
|
||||
|
|
@ -1876,7 +1980,7 @@ if ($NeedFrontendBuild -and -not $IsPipInstall) {
|
|||
}
|
||||
}
|
||||
|
||||
if (Test-Path $OxcValidatorDir) {
|
||||
if ((Test-Path $OxcValidatorDir) -and $NodeSource -ne "skip" -and (Get-Command npm -ErrorAction SilentlyContinue)) {
|
||||
substep "installing OXC validator runtime..."
|
||||
$prevEAP_oxc = $ErrorActionPreference
|
||||
$ErrorActionPreference = "Continue"
|
||||
|
|
@ -1891,6 +1995,10 @@ if (Test-Path $OxcValidatorDir) {
|
|||
Pop-Location
|
||||
$ErrorActionPreference = $prevEAP_oxc
|
||||
step "oxc runtime" "installed"
|
||||
} elseif ((Test-Path $OxcValidatorDir) -and $NodeSource -ne "skip") {
|
||||
# No npm on PATH (e.g. a pip install with no system Node and no isolated Node
|
||||
# provisioned). Skip rather than abort; the runtime resolver degrades. Mirrors setup.sh.
|
||||
substep "OXC validator runtime skipped (no npm found); code validation degrades until Node is available" "Yellow"
|
||||
}
|
||||
|
||||
# ==========================================================================
|
||||
|
|
|
|||
198
studio/setup.sh
198
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 ──
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue