Studio: add UNSLOTH_SKIP_AUTOSTART installer flag (#7093)

* Studio: add installer autostart opt-out

* CI: run installer autostart tests cross-platform

* Tests: combine Studio installer skip flags
This commit is contained in:
Daniel Han 2026-07-12 21:23:14 -07:00 committed by GitHub
commit ca979e9643
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 169 additions and 10 deletions

View file

@ -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

View file

@ -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

View file

@ -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]"

View file

@ -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.

View file

@ -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=$?

View file

@ -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