diff --git a/.github/workflows/cross-platform-parity-ci.yml b/.github/workflows/cross-platform-parity-ci.yml index 4632794587..bb7dcbf8e4 100644 --- a/.github/workflows/cross-platform-parity-ci.yml +++ b/.github/workflows/cross-platform-parity-ci.yml @@ -1,7 +1,7 @@ # SPDX-License-Identifier: AGPL-3.0-only # Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. -# Runs tests/python/test_cross_platform_parity.py on Windows and macOS. +# Runs installer parity and autostart opt-out tests on Windows and macOS. # # Why: that test is the guard that install.sh and install.ps1 stay in # sync, but today it only runs on ubuntu-latest (auto-discovered by @@ -21,6 +21,7 @@ on: paths: - 'install.sh' - 'install.ps1' + - 'tests/test_installer_skip_autostart.py' - 'tests/python/test_cross_platform_parity.py' - '.github/workflows/cross-platform-parity-ci.yml' push: @@ -28,6 +29,7 @@ on: paths: - 'install.sh' - 'install.ps1' + - 'tests/test_installer_skip_autostart.py' - 'tests/python/test_cross_platform_parity.py' - '.github/workflows/cross-platform-parity-ci.yml' workflow_dispatch: @@ -57,5 +59,11 @@ jobs: python-version: '3.12' cache: 'pip' - run: python -m pip install -U pip pytest - - name: Cross-platform parity test - run: python -m pytest tests/python/test_cross_platform_parity.py -q + - name: Cross-platform parity tests + env: + UNSLOTH_NO_TORCH: '1' + run: >- + python -m pytest + tests/python/test_cross_platform_parity.py + tests/test_installer_skip_autostart.py + -q diff --git a/README.md b/README.md index 849ee2e87b..5f1630e2ba 100644 --- a/README.md +++ b/README.md @@ -230,6 +230,14 @@ curl -fsSL https://unsloth.ai/install.sh | UNSLOTH_NO_TORCH=1 sh $env:UNSLOTH_NO_TORCH=1; irm https://unsloth.ai/install.ps1 | iex ``` +Skip the post-install prompt that starts Studio (useful for automated installs): +```bash +curl -fsSL https://unsloth.ai/install.sh | UNSLOTH_SKIP_AUTOSTART=1 sh +``` +```powershell +$env:UNSLOTH_SKIP_AUTOSTART=1; irm https://unsloth.ai/install.ps1 | iex +``` + Pin the Python version: ```bash curl -fsSL https://unsloth.ai/install.sh | UNSLOTH_PYTHON=3.12 sh diff --git a/install.ps1 b/install.ps1 index 0797cd3868..100a3177ba 100644 --- a/install.ps1 +++ b/install.ps1 @@ -6,6 +6,7 @@ # irm | iex cannot forward arguments, so web installs take options as env vars set # before the pipe (flags still work via .\install.ps1): # $env:UNSLOTH_NO_TORCH=1; irm https://unsloth.ai/install.ps1 | iex # skip PyTorch (GGUF-only) +# $env:UNSLOTH_SKIP_AUTOSTART=1; irm https://unsloth.ai/install.ps1 | iex # do not prompt to launch # $env:UNSLOTH_PYTHON='3.12'; irm https://unsloth.ai/install.ps1 | iex # pin Python version # $env:UNSLOTH_STUDIO_HOME='C:\path'; irm https://unsloth.ai/install.ps1 | iex # .\install.ps1 --no-torch # equivalent flag @@ -98,6 +99,7 @@ function Install-UnslothStudio { $RepoRoot = "" $TauriMode = $false $SkipTorch = $false + $SkipAutostart = $false $ShortcutsOnly = $false $WithLlamaCppDir = "" $argList = $args @@ -130,6 +132,7 @@ function Install-UnslothStudio { # Env-var equivalent for web installs; an explicit flag still wins. if ($env:UNSLOTH_NO_TORCH -in @('1', 'true', 'yes', 'on')) { $SkipTorch = $true } + if ($env:UNSLOTH_SKIP_AUTOSTART -in @('1', 'true', 'yes', 'on')) { $SkipAutostart = $true } # Propagate to child processes so they also respect verbose mode. # Process-scoped -- does not persist. @@ -2612,9 +2615,10 @@ exit 0 # Diagnostic only; never block install on a probe failure. } - # In interactive terminals, ask the user before starting Studio. + # In interactive terminals, ask the user before starting Studio unless the + # caller explicitly disabled the post-install prompt. # In non-interactive environments (CI, Docker) just print instructions. - $IsInteractive = [Environment]::UserInteractive -and (-not [Console]::IsInputRedirected) + $IsInteractive = (-not $SkipAutostart) -and [Environment]::UserInteractive -and (-not [Console]::IsInputRedirected) if ($IsInteractive) { Write-Host "" $reply = Read-Host " Start Unsloth Studio now? [Y/n]" diff --git a/install.sh b/install.sh index 3f4ea92387..3bf6fd1855 100755 --- a/install.sh +++ b/install.sh @@ -8,8 +8,9 @@ # # Piped installs take options as env vars after the pipe (a bare `| sh --no-torch` # makes sh reject --no-torch as its own option). Flags still work via ./install.sh: -# curl -fsSL https://unsloth.ai/install.sh | UNSLOTH_NO_TORCH=1 sh # skip PyTorch (GGUF-only) -# curl -fsSL https://unsloth.ai/install.sh | UNSLOTH_PYTHON=3.12 sh # pin Python version +# curl -fsSL https://unsloth.ai/install.sh | UNSLOTH_NO_TORCH=1 sh # skip PyTorch (GGUF-only) +# curl -fsSL https://unsloth.ai/install.sh | UNSLOTH_SKIP_AUTOSTART=1 sh # do not prompt to launch +# curl -fsSL https://unsloth.ai/install.sh | UNSLOTH_PYTHON=3.12 sh # pin Python version # curl -fsSL https://unsloth.ai/install.sh | UNSLOTH_STUDIO_HOME=/abs/path sh # Equivalent flags: ./install.sh --no-torch --python 3.12 (or pipe them: sh -s -- --no-torch) # @@ -49,6 +50,7 @@ PACKAGE_NAME="unsloth" TAURI_MODE=false _USER_PYTHON="" _NO_TORCH_FLAG=false +_SKIP_AUTOSTART=false _VERBOSE=false _SHORTCUTS_ONLY=false _next_is_package=false @@ -88,6 +90,7 @@ done # Env-var equivalents for piped installs; an explicit flag still wins. case "${UNSLOTH_NO_TORCH:-}" in 1|true|TRUE|yes|YES|on|ON) _NO_TORCH_FLAG=true ;; esac +case "${UNSLOTH_SKIP_AUTOSTART:-}" in 1|true|TRUE|yes|YES|on|ON) _SKIP_AUTOSTART=true ;; esac [ -z "$_USER_PYTHON" ] && [ -n "${UNSLOTH_PYTHON:-}" ] && _USER_PYTHON="$UNSLOTH_PYTHON" if [ "$_VERBOSE" = true ]; then @@ -1631,6 +1634,7 @@ _maybe_reroute_strixhalo_to_2404() { # Forward explicit ROCm-bootstrap consent (e.g. Tauri) so the child auto-enables the # GPU instead of falling back to the desktop-app prompt path. [ "${UNSLOTH_ROCM_WSL_AUTO:-0}" = "1" ] && _rr_exports="$_rr_exports; export UNSLOTH_ROCM_WSL_AUTO=1" + [ "$_SKIP_AUTOSTART" = true ] && _rr_exports="$_rr_exports; export UNSLOTH_SKIP_AUTOSTART=1" _rr_args="" [ "$PACKAGE_NAME" != "unsloth" ] && _rr_args="$_rr_args --package $(_rr_q "$PACKAGE_NAME")" [ -n "$_USER_PYTHON" ] && _rr_args="$_rr_args --python $(_rr_q "$_USER_PYTHON")" @@ -3223,9 +3227,10 @@ printf " ${C_TITLE}%s${C_RST}\n" "Unsloth Studio installed!" printf " ${C_DIM}%s${C_RST}\n" "$RULE" echo "" -# In interactive terminals, ask the user before starting Studio. +# In interactive terminals, ask the user before starting Studio unless the +# caller explicitly disabled the post-install prompt. # In non-interactive environments (Docker, CI, cloud-init) just print instructions. -if [ -t 1 ]; then +if [ "$_SKIP_AUTOSTART" != true ] && [ -t 1 ]; then echo "" printf " Start Unsloth Studio now? [Y/n] " # No readable answer (closed/EOF tty) defaults to no; Enter is still yes. diff --git a/tests/sh/test_strixhalo_wsl_reroute.sh b/tests/sh/test_strixhalo_wsl_reroute.sh index 7edf475ccb..9c9b04cf21 100644 --- a/tests/sh/test_strixhalo_wsl_reroute.sh +++ b/tests/sh/test_strixhalo_wsl_reroute.sh @@ -309,7 +309,15 @@ if [ "$_rc" = "2" ]; then echo " PASS: tauri child exit 2 -> reroute propagates assert_absent "tauri exit 2 -> not a CPU fallback" "$_out" "__NOROUTE__" rm -rf "$_d" -# 27) Non-tauri mode: a child exit 2 is just a failure -> CPU fallback, not propagated. +# 27) The post-install autostart opt-out must reach the target distro, where the +# final launch prompt is evaluated. +_d=$(make_fixture 1 strix 0 26.04 1) +_out=$(run_func "$_d" _SKIP_AUTOSTART=true UNSLOTH_SKIP_AUTOSTART= \ + UNSLOTH_WSL_REROUTE_CMD='echo skip=[$UNSLOTH_SKIP_AUTOSTART]') +assert_contains "UNSLOTH_SKIP_AUTOSTART forwarded to reroute" "$_out" "skip=[1]" +rm -rf "$_d" + +# 28) Non-tauri mode: a child exit 2 is just a failure -> CPU fallback, not propagated. _d=$(make_fixture 1 strix 0 26.04 1) _rc=0 _out=$(run_func "$_d" UNSLOTH_WSL_REROUTE_CMD='exit 2') || _rc=$? diff --git a/tests/test_installer_skip_autostart.py b/tests/test_installer_skip_autostart.py new file mode 100644 index 0000000000..8c283f4458 --- /dev/null +++ b/tests/test_installer_skip_autostart.py @@ -0,0 +1,126 @@ +"""Regression tests for the installers' post-install autostart opt-out.""" + +from __future__ import annotations + +import os +import re +import shutil +import subprocess +from pathlib import Path + +import pytest + +REPO_ROOT = Path(__file__).resolve().parents[1] +INSTALL_SH = REPO_ROOT / "install.sh" +INSTALL_PS1 = REPO_ROOT / "install.ps1" +README = REPO_ROOT / "README.md" + +TRUTHY_VALUES = ("1", "true", "TRUE", "yes", "YES", "on", "ON") +FALSEY_VALUES = ("", "0", "false", "no", "off", "anything-else") + + +def _extract(pattern: str, source: str) -> str: + match = re.search(pattern, source, flags = re.DOTALL) + assert match is not None, f"installer block not found: {pattern}" + return match.group(0) + + +@pytest.mark.parametrize( + ("value", "expected"), + [(value, "true") for value in TRUTHY_VALUES] + [(value, "false") for value in FALSEY_VALUES], +) +@pytest.mark.skipif(shutil.which("sh") is None, reason = "POSIX shell is unavailable") +def test_posix_skip_autostart_value_parsing_with_no_torch(value: str, expected: str): + source = INSTALL_SH.read_text(encoding = "utf-8") + no_torch_parser = _extract( + r'case "\$\{UNSLOTH_NO_TORCH:-\}" in.*?esac', + source, + ) + autostart_parser = _extract( + r'case "\$\{UNSLOTH_SKIP_AUTOSTART:-\}" in.*?esac', + source, + ) + env = os.environ.copy() + env["UNSLOTH_NO_TORCH"] = "1" + env["UNSLOTH_SKIP_AUTOSTART"] = value + result = subprocess.run( + [ + "sh", + "-c", + ( + f"_NO_TORCH_FLAG=false\n_SKIP_AUTOSTART=false\n{no_torch_parser}\n" + f'{autostart_parser}\nprintf "%s %s" "$_NO_TORCH_FLAG" "$_SKIP_AUTOSTART"' + ), + ], + check = True, + capture_output = True, + text = True, + env = env, + ) + assert result.stdout == f"true {expected}" + + +def test_posix_skip_autostart_bypasses_only_the_interactive_prompt(): + source = INSTALL_SH.read_text(encoding = "utf-8") + gate = 'if [ "$_SKIP_AUTOSTART" != true ] && [ -t 1 ]; then' + assert gate in source + assert source.index(gate) < source.index("Start Unsloth Studio now? [Y/n]") + assert source.count("Start Unsloth Studio now? [Y/n]") == 1 + assert source.index("Start Unsloth Studio now? [Y/n]") < source.index( + 'step "launch" "manual commands:"' + ) + assert "export UNSLOTH_SKIP_AUTOSTART=1" in source + + +@pytest.mark.skipif(shutil.which("pwsh") is None, reason = "PowerShell is unavailable") +@pytest.mark.parametrize( + ("value", "expected"), + [(value, "True") for value in TRUTHY_VALUES] + [(value, "False") for value in FALSEY_VALUES], +) +def test_windows_skip_autostart_value_parsing_with_no_torch(value: str, expected: str): + source = INSTALL_PS1.read_text(encoding = "utf-8") + parser = _extract( + r"\$SkipTorch = \$false\s+\$SkipAutostart = \$false\s+.*?" + r"if \(\$env:UNSLOTH_NO_TORCH -in @\('1', 'true', 'yes', 'on'\)\) " + r"\{ \$SkipTorch = \$true \}\s+" + r"if \(\$env:UNSLOTH_SKIP_AUTOSTART -in @\('1', 'true', 'yes', 'on'\)\) " + r"\{ \$SkipAutostart = \$true \}", + source, + ) + env = os.environ.copy() + env["UNSLOTH_NO_TORCH"] = "1" + env["UNSLOTH_SKIP_AUTOSTART"] = value + result = subprocess.run( + [ + "pwsh", + "-NoProfile", + "-NonInteractive", + "-Command", + f'{parser}; "$SkipTorch $SkipAutostart"', + ], + check = True, + capture_output = True, + text = True, + env = env, + ) + assert result.stdout.strip() == f"True {expected}" + + +def test_windows_skip_autostart_bypasses_only_the_interactive_prompt(): + source = INSTALL_PS1.read_text(encoding = "utf-8") + gate = ( + "$IsInteractive = (-not $SkipAutostart) -and " + "[Environment]::UserInteractive -and (-not [Console]::IsInputRedirected)" + ) + assert gate in source + assert source.index(gate) < source.index("Start Unsloth Studio now? [Y/n]") + assert source.count("Start Unsloth Studio now? [Y/n]") == 1 + assert source.index("Start Unsloth Studio now? [Y/n]") < source.index( + 'step "launch" "manual commands:"' + ) + + +def test_skip_autostart_is_documented_for_all_installers(): + readme = README.read_text(encoding = "utf-8") + assert "UNSLOTH_SKIP_AUTOSTART=1 sh" in readme + assert "$env:UNSLOTH_SKIP_AUTOSTART=1" in readme