diff --git a/.github/workflows/studio-backend-ci.yml b/.github/workflows/studio-backend-ci.yml index 3022127a2b..e4ef484371 100644 --- a/.github/workflows/studio-backend-ci.yml +++ b/.github/workflows/studio-backend-ci.yml @@ -232,7 +232,8 @@ jobs: tests/sh/test_torch_constraint.sh \ tests/sh/test_torch_flavor.sh \ tests/sh/test_with_llama_cpp_dir_flag.sh \ - tests/sh/test_with_llama_cpp_dir_link_behavior.sh; do + tests/sh/test_with_llama_cpp_dir_link_behavior.sh \ + tests/sh/test_launcher_no_browser.sh; do echo "::group::$s" bash "$s" echo "::endgroup::" diff --git a/README.md b/README.md index ef45b91430..abc2ca46b9 100644 --- a/README.md +++ b/README.md @@ -86,6 +86,8 @@ unsloth studio -p 8888 ``` For LAN or cloud access, add `-H 0.0.0.0` (raw port only; add `--cloudflare` for a public URL). By default, Unsloth is accessible only locally. +Launching from the terminal never opens a browser -- open the printed URL yourself. The desktop shortcut opens your default browser once the server is up; to launch the server without that (e.g. when running Studio as a browser PWA / app window), answer "n" at the installer's browser prompt, pass `--no-browser` to the launcher, or set `UNSLOTH_STUDIO_NO_BROWSER=1`. + To reach Studio over HTTPS, use `unsloth studio --secure`. Studio stays bound to localhost and is reached only through a free Cloudflare tunnel, which publishes it at a public `https://*.trycloudflare.com` URL (it fails closed if the tunnel can't start, so the raw port is never exposed). This makes Studio reachable from the internet, so anyone with the link and API key can use it and run code: keep your API key private (see Remote access below). #### Docker diff --git a/install.ps1 b/install.ps1 index c25d7e7b7f..623c252029 100644 --- a/install.ps1 +++ b/install.ps1 @@ -102,6 +102,8 @@ function Install-UnslothStudio { $SkipTorch = $false $SkipAutostart = $false $ShortcutsOnly = $false + # Launcher browser auto-open: "" = undecided (prompt, else keep existing, else on). + $OpenBrowserPref = "" $WithLlamaCppDir = "" $argList = $args for ($i = 0; $i -lt $argList.Count; $i++) { @@ -112,6 +114,8 @@ function Install-UnslothStudio { "--verbose" { $script:UnslothVerbose = $true } "-v" { $script:UnslothVerbose = $true } "--shortcuts-only" { $ShortcutsOnly = $true } + "--no-browser" { $OpenBrowserPref = '0' } + "--browser" { $OpenBrowserPref = '1' } "--package" { $i++ if ($i -ge $argList.Count) { @@ -656,6 +660,20 @@ function Install-UnslothStudio { "`$portFile = `$null`n`$mutexName = 'Local\UnslothStudioLauncher'`n" } + # Browser auto-open: explicit installer choice wins; else keep the + # value baked into the existing launcher so `studio update` + # (--shortcuts-only) never resets it. + $_openBrowser = $OpenBrowserPref + if (-not $_openBrowser -and (Test-Path -LiteralPath $launcherPs1)) { + try { + $_prevLauncher = [System.IO.File]::ReadAllText($launcherPs1) + if ($_prevLauncher -match "(?m)^\`$openBrowserDefault = '([01])'") { + $_openBrowser = $Matches[1] + } + } catch {} + } + if ($_openBrowser -ne '0') { $_openBrowser = '1' } + $launcherContent = @" $studioHomeExport`$ErrorActionPreference = 'Stop' `$basePort = 8888 @@ -663,6 +681,30 @@ $studioHomeExport`$ErrorActionPreference = 'Stop' `$timeoutSec = 60 `$pollIntervalMs = 1000 `$_ExpectedStudioRootId = '$_studioRootId' +`$openBrowserDefault = '$_openBrowser' + +# Browser auto-open: disabled by -NoBrowser/--no-browser, the +# UNSLOTH_STUDIO_NO_BROWSER env var, or the baked installer preference. +# When off the server still starts; the URL is printed instead (PWA use). +`$openBrowser = (`$openBrowserDefault -ne '0') +if (`$env:UNSLOTH_STUDIO_NO_BROWSER -and + (`$env:UNSLOTH_STUDIO_NO_BROWSER -notin @('0', 'false', 'no', 'off'))) { + `$openBrowser = `$false +} +foreach (`$_launchArg in `$args) { + if (`$_launchArg -in @('-NoBrowser', '--no-browser')) { `$openBrowser = `$false } + elseif (`$_launchArg -in @('-Browser', '--browser')) { `$openBrowser = `$true } +} + +function Open-StudioUrl { + param([Parameter(Mandatory = `$true)][string]`$Url) + if (`$openBrowser) { + Start-Process `$Url + } else { + # Hidden-window launches have no console; never fail on the echo. + try { Write-Host "Unsloth Studio is running at: `$Url" } catch {} + } +} function Test-StudioHealth { param([Parameter(Mandatory = `$true)][int]`$Port) @@ -759,7 +801,7 @@ function Find-FreeLaunchPort { # If Studio is already healthy on any expected port, just open it and exit. `$existingPort = Find-HealthyStudioPort if (`$existingPort) { - Start-Process "http://localhost:`$existingPort" + Open-StudioUrl "http://localhost:`$existingPort" exit 0 } @@ -776,7 +818,7 @@ try { `$deadline = (Get-Date).AddSeconds(`$timeoutSec) while ((Get-Date) -lt `$deadline) { `$port = Find-HealthyStudioPort - if (`$port) { Start-Process "http://localhost:`$port"; exit 0 } + if (`$port) { Open-StudioUrl "http://localhost:`$port"; exit 0 } Start-Sleep -Milliseconds `$pollIntervalMs } exit 0 @@ -825,7 +867,7 @@ try { [System.IO.File]::WriteAllText(`$portFile, "`$launchPort`n") } catch {} } - Start-Process "http://localhost:`$launchPort" + Open-StudioUrl "http://localhost:`$launchPort" `$browserOpened = `$true break } @@ -2586,6 +2628,32 @@ exit 0 return } + # Ask once (interactive installs only) whether the launcher should open + # the browser after the server is up. Skipped when --no-browser/--browser + # was passed or input is redirected; then an existing choice is kept. + # Enter keeps the choice baked into the existing launcher so a reinstall + # that accepts the defaults never flips a saved no-browser preference. + $_browserPromptOk = [Environment]::UserInteractive -and (-not [Console]::IsInputRedirected) + if (-not $OpenBrowserPref -and $_browserPromptOk) { + $_existingPref = "" + $_promptLauncher = if ($StudioDataDir) { Join-Path $StudioDataDir "launch-studio.ps1" } else { $null } + if ($_promptLauncher -and (Test-Path -LiteralPath $_promptLauncher)) { + try { + $_prevText = [System.IO.File]::ReadAllText($_promptLauncher) + if ($_prevText -match "(?m)^\`$openBrowserDefault = '([01])'") { + $_existingPref = $Matches[1] + } + } catch {} + } + $_browserHint = if ($_existingPref -eq '0') { '[y/N]' } else { '[Y/n]' } + Write-Host "" + $_browserReply = Read-Host " Open Unsloth Studio in your default browser after launch? $_browserHint" + $OpenBrowserPref = if ($_browserReply -match '^[Nn]') { '0' } + elseif ($_browserReply -match '^[Yy]') { '1' } + elseif ($_existingPref) { $_existingPref } + else { '1' } + } + # New-StudioShortcuts gates the .lnk shortcuts on env-mode internally. New-StudioShortcuts -UnslothExePath $UnslothExe @@ -2620,11 +2688,75 @@ exit 0 # caller explicitly disabled the post-install prompt. # In non-interactive environments (CI, Docker) just print instructions. $IsInteractive = (-not $SkipAutostart) -and [Environment]::UserInteractive -and (-not [Console]::IsInputRedirected) + # Select the same bounded free-port range as the generated desktop launcher. + # Passing the selected port to both the server and watcher prevents an + # existing Studio on 8888 from moving the backend while the watcher stays + # behind. + function Find-PostInstallStudioPort { + param([int]$BasePort = 8888, [int]$MaxPortOffset = 20) + $probes = @( + @{ Family = [System.Net.Sockets.AddressFamily]::InterNetwork; Host = '127.0.0.1' }, + @{ Family = [System.Net.Sockets.AddressFamily]::InterNetworkV6; Host = '::1' } + ) + for ($offset = 0; $offset -le $MaxPortOffset; $offset++) { + $candidate = $BasePort + $offset + $busy = $false + foreach ($probe in $probes) { + $client = $null + try { + $client = [System.Net.Sockets.TcpClient]::new($probe.Family) + $connect = $client.ConnectAsync($probe.Host, $candidate) + if ($connect.Wait(100) -and $client.Connected) { + $busy = $true + break + } + } catch { + } finally { + if ($client) { $client.Dispose() } + } + } + if (-not $busy) { return $candidate } + } + return $BasePort + } + + # Background watcher for the foreground launch below: once the server is + # healthy on the selected port, open the browser per the persisted + # preference. Guarded by the per-install root id so a different Studio is + # never the one opened. + $_browserWatch = { + param($RootId, $Port) + $deadline = (Get-Date).AddSeconds(120) + while ((Get-Date) -lt $deadline) { + try { + $r = Invoke-RestMethod -Uri "http://127.0.0.1:$Port/api/health" -TimeoutSec 1 -Method Get + if ($r.service -eq 'Unsloth UI Backend' -and + ((-not $RootId) -or $r.studio_root_id -eq $RootId)) { + Start-Process "http://localhost:$Port" + break + } + } catch {} + Start-Sleep -Seconds 1 + } + } if ($IsInteractive) { Write-Host "" $reply = Read-Host " Start Unsloth Studio now? [Y/n]" if ([string]::IsNullOrWhiteSpace($reply) -or $reply -match '^[Yy]') { - & $UnslothExe studio -p 8888 + $_launchPort = Find-PostInstallStudioPort + # Open the browser once the server is up, unless opted out. The + # server prints its own URL, so no watcher is needed when off. + if ($OpenBrowserPref -ne '0') { + $_watchRootId = "" + $_watchIdFile = Join-Path $StudioHome "share\studio_install_id" + if (Test-Path -LiteralPath $_watchIdFile) { + try { $_watchRootId = ([System.IO.File]::ReadAllText($_watchIdFile)).Trim() } catch {} + } + try { + $null = Start-Job -ScriptBlock $_browserWatch -ArgumentList @($_watchRootId, $_launchPort) + } catch {} + } + & $UnslothExe studio -p $_launchPort } else { step "launch" "to start later, run:" substep "unsloth studio -p 8888" diff --git a/install.sh b/install.sh index 4a3c4471fa..2a36b07ee3 100755 --- a/install.sh +++ b/install.sh @@ -13,6 +13,7 @@ # 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) +# ./install.sh --no-browser: launcher starts the server without opening the browser. # # Install dir priority: UNSLOTH_STUDIO_HOME > STUDIO_HOME (alias) > $HOME/.unsloth/studio # @@ -53,6 +54,8 @@ _NO_TORCH_FLAG=false _SKIP_AUTOSTART=false _VERBOSE=false _SHORTCUTS_ONLY=false +# Launcher browser auto-open: "" = undecided (prompt, else keep existing, else on). +_STUDIO_OPEN_BROWSER="" _next_is_package=false _next_is_python=false _next_is_llama_cpp_dir=false @@ -84,6 +87,8 @@ for arg in "$@"; do --no-torch) _NO_TORCH_FLAG=true ;; --verbose|-v) _VERBOSE=true ;; --shortcuts-only) _SHORTCUTS_ONLY=true ;; + --no-browser) _STUDIO_OPEN_BROWSER=0 ;; + --browser) _STUDIO_OPEN_BROWSER=1 ;; --with-llama-cpp-dir) _next_is_llama_cpp_dir=true ;; esac done @@ -656,6 +661,22 @@ if [ -z "${UNSLOTH_EXE:-}" ] || [ ! -x "${UNSLOTH_EXE:-}" ]; then exit 1 fi +# Browser auto-open. Priority: --no-browser/--browser arg, then +# UNSLOTH_STUDIO_NO_BROWSER env var, then studio.conf, default on. +# When off the server still starts; the URL is printed instead (PWA use). +OPEN_BROWSER="${STUDIO_OPEN_BROWSER:-1}" +# Case-insensitive falsy check, matching the PowerShell launcher's -notin. +case "$(printf '%s' "${UNSLOTH_STUDIO_NO_BROWSER:-}" | tr '[:upper:]' '[:lower:]')" in + ''|0|false|no|off) ;; + *) OPEN_BROWSER=0 ;; +esac +for _arg in "$@"; do + case "$_arg" in + --no-browser) OPEN_BROWSER=0 ;; + --browser) OPEN_BROWSER=1 ;; + esac +done + BASE_PORT=8888 MAX_PORT_OFFSET=20 TIMEOUT_SEC=60 @@ -789,6 +810,10 @@ _find_launch_port() { # ── Open browser ── _open_browser() { _url="$1" + if [ "$OPEN_BROWSER" = "0" ]; then + echo "Unsloth Studio is running at: $_url" + return 0 + fi if [ "$(uname)" = "Darwin" ] && command -v open >/dev/null 2>&1; then open "$_url" elif grep -qi microsoft /proc/version 2>/dev/null; then @@ -1020,11 +1045,21 @@ LAUNCHER_EOF chmod +x "$_css_launcher" + # Browser auto-open: explicit installer choice wins; else keep the existing + # studio.conf value so `studio update` (--shortcuts-only) never resets it. + _css_open_browser="${_STUDIO_OPEN_BROWSER:-}" + if [ -z "$_css_open_browser" ] && [ -f "$_css_data_dir/studio.conf" ]; then + _css_open_browser=$(sed -n "s/^STUDIO_OPEN_BROWSER='\([01]\)'\$/\1/p" \ + "$_css_data_dir/studio.conf" 2>/dev/null | head -n 1) + fi + [ "$_css_open_browser" = "0" ] || _css_open_browser=1 + # studio.conf: exe path + (env-mode only) persisted env vars so fresh # shells launch the right install without re-exporting. _css_quoted_exe=$(printf '%s' "$_css_exe" | sed "s/'/'\\\\''/g") { printf '%s\n' "UNSLOTH_EXE='$_css_quoted_exe'" + printf '%s\n' "STUDIO_OPEN_BROWSER='$_css_open_browser'" if [ "$_STUDIO_HOME_REDIRECT" = "env" ]; then # When an override resolves to the legacy default, llama.cpp # still lives at ~/.unsloth/llama.cpp (one shared build). @@ -1407,6 +1442,10 @@ if [ "$_SHORTCUTS_ONLY" = true ]; then fi create_studio_shortcuts "$VENV_ABS_BIN/unsloth" "$OS" fi + # Drain piped stdin (curl | sh -s -- --shortcuts-only ...) before this + # early exit; otherwise curl dies with EPIPE, prints "curl: (23) Failure + # writing output to destination", and fails the whole pipeline. + [ ! -t 0 ] && cat > /dev/null 2>&1 exit 0 fi @@ -1640,6 +1679,10 @@ _maybe_reroute_strixhalo_to_2404() { [ -n "$_USER_PYTHON" ] && _rr_args="$_rr_args --python $(_rr_q "$_USER_PYTHON")" [ "$_VERBOSE" = true ] && _rr_args="$_rr_args --verbose" [ "$TAURI_MODE" = true ] && _rr_args="$_rr_args --tauri" + # Forward an explicit browser choice; "" (undecided) forwards nothing so + # the rerouted install keeps its own default. + [ "$_STUDIO_OPEN_BROWSER" = "0" ] && _rr_args="$_rr_args --no-browser" + [ "$_STUDIO_OPEN_BROWSER" = "1" ] && _rr_args="$_rr_args --browser" if [ -n "${UNSLOTH_WSL_REROUTE_CMD:-}" ]; then _rr_cmd="$UNSLOTH_WSL_REROUTE_CMD" # user took full control elif [ -n "$_rr_args" ]; then @@ -3170,6 +3213,31 @@ esac # create_studio_shortcuts gates persistent menu shortcuts on env-mode; # launcher + studio.conf + icon are always written. if [ "$TAURI_MODE" != true ]; then + # Ask once (interactive installs only) whether the launcher should open + # the browser after the server is up. Skipped when --no-browser/--browser + # was passed or no TTY; then an existing choice is kept, defaulting to on. + # Enter keeps the choice persisted in studio.conf so a reinstall that + # accepts the defaults never flips a saved no-browser preference. + if [ -z "$_STUDIO_OPEN_BROWSER" ] && [ -t 1 ] && [ -r /dev/tty ]; then + _existing_open_browser="" + if [ -f "$DATA_DIR/studio.conf" ]; then + _existing_open_browser=$(sed -n "s/^STUDIO_OPEN_BROWSER='\([01]\)'\$/\1/p" \ + "$DATA_DIR/studio.conf" 2>/dev/null | head -n 1) + fi + if [ "$_existing_open_browser" = "0" ]; then + _browser_hint="[y/N]" + else + _browser_hint="[Y/n]" + fi + echo "" + printf " Open Unsloth Studio in your default browser after launch? %s " "$_browser_hint" + read -r _browser_reply /dev/null || true) + ( + _pibw_deadline=$(($(date +%s) + 120)) + while [ "$(date +%s)" -lt "$_pibw_deadline" ]; do + _pibw_resp=$(curl -fsS --max-time 1 "http://127.0.0.1:$_pibw_port/api/health" 2>/dev/null \ + || wget -qO- --timeout=1 "http://127.0.0.1:$_pibw_port/api/health" 2>/dev/null \ + || true) + case "$_pibw_resp" in + *'"Unsloth UI Backend"'*) + if [ -n "$_pibw_id" ]; then + case "$_pibw_resp" in + *"\"studio_root_id\":\"$_pibw_id\""*|*"\"studio_root_id\": \"$_pibw_id\""*) ;; + *) sleep 1; continue ;; + esac + fi + if [ "$(uname)" = "Darwin" ] && command -v open >/dev/null 2>&1; then + open "$_pibw_url" 2>/dev/null + elif grep -qi microsoft /proc/version 2>/dev/null; then + if command -v powershell.exe >/dev/null 2>&1; then + powershell.exe -NoProfile -Command "Start-Process '$_pibw_url'" >/dev/null 2>&1 + elif command -v cmd.exe >/dev/null 2>&1; then + cmd.exe /c start "" "$_pibw_url" >/dev/null 2>&1 + elif command -v xdg-open >/dev/null 2>&1; then + xdg-open "$_pibw_url" >/dev/null 2>&1 + fi + elif command -v xdg-open >/dev/null 2>&1; then + xdg-open "$_pibw_url" >/dev/null 2>&1 + fi + exit 0 + ;; + esac + sleep 1 + done + ) & +} + # 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. @@ -3242,6 +3391,15 @@ if [ "$_SKIP_AUTOSTART" != true ] && [ -t 1 ]; then case "${_reply:-y}" in [Yy]*|"") step "launch" "starting Unsloth Studio..." + _post_install_port=$(_find_post_install_port 8888 20) || _post_install_port=8888 + case "$_post_install_port" in + ''|*[!0-9]*) _post_install_port=8888 ;; + esac + # Open the browser once the server is up, unless opted out. The + # server prints its own URL, so no watcher is needed when off. + if [ "${_STUDIO_OPEN_BROWSER:-1}" != "0" ]; then + _post_install_browser_watch "$_post_install_port" + fi # Detach stdin from the `curl | sh` pipe: as a foreground server the # studio would otherwise drain the rest of this piped script, leaving # the shell to die parsing the now-truncated tail (`unexpected fi`). @@ -3250,7 +3408,7 @@ if [ "$_SKIP_AUTOSTART" != true ] && [ -t 1 ]; then trap '' INT # `|| ...`: capture the exit code without set -e aborting first. _LAUNCH_EXIT=0 - (trap - INT; exec "$VENV_DIR/bin/unsloth" studio -p 8888 "\$_css_launcher"/{found=1} found{print} /^LAUNCHER_EOF$/{found=0}' "$INSTALL_SH") +assert_contains \ + "launcher template: --no-browser argument handled" \ + "$_launcher" "--no-browser) OPEN_BROWSER=0" +assert_contains \ + "launcher template: UNSLOTH_STUDIO_NO_BROWSER env var handled" \ + "$_launcher" "UNSLOTH_STUDIO_NO_BROWSER" +# Mixed-case falsy values (False, Off) must not disable, matching the +# PowerShell launcher's case-insensitive -notin. +assert_contains \ + "launcher template: env var check is case-insensitive" \ + "$_launcher" "tr '[:upper:]' '[:lower:]'" +assert_contains \ + "launcher template: studio.conf preference is the default" \ + "$_launcher" 'OPEN_BROWSER="${STUDIO_OPEN_BROWSER:-1}"' +assert_contains \ + "launcher template: _open_browser is gated on OPEN_BROWSER" \ + "$_launcher" '[ "$OPEN_BROWSER" = "0" ]' +assert_contains \ + "launcher template: URL still printed when auto-open is off" \ + "$_launcher" "Unsloth Studio is running at:" + +echo "" +echo "=== install.sh installer plumbing ===" + +_installer=$(cat "$INSTALL_SH") +assert_contains \ + "install.sh: --no-browser flag parsed" \ + "$_installer" "--no-browser) _STUDIO_OPEN_BROWSER=0" +assert_contains \ + "install.sh: preference persisted into studio.conf" \ + "$_installer" "STUDIO_OPEN_BROWSER='\$_css_open_browser'" +assert_contains \ + "install.sh: existing studio.conf choice preserved on refresh" \ + "$_installer" "s/^STUDIO_OPEN_BROWSER=" +assert_contains \ + "install.sh: interactive prompt asks about browser auto-open" \ + "$_installer" "Open Unsloth Studio in your default browser after launch?" +# A reinstall that accepts the prompt default must keep the saved choice. +assert_contains \ + "install.sh: prompt Enter keeps the persisted preference" \ + "$_installer" '*) _STUDIO_OPEN_BROWSER="${_existing_open_browser:-1}"' +# The WSL Strix Halo reroute must forward an explicit browser choice. +assert_contains \ + "install.sh: reroute forwards --no-browser" \ + "$_installer" '[ "$_STUDIO_OPEN_BROWSER" = "0" ] && _rr_args="$_rr_args --no-browser"' +# The post-install foreground launch honors the preference too. +assert_contains \ + "install.sh: post-install launch opens browser via gated watcher" \ + "$_installer" '_post_install_browser_watch "$_post_install_port"' +assert_contains \ + "install.sh: post-install launch selects a free port" \ + "$_installer" "_find_post_install_port()" +assert_contains \ + "install.sh: server uses the watcher-selected port" \ + "$_installer" 'studio -p "$_post_install_port"' +# The --shortcuts-only early exit must not EPIPE a curl | sh pipeline. +assert_contains \ + "install.sh: shortcuts-only exit drains piped stdin" \ + "$_installer" '[ ! -t 0 ] && cat > /dev/null' + +echo "" +echo "=== install.sh _open_browser gating (functional) ===" + +# Extract the _open_browser function from the (column-0) launcher heredoc and +# drive it with stubbed browser openers on PATH. +_fn=$(printf '%s\n' "$_launcher" | awk '/^_open_browser\(\) \{/{found=1} found{print} found && /^\}/{exit}') +if [ -z "$_fn" ]; then + echo " FAIL: could not extract _open_browser from launcher template" + FAIL=$((FAIL + 1)) +else + _tmp=$(mktemp -d) + trap 'rm -rf "$_tmp"' EXIT + for _stub in open xdg-open; do + printf '#!/bin/sh\necho "BROWSER_OPENED:$1" >> "$RECORD"\n' > "$_tmp/$_stub" + chmod +x "$_tmp/$_stub" + done + + # Off: no browser process, URL echoed instead. + _out=$(RECORD="$_tmp/record_off" PATH="$_tmp:$PATH" bash -c \ + "OPEN_BROWSER=0; $_fn; _open_browser http://localhost:9999") + assert_contains \ + "OPEN_BROWSER=0 prints the URL" \ + "$_out" "Unsloth Studio is running at: http://localhost:9999" + if [ -f "$_tmp/record_off" ]; then + echo " FAIL: OPEN_BROWSER=0 still invoked a browser opener" + FAIL=$((FAIL + 1)) + else + echo " PASS: OPEN_BROWSER=0 does not invoke a browser opener" + PASS=$((PASS + 1)) + fi + + # On (default): browser opener invoked with the URL. + RECORD="$_tmp/record_on" PATH="$_tmp:$PATH" bash -c \ + "OPEN_BROWSER=1; $_fn; _open_browser http://localhost:9999" > /dev/null + # xdg-open is backgrounded inside _open_browser; give the stub a moment. + _i=0 + while [ ! -s "$_tmp/record_on" ] && [ "$_i" -lt 20 ]; do + sleep 0.1 + _i=$((_i + 1)) + done + assert_contains \ + "OPEN_BROWSER=1 invokes a browser opener with the URL" \ + "$(cat "$_tmp/record_on" 2>/dev/null)" "BROWSER_OPENED:http://localhost:9999" +fi + +echo "" +echo "=== install.ps1 launcher template ===" + +# grep the file directly: piping the whole installer into grep -q trips +# SIGPIPE noise from echo once grep exits on first match. +assert_file_contains() { + _label="$1"; _file="$2"; _needle="$3" + if grep -qF -- "$_needle" "$_file"; then + echo " PASS: $_label" + PASS=$((PASS + 1)) + else + echo " FAIL: $_label (expected to find '$_needle')" + FAIL=$((FAIL + 1)) + fi +} + +assert_file_contains \ + "install.ps1: --no-browser flag parsed" \ + "$INSTALL_PS1" "\"--no-browser\" { \$OpenBrowserPref = '0' }" +assert_file_contains \ + "install.ps1: preference baked into launch-studio.ps1" \ + "$INSTALL_PS1" "openBrowserDefault = '\$_openBrowser'" +assert_file_contains \ + "install.ps1: launcher honors UNSLOTH_STUDIO_NO_BROWSER" \ + "$INSTALL_PS1" "UNSLOTH_STUDIO_NO_BROWSER" +assert_file_contains \ + "install.ps1: gated helper defined" \ + "$INSTALL_PS1" "function Open-StudioUrl {" +assert_file_contains \ + "install.ps1: interactive prompt asks about browser auto-open" \ + "$INSTALL_PS1" "Open Unsloth Studio in your default browser after launch?" +assert_file_contains \ + "install.ps1: prompt Enter keeps the baked preference" \ + "$INSTALL_PS1" 'elseif ($_existingPref) { $_existingPref }' +assert_file_contains \ + "install.ps1: post-install launch opens browser via gated watcher" \ + "$INSTALL_PS1" 'Start-Job -ScriptBlock $_browserWatch' +assert_file_contains \ + "install.ps1: post-install launch selects a free port" \ + "$INSTALL_PS1" 'function Find-PostInstallStudioPort {' +assert_file_contains \ + "install.ps1: watcher receives the selected port" \ + "$INSTALL_PS1" 'ArgumentList @($_watchRootId, $_launchPort)' +assert_file_contains \ + "install.ps1: server uses the watcher-selected port" \ + "$INSTALL_PS1" '& $UnslothExe studio -p $_launchPort' +# All launcher URL opens must route through the gated helper. The one +# allowed direct call is inside the post-install $_browserWatch scriptblock, +# whose Start-Job call site is itself gated on the preference. +_ps1_direct_open=$(grep -cE 'Start-Process "http://localhost:' "$INSTALL_PS1" || true) +_ps1_watch_open=$(awk '/\$_browserWatch = \{/{f=1} f && /^ \}$/{exit} f' "$INSTALL_PS1" \ + | grep -cE 'Start-Process "http://localhost:' || true) +if [ "$_ps1_direct_open" -eq 1 ] && [ "$_ps1_watch_open" -eq 1 ]; then + echo " PASS: only the gated browser watcher opens a URL directly" + PASS=$((PASS + 1)) +else + echo " FAIL: found $_ps1_direct_open direct URL opens ($_ps1_watch_open in the watcher); all others must route through Open-StudioUrl" + FAIL=$((FAIL + 1)) +fi + +echo "" +echo "=== Results ===" +echo " PASS: $PASS" +echo " FAIL: $FAIL" +if [ "$FAIL" -gt 0 ]; then + echo "FAILED" + exit 1 +fi +echo "ALL PASSED"