Installer: stop requiring a developer toolchain on the consumer path (#7547)
* Installer: stop requiring a developer toolchain on the consumer path A brand new Mac cannot install Studio at all. install.sh gates on `xcode-select -p` and exits 1 with 'Xcode Command Line Tools are required', and Linux exits 1 on any non-apt distro over cmake/gcc/git/libcurl headers. Nothing under either gate needs a toolchain. uv is a prebuilt binary, CPython comes from uv's managed python-build-standalone, llama.cpp and whisper.cpp are prebuilt downloads, Node is a pinned nodejs.org archive, and triton is skipped on macOS. unslothai/llama.cpp b10107-mix-1911198 publishes macos-arm64, macos-x64, linux-x64 and linux-arm64 builds covering cpu, cuda12, cuda13, rocm and vulkan. PR #6617 already dropped the Homebrew/cmake stop on macOS for this reason and just left the CLT stop behind. macOS: warn and continue when the CLT are absent. Linux: only a download transport (curl or wget) is fatal; build tooling warns. Both keep a hard git requirement for --local, which installs unsloth-zoo from a git+https URL. Both gates move into functions so tests/sh can extract them. The old inline form could not be reached by the tests/sh convention, which is why this shipped broken and stayed broken. test_macos_clt_gate.sh (19 assertions) and test_linux_deps_gate.sh (25) cover the clean machine, the CLT-stub shape where /usr/bin/git exists but fails, the non-apt distro, and the --local paths. Writing the Linux test caught a latent bug: the gate trimmed its list with $(echo ... | sed ...), so on a minimal image without sed the substitution yields empty and it reports 'all system dependencies found' on a machine with none of them. Replaced with parameter expansion. Also caps av<16 in the single-env constraints. av 16+ ships no cp313 macOS arm64 wheel, and it is a C extension over FFmpeg, so uv would silently fall back to a source build needing both a compiler and FFmpeg headers. Verified on GitHub-hosted macOS runners with /var/db/xcode_select_link, /Library/Developer/CommandLineTools, /Applications/Xcode*.app and Homebrew moved aside. macos-14, macos-15 and macos-26 fail on main and install cleanly with this; the recorded tool-invocation trace for the whole install is a single `xcode-select -p`, so nothing compiled and nothing installed a toolchain. * Linux: auto-install git rather than dropping it, and skip triton kernels without it Making git optional on Linux was too broad. studio/backend/requirements/ triton-kernels.txt line 2 is a git+https URL, so step 6/14 died with 'Cannot find command git' and failed the whole setup on ubuntu2404-root, ubuntu2404-arm-root and fedora41, all of which had been passing. The claim that nothing on the consumer path needs git holds on macOS, where triton is skipped, but not here. install.sh now auto-installs git through apt with the other optional tooling, so Debian and Ubuntu are unchanged. The triton kernels step skips with a message when git is absent instead of failing: they are a training speedup, not a boot requirement, and a GGUF chat install has no use for them. Six more assertions pin both halves. * macOS Intel: skip the one package with no x86_64 wheel The Intel clean-machine leg installed with the toolchain masked, then died in studio setup: subprocess.CalledProcessError: Command '['cmake', ...]' returned non-zero ERROR: Failed building wheel for pytorch_tokenizers pytorch_tokenizers publishes wheels for macOS arm64, linux x86_64, linux aarch64 and windows, but none for macOS x86_64 at any Python version, so uv falls back to an sdist that shells out to cmake. Nothing passes --only-binary, so the compiler-free property was an assumption rather than a contract, and Intel is where it broke. Marked so it installs everywhere except Intel macOS. Apple Silicon is unaffected. * Stop the optional dep gate from aborting the install _smart_apt_install exits rather than returns, and `|| true` does not catch an exit, so a box missing cmake or git aborted at the gate added to let it continue. Verified in sh, dash and bash. Run it in a subshell and re-raise only code 2, the NEED_SUDO handshake install.rs answers with an elevation prompt. install.sh treats a present-but-broken git as missing, but the Python side tested only shutil.which, so it promised to skip the git+https triton requirement and then fetched it anyway. Same check on both sides now. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Never elevate for optional build tools Re-raising code 2 turned the optional set into a NEED_SUDO handshake, so a box missing cmake or gcc got the desktop's mandatory permission dialog, whose Cancel drops back to not-installed. That re-imposes through a prompt the build-tool requirement this gate removes, and none of those tools are needed to run. Suppress the handshake for optional callers; a required package still elevates. Verified in sh, dash and bash. Also advance the progress bar on the no-git triton skip, which otherwise ends at 14/15. * Tighten the comments on the dependency gate * Correct why the PyAV cap is needed 16.0.0 does ship cp313-cp313-macosx_14_0_arm64; the comment claimed no cp313 wheel exists. The actual reason is the deployment target: 15.1.0 is macosx_13_0 and 16+ is macosx_14_0, so the cap is what keeps macOS 13 off a source build. * Tighten the installer gate comments * Cap cryptography on x86_64 macOS so the consumer install needs no Rust cryptography 49.0.0 (2026-06-12) dropped the macosx_10_9_universal2 wheel and now ships macosx_11_0_arm64 only, so x86_64 macOS has no wheel and uv falls back to the sdist. That build calls maturin, which pulls Rust and then fails at 'linking with cc failed' on a clean Mac without the Xcode Command Line Tools. It surfaced in the clean-machine leg mac macos-15-intel / mask / file, several minutes into the studio dependency step, which is exactly the up-front toolchain requirement this branch removes. 48.0.1 is the newest release carrying a universal2 wheel, and its cp39-abi3 / cp311-abi3 tags cover the 3.12 and 3.13 interpreters the installer creates. The cap is marker-scoped to darwin + x86_64, so arm64 macOS and every other platform still resolve to the latest. Lift it when cryptography ships an x86_64-capable macOS wheel again. Resolution of studio/backend/requirements/studio.txt under this constraints file gives 48.0.1 on x86_64-apple-darwin and 49.0.0 on aarch64-apple-darwin and x86_64-unknown-linux-gnu, on both 3.12 and 3.13. * Correct the av note now that cryptography also compiles on macOS * Never escalate for optional apt packages outside Tauri mode The optional bypass sat inside the TAURI_MODE branch, so a plain curl | sh install on a non-root Debian or Ubuntu box still fell through to the escalation branch and showed the default-yes permission prompt for cmake, GCC and the libcurl headers. That is exactly the toolchain this change set declared unnecessary on the consumer path, so the prompt asked for a password to install packages nothing here uses, and a headless run failed the same way instead of falling through to prebuilt llama.cpp. Move the check above the mode split so optional callers return 2 in both modes. Required packages such as curl still escalate unchanged. --------- Co-authored-by: danielhanchen <unslothai@gmail.com> Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
This commit is contained in:
parent
9e2fc49851
commit
df63522369
6 changed files with 573 additions and 66 deletions
196
install.sh
196
install.sh
|
|
@ -800,8 +800,17 @@ _smart_apt_install() {
|
|||
return 0
|
||||
fi
|
||||
|
||||
# In Tauri mode, report needed packages and exit — Rust handles elevation
|
||||
# Optional callers never elevate, in any mode: nothing on the consumer path
|
||||
# builds anything, so neither the terminal sudo prompt below nor the Tauri
|
||||
# NEED_SUDO dialog (whose Cancel leaves the user not installed) may gate the
|
||||
# run over unused tools. The caller falls through to prebuilt llama.cpp.
|
||||
# Required packages such as curl still escalate.
|
||||
if [ "${_SMART_APT_OPTIONAL:-false}" = true ]; then
|
||||
return 2
|
||||
fi
|
||||
|
||||
if [ "$TAURI_MODE" = true ]; then
|
||||
# Report needed packages and exit — Rust handles elevation.
|
||||
tauri_log "NEED_SUDO" "$_STILL_MISSING"
|
||||
exit 2
|
||||
fi
|
||||
|
|
@ -1998,67 +2007,142 @@ _maybe_reroute_strixhalo_to_2404() {
|
|||
_maybe_reroute_strixhalo_to_2404 || true
|
||||
|
||||
# ── Check system dependencies ──
|
||||
# cmake/git are only needed to *build* llama.cpp from source. Unsloth downloads a
|
||||
# prebuilt by default, and setup.sh self-skips the source build when they're
|
||||
# absent -- so macOS doesn't block on cmake (requiring it would force a manual
|
||||
# Homebrew install). Linux keeps requiring them; its package manager has them.
|
||||
tauri_log "STEP" "Checking system dependencies"
|
||||
|
||||
# Without the Xcode CLT, macOS still ships /usr/bin/git as a stub that errors and pops
|
||||
# a GUI dialog, so `command -v git` is not enough -- only running it tells the truth.
|
||||
_has_working_git() {
|
||||
command -v git >/dev/null 2>&1 || return 1
|
||||
git --version >/dev/null 2>&1
|
||||
}
|
||||
|
||||
# macOS system-dependency check. A function so tests/sh can sed-extract it; the old
|
||||
# inline form was untestable, which is why this gate shipped broken.
|
||||
#
|
||||
# The consumer install needs no developer toolchain: uv is a prebuilt binary, CPython
|
||||
# is uv-managed, llama.cpp/whisper.cpp/Node are prebuilt downloads, and triton is
|
||||
# skipped on macOS. Only `--local` needs git, for the unsloth-zoo git+https URL.
|
||||
_check_macos_deps() {
|
||||
_clt_missing=false
|
||||
xcode-select -p >/dev/null 2>&1 || _clt_missing=true
|
||||
|
||||
if [ "$STUDIO_LOCAL_INSTALL" = true ] && ! _has_working_git; then
|
||||
echo ""
|
||||
step "deps" "git is required for --local installs" "$C_ERR"
|
||||
substep "--local installs unsloth-zoo from git+https://github.com/unslothai/unsloth-zoo,"
|
||||
substep "which needs a working git. Install the Xcode Command Line Tools:"
|
||||
substep " xcode-select --install"
|
||||
substep "Then re-run this script. A normal (non---local) install needs no compiler"
|
||||
substep "and no git -- it uses prebuilt binaries and wheels only."
|
||||
tauri_log "NEED_XCODE_CLT" "git"
|
||||
return 1
|
||||
fi
|
||||
|
||||
if [ "$_clt_missing" = true ]; then
|
||||
# Not fatal, and no GUI dialog: firing xcode-select --install and exiting is
|
||||
# what stranded clean Macs.
|
||||
step "deps" "no Xcode Command Line Tools (not required)" "$C_WARN"
|
||||
substep "Unsloth installs prebuilt binaries and wheels, so no compiler is needed."
|
||||
substep "Install them only for a llama.cpp source build: xcode-select --install"
|
||||
elif command -v cmake >/dev/null 2>&1; then
|
||||
step "deps" "all system dependencies found"
|
||||
else
|
||||
# cmake is only for a source build, so its absence is not fatal.
|
||||
step "deps" "using prebuilt llama.cpp (cmake not found)" "$C_WARN"
|
||||
substep "Install cmake only if you want a source build: brew install cmake"
|
||||
fi
|
||||
return 0
|
||||
}
|
||||
|
||||
# Linux/WSL system-dependency check. Same split as macOS, and a function for the same
|
||||
# reason: tests/sh can extract it.
|
||||
#
|
||||
# Only a download transport is required. cmake, gcc and the libcurl headers exist
|
||||
# solely for a llama.cpp source build the consumer path never does -- unslothai/
|
||||
# llama.cpp publishes linux-x64/arm64 prebuilts for cpu, cuda12, cuda13, rocm and
|
||||
# vulkan. Requiring them turned every non-apt distro into a hard exit 1 over unused
|
||||
# tooling. git follows macOS: --local only.
|
||||
_check_linux_deps() {
|
||||
_transport_missing=false
|
||||
if ! command -v curl >/dev/null 2>&1 && ! command -v wget >/dev/null 2>&1; then
|
||||
_transport_missing=true
|
||||
fi
|
||||
|
||||
# Wanted, never required: git fetches the triton_kernels git+https requirement (a
|
||||
# training speedup), the rest serve the optional source build. Warn, never stop.
|
||||
_optional_missing=""
|
||||
command -v cmake >/dev/null 2>&1 || _optional_missing="$_optional_missing cmake"
|
||||
_has_working_git || _optional_missing="$_optional_missing git"
|
||||
command -v gcc >/dev/null 2>&1 || _optional_missing="$_optional_missing build-essential"
|
||||
command -v curl-config >/dev/null 2>&1 || _optional_missing="$_optional_missing libcurl4-openssl-dev"
|
||||
# Parameter expansion, not `sed`: sed may be absent on a minimal image, and a
|
||||
# failed `$(... | sed ...)` yields "" -- "all found" on a machine that has none.
|
||||
_optional_missing="${_optional_missing# }"
|
||||
|
||||
if [ "$STUDIO_LOCAL_INSTALL" = true ] && ! _has_working_git; then
|
||||
echo ""
|
||||
step "deps" "git is required for --local installs" "$C_ERR"
|
||||
substep "--local installs unsloth-zoo from git+https://github.com/unslothai/unsloth-zoo,"
|
||||
substep "which needs git. Install it with your package manager, then re-run."
|
||||
substep "A normal (non---local) install needs no git and no compiler."
|
||||
return 1
|
||||
fi
|
||||
|
||||
# The one fatal case: nothing can be downloaded. apt is the only distro family we
|
||||
# can drive unattended.
|
||||
if [ "$_transport_missing" = true ]; then
|
||||
if command -v apt-get >/dev/null 2>&1; then
|
||||
echo ""
|
||||
step "deps" "missing: curl" "$C_WARN"
|
||||
substep "Needed to download uv, Python and the prebuilt inference engine."
|
||||
_smart_apt_install curl
|
||||
echo ""
|
||||
else
|
||||
echo ""
|
||||
step "deps" "missing: curl (or wget)" "$C_ERR"
|
||||
substep "Unsloth needs one of them to download uv, Python and the prebuilt"
|
||||
substep "inference engine. Install one, then re-run setup:"
|
||||
substep " Fedora/RHEL: sudo dnf install curl"
|
||||
substep " Arch: sudo pacman -S --needed curl"
|
||||
substep " openSUSE: sudo zypper install curl"
|
||||
return 1
|
||||
fi
|
||||
fi
|
||||
|
||||
# Try apt for the optional set too; failing only costs the features warned about
|
||||
# below.
|
||||
if [ -n "$_optional_missing" ] && command -v apt-get >/dev/null 2>&1; then
|
||||
step "deps" "installing optional build tools: $_optional_missing" "$C_DIM"
|
||||
# Subshell because _smart_apt_install exits rather than returns, so `|| true`
|
||||
# alone would not catch it. _SMART_APT_OPTIONAL suppresses every escalation
|
||||
# path, so no install hinges on a prompt for tools nothing here needs.
|
||||
( _SMART_APT_OPTIONAL=true; _smart_apt_install $_optional_missing ) || true
|
||||
_optional_missing=""
|
||||
command -v cmake >/dev/null 2>&1 || _optional_missing="$_optional_missing cmake"
|
||||
_has_working_git || _optional_missing="$_optional_missing git"
|
||||
command -v gcc >/dev/null 2>&1 || _optional_missing="$_optional_missing build-essential"
|
||||
command -v curl-config >/dev/null 2>&1 || _optional_missing="$_optional_missing libcurl4-openssl-dev"
|
||||
_optional_missing="${_optional_missing# }"
|
||||
fi
|
||||
|
||||
if [ -n "$_optional_missing" ]; then
|
||||
step "deps" "using prebuilt llama.cpp (missing: $_optional_missing)" "$C_WARN"
|
||||
substep "Not required to run: Unsloth downloads a prebuilt inference engine."
|
||||
case " $_optional_missing " in
|
||||
*" git "*) substep "Without git the triton kernels training speedup is skipped." ;;
|
||||
esac
|
||||
else
|
||||
step "deps" "all system dependencies found"
|
||||
fi
|
||||
return 0
|
||||
}
|
||||
|
||||
case "$OS" in
|
||||
macos)
|
||||
# Xcode Command Line Tools provide the C/C++ compiler and git.
|
||||
if ! xcode-select -p >/dev/null 2>&1; then
|
||||
echo ""
|
||||
echo "==> Xcode Command Line Tools are required."
|
||||
echo " Installing (a system dialog will appear)..."
|
||||
xcode-select --install </dev/null 2>/dev/null || true
|
||||
echo " After the installation completes, please re-run this script."
|
||||
exit 1
|
||||
fi
|
||||
# cmake is only needed for a source build; the default prebuilt path
|
||||
# doesn't use it, so its absence is not fatal -- no Homebrew prerequisite.
|
||||
if command -v cmake >/dev/null 2>&1; then
|
||||
step "deps" "all system dependencies found"
|
||||
else
|
||||
step "deps" "using prebuilt llama.cpp (cmake not found)" "$C_WARN"
|
||||
substep "Install cmake only if you want a source build: brew install cmake"
|
||||
fi
|
||||
_check_macos_deps || exit 1
|
||||
;;
|
||||
linux|wsl)
|
||||
MISSING=""
|
||||
command -v cmake >/dev/null 2>&1 || MISSING="$MISSING cmake"
|
||||
command -v git >/dev/null 2>&1 || MISSING="$MISSING git"
|
||||
# curl or wget is needed for downloads; check both
|
||||
if ! command -v curl >/dev/null 2>&1 && ! command -v wget >/dev/null 2>&1; then
|
||||
MISSING="$MISSING curl"
|
||||
fi
|
||||
command -v gcc >/dev/null 2>&1 || MISSING="$MISSING build-essential"
|
||||
# libcurl dev headers for llama.cpp HTTPS support
|
||||
command -v curl-config >/dev/null 2>&1 || MISSING="$MISSING libcurl4-openssl-dev"
|
||||
|
||||
MISSING=$(echo "$MISSING" | sed 's/^ *//')
|
||||
if [ -n "$MISSING" ]; then
|
||||
echo ""
|
||||
step "deps" "missing: $MISSING" "$C_WARN"
|
||||
substep "These are needed to build the GGUF inference engine."
|
||||
if command -v apt-get >/dev/null 2>&1; then
|
||||
_smart_apt_install $MISSING
|
||||
else
|
||||
echo " Automatic system package installation is supported on apt-based"
|
||||
echo " Linux distributions (Ubuntu/Debian) only. Please install the"
|
||||
echo " missing dependencies with your package manager, then re-run setup:"
|
||||
echo " $MISSING"
|
||||
echo ""
|
||||
echo " Examples:"
|
||||
echo " Fedora/RHEL: sudo dnf install cmake git gcc gcc-c++ make libcurl-devel"
|
||||
echo " Arch: sudo pacman -S --needed cmake git base-devel curl"
|
||||
echo " openSUSE: sudo zypper install cmake git gcc gcc-c++ make libcurl-devel"
|
||||
exit 1
|
||||
fi
|
||||
echo ""
|
||||
else
|
||||
step "deps" "all system dependencies found"
|
||||
fi
|
||||
_check_linux_deps || exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
|
|
|
|||
|
|
@ -15,7 +15,9 @@ trl==0.23.1
|
|||
torch-c-dlpack-ext
|
||||
sentence_transformers==5.2.0
|
||||
transformers==4.57.6
|
||||
pytorch_tokenizers
|
||||
# No macOS x86_64 wheel at any version, so uv falls back to an sdist that shells out to
|
||||
# cmake. Skipping it on Intel Macs keeps that install compiler-free.
|
||||
pytorch_tokenizers; sys_platform != "darwin" or platform_machine == "arm64"
|
||||
kernels==0.12.1
|
||||
# kernels<3.11 imports tomli as its tomllib fallback; --no-deps skips its own
|
||||
# marker dep, so list it here (no-op on the 3.12/3.13 default installs).
|
||||
|
|
|
|||
|
|
@ -21,3 +21,20 @@ websockets>=15.0.1
|
|||
anyio<4.14.0
|
||||
|
||||
pandas==2.3.3
|
||||
|
||||
# av (PyAV) 16+ builds its macOS arm64 wheels against macosx_14_0, so on macOS 13 none
|
||||
# are installable and the resolver falls back to a source build, which needs FFmpeg
|
||||
# headers the Xcode CLT do not supply and so fails however that Mac is equipped.
|
||||
# 15.1.0 is the newest release with a macosx_13_0 arm64 wheel; 17+ moves to cp311-abi3
|
||||
# at macosx_14_0 too.
|
||||
#
|
||||
# The remaining sdist-only macOS defaults are pure Python, hence allowlisted in
|
||||
# .github/scripts/clean-machine-assert.sh instead; cryptography below is the one
|
||||
# other package that would compile.
|
||||
av<16
|
||||
|
||||
# cryptography 49.0.0 dropped the macosx_10_9_universal2 wheel for arm64-only, so
|
||||
# x86_64 macOS has no wheel and builds the sdist, needing Rust plus a working
|
||||
# linker. 48.0.1 is the newest release with a universal2 wheel. Lift when
|
||||
# cryptography ships an x86_64-capable macOS wheel again.
|
||||
cryptography<49; sys_platform == "darwin" and platform_machine == "x86_64"
|
||||
|
|
|
|||
|
|
@ -2892,6 +2892,30 @@ def patch_package_file(package_name: str, relative_path: str, url: str) -> None:
|
|||
# -- Main install sequence ---------------------------------------------
|
||||
|
||||
|
||||
def _has_working_git() -> bool:
|
||||
"""Match install.sh's _has_working_git: on PATH *and* actually runnable.
|
||||
|
||||
A present-but-broken git (a bare xcrun shim) counts as missing there too. Testing
|
||||
only shutil.which disagreed, so the installer promised to skip the git+https triton
|
||||
requirement and then tried to fetch it anyway.
|
||||
"""
|
||||
exe = shutil.which("git")
|
||||
if exe is None:
|
||||
return False
|
||||
try:
|
||||
return (
|
||||
subprocess.run(
|
||||
[exe, "--version"],
|
||||
stdout = subprocess.DEVNULL,
|
||||
stderr = subprocess.DEVNULL,
|
||||
timeout = 30,
|
||||
).returncode
|
||||
== 0
|
||||
)
|
||||
except (OSError, subprocess.SubprocessError):
|
||||
return False
|
||||
|
||||
|
||||
def install_python_stack() -> int:
|
||||
global USE_UV, _STEP, _TOTAL
|
||||
_STEP = 0
|
||||
|
|
@ -3197,17 +3221,22 @@ def install_python_stack() -> int:
|
|||
_torchao_spec,
|
||||
)
|
||||
|
||||
# 5. Triton kernels (no-deps, from source). Skip on Windows and macOS
|
||||
# (no support).
|
||||
# 5. Triton kernels (no-deps, from source). Skipped on Windows/macOS (no support)
|
||||
# and without git (the requirement is a git+https URL); a training speedup
|
||||
# only, so warn rather than fail the install.
|
||||
if not IS_WINDOWS and not IS_MACOS:
|
||||
_progress("triton kernels")
|
||||
pip_install(
|
||||
"Installing triton kernels",
|
||||
"--no-deps",
|
||||
"--no-cache-dir",
|
||||
req = REQ_ROOT / "triton-kernels.txt",
|
||||
constrain = False,
|
||||
)
|
||||
if not _has_working_git():
|
||||
_progress("triton kernels (skipped, no git)")
|
||||
_safe_print(" no working git -- skipping triton kernels (training speedup only)")
|
||||
else:
|
||||
_progress("triton kernels")
|
||||
pip_install(
|
||||
"Installing triton kernels",
|
||||
"--no-deps",
|
||||
"--no-cache-dir",
|
||||
req = REQ_ROOT / "triton-kernels.txt",
|
||||
constrain = False,
|
||||
)
|
||||
|
||||
if not IS_WINDOWS and not IS_MACOS and not NO_TORCH:
|
||||
_progress("flash-attn")
|
||||
|
|
|
|||
210
tests/sh/test_linux_deps_gate.sh
Executable file
210
tests/sh/test_linux_deps_gate.sh
Executable file
|
|
@ -0,0 +1,210 @@
|
|||
#!/bin/bash
|
||||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
|
||||
#
|
||||
# Guards the Linux/WSL system-dependency gate in install.sh.
|
||||
#
|
||||
# History: the gate hard-required cmake, git, gcc and libcurl4-openssl-dev, installing
|
||||
# them on apt distros and `exit 1`-ing everywhere else. Nothing on the consumer path
|
||||
# builds anything, so it stranded every non-apt distro over unused tooling.
|
||||
#
|
||||
# The contract now: only a download transport (curl or wget) is fatal, build tooling
|
||||
# is a warning, and git is required for --local only (unsloth-zoo git+https URL).
|
||||
set -e
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
INSTALL_SH="$SCRIPT_DIR/../../install.sh"
|
||||
PASS=0
|
||||
FAIL=0
|
||||
|
||||
assert_contains() {
|
||||
_label="$1"; _haystack="$2"; _needle="$3"
|
||||
if echo "$_haystack" | grep -qF "$_needle"; then
|
||||
echo " PASS: $_label"
|
||||
PASS=$((PASS + 1))
|
||||
else
|
||||
echo " FAIL: $_label (expected to find '$_needle')"
|
||||
echo " ---- output ----"; echo "$_haystack" | sed 's/^/ | /'
|
||||
FAIL=$((FAIL + 1))
|
||||
fi
|
||||
}
|
||||
|
||||
assert_not_contains() {
|
||||
_label="$1"; _haystack="$2"; _needle="$3"
|
||||
if echo "$_haystack" | grep -qF "$_needle"; then
|
||||
echo " FAIL: $_label (found '$_needle' but should not)"
|
||||
FAIL=$((FAIL + 1))
|
||||
else
|
||||
echo " PASS: $_label"
|
||||
PASS=$((PASS + 1))
|
||||
fi
|
||||
}
|
||||
|
||||
# ── Extract the functions under test ──
|
||||
_FN_FILE=$(mktemp)
|
||||
sed -n '/^_has_working_git()/,/^}/p' "$INSTALL_SH" > "$_FN_FILE"
|
||||
sed -n '/^_check_linux_deps()/,/^}/p' "$INSTALL_SH" >> "$_FN_FILE"
|
||||
|
||||
if ! grep -q '_check_linux_deps()' "$_FN_FILE"; then
|
||||
echo "FAIL: could not extract _check_linux_deps from install.sh"
|
||||
echo " (the gate must stay a top-level function so this test can reach it)"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
_HARNESS=$(mktemp)
|
||||
cat > "$_HARNESS" <<'HARNESS'
|
||||
C_WARN=''; C_ERR=''; C_OK=''; C_DIM=''; C_RST=''
|
||||
step() { echo "STEP $1 $2"; }
|
||||
substep() { echo "SUBSTEP $1"; }
|
||||
tauri_log() { echo "[TAURI:$1] $2"; }
|
||||
# Records its args so a test can tell "asked apt for curl" from "asked for everything".
|
||||
_smart_apt_install() { echo "APT_CALLED: $*"; }
|
||||
HARNESS
|
||||
|
||||
_BIN=$(mktemp -d)
|
||||
_mk() { printf '#!/bin/sh\n%s\n' "$2" > "$_BIN/$1"; chmod +x "$_BIN/$1"; }
|
||||
|
||||
# PATH is the sandbox and ONLY the sandbox, so unstocked tools are genuinely absent and
|
||||
# the host's /usr/bin/cmake cannot leak in. bash must therefore be invoked absolutely.
|
||||
_SH="${BASH:-/bin/bash}"
|
||||
|
||||
_run_gate() {
|
||||
# $1 = STUDIO_LOCAL_INSTALL
|
||||
( PATH="$_BIN"; export PATH
|
||||
"$_SH" -c ". '$_HARNESS'; . '$_FN_FILE'; STUDIO_LOCAL_INSTALL=$1; _check_linux_deps; echo \"RC=\$?\"" 2>&1 )
|
||||
}
|
||||
|
||||
echo "=== Fedora/Arch/openSUSE shape: curl present, no build tooling, no apt ==="
|
||||
# Used to exit 1 with "supported on apt-based Linux distributions only".
|
||||
rm -f "$_BIN"/*
|
||||
_mk curl 'exit 0'
|
||||
_out="$(_run_gate false)"
|
||||
assert_contains "install proceeds" "$_out" "RC=0"
|
||||
assert_contains "says the prebuilt is used" "$_out" "using prebuilt llama.cpp"
|
||||
assert_contains "names what is missing" "$_out" "cmake"
|
||||
assert_contains "says it is not required" "$_out" "Not required"
|
||||
assert_not_contains "does not demand a package manager" "$_out" "apt-based"
|
||||
assert_not_contains "does not reach apt for build tools" "$_out" "APT_CALLED"
|
||||
|
||||
echo "=== wget instead of curl is an acceptable transport ==="
|
||||
rm -f "$_BIN"/*
|
||||
_mk wget 'exit 0'
|
||||
_out="$(_run_gate false)"
|
||||
assert_contains "install proceeds" "$_out" "RC=0"
|
||||
assert_not_contains "does not ask apt for curl" "$_out" "APT_CALLED"
|
||||
|
||||
echo "=== no transport at all, no apt: the one genuinely fatal case ==="
|
||||
rm -f "$_BIN"/*
|
||||
_out="$(_run_gate false)"
|
||||
assert_contains "fails" "$_out" "RC=1"
|
||||
assert_contains "names the missing transport" "$_out" "curl"
|
||||
assert_contains "explains what it is needed for" "$_out" "download"
|
||||
assert_contains "gives a non-apt remedy" "$_out" "dnf install curl"
|
||||
|
||||
echo "=== no transport, apt available: auto-install curl and ONLY curl ==="
|
||||
rm -f "$_BIN"/*
|
||||
_mk apt-get 'exit 0'
|
||||
_out="$(_run_gate false)"
|
||||
assert_contains "install proceeds" "$_out" "RC=0"
|
||||
assert_contains "asks apt for curl" "$_out" "APT_CALLED: curl"
|
||||
assert_not_contains "does not ask apt for cmake" "$_out" "APT_CALLED: curl cmake"
|
||||
# Build tooling still appears in the warning line, so match the apt call, not names.
|
||||
assert_contains "apt asked for exactly curl" "$_out" "APT_CALLED: curl
|
||||
"
|
||||
assert_contains "build tooling only warned about" "$_out" "using prebuilt llama.cpp"
|
||||
|
||||
echo "=== fully equipped machine: no warnings ==="
|
||||
rm -f "$_BIN"/*
|
||||
for t in curl cmake gcc curl-config git; do _mk "$t" 'exit 0'; done
|
||||
_out="$(_run_gate false)"
|
||||
assert_contains "install proceeds" "$_out" "RC=0"
|
||||
assert_contains "reports everything found" "$_out" "all system dependencies found"
|
||||
assert_not_contains "no prebuilt fallback warning" "$_out" "using prebuilt llama.cpp"
|
||||
|
||||
echo "=== apt present: git is auto-installed, because triton_kernels needs it ==="
|
||||
# Regression: making git optional without this failed at "6/14 triton kernels", whose
|
||||
# requirement is a git+https URL.
|
||||
rm -f "$_BIN"/*
|
||||
_mk curl 'exit 0'
|
||||
_mk apt-get 'exit 0'
|
||||
_out="$(_run_gate false)"
|
||||
assert_contains "install proceeds" "$_out" "RC=0"
|
||||
assert_contains "apt is asked for git" "$_out" "git"
|
||||
assert_contains "apt is actually called" "$_out" "APT_CALLED"
|
||||
|
||||
echo "=== no apt and no git: warn about the triton skip, do not fail ==="
|
||||
rm -f "$_BIN"/*
|
||||
_mk curl 'exit 0'
|
||||
_out="$(_run_gate false)"
|
||||
assert_contains "install proceeds" "$_out" "RC=0"
|
||||
assert_contains "names the consequence of no git" "$_out" "triton kernels"
|
||||
assert_not_contains "does not call it required to run" "$_out" "is required"
|
||||
|
||||
echo "=== --local without git: must fail loudly (matches macOS) ==="
|
||||
rm -f "$_BIN"/*
|
||||
_mk curl 'exit 0'
|
||||
_out="$(_run_gate true)"
|
||||
assert_contains "fails" "$_out" "RC=1"
|
||||
assert_contains "explains why git is needed" "$_out" "unsloth-zoo"
|
||||
assert_contains "says a normal install needs none" "$_out" "non---local"
|
||||
|
||||
echo "=== --local with a git that exists but does not work ==="
|
||||
# Mirrors the macOS CLT-stub shape: `command -v git` succeeds, running it fails.
|
||||
rm -f "$_BIN"/*
|
||||
_mk curl 'exit 0'
|
||||
_mk git 'echo "broken" >&2; exit 1'
|
||||
_out="$(_run_gate true)"
|
||||
assert_contains "still fails" "$_out" "RC=1"
|
||||
|
||||
echo "=== --local with a working git proceeds ==="
|
||||
rm -f "$_BIN"/*
|
||||
_mk curl 'exit 0'
|
||||
_mk git 'exit 0'
|
||||
_out="$(_run_gate true)"
|
||||
assert_contains "install proceeds" "$_out" "RC=0"
|
||||
|
||||
echo "=== optional apt packages never ask for elevation, in any mode ==="
|
||||
# Regression: the optional bypass sat inside the TAURI_MODE branch, so a plain
|
||||
# `curl | sh` on a non-root Debian box still hit the sudo prompt (default yes) and
|
||||
# installed cmake, GCC and dev headers that nothing on the consumer path uses.
|
||||
_APT_FN=$(mktemp)
|
||||
{
|
||||
sed -n '/^_is_pkg_installed()/,/^}$/p' "$INSTALL_SH"
|
||||
sed -n '/^_apt_distro_description()/,/^}$/p' "$INSTALL_SH"
|
||||
sed -n '/^_can_read_tty()/,/^}$/p' "$INSTALL_SH"
|
||||
sed -n '/^_smart_apt_install()/,/^}$/p' "$INSTALL_SH"
|
||||
} > "$_APT_FN"
|
||||
|
||||
_run_apt() {
|
||||
# $1 = TAURI_MODE, $2 = _SMART_APT_OPTIONAL. apt-get always fails, as it does
|
||||
# for a non-root user, so the function reaches its escalation decision.
|
||||
rm -f "$_BIN"/*
|
||||
_mk apt-get 'exit 100'
|
||||
_mk sudo 'echo "ELEVATION_ATTEMPTED: $*"; exit 1'
|
||||
ln -sf "$(command -v sed)" "$_BIN/sed" # the function trims its list with sed
|
||||
# _APT_FN after _HARNESS so the real function replaces the recording stub.
|
||||
( PATH="$_BIN"; export PATH
|
||||
"$_SH" -c ". '$_HARNESS'; . '$_APT_FN'; TAURI_MODE=$1; _SMART_APT_OPTIONAL=$2
|
||||
( _smart_apt_install unsloth_absent_pkg ); echo \"RC=\$?\"" 2>&1 )
|
||||
}
|
||||
|
||||
_out="$(_run_apt false true)"
|
||||
assert_contains "optional: returns 2 so the caller can continue" "$_out" "RC=2"
|
||||
assert_not_contains "optional: no sudo prompt" "$_out" "elevated permissions"
|
||||
assert_not_contains "optional: sudo never invoked" "$_out" "ELEVATION_ATTEMPTED"
|
||||
|
||||
_out="$(_run_apt true true)"
|
||||
assert_contains "optional in Tauri: returns 2" "$_out" "RC=2"
|
||||
assert_not_contains "optional in Tauri: no NEED_SUDO dialog" "$_out" "NEED_SUDO"
|
||||
|
||||
_out="$(_run_apt false false)"
|
||||
assert_contains "required: still escalates" "$_out" "ELEVATION_ATTEMPTED"
|
||||
|
||||
_out="$(_run_apt true false)"
|
||||
assert_contains "required in Tauri: still asks Rust to elevate" "$_out" "NEED_SUDO"
|
||||
|
||||
rm -f "$_APT_FN"
|
||||
rm -rf "$_BIN" "$_FN_FILE" "$_HARNESS"
|
||||
echo ""
|
||||
echo "=== $PASS passed, $FAIL failed ==="
|
||||
[ "$FAIL" -eq 0 ]
|
||||
165
tests/sh/test_macos_clt_gate.sh
Executable file
165
tests/sh/test_macos_clt_gate.sh
Executable file
|
|
@ -0,0 +1,165 @@
|
|||
#!/bin/bash
|
||||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
|
||||
#
|
||||
# Guards the macOS system-dependency gate in install.sh.
|
||||
#
|
||||
# History: the gate was inline top-level code running
|
||||
# xcode-select -p || { xcode-select --install; exit 1; }
|
||||
# so a brand-new Mac could not install at all, and being inline rather than a function
|
||||
# it was out of reach of the tests/sh sed-extraction convention that would have caught
|
||||
# it.
|
||||
#
|
||||
# The contract now: a consumer install must SUCCEED with no Xcode Command Line Tools
|
||||
# (uv, CPython, llama.cpp/whisper.cpp/Node are all prebuilt, triton is skipped on
|
||||
# macOS), while `--local` must still fail loudly: unsloth-zoo comes from a git+https
|
||||
# URL.
|
||||
set -e
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
INSTALL_SH="$SCRIPT_DIR/../../install.sh"
|
||||
PASS=0
|
||||
FAIL=0
|
||||
|
||||
assert_eq() {
|
||||
_label="$1"; _expected="$2"; _actual="$3"
|
||||
if [ "$_actual" = "$_expected" ]; then
|
||||
echo " PASS: $_label"
|
||||
PASS=$((PASS + 1))
|
||||
else
|
||||
echo " FAIL: $_label (expected '$_expected', got '$_actual')"
|
||||
FAIL=$((FAIL + 1))
|
||||
fi
|
||||
}
|
||||
|
||||
assert_contains() {
|
||||
_label="$1"; _haystack="$2"; _needle="$3"
|
||||
if echo "$_haystack" | grep -qF "$_needle"; then
|
||||
echo " PASS: $_label"
|
||||
PASS=$((PASS + 1))
|
||||
else
|
||||
echo " FAIL: $_label (expected to find '$_needle')"
|
||||
FAIL=$((FAIL + 1))
|
||||
fi
|
||||
}
|
||||
|
||||
assert_not_contains() {
|
||||
_label="$1"; _haystack="$2"; _needle="$3"
|
||||
if echo "$_haystack" | grep -qF "$_needle"; then
|
||||
echo " FAIL: $_label (found '$_needle' but should not)"
|
||||
FAIL=$((FAIL + 1))
|
||||
else
|
||||
echo " PASS: $_label"
|
||||
PASS=$((PASS + 1))
|
||||
fi
|
||||
}
|
||||
|
||||
# ── Extract the functions under test ──
|
||||
_FN_FILE=$(mktemp)
|
||||
sed -n '/^_has_working_git()/,/^}/p' "$INSTALL_SH" > "$_FN_FILE"
|
||||
sed -n '/^_check_macos_deps()/,/^}/p' "$INSTALL_SH" >> "$_FN_FILE"
|
||||
|
||||
if ! grep -q '_check_macos_deps()' "$_FN_FILE"; then
|
||||
echo "FAIL: could not extract _check_macos_deps from install.sh"
|
||||
echo " (the gate must stay a top-level function so this test can reach it)"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Minimal harness: the output helpers install.sh would otherwise provide.
|
||||
_HARNESS=$(mktemp)
|
||||
cat > "$_HARNESS" <<'HARNESS'
|
||||
C_WARN=''; C_ERR=''; C_OK=''; C_DIM=''; C_RST=''
|
||||
step() { echo "STEP $1 $2"; }
|
||||
substep() { echo "SUBSTEP $1"; }
|
||||
tauri_log() { echo "[TAURI:$1] $2"; }
|
||||
HARNESS
|
||||
|
||||
_BIN=$(mktemp -d)
|
||||
|
||||
# Each tool is absent, a working stub, or a broken stub mimicking the Xcode CLT shim
|
||||
# (exists, exits non-zero).
|
||||
_mk() { printf '#!/bin/sh\n%s\n' "$2" > "$_BIN/$1"; chmod +x "$_BIN/$1"; }
|
||||
|
||||
# PATH is the sandbox and ONLY the sandbox, so unstocked tools are genuinely absent and
|
||||
# the host's /usr/bin/git cannot leak in. bash must therefore be invoked absolutely.
|
||||
_SH="${BASH:-/bin/bash}"
|
||||
|
||||
_run_gate() {
|
||||
# $1 = STUDIO_LOCAL_INSTALL
|
||||
( PATH="$_BIN"; export PATH
|
||||
"$_SH" -c ". '$_HARNESS'; . '$_FN_FILE'; STUDIO_LOCAL_INSTALL=$1; _check_macos_deps; echo \"RC=\$?\"" 2>&1 )
|
||||
}
|
||||
|
||||
echo "=== clean Mac: no CLT at all (xcode-select missing) ==="
|
||||
rm -f "$_BIN"/*
|
||||
_out="$(_run_gate false)"
|
||||
assert_contains "does not exit 1" "$_out" "RC=0"
|
||||
assert_contains "says CLT are not required" "$_out" "not required"
|
||||
assert_not_contains "never claims CLT are required" "$_out" "are required"
|
||||
|
||||
echo "=== clean Mac: CLT stubs present but non-functional (the real virgin-Mac shape) ==="
|
||||
# With no CLT, /usr/bin/git EXISTS and fails when run, so `command -v git` succeeds.
|
||||
# The gate must not be fooled by that.
|
||||
rm -f "$_BIN"/*
|
||||
_mk xcode-select 'exit 1'
|
||||
_mk git 'echo "xcrun: error: invalid active developer path" >&2; exit 1'
|
||||
_out="$(_run_gate false)"
|
||||
assert_contains "consumer install proceeds" "$_out" "RC=0"
|
||||
assert_contains "reports CLT absent but optional" "$_out" "not required"
|
||||
|
||||
echo "=== --local with a non-functional git: must fail loudly ==="
|
||||
_out="$(_run_gate true)"
|
||||
assert_contains "fails" "$_out" "RC=1"
|
||||
assert_contains "explains why git is needed" "$_out" "unsloth-zoo"
|
||||
assert_contains "names the remedy" "$_out" "xcode-select --install"
|
||||
assert_contains "emits a machine-readable marker" "$_out" "[TAURI:NEED_XCODE_CLT]"
|
||||
assert_contains "says a normal install needs none" "$_out" "non---local"
|
||||
|
||||
echo "=== --local with a working git: proceeds ==="
|
||||
rm -f "$_BIN"/*
|
||||
_mk xcode-select 'exit 1'
|
||||
_mk git 'echo "git version 2.50.0"; exit 0'
|
||||
_out="$(_run_gate true)"
|
||||
assert_contains "--local proceeds when git works" "$_out" "RC=0"
|
||||
|
||||
echo "=== CLT installed + cmake present ==="
|
||||
rm -f "$_BIN"/*
|
||||
_mk xcode-select 'echo /Library/Developer/CommandLineTools; exit 0'
|
||||
_mk git 'echo "git version 2.50.0"; exit 0'
|
||||
_mk cmake 'echo "cmake version 3.30.0"; exit 0'
|
||||
_out="$(_run_gate false)"
|
||||
assert_contains "all deps found" "$_out" "all system dependencies found"
|
||||
assert_contains "rc 0" "$_out" "RC=0"
|
||||
|
||||
echo "=== CLT installed, cmake missing: prebuilt path, not fatal ==="
|
||||
rm -f "$_BIN"/*
|
||||
_mk xcode-select 'echo /Library/Developer/CommandLineTools; exit 0'
|
||||
_mk git 'echo "git version 2.50.0"; exit 0'
|
||||
_out="$(_run_gate false)"
|
||||
assert_contains "uses prebuilt llama.cpp" "$_out" "using prebuilt llama.cpp"
|
||||
assert_contains "rc 0" "$_out" "RC=0"
|
||||
|
||||
echo "=== the gate never fires the GUI installer on the consumer path ==="
|
||||
# The dialog needs a GUI session a curl-piped or Tauri-spawned install does not have.
|
||||
rm -f "$_BIN"/*
|
||||
_mk xcode-select 'if [ "$1" = "--install" ]; then echo "GUI-DIALOG-FIRED"; fi; exit 1'
|
||||
_out="$(_run_gate false)"
|
||||
assert_not_contains "no GUI dialog on consumer path" "$_out" "GUI-DIALOG-FIRED"
|
||||
|
||||
echo "=== _has_working_git distinguishes present-but-broken from working ==="
|
||||
rm -f "$_BIN"/*
|
||||
_mk git 'exit 1'
|
||||
_r="$(PATH="$_BIN" "$_SH" -c ". '$_FN_FILE'; _has_working_git && echo yes || echo no")"
|
||||
assert_eq "broken git stub -> no" "no" "$_r"
|
||||
_mk git 'echo ok; exit 0'
|
||||
_r="$(PATH="$_BIN" "$_SH" -c ". '$_FN_FILE'; _has_working_git && echo yes || echo no")"
|
||||
assert_eq "working git -> yes" "yes" "$_r"
|
||||
rm -f "$_BIN"/git
|
||||
_r="$(PATH="$_BIN" "$_SH" -c ". '$_FN_FILE'; _has_working_git && echo yes || echo no")"
|
||||
assert_eq "absent git -> no" "no" "$_r"
|
||||
|
||||
rm -rf "$_BIN" "$_FN_FILE" "$_HARNESS"
|
||||
|
||||
echo ""
|
||||
echo "=== $PASS passed, $FAIL failed ==="
|
||||
[ "$FAIL" -eq 0 ] || exit 1
|
||||
Loading…
Add table
Add a link
Reference in a new issue