* studio: regenerate desktop launcher on `unsloth studio update` Today `unsloth studio update` only mutates the venv. The macOS .app bundle, the Linux .desktop file, and the shared launch-studio.sh stub bake their paths and `studio_install_id` at install time and never refresh. Users who update an existing Studio install report the Dock / Applications icon still pointing at the old launcher; only a fresh `curl ... install.sh | sh` fixes it because that path re-enters install.sh's create_studio_shortcuts. Wire the same logic into the update path: - install.sh: add --shortcuts-only. Skips the heavy install steps, resolves STUDIO_HOME / OS / DATA_DIR through the existing _resolve_studio_destinations + platform detection, then calls create_studio_shortcuts and exits. - unsloth_cli/commands/studio.py: after setup.sh succeeds, call install.sh with --shortcuts-only. Prefers a local checkout's install.sh (when STUDIO_LOCAL_REPO is set) or one shipped under _PACKAGE_ROOT, and falls back to fetching the upstream installer from https://unsloth.ai/install.sh for PyPI-installed users (the wheel does not ship install.sh). Net effect: `unsloth studio update` now refreshes the macOS .app stub, launcher script, studio.conf, and Linux .desktop entry on every update, so the desktop icon stays in sync with the venv that setup.sh just updated. Env-override and Tauri modes keep their existing behavior (no persistent menu shortcuts, but the launch-studio.sh is still regenerated). Windows is unchanged here; setup.ps1 already handles its own Start Menu / Desktop .lnk creation on update. * studio: also regenerate Windows .lnk shortcuts on update Mirror the macOS fix: install.ps1 gains --shortcuts-only that short-circuits to New-StudioShortcuts, and unsloth studio update calls it after setup.ps1 the same way it now does on macOS / Linux. PyPI installs do not ship install.ps1, so the Python helper fetches the upstream script from https://unsloth.ai/install.ps1 and pipes it into powershell.exe -Command - with an explicit Install-UnslothStudio call appended (irm | iex relies on the trailing @args, which is empty when launched from stdin). setup.ps1 alone never recreates the Start Menu / Desktop .lnk targets or the launch-studio.{ps1,vbs} scripts, so without this update users on Windows hit the same stale-icon regression that triggered the macOS PR. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * studio: rename unsloth.exe to .deleteme before update on Windows Pip's editable reinstall calls uninstall first, which deletes every RECORD entry. unsloth.exe is one of them, and Windows refuses to delete a file whose image is mapped into the running process tree. The first unsloth studio update after install therefore fails with: OSError: [WinError 32] The process cannot access the file because it is being used by another process: ...\Scripts\unsloth.exe Windows does allow renaming an in-use exe, so move it aside before _run_setup_script kicks pip. pip then drops a fresh unsloth.exe at the original path; the *.exe.deleteme left behind is cleaned up at the start of the next update once the previous shim has exited. * studio: rename unsloth.exe from setup.ps1 to reliably bypass exe lock * studio: print python -m workaround when Windows exe lock blocks update * studio: use python -c hint (unsloth_cli has no __main__) * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * install.sh: reshape --shortcuts-only Tauri guard to pass exit-order test * shorter comments in update / launcher regen logic * studio update: env-mode passthrough + non-silent shortcuts-only error * studio update: address codex/gemini PR review - Strip install.ps1's `Install-UnslothStudio @args` auto-invoke before appending an explicit `--shortcuts-only` call so PyPI Windows installs don't re-run the full installer over stdin. - subprocess.run(input=wrapper, ...) now uses encoding="utf-8" so box drawing chars in install.ps1 don't UnicodeEncodeError on CP1252. - Wrap _run_setup_script in try/except to restore unsloth.exe from .deleteme if setup fails, and mirror that rollback inside setup.ps1 when install_python_stack.py exits non-zero. - Capture subprocess return codes in _refresh_desktop_shortcuts and echo a one-line warning on non-zero so silent stale-shortcut failures surface. - Drop --local from the Windows lock-recovery hint so users on PyPI installs don't accidentally switch into editable-checkout mode. - Quote $VENV_ABS_BIN/unsloth in the install.sh shortcuts-only error so paths with spaces print legibly. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * studio update: harden Windows refresh per multi-reviewer pass - PowerShell stdin path now writes the wrapper to a UTF-8 BOM tempfile and runs it via `-File`. `powershell.exe -Command -` decodes stdin with the OEM code page, which mangles box-drawing chars in the fetched install.ps1; -File reads the BOM and decodes UTF-8 cleanly. - _restore_self_exe_lock_windows now treats a zero-byte unsloth.exe as a partial-write and prefers the .deleteme copy. setup.ps1 mirrors the same check. - _release_self_exe_lock_windows uses os.replace for atomic overwrite so a stale .deleteme from an aborted prior update doesn't break the rename. - Lock-recovery hint mentions that --local should be re-added when the user installed from a repo checkout. * studio update: respect Tauri context and tidy Windows .deleteme Tauri's update.rs spawns `unsloth studio update`; without a signal, the CLI's _refresh_desktop_shortcuts would call install.{sh,ps1} --shortcuts-only and create duplicate ~/Applications/Unsloth Studio.app (or .desktop / .lnk) entries that collide with the Tauri bundle. - update.rs now sets UNSLOTH_TAURI_UPDATE=1 on the spawned child. - studio.py's update() skips _refresh_desktop_shortcuts when that env var is set; Tauri owns its own bundle entries. - After a successful Windows update, drop the .deleteme orphan so repeated updates don't accumulate stale binaries that could later be promoted by _restore_self_exe_lock_windows on a cross-version failure. - Tempfile for the PyPI-fallback PowerShell path now uses an unsloth-studio-refresh- prefix so AV/EDR rules and user greps can identify it. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * studio update: drop obsolete WinError 32 hint, echo Tauri skip The rename trick in _release_self_exe_lock_windows + setup.ps1's restore now handle the .exe-lock case in-flow; the printed hint suggested re-running update via venv python, but that just re-enters the same update() and hits the same failure if the rename didn't help. Removing the misleading hint and its helper. Also surface a one-line typer.echo when refresh is skipped under UNSLOTH_TAURI_UPDATE so --verbose logs make the branch visible. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --------- Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
1655 lines
80 KiB
PowerShell
1655 lines
80 KiB
PowerShell
# Unsloth Studio Installer for Windows PowerShell
|
|
# Usage: irm https://raw.githubusercontent.com/unslothai/unsloth/main/install.ps1 | iex
|
|
# Local: Set-ExecutionPolicy -Scope Process -ExecutionPolicy Bypass; .\install.ps1 --local
|
|
# NoTorch: .\install.ps1 --no-torch (skip PyTorch, GGUF-only mode)
|
|
# Test: .\install.ps1 --package roland-sloth
|
|
#
|
|
# Env vars (priority: UNSLOTH_STUDIO_HOME > STUDIO_HOME > USERPROFILE-redirect > default):
|
|
# UNSLOTH_STUDIO_HOME / STUDIO_HOME = path -> install under that path
|
|
# (DataDir nests inside; user PATH not modified persistently).
|
|
# Default ($USERPROFILE\.unsloth\studio) is preserved when no env var is set.
|
|
|
|
function Install-UnslothStudio {
|
|
$ErrorActionPreference = "Stop"
|
|
$script:UnslothVerbose = ($env:UNSLOTH_VERBOSE -eq "1")
|
|
|
|
# ── Tauri structured output ──
|
|
function Write-TauriLog {
|
|
param([string]$Tag, [string]$Message)
|
|
if ($TauriMode) {
|
|
Write-Host "[TAURI:$Tag] $Message"
|
|
}
|
|
}
|
|
|
|
function Format-TauriDiagBool {
|
|
param([bool]$Value)
|
|
if ($Value) { return "true" }
|
|
return "false"
|
|
}
|
|
|
|
function Get-TauriDiagArch {
|
|
$arch = [string]$env:PROCESSOR_ARCHITECTURE
|
|
if ([string]::IsNullOrWhiteSpace($arch)) {
|
|
try { $arch = [System.Runtime.InteropServices.RuntimeInformation]::OSArchitecture.ToString() } catch { $arch = "unknown" }
|
|
}
|
|
$arch = $arch.ToLowerInvariant()
|
|
switch ($arch) {
|
|
"amd64" { return "x86_64" }
|
|
"x64" { return "x86_64" }
|
|
"arm64" { return "arm64" }
|
|
"x86" { return "x86" }
|
|
default { return ($arch -replace '[^a-z0-9_.-]', '_') }
|
|
}
|
|
}
|
|
|
|
function Get-TauriTorchIndexFamily {
|
|
param([string]$TorchIndexUrl)
|
|
if ($SkipTorch) { return "none" }
|
|
if ([string]::IsNullOrWhiteSpace($TorchIndexUrl)) { return "none" }
|
|
$leaf = ($TorchIndexUrl.TrimEnd('/') -split '/')[-1].ToLowerInvariant()
|
|
if (@("cpu", "cu118", "cu124", "cu126", "cu128", "cu130") -contains $leaf) { return $leaf }
|
|
if ($leaf -match '^rocm[0-9]+\.[0-9]+$') { return $leaf }
|
|
return "auto"
|
|
}
|
|
|
|
function Get-TauriGpuBranch {
|
|
param([string]$TorchIndexFamily)
|
|
if ($SkipTorch) { return "no_torch" }
|
|
if ($TorchIndexFamily -like "cu*") { return "cuda" }
|
|
if ($TorchIndexFamily -like "rocm*") { return "rocm" }
|
|
if ($TorchIndexFamily -eq "cpu") { return "cpu" }
|
|
return "unknown"
|
|
}
|
|
|
|
function Write-TauriDiag {
|
|
param(
|
|
[string]$GpuBranch = "unknown",
|
|
[string]$TorchIndexFamily = "none",
|
|
[string]$PythonVersionForDiag = $PythonVersion
|
|
)
|
|
if ([string]::IsNullOrWhiteSpace($PythonVersionForDiag)) { $PythonVersionForDiag = "unknown" }
|
|
Write-TauriLog "DIAG" "diag_schema=1 platform=windows arch=$(Get-TauriDiagArch) python_version=$($PythonVersionForDiag.ToLowerInvariant()) skip_torch=$(Format-TauriDiagBool $SkipTorch) mac_intel=false gpu_branch=$GpuBranch torch_index_family=$TorchIndexFamily"
|
|
}
|
|
|
|
function Exit-InstallFailure {
|
|
param(
|
|
[Parameter(Mandatory = $true)][string]$Message,
|
|
[int]$Code = 1
|
|
)
|
|
if ($Code -eq 0) { $Code = 1 }
|
|
Write-TauriLog "ERROR" $Message
|
|
if (Get-Command Restore-StudioVenvRollback -CommandType Function -ErrorAction SilentlyContinue) {
|
|
Restore-StudioVenvRollback
|
|
}
|
|
if ($TauriMode) {
|
|
exit $Code
|
|
}
|
|
}
|
|
|
|
# ── Parse flags ──
|
|
$StudioLocalInstall = $false
|
|
$PackageName = "unsloth"
|
|
$RepoRoot = ""
|
|
$TauriMode = $false
|
|
$SkipTorch = $false
|
|
$ShortcutsOnly = $false
|
|
$argList = $args
|
|
for ($i = 0; $i -lt $argList.Count; $i++) {
|
|
switch ($argList[$i]) {
|
|
"--local" { $StudioLocalInstall = $true }
|
|
"--tauri" { $TauriMode = $true }
|
|
"--no-torch" { $SkipTorch = $true }
|
|
"--verbose" { $script:UnslothVerbose = $true }
|
|
"-v" { $script:UnslothVerbose = $true }
|
|
"--shortcuts-only" { $ShortcutsOnly = $true }
|
|
"--package" {
|
|
$i++
|
|
if ($i -ge $argList.Count) {
|
|
Write-Host "[ERROR] --package requires an argument." -ForegroundColor Red
|
|
return (Exit-InstallFailure "--package requires an argument.")
|
|
}
|
|
$PackageName = $argList[$i]
|
|
}
|
|
}
|
|
}
|
|
# Propagate to child processes so they also respect verbose mode.
|
|
# Process-scoped -- does not persist.
|
|
if ($script:UnslothVerbose) {
|
|
$env:UNSLOTH_VERBOSE = '1'
|
|
}
|
|
|
|
if ($StudioLocalInstall) {
|
|
$RepoRoot = (Resolve-Path (Split-Path -Parent $PSCommandPath)).Path
|
|
if (-not (Test-Path (Join-Path $RepoRoot "pyproject.toml"))) {
|
|
Write-Host "[ERROR] --local must be run from the unsloth repo root (pyproject.toml not found at $RepoRoot)" -ForegroundColor Red
|
|
return (Exit-InstallFailure "--local must be run from the unsloth repo root")
|
|
}
|
|
}
|
|
|
|
# Validate --package to prevent injection into shell/Python commands
|
|
if ($PackageName -notmatch '^[a-zA-Z0-9][a-zA-Z0-9._-]*$') {
|
|
Write-Host "[ERROR] --package name contains invalid characters (allowed: a-z A-Z 0-9 . _ -)" -ForegroundColor Red
|
|
return (Exit-InstallFailure "--package name contains invalid characters")
|
|
}
|
|
|
|
$PythonVersion = "3.13"
|
|
|
|
# Resolve install destinations. Priority: UNSLOTH_STUDIO_HOME, then
|
|
# STUDIO_HOME alias, then USERPROFILE-redirect, then default.
|
|
# Reject whitespace-only values so " " is treated as unset (matches the
|
|
# Python resolvers' .strip()), preventing install/runtime layout drift.
|
|
$envOverrideVar = $null
|
|
$envOverride = $null
|
|
if (-not [string]::IsNullOrWhiteSpace($env:UNSLOTH_STUDIO_HOME)) {
|
|
$envOverrideVar = "UNSLOTH_STUDIO_HOME"
|
|
$envOverride = $env:UNSLOTH_STUDIO_HOME.Trim()
|
|
} elseif (-not [string]::IsNullOrWhiteSpace($env:STUDIO_HOME)) {
|
|
$envOverrideVar = "STUDIO_HOME"
|
|
$envOverride = $env:STUDIO_HOME.Trim()
|
|
}
|
|
|
|
# Custom Studio roots are not supported with --tauri (desktop app still
|
|
# resolves %USERPROFILE%\.unsloth\studio). Pass through if override == legacy.
|
|
if ($TauriMode -and $envOverride) {
|
|
$_tauriOverride = $envOverride
|
|
if ($_tauriOverride -eq "~" -or $_tauriOverride -like "~/*" -or $_tauriOverride -like "~\*") {
|
|
$_tauriOverride = (Join-Path $env:USERPROFILE $_tauriOverride.Substring(1).TrimStart('/','\'))
|
|
}
|
|
try {
|
|
$_tauriOverride = [System.IO.Path]::GetFullPath($_tauriOverride)
|
|
} catch {}
|
|
$_legacyTauriRoot = Join-Path $env:USERPROFILE ".unsloth\studio"
|
|
try {
|
|
$_legacyTauriRoot = [System.IO.Path]::GetFullPath($_legacyTauriRoot)
|
|
} catch {}
|
|
# Strip trailing separators so ".../studio\" matches ".../studio".
|
|
$_trimSeps = @(
|
|
[System.IO.Path]::DirectorySeparatorChar,
|
|
[System.IO.Path]::AltDirectorySeparatorChar
|
|
)
|
|
$_tauriOverride = $_tauriOverride.TrimEnd($_trimSeps)
|
|
$_legacyTauriRoot = $_legacyTauriRoot.TrimEnd($_trimSeps)
|
|
if ($_tauriOverride -ne $_legacyTauriRoot) {
|
|
Write-Host "ERROR: $envOverrideVar is not supported with --tauri." -ForegroundColor Red
|
|
Write-Host " The desktop app still uses the legacy %USERPROFILE%\.unsloth\studio root." -ForegroundColor Red
|
|
Write-Host " Run install.ps1 without --tauri for custom-root shell installs," -ForegroundColor Yellow
|
|
Write-Host " or unset the env var for default desktop installs." -ForegroundColor Yellow
|
|
throw "$envOverrideVar is not supported with --tauri."
|
|
}
|
|
}
|
|
|
|
$defaultProfile = $null
|
|
try { $defaultProfile = [Environment]::GetFolderPath("UserProfile") } catch {}
|
|
|
|
# LOCALAPPDATA may be unset in service / CI contexts; Join-Path would abort
|
|
# under ErrorActionPreference=Stop without this guard.
|
|
$defaultDataDir = if ($env:LOCALAPPDATA -and -not [string]::IsNullOrWhiteSpace($env:LOCALAPPDATA)) {
|
|
Join-Path $env:LOCALAPPDATA "Unsloth Studio"
|
|
} else { $null }
|
|
|
|
if ($envOverride) {
|
|
# Tilde expansion: env vars aren't subject to it when quoted on assignment.
|
|
if ($envOverride -eq "~" -or $envOverride -like "~/*" -or $envOverride -like "~\*") {
|
|
$envOverride = (Join-Path $env:USERPROFILE $envOverride.Substring(1).TrimStart('/','\'))
|
|
}
|
|
try {
|
|
# .NET API: New-Item -Path treats brackets as wildcards and has no
|
|
# -LiteralPath in PS 5.1, so a root like C:\studio[abc] would fail.
|
|
[System.IO.Directory]::CreateDirectory($envOverride) | Out-Null
|
|
$StudioHome = (Resolve-Path -LiteralPath $envOverride).Path
|
|
} catch {
|
|
Write-Host "ERROR: $envOverrideVar=$envOverride cannot be created or accessed." -ForegroundColor Red
|
|
throw "$envOverrideVar=$envOverride cannot be created or accessed."
|
|
}
|
|
$probe = Join-Path $StudioHome (".unsloth-write-probe-" + [guid]::NewGuid())
|
|
try {
|
|
# WriteAllText: literal-path safe + closes handle so Remove-Item works.
|
|
[System.IO.File]::WriteAllText($probe, "")
|
|
Remove-Item -LiteralPath $probe -Force -ErrorAction SilentlyContinue
|
|
} catch {
|
|
Write-Host "ERROR: $envOverrideVar=$StudioHome is not writable." -ForegroundColor Red
|
|
throw "$envOverrideVar=$StudioHome is not writable."
|
|
}
|
|
$StudioDataDir = Join-Path $StudioHome "share"
|
|
$StudioRedirectMode = 'env'
|
|
} elseif ($defaultProfile -and $env:USERPROFILE -and ($env:USERPROFILE -ne $defaultProfile)) {
|
|
$StudioHome = Join-Path $env:USERPROFILE ".unsloth\studio"
|
|
$StudioDataDir = $defaultDataDir
|
|
$StudioRedirectMode = 'profile'
|
|
} else {
|
|
$StudioHome = Join-Path $env:USERPROFILE ".unsloth\studio"
|
|
$StudioDataDir = $defaultDataDir
|
|
$StudioRedirectMode = 'default'
|
|
}
|
|
$VenvDir = Join-Path $StudioHome "unsloth_studio"
|
|
|
|
$Rule = [string]::new([char]0x2500, 52)
|
|
$Sloth = [char]::ConvertFromUtf32(0x1F9A5)
|
|
|
|
function Enable-StudioVirtualTerminal {
|
|
if ($env:NO_COLOR) { return $false }
|
|
try {
|
|
if (-not ("StudioVT.Native" -as [type])) {
|
|
Add-Type -Namespace StudioVT -Name Native -MemberDefinition @'
|
|
[DllImport("kernel32.dll")] public static extern IntPtr GetStdHandle(int nStdHandle);
|
|
[DllImport("kernel32.dll")] public static extern bool GetConsoleMode(IntPtr h, out uint m);
|
|
[DllImport("kernel32.dll")] public static extern bool SetConsoleMode(IntPtr h, uint m);
|
|
'@ -ErrorAction Stop
|
|
}
|
|
$h = [StudioVT.Native]::GetStdHandle(-11)
|
|
[uint32]$mode = 0
|
|
if (-not [StudioVT.Native]::GetConsoleMode($h, [ref]$mode)) { return $false }
|
|
$mode = $mode -bor 0x0004
|
|
return [StudioVT.Native]::SetConsoleMode($h, $mode)
|
|
} catch {
|
|
return $false
|
|
}
|
|
}
|
|
$script:StudioVtOk = Enable-StudioVirtualTerminal
|
|
|
|
function Get-StudioAnsi {
|
|
param(
|
|
[Parameter(Mandatory = $true)]
|
|
[ValidateSet('Title', 'Dim', 'Ok', 'Warn', 'Err', 'Reset')]
|
|
[string]$Kind
|
|
)
|
|
$e = [char]27
|
|
switch ($Kind) {
|
|
'Title' { return "${e}[38;5;150m" }
|
|
'Dim' { return "${e}[38;5;245m" }
|
|
'Ok' { return "${e}[38;5;108m" }
|
|
'Warn' { return "${e}[38;5;136m" }
|
|
'Err' { return "${e}[91m" }
|
|
'Reset' { return "${e}[0m" }
|
|
}
|
|
}
|
|
|
|
Write-Host ""
|
|
if ($script:StudioVtOk -and -not $env:NO_COLOR) {
|
|
Write-Host (" " + (Get-StudioAnsi Title) + $Sloth + " Unsloth Studio Installer (Windows)" + (Get-StudioAnsi Reset))
|
|
Write-Host (" {0}{1}{2}" -f (Get-StudioAnsi Dim), $Rule, (Get-StudioAnsi Reset))
|
|
} else {
|
|
Write-Host (" {0} Unsloth Studio Installer (Windows)" -f $Sloth) -ForegroundColor DarkGreen
|
|
Write-Host " $Rule" -ForegroundColor DarkGray
|
|
}
|
|
Write-Host ""
|
|
|
|
# ── Helper: refresh PATH from registry (deduplicating entries) ──
|
|
# Merge order: venv Scripts (if active) > Machine > User > current $env:Path.
|
|
# Dedup compares both raw and expanded forms (%VAR% vs literal).
|
|
function Refresh-SessionPath {
|
|
$machine = [System.Environment]::GetEnvironmentVariable("Path", "Machine")
|
|
$user = [System.Environment]::GetEnvironmentVariable("Path", "User")
|
|
$venvScripts = if ($env:VIRTUAL_ENV) { Join-Path $env:VIRTUAL_ENV "Scripts" } else { $null }
|
|
$sources = @()
|
|
if ($venvScripts) { $sources += $venvScripts }
|
|
$sources += @($machine, $user, $env:Path)
|
|
$merged = ($sources | Where-Object { $_ }) -join ";"
|
|
$seen = @{}
|
|
$unique = New-Object System.Collections.Generic.List[string]
|
|
foreach ($p in $merged -split ";") {
|
|
$rawKey = $p.Trim().Trim('"').TrimEnd("\").ToLowerInvariant()
|
|
$expKey = [Environment]::ExpandEnvironmentVariables($p).Trim().Trim('"').TrimEnd("\").ToLowerInvariant()
|
|
if ($rawKey -and -not $seen.ContainsKey($rawKey) -and -not $seen.ContainsKey($expKey)) {
|
|
$seen[$rawKey] = $true
|
|
if ($expKey -and $expKey -ne $rawKey) { $seen[$expKey] = $true }
|
|
$unique.Add($p)
|
|
}
|
|
}
|
|
$env:Path = $unique -join ";"
|
|
}
|
|
|
|
# ── Helper: safely add a directory to the persistent User PATH ──
|
|
# Direct registry access preserves REG_EXPAND_SZ (avoids dotnet/runtime#1442).
|
|
# Append (default) keeps existing tools first; Prepend for must-win entries.
|
|
function Add-ToUserPath {
|
|
param(
|
|
[Parameter(Mandatory = $true)][string]$Directory,
|
|
[ValidateSet('Append','Prepend')]
|
|
[string]$Position = 'Append'
|
|
)
|
|
try {
|
|
$regKey = [Microsoft.Win32.Registry]::CurrentUser.CreateSubKey('Environment')
|
|
try {
|
|
$rawPath = $regKey.GetValue('Path', '', [Microsoft.Win32.RegistryValueOptions]::DoNotExpandEnvironmentNames)
|
|
[string[]]$entries = if ($rawPath) { $rawPath -split ';' } else { @() } # string[] prevents scalar collapse
|
|
$normalDir = $Directory.Trim().Trim('"').TrimEnd('\').ToLowerInvariant()
|
|
$expNormalDir = [Environment]::ExpandEnvironmentVariables($Directory).Trim().Trim('"').TrimEnd('\').ToLowerInvariant()
|
|
$kept = New-Object System.Collections.Generic.List[string]
|
|
$matchIndices = New-Object System.Collections.Generic.List[int]
|
|
for ($i = 0; $i -lt $entries.Count; $i++) {
|
|
$stripped = $entries[$i].Trim().Trim('"')
|
|
$rawNorm = $stripped.TrimEnd('\').ToLowerInvariant()
|
|
$expNorm = [Environment]::ExpandEnvironmentVariables($stripped).TrimEnd('\').ToLowerInvariant()
|
|
$isMatch = ($rawNorm -and ($rawNorm -eq $normalDir -or $rawNorm -eq $expNormalDir)) -or
|
|
($expNorm -and ($expNorm -eq $normalDir -or $expNorm -eq $expNormalDir))
|
|
if ($isMatch) {
|
|
$matchIndices.Add($i)
|
|
continue
|
|
}
|
|
$kept.Add($entries[$i])
|
|
}
|
|
$alreadyPresent = $matchIndices.Count -gt 0
|
|
if ($alreadyPresent -and $Position -eq 'Append') { # Append: idempotent no-op
|
|
return $false
|
|
}
|
|
if ($alreadyPresent -and $Position -eq 'Prepend' -and # Prepend: no-op if already at front
|
|
$matchIndices.Count -eq 1 -and $matchIndices[0] -eq 0) {
|
|
return $false
|
|
}
|
|
# One-time backup under HKCU\Software\Unsloth\PathBackup
|
|
if ($rawPath) {
|
|
try {
|
|
$backupKey = [Microsoft.Win32.Registry]::CurrentUser.CreateSubKey('Software\Unsloth')
|
|
try {
|
|
$existingBackup = $backupKey.GetValue('PathBackup', $null)
|
|
if (-not $existingBackup) {
|
|
$backupKey.SetValue('PathBackup', $rawPath, [Microsoft.Win32.RegistryValueKind]::ExpandString)
|
|
}
|
|
} finally {
|
|
$backupKey.Close()
|
|
}
|
|
} catch { }
|
|
}
|
|
if (-not $rawPath) {
|
|
Write-Host "[WARN] User PATH is empty - initializing with $Directory" -ForegroundColor Yellow
|
|
}
|
|
$newPath = if ($rawPath) {
|
|
if ($Position -eq 'Prepend') {
|
|
(@($Directory) + $kept) -join ';'
|
|
} else {
|
|
($kept + @($Directory)) -join ';'
|
|
}
|
|
} else {
|
|
$Directory
|
|
}
|
|
if ($newPath -ceq $rawPath) { # no actual change
|
|
return $false
|
|
}
|
|
$regKey.SetValue('Path', $newPath, [Microsoft.Win32.RegistryValueKind]::ExpandString)
|
|
# Broadcast WM_SETTINGCHANGE via dummy env-var roundtrip.
|
|
# [NullString]::Value avoids PS 7.5+/.NET 9 $null-to-"" coercion.
|
|
try {
|
|
$d = "UnslothPathRefresh_$([guid]::NewGuid().ToString('N').Substring(0,8))"
|
|
[Environment]::SetEnvironmentVariable($d, '1', 'User')
|
|
[Environment]::SetEnvironmentVariable($d, [NullString]::Value, 'User')
|
|
} catch { }
|
|
return $true
|
|
} finally {
|
|
$regKey.Close()
|
|
}
|
|
} catch {
|
|
Write-Host "[WARN] Could not update User PATH: $($_.Exception.Message)" -ForegroundColor Yellow
|
|
return $false
|
|
}
|
|
}
|
|
|
|
function step {
|
|
param(
|
|
[Parameter(Mandatory = $true)][string]$Label,
|
|
[Parameter(Mandatory = $true)][string]$Value,
|
|
[string]$Color = "Green"
|
|
)
|
|
if ($script:StudioVtOk -and -not $env:NO_COLOR) {
|
|
$dim = Get-StudioAnsi Dim
|
|
$rst = Get-StudioAnsi Reset
|
|
$val = switch ($Color) {
|
|
'Green' { Get-StudioAnsi Ok }
|
|
'Yellow' { Get-StudioAnsi Warn }
|
|
'Red' { Get-StudioAnsi Err }
|
|
'DarkGray' { Get-StudioAnsi Dim }
|
|
default { Get-StudioAnsi Ok }
|
|
}
|
|
$padded = if ($Label.Length -ge 15) { $Label.Substring(0, 15) } else { $Label.PadRight(15) }
|
|
Write-Host (" {0}{1}{2}{3}{4}{2}" -f $dim, $padded, $rst, $val, $Value)
|
|
} else {
|
|
$padded = if ($Label.Length -ge 15) { $Label.Substring(0, 15) } else { $Label.PadRight(15) }
|
|
Write-Host (" {0}" -f $padded) -NoNewline -ForegroundColor DarkGray
|
|
$fc = switch ($Color) {
|
|
'Green' { 'DarkGreen' }
|
|
'Yellow' { 'Yellow' }
|
|
'Red' { 'Red' }
|
|
'DarkGray' { 'DarkGray' }
|
|
default { 'DarkGreen' }
|
|
}
|
|
Write-Host $Value -ForegroundColor $fc
|
|
}
|
|
}
|
|
|
|
function substep {
|
|
param(
|
|
[Parameter(Mandatory = $true)][string]$Message,
|
|
[string]$Color = "DarkGray"
|
|
)
|
|
if ($script:StudioVtOk -and -not $env:NO_COLOR) {
|
|
$msgCol = switch ($Color) {
|
|
'Yellow' { (Get-StudioAnsi Warn) }
|
|
'Red' { (Get-StudioAnsi Err) }
|
|
default { (Get-StudioAnsi Dim) }
|
|
}
|
|
$pad = "".PadRight(15)
|
|
Write-Host (" {0}{1}{2}{3}" -f $msgCol, $pad, $Message, (Get-StudioAnsi Reset))
|
|
} else {
|
|
$fc = switch ($Color) {
|
|
'Yellow' { 'Yellow' }
|
|
'Red' { 'Red' }
|
|
default { 'DarkGray' }
|
|
}
|
|
Write-Host (" {0,-15}{1}" -f "", $Message) -ForegroundColor $fc
|
|
}
|
|
}
|
|
|
|
# Run native commands quietly by default to match install.sh behavior.
|
|
# Full command output is shown only when --verbose / UNSLOTH_VERBOSE=1.
|
|
function Invoke-InstallCommand {
|
|
param(
|
|
[Parameter(Mandatory = $true)][ScriptBlock]$Command
|
|
)
|
|
$prevEap = $ErrorActionPreference
|
|
$ErrorActionPreference = "Continue"
|
|
try {
|
|
# Reset to avoid stale values from prior native commands.
|
|
$global:LASTEXITCODE = 0
|
|
if ($script:UnslothVerbose) {
|
|
# Merge stderr into stdout so progress/warning output stays visible
|
|
# without flipping $? on successful native commands (PS 5.1 treats
|
|
# stderr records as errors that set $? = $false even on exit code 0).
|
|
& $Command 2>&1 | Out-Host
|
|
} else {
|
|
$output = & $Command 2>&1 | Out-String
|
|
if ($LASTEXITCODE -ne 0) {
|
|
Write-Host $output -ForegroundColor Red
|
|
}
|
|
}
|
|
return [int]$LASTEXITCODE
|
|
} finally {
|
|
$ErrorActionPreference = $prevEap
|
|
}
|
|
}
|
|
|
|
function New-StudioShortcuts {
|
|
param(
|
|
[Parameter(Mandatory = $true)][string]$UnslothExePath
|
|
)
|
|
|
|
if (-not (Test-Path -LiteralPath $UnslothExePath)) {
|
|
substep "cannot create shortcuts, unsloth.exe not found at $UnslothExePath" "Yellow"
|
|
return
|
|
}
|
|
try {
|
|
# Persist an absolute path in launcher scripts so shortcut working
|
|
# directory changes do not break process startup.
|
|
$UnslothExePath = (Resolve-Path -LiteralPath $UnslothExePath).Path
|
|
# Escape for single-quoted embedding in generated launcher script.
|
|
# This prevents runtime variable expansion for paths containing '$'.
|
|
$SingleQuotedExePath = $UnslothExePath -replace "'", "''"
|
|
|
|
# $StudioDataDir = LOCALAPPDATA\Unsloth Studio, or $StudioHome\share in env-mode.
|
|
if (-not $StudioDataDir -or [string]::IsNullOrWhiteSpace($StudioDataDir)) {
|
|
substep "DataDir path unavailable; skipped shortcut creation" "Yellow"
|
|
return
|
|
}
|
|
$appDir = $StudioDataDir
|
|
$launcherPs1 = Join-Path $appDir "launch-studio.ps1"
|
|
$launcherVbs = Join-Path $appDir "launch-studio.vbs"
|
|
$desktopDir = [Environment]::GetFolderPath("Desktop")
|
|
$desktopLink = if ($desktopDir -and $desktopDir.Trim()) {
|
|
Join-Path $desktopDir "Unsloth Studio.lnk"
|
|
} else {
|
|
$null
|
|
}
|
|
$startMenuDir = if ($env:APPDATA -and $env:APPDATA.Trim()) {
|
|
Join-Path $env:APPDATA "Microsoft\Windows\Start Menu\Programs"
|
|
} else {
|
|
$null
|
|
}
|
|
$startMenuLink = if ($startMenuDir -and $startMenuDir.Trim()) {
|
|
Join-Path $startMenuDir "Unsloth Studio.lnk"
|
|
} else {
|
|
$null
|
|
}
|
|
if (-not $desktopLink) {
|
|
substep "Desktop path unavailable; skipped desktop shortcut creation" "Yellow"
|
|
}
|
|
if (-not $startMenuLink) {
|
|
substep "APPDATA/Start Menu path unavailable; skipped Start menu shortcut creation" "Yellow"
|
|
}
|
|
$iconPath = Join-Path $appDir "unsloth.ico"
|
|
$bundledIcon = $null
|
|
if ($PSScriptRoot -and $PSScriptRoot.Trim()) {
|
|
$bundledIcon = Join-Path $PSScriptRoot "studio\frontend\public\unsloth.ico"
|
|
}
|
|
$iconUrl = "https://raw.githubusercontent.com/unslothai/unsloth/main/studio/frontend/public/unsloth.ico"
|
|
|
|
if (-not (Test-Path -LiteralPath $appDir)) {
|
|
[System.IO.Directory]::CreateDirectory($appDir) | Out-Null
|
|
}
|
|
|
|
# Same-install discriminator: per-install opaque id written once at
|
|
# install time and read by both this launcher and the backend
|
|
# (/api/health). Replaces the older sha256(resolved $StudioHome)
|
|
# scheme to (a) avoid leaking the install path on -H 0.0.0.0
|
|
# deployments and (b) sidestep launcher/backend canonicalization
|
|
# drift (Resolve-Path vs Path.resolve() junction handling). Lives
|
|
# at $StudioHome\share\ (not $appDir) so the backend can find it
|
|
# via _STUDIO_ROOT_RESOLVED / "share" / "studio_install_id"
|
|
# regardless of mode. 32 bytes of crypto random -> 64 hex chars.
|
|
$_studioIdDir = Join-Path $StudioHome "share"
|
|
if (-not (Test-Path -LiteralPath $_studioIdDir)) {
|
|
[System.IO.Directory]::CreateDirectory($_studioIdDir) | Out-Null
|
|
}
|
|
$_studioIdFile = Join-Path $_studioIdDir "studio_install_id"
|
|
$_studioRootId = ""
|
|
if ((Test-Path -LiteralPath $_studioIdFile) -and `
|
|
((Get-Item -LiteralPath $_studioIdFile).Length -gt 0)) {
|
|
$_studioRootId = ([System.IO.File]::ReadAllText($_studioIdFile)).Trim()
|
|
}
|
|
if (-not $_studioRootId) {
|
|
$_idBytes = New-Object byte[] 32
|
|
[Security.Cryptography.RandomNumberGenerator]::Create().GetBytes($_idBytes)
|
|
$_studioRootId = -join ($_idBytes | ForEach-Object { $_.ToString('x2') })
|
|
# Atomic write: write to a temp sibling then rename, so a partial
|
|
# install cannot leave a half-written id.
|
|
$_idTmp = $_studioIdFile + ".$PID.tmp"
|
|
[System.IO.File]::WriteAllText($_idTmp, $_studioRootId)
|
|
Move-Item -LiteralPath $_idTmp -Destination $_studioIdFile -Force
|
|
}
|
|
|
|
# Env-mode: persist UNSLOTH_STUDIO_HOME (and llama path) so fresh
|
|
# shells don't need to re-export, and bake per-install $portFile /
|
|
# $mutexName so concurrent custom-root launchers cannot serialize
|
|
# through one global mutex on 8888..8908. Default installs get an
|
|
# empty prefix to match pre-PR behavior.
|
|
$studioHomeExport = if ($StudioRedirectMode -eq 'env') {
|
|
# When override == legacy default, llama.cpp stays at
|
|
# ~/.unsloth/llama.cpp (one shared build). Canonicalize the
|
|
# legacy side so the comparison survives path normalization.
|
|
$_legacyStudio = Join-Path $env:USERPROFILE ".unsloth\studio"
|
|
if (Test-Path -LiteralPath $_legacyStudio -PathType Container) {
|
|
$_legacyStudio = (Resolve-Path -LiteralPath $_legacyStudio).Path
|
|
}
|
|
$_llamaPath = if ($StudioHome -eq $_legacyStudio) {
|
|
Join-Path $env:USERPROFILE ".unsloth\llama.cpp"
|
|
} else {
|
|
Join-Path $StudioHome "llama.cpp"
|
|
}
|
|
$_sq = $StudioHome -replace "'", "''"
|
|
$_llama = $_llamaPath -replace "'", "''"
|
|
$_appDirSq = $appDir -replace "'", "''"
|
|
$_appBytes = [Text.Encoding]::UTF8.GetBytes($appDir)
|
|
$_appHash = ([BitConverter]::ToString(
|
|
[Security.Cryptography.SHA256]::Create().ComputeHash($_appBytes)
|
|
) -replace '-', '').Substring(0, 16)
|
|
# UNSLOTH_LLAMA_CPP_PATH is a pre-existing user override; only default if unset.
|
|
"`$env:UNSLOTH_STUDIO_HOME = '$_sq'`nif (-not `$env:UNSLOTH_LLAMA_CPP_PATH) {`n `$env:UNSLOTH_LLAMA_CPP_PATH = '$_llama'`n}`n`$portFile = '$_appDirSq\studio.port'`n`$mutexName = 'Local\UnslothStudioLauncher-$_appHash'`n"
|
|
} else {
|
|
"`$portFile = `$null`n`$mutexName = 'Local\UnslothStudioLauncher'`n"
|
|
}
|
|
|
|
$launcherContent = @"
|
|
$studioHomeExport`$ErrorActionPreference = 'Stop'
|
|
`$basePort = 8888
|
|
`$maxPortOffset = 20
|
|
`$timeoutSec = 60
|
|
`$pollIntervalMs = 1000
|
|
`$_ExpectedStudioRootId = '$_studioRootId'
|
|
|
|
function Test-StudioHealth {
|
|
param([Parameter(Mandatory = `$true)][int]`$Port)
|
|
try {
|
|
`$url = "http://127.0.0.1:`$Port/api/health"
|
|
`$resp = Invoke-RestMethod -Uri `$url -TimeoutSec 1 -Method Get
|
|
if (-not (`$resp -and `$resp.status -eq 'healthy' -and `$resp.service -eq 'Unsloth UI Backend')) { return `$false }
|
|
# why: verify the backend belongs to THIS install via the install-time
|
|
# hex digest; raw path is not leaked over /api/health.
|
|
if (`$_ExpectedStudioRootId -and `$resp.studio_root_id -ne `$_ExpectedStudioRootId) { return `$false }
|
|
return `$true
|
|
} catch {
|
|
return `$false
|
|
}
|
|
}
|
|
|
|
function Get-CandidatePorts {
|
|
# Fast path: only probe base port + currently listening ports in range.
|
|
`$ports = @(`$basePort)
|
|
try {
|
|
`$maxPort = `$basePort + `$maxPortOffset
|
|
`$listening = Get-NetTCPConnection -State Listen -ErrorAction Stop |
|
|
Where-Object { `$_.LocalPort -ge `$basePort -and `$_.LocalPort -le `$maxPort } |
|
|
Select-Object -ExpandProperty LocalPort
|
|
`$ports = (@(`$basePort) + `$listening) | Sort-Object -Unique
|
|
} catch {
|
|
Write-Host "[DEBUG] Get-NetTCPConnection failed: `$(`$_.Exception.Message). Falling back to full port scan." -ForegroundColor DarkGray
|
|
# Fallback when Get-NetTCPConnection is unavailable/restricted.
|
|
for (`$offset = 1; `$offset -le `$maxPortOffset; `$offset++) {
|
|
`$ports += (`$basePort + `$offset)
|
|
}
|
|
}
|
|
return `$ports
|
|
}
|
|
|
|
function Find-HealthyStudioPort {
|
|
if (`$portFile) {
|
|
if (Test-Path -LiteralPath `$portFile) {
|
|
`$cached = Get-Content -LiteralPath `$portFile -ErrorAction SilentlyContinue | Select-Object -First 1
|
|
if (`$cached -match '^\d+`$') {
|
|
`$cachedPort = [int]`$cached
|
|
if (Test-StudioHealth -Port `$cachedPort) { return `$cachedPort }
|
|
Remove-Item -LiteralPath `$portFile -Force -ErrorAction SilentlyContinue
|
|
}
|
|
}
|
|
return `$null
|
|
}
|
|
foreach (`$candidate in (Get-CandidatePorts)) {
|
|
if (Test-StudioHealth -Port `$candidate) {
|
|
return `$candidate
|
|
}
|
|
}
|
|
return `$null
|
|
}
|
|
|
|
function Test-PortBusy {
|
|
param([Parameter(Mandatory = `$true)][int]`$Port)
|
|
`$listener = `$null
|
|
try {
|
|
`$listener = [System.Net.Sockets.TcpListener]::new([System.Net.IPAddress]::Any, `$Port)
|
|
`$listener.Start()
|
|
return `$false
|
|
} catch {
|
|
return `$true
|
|
} finally {
|
|
if (`$listener) { try { `$listener.Stop() } catch {} }
|
|
}
|
|
}
|
|
|
|
function Find-FreeLaunchPort {
|
|
`$maxPort = `$basePort + `$maxPortOffset
|
|
try {
|
|
`$listening = Get-NetTCPConnection -State Listen -ErrorAction Stop |
|
|
Where-Object { `$_.LocalPort -ge `$basePort -and `$_.LocalPort -le `$maxPort } |
|
|
Select-Object -ExpandProperty LocalPort
|
|
for (`$offset = 0; `$offset -le `$maxPortOffset; `$offset++) {
|
|
`$candidate = `$basePort + `$offset
|
|
if (`$candidate -notin `$listening) {
|
|
return `$candidate
|
|
}
|
|
}
|
|
} catch {
|
|
# Get-NetTCPConnection unavailable or restricted; probe ports directly
|
|
for (`$offset = 0; `$offset -le `$maxPortOffset; `$offset++) {
|
|
`$candidate = `$basePort + `$offset
|
|
if (-not (Test-PortBusy -Port `$candidate)) {
|
|
return `$candidate
|
|
}
|
|
}
|
|
}
|
|
return `$null
|
|
}
|
|
|
|
# If Studio is already healthy on any expected port, just open it and exit.
|
|
`$existingPort = Find-HealthyStudioPort
|
|
if (`$existingPort) {
|
|
Start-Process "http://localhost:`$existingPort"
|
|
exit 0
|
|
}
|
|
|
|
`$launchMutex = [System.Threading.Mutex]::new(`$false, `$mutexName)
|
|
`$haveMutex = `$false
|
|
try {
|
|
try {
|
|
`$haveMutex = `$launchMutex.WaitOne(0)
|
|
} catch [System.Threading.AbandonedMutexException] {
|
|
`$haveMutex = `$true
|
|
}
|
|
if (-not `$haveMutex) {
|
|
# Another launcher is already running; wait for it to bring Studio up
|
|
`$deadline = (Get-Date).AddSeconds(`$timeoutSec)
|
|
while ((Get-Date) -lt `$deadline) {
|
|
`$port = Find-HealthyStudioPort
|
|
if (`$port) { Start-Process "http://localhost:`$port"; exit 0 }
|
|
Start-Sleep -Milliseconds `$pollIntervalMs
|
|
}
|
|
exit 0
|
|
}
|
|
|
|
`$powershellExe = Join-Path `$env:SystemRoot 'System32\WindowsPowerShell\v1.0\powershell.exe'
|
|
`$studioExe = '$SingleQuotedExePath'
|
|
`$launchPort = Find-FreeLaunchPort
|
|
if (-not `$launchPort) {
|
|
`$msg = "No free port found in range `$basePort-`$(`$basePort + `$maxPortOffset)"
|
|
try {
|
|
Add-Type -AssemblyName System.Windows.Forms -ErrorAction Stop
|
|
[System.Windows.Forms.MessageBox]::Show(`$msg, 'Unsloth Studio') | Out-Null
|
|
} catch {}
|
|
exit 1
|
|
}
|
|
# Single-quote the path in the child -Command so `$` / backtick in custom
|
|
# roots don't get reparsed; double any apostrophes so 'O''Brien' survives.
|
|
`$studioCommand = "& '" + (`$studioExe -replace "'", "''") + "' studio -p " + `$launchPort
|
|
`$launchArgs = @(
|
|
'-NoExit',
|
|
'-NoProfile',
|
|
'-ExecutionPolicy',
|
|
'Bypass',
|
|
'-Command',
|
|
`$studioCommand
|
|
)
|
|
|
|
try {
|
|
`$proc = Start-Process -FilePath `$powershellExe -ArgumentList `$launchArgs -WorkingDirectory `$env:USERPROFILE -PassThru
|
|
} catch {
|
|
`$msg = "Could not launch Unsloth Studio terminal.`n`nError: `$(`$_.Exception.Message)"
|
|
try {
|
|
Add-Type -AssemblyName System.Windows.Forms -ErrorAction Stop
|
|
[System.Windows.Forms.MessageBox]::Show(`$msg, 'Unsloth Studio') | Out-Null
|
|
} catch {}
|
|
exit 1
|
|
}
|
|
|
|
`$browserOpened = `$false
|
|
`$deadline = (Get-Date).AddSeconds(`$timeoutSec)
|
|
while ((Get-Date) -lt `$deadline) {
|
|
if (Test-StudioHealth -Port `$launchPort) {
|
|
if (`$portFile) {
|
|
try {
|
|
[System.IO.File]::WriteAllText(`$portFile, "`$launchPort`n")
|
|
} catch {}
|
|
}
|
|
Start-Process "http://localhost:`$launchPort"
|
|
`$browserOpened = `$true
|
|
break
|
|
}
|
|
if (`$proc.HasExited) { break }
|
|
Start-Sleep -Milliseconds `$pollIntervalMs
|
|
}
|
|
if (-not `$browserOpened) {
|
|
if (`$proc.HasExited) {
|
|
`$msg = "Unsloth Studio exited before becoming healthy. Check terminal output for errors."
|
|
} else {
|
|
`$msg = "Unsloth Studio is still starting but did not become healthy within `$timeoutSec seconds. Check the terminal window for the selected port and open it manually."
|
|
}
|
|
try {
|
|
Add-Type -AssemblyName System.Windows.Forms -ErrorAction Stop
|
|
[System.Windows.Forms.MessageBox]::Show(`$msg, 'Unsloth Studio') | Out-Null
|
|
} catch {}
|
|
}
|
|
} finally {
|
|
if (`$haveMutex) { `$launchMutex.ReleaseMutex() | Out-Null }
|
|
`$launchMutex.Dispose()
|
|
}
|
|
exit 0
|
|
"@
|
|
|
|
# Write UTF-8 with BOM for reliable decoding by Windows PowerShell 5.1,
|
|
# even when install.ps1 is executed from PowerShell 7.
|
|
$utf8Bom = New-Object System.Text.UTF8Encoding($true)
|
|
[System.IO.File]::WriteAllText($launcherPs1, $launcherContent, $utf8Bom)
|
|
$vbsContent = @"
|
|
Set shell = CreateObject("WScript.Shell")
|
|
cmd = "powershell -NoProfile -ExecutionPolicy Bypass -WindowStyle Hidden -File ""$launcherPs1"""
|
|
shell.Run cmd, 0, False
|
|
"@
|
|
# WSH handles UTF-16LE reliably for .vbs files with non-ASCII paths.
|
|
Set-Content -LiteralPath $launcherVbs -Value $vbsContent -Encoding Unicode -Force
|
|
|
|
# Prefer bundled icon from local clone/dev installs.
|
|
# If not available, best-effort download from raw GitHub.
|
|
# We only attach the icon if the resulting file has a valid ICO header.
|
|
$hasValidIcon = $false
|
|
if ($bundledIcon -and (Test-Path -LiteralPath $bundledIcon)) {
|
|
try {
|
|
Copy-Item -LiteralPath $bundledIcon -Destination $iconPath -Force
|
|
} catch {
|
|
Write-Host "[DEBUG] Error copying bundled icon: $($_.Exception.Message)" -ForegroundColor DarkGray
|
|
}
|
|
} elseif (-not (Test-Path -LiteralPath $iconPath)) {
|
|
try {
|
|
Invoke-WebRequest -Uri $iconUrl -OutFile $iconPath -UseBasicParsing
|
|
} catch {
|
|
Write-Host "[DEBUG] Error downloading icon: $($_.Exception.Message)" -ForegroundColor DarkGray
|
|
}
|
|
}
|
|
|
|
if (Test-Path -LiteralPath $iconPath) {
|
|
try {
|
|
$bytes = [System.IO.File]::ReadAllBytes($iconPath)
|
|
if (
|
|
$bytes.Length -ge 4 -and
|
|
$bytes[0] -eq 0 -and
|
|
$bytes[1] -eq 0 -and
|
|
$bytes[2] -eq 1 -and
|
|
$bytes[3] -eq 0
|
|
) {
|
|
$hasValidIcon = $true
|
|
} else {
|
|
Remove-Item -LiteralPath $iconPath -Force -ErrorAction SilentlyContinue
|
|
}
|
|
} catch {
|
|
Write-Host "[DEBUG] Error validating or removing icon: $($_.Exception.Message)" -ForegroundColor DarkGray
|
|
Remove-Item -LiteralPath $iconPath -Force -ErrorAction SilentlyContinue
|
|
}
|
|
}
|
|
|
|
# Env-mode: skip persistent Desktop / Start Menu .lnk shortcuts
|
|
# that may point at a deleted workspace; launcher + icon stay.
|
|
if ($StudioRedirectMode -eq 'env') {
|
|
substep "wrote launcher at $launcherPs1 (persistent shortcuts skipped in env-override mode)"
|
|
return
|
|
}
|
|
|
|
$wscriptExe = Join-Path $env:SystemRoot "System32\wscript.exe"
|
|
$shortcutArgs = "//B //Nologo `"$launcherVbs`""
|
|
|
|
try {
|
|
$wshell = New-Object -ComObject WScript.Shell
|
|
$createdShortcutCount = 0
|
|
foreach ($linkPath in @($desktopLink, $startMenuLink)) {
|
|
if (-not $linkPath -or [string]::IsNullOrWhiteSpace($linkPath)) { continue }
|
|
try {
|
|
$shortcut = $wshell.CreateShortcut($linkPath)
|
|
$shortcut.TargetPath = $wscriptExe
|
|
$shortcut.Arguments = $shortcutArgs
|
|
$shortcut.WorkingDirectory = $appDir
|
|
$shortcut.Description = "Launch Unsloth Studio"
|
|
if ($hasValidIcon) {
|
|
$shortcut.IconLocation = "$iconPath,0"
|
|
}
|
|
$shortcut.Save()
|
|
$createdShortcutCount++
|
|
} catch {
|
|
substep "could not create shortcut at ${linkPath}: $($_.Exception.Message)" "Yellow"
|
|
}
|
|
}
|
|
if ($createdShortcutCount -gt 0) {
|
|
substep "Created Unsloth Studio shortcut"
|
|
} else {
|
|
substep "no Unsloth Studio shortcuts were created" "Yellow"
|
|
}
|
|
} catch {
|
|
substep "shortcut creation unavailable: $($_.Exception.Message)" "Yellow"
|
|
}
|
|
} catch {
|
|
substep "shortcut setup failed; skipping shortcuts: $($_.Exception.Message)" "Yellow"
|
|
}
|
|
}
|
|
|
|
# Regen .lnk + launcher only; used by `unsloth studio update`.
|
|
if ($ShortcutsOnly) {
|
|
if ($TauriMode) { return }
|
|
$UnslothExe = Join-Path $VenvDir "Scripts\unsloth.exe"
|
|
if (-not (Test-Path -LiteralPath $UnslothExe)) {
|
|
Write-Host "[ERROR] unsloth.exe missing at $UnslothExe; run install.ps1 first." -ForegroundColor Red
|
|
# throw (not Exit-InstallFailure) so non-Tauri callers see rc != 0.
|
|
throw "unsloth.exe missing"
|
|
}
|
|
New-StudioShortcuts -UnslothExePath $UnslothExe
|
|
return
|
|
}
|
|
|
|
# ── Check winget ──
|
|
Write-TauriLog "STEP" "Checking system dependencies"
|
|
if (-not (Get-Command winget -ErrorAction SilentlyContinue)) {
|
|
step "winget" "not available" "Red"
|
|
substep "Install it from https://aka.ms/getwinget" "Yellow"
|
|
substep "or install Python $PythonVersion and uv manually, then re-run." "Yellow"
|
|
return (Exit-InstallFailure "winget is not available")
|
|
}
|
|
|
|
# ── Helper: detect a working Python 3.11-3.13 on the system ──
|
|
# Returns the version string (e.g. "3.13") or "" if none found.
|
|
# Uses try-catch + stderr redirection so that App Execution Alias stubs
|
|
# (WindowsApps) and other non-functional executables are probed safely
|
|
# without triggering $ErrorActionPreference = "Stop".
|
|
#
|
|
# Skips Anaconda/Miniconda Python: conda-bundled CPython ships modified
|
|
# DLL search paths that break torch's c10.dll loading on Windows.
|
|
# Standalone CPython (python.org, winget, uv) does not have this issue.
|
|
#
|
|
# NOTE: A venv created from conda Python inherits conda's base_prefix
|
|
# even if the venv path does not contain "conda". We check both the
|
|
# executable path AND sys.base_prefix to catch this.
|
|
$script:CondaSkipPattern = '(?i)(conda|miniconda|anaconda|miniforge|mambaforge)'
|
|
|
|
function Test-IsCondaPython {
|
|
param([string]$Exe)
|
|
if ($Exe -match $script:CondaSkipPattern) { return $true }
|
|
try {
|
|
$basePrefix = (& $Exe -c "import sys; print(sys.base_prefix)" 2>$null | Out-String).Trim()
|
|
if ($basePrefix -match $script:CondaSkipPattern) { return $true }
|
|
} catch { }
|
|
return $false
|
|
}
|
|
|
|
# Returns @{ Version = "3.13"; Path = "C:\...\python.exe" } or $null.
|
|
# The resolved Path is passed to `uv venv --python` to prevent uv from
|
|
# re-resolving the version string back to a conda interpreter.
|
|
function Find-CompatiblePython {
|
|
# Try the Python Launcher first (most reliable on Windows)
|
|
# py.exe resolves to the standard CPython install, not conda.
|
|
$pyLauncher = Get-Command py -CommandType Application -ErrorAction SilentlyContinue
|
|
if ($pyLauncher -and $pyLauncher.Source -notmatch $script:CondaSkipPattern) {
|
|
foreach ($minor in @("3.13", "3.12", "3.11")) {
|
|
try {
|
|
$out = & $pyLauncher.Source "-$minor" --version 2>&1 | Out-String
|
|
if ($out -match "Python (3\.1[1-3])\.\d+") {
|
|
$ver = $Matches[1]
|
|
# Resolve the actual executable path and verify it is not conda-based
|
|
$resolvedExe = (& $pyLauncher.Source "-$minor" -c "import sys; print(sys.executable)" 2>$null | Out-String).Trim()
|
|
if ($resolvedExe -and (Test-Path $resolvedExe) -and -not (Test-IsCondaPython $resolvedExe)) {
|
|
return @{ Version = $ver; Path = $resolvedExe }
|
|
}
|
|
}
|
|
} catch {}
|
|
}
|
|
}
|
|
# Try python3 / python via Get-Command -All to look past stubs that
|
|
# might shadow a real Python further down PATH.
|
|
# Skip WindowsApps entries: the App Execution Alias stubs live there
|
|
# and can open the Microsoft Store as a side effect. Legitimate Store
|
|
# Python is already detected via the py launcher above (Store packages
|
|
# include py since Python 3.11).
|
|
# Skip Anaconda/Miniconda: check both path and sys.base_prefix.
|
|
foreach ($name in @("python3", "python")) {
|
|
foreach ($cmd in @(Get-Command $name -All -ErrorAction SilentlyContinue)) {
|
|
if (-not $cmd.Source) { continue }
|
|
if ($cmd.Source -like "*\WindowsApps\*") { continue }
|
|
if (Test-IsCondaPython $cmd.Source) { continue }
|
|
try {
|
|
$out = & $cmd.Source --version 2>&1 | Out-String
|
|
if ($out -match "Python (3\.1[1-3])\.\d+") {
|
|
return @{ Version = $Matches[1]; Path = $cmd.Source }
|
|
}
|
|
} catch {}
|
|
}
|
|
}
|
|
return $null
|
|
}
|
|
|
|
# ── Install Python if no compatible version (3.11-3.13) found ──
|
|
# Find-CompatiblePython returns @{ Version = "3.13"; Path = "C:\...\python.exe" } or $null.
|
|
Write-TauriLog "STEP" "Installing Python"
|
|
$DetectedPython = Find-CompatiblePython
|
|
if ($DetectedPython) {
|
|
step "python" "Python $($DetectedPython.Version) already installed"
|
|
}
|
|
if (-not $DetectedPython) {
|
|
substep "installing Python ${PythonVersion}..."
|
|
$pythonPackageId = "Python.Python.$PythonVersion"
|
|
# Temporarily lower ErrorActionPreference so that winget stderr
|
|
# (progress bars, warnings) does not become a terminating error
|
|
# on PowerShell 5.1 where native-command stderr is ErrorRecord.
|
|
$prevEAP = $ErrorActionPreference
|
|
$ErrorActionPreference = "Continue"
|
|
try {
|
|
winget install -e --id $pythonPackageId --accept-package-agreements --accept-source-agreements
|
|
$wingetExit = $LASTEXITCODE
|
|
} catch { $wingetExit = 1 }
|
|
$ErrorActionPreference = $prevEAP
|
|
Refresh-SessionPath
|
|
|
|
# Re-detect after install (PATH may have changed)
|
|
$DetectedPython = Find-CompatiblePython
|
|
|
|
if (-not $DetectedPython) {
|
|
# Python still not functional after winget -- force reinstall.
|
|
# This handles both real failures AND "already installed" codes where
|
|
# winget thinks Python is present but it's not actually on PATH
|
|
# (e.g. user partially uninstalled, or installed via a different method).
|
|
substep "Python not found on PATH after winget. Retrying with --force..." "Yellow"
|
|
$ErrorActionPreference = "Continue"
|
|
try {
|
|
winget install -e --id $pythonPackageId --accept-package-agreements --accept-source-agreements --force
|
|
$wingetExit = $LASTEXITCODE
|
|
} catch { $wingetExit = 1 }
|
|
$ErrorActionPreference = $prevEAP
|
|
Refresh-SessionPath
|
|
$DetectedPython = Find-CompatiblePython
|
|
}
|
|
|
|
if (-not $DetectedPython) {
|
|
Write-Host "[ERROR] Python installation failed (exit code $wingetExit)" -ForegroundColor Red
|
|
Write-Host " Please install Python $PythonVersion manually from https://www.python.org/downloads/" -ForegroundColor Yellow
|
|
Write-Host " Make sure to check 'Add Python to PATH' during installation." -ForegroundColor Yellow
|
|
Write-Host " Then re-run this installer." -ForegroundColor Yellow
|
|
return (Exit-InstallFailure "Python installation failed")
|
|
}
|
|
}
|
|
$DiagPythonVersion = $PythonVersion
|
|
if ($DetectedPython) { $DiagPythonVersion = $DetectedPython.Version }
|
|
$InitialGpuBranch = "unknown"
|
|
if ($SkipTorch) { $InitialGpuBranch = "no_torch" }
|
|
Write-TauriDiag -GpuBranch $InitialGpuBranch -TorchIndexFamily "none" -PythonVersionForDiag $DiagPythonVersion
|
|
|
|
# ── Install uv if not present ──
|
|
Write-TauriLog "STEP" "Installing uv package manager"
|
|
if (-not (Get-Command uv -ErrorAction SilentlyContinue)) {
|
|
substep "installing uv package manager..."
|
|
$prevEAP = $ErrorActionPreference
|
|
$ErrorActionPreference = "Continue"
|
|
try { winget install --id=astral-sh.uv -e --accept-package-agreements --accept-source-agreements } catch {}
|
|
$ErrorActionPreference = $prevEAP
|
|
Refresh-SessionPath
|
|
# Fallback: if winget didn't put uv on PATH, try the PowerShell installer
|
|
if (-not (Get-Command uv -ErrorAction SilentlyContinue)) {
|
|
substep "trying alternative uv installer..." "Yellow"
|
|
Invoke-Expression (Invoke-RestMethod -Uri "https://astral.sh/uv/install.ps1")
|
|
Refresh-SessionPath
|
|
}
|
|
}
|
|
|
|
if (-not (Get-Command uv -ErrorAction SilentlyContinue)) {
|
|
step "uv" "could not be installed" "Red"
|
|
substep "Install it from https://docs.astral.sh/uv/" "Yellow"
|
|
return (Exit-InstallFailure "uv could not be installed")
|
|
}
|
|
|
|
# ── Create venv (migrate old layout if possible, otherwise fresh) ──
|
|
# Pass the resolved executable path to uv so it does not re-resolve
|
|
# a version string back to a conda interpreter.
|
|
Write-TauriLog "STEP" "Creating virtual environment"
|
|
if (-not (Test-Path -LiteralPath $StudioHome)) {
|
|
# .NET API: New-Item -Path treats brackets as wildcards.
|
|
[System.IO.Directory]::CreateDirectory($StudioHome) | Out-Null
|
|
}
|
|
|
|
$VenvPython = Join-Path $VenvDir "Scripts\python.exe"
|
|
$_Migrated = $false
|
|
$script:StudioVenvRollbackDir = $null
|
|
$script:StudioVenvRollbackTarget = $VenvDir
|
|
$script:StudioVenvRollbackActive = $false
|
|
|
|
function Start-StudioVenvRollback {
|
|
param([Parameter(Mandatory = $true)][string]$ExistingDir)
|
|
$stamp = Get-Date -Format "yyyyMMddHHmmss"
|
|
$candidate = Join-Path $StudioHome "unsloth_studio.rollback.$stamp.$PID"
|
|
$suffix = 0
|
|
# -LiteralPath: a custom $StudioHome may contain [ ] * ? which
|
|
# plain Test-Path / Move-Item would interpret as wildcards.
|
|
while (Test-Path -LiteralPath $candidate) {
|
|
$suffix++
|
|
$candidate = Join-Path $StudioHome "unsloth_studio.rollback.$stamp.$PID.$suffix"
|
|
}
|
|
Move-Item -LiteralPath $ExistingDir -Destination $candidate -ErrorAction Stop
|
|
$script:StudioVenvRollbackDir = $candidate
|
|
$script:StudioVenvRollbackTarget = $ExistingDir
|
|
$script:StudioVenvRollbackActive = $true
|
|
substep "previous environment preserved for rollback"
|
|
}
|
|
|
|
function Restore-StudioVenvRollback {
|
|
if (-not $script:StudioVenvRollbackActive) { return }
|
|
$backup = $script:StudioVenvRollbackDir
|
|
$target = $script:StudioVenvRollbackTarget
|
|
if (-not $backup -or -not (Test-Path -LiteralPath $backup)) {
|
|
$script:StudioVenvRollbackActive = $false
|
|
return
|
|
}
|
|
substep "restoring previous environment after failed install..." "Yellow"
|
|
try {
|
|
if (Test-Path -LiteralPath $target) {
|
|
Remove-Item -LiteralPath $target -Recurse -Force -ErrorAction SilentlyContinue
|
|
}
|
|
Move-Item -LiteralPath $backup -Destination $target -Force -ErrorAction Stop
|
|
substep "restored previous environment"
|
|
$script:StudioVenvRollbackActive = $false
|
|
$script:StudioVenvRollbackDir = $null
|
|
} catch {
|
|
Write-Host "[WARN] Could not restore previous environment from $backup to $target" -ForegroundColor Yellow
|
|
Write-Host " $($_.Exception.Message)" -ForegroundColor Yellow
|
|
}
|
|
}
|
|
|
|
function Complete-StudioVenvRollback {
|
|
if (-not $script:StudioVenvRollbackActive) { return }
|
|
$backup = $script:StudioVenvRollbackDir
|
|
if ($backup -and (Test-Path -LiteralPath $backup)) {
|
|
Remove-Item -LiteralPath $backup -Recurse -Force -ErrorAction SilentlyContinue
|
|
}
|
|
$script:StudioVenvRollbackActive = $false
|
|
$script:StudioVenvRollbackDir = $null
|
|
}
|
|
|
|
if (Test-Path -LiteralPath $VenvPython) {
|
|
# why: matching guard to the .venv branch below -- in env-mode
|
|
# $StudioHome is a user-chosen workspace, so refuse to nuke an
|
|
# existing $StudioHome\unsloth_studio that lacks Studio sentinels.
|
|
# -PathType Leaf rejects a directory at the sentinel path. Accept the
|
|
# in-VENV ownership marker so partial-install retries are not blocked.
|
|
if (
|
|
$StudioRedirectMode -eq 'env' -and
|
|
-not (Test-Path -LiteralPath (Join-Path $VenvDir ".unsloth-studio-owned") -PathType Leaf) -and
|
|
-not (Test-Path -LiteralPath (Join-Path $StudioHome "share\studio.conf") -PathType Leaf) -and
|
|
-not (Test-Path -LiteralPath (Join-Path $StudioHome "bin\unsloth.exe") -PathType Leaf)
|
|
) {
|
|
Write-Host "[ERROR] $VenvDir already exists but does not look like an Unsloth Studio install." -ForegroundColor Red
|
|
Write-Host " Move it aside or choose an empty UNSLOTH_STUDIO_HOME." -ForegroundColor Yellow
|
|
throw "Refusing to delete non-Studio venv at $VenvDir"
|
|
}
|
|
# New layout already exists -- replace only after preserving rollback copy.
|
|
substep "preserving existing environment for rollback..."
|
|
try {
|
|
Start-StudioVenvRollback -ExistingDir $VenvDir
|
|
} catch {
|
|
Write-Host "[ERROR] Could not prepare existing environment for reinstall: $($_.Exception.Message)" -ForegroundColor Red
|
|
return (Exit-InstallFailure "Could not prepare existing environment for reinstall")
|
|
}
|
|
} elseif (
|
|
$StudioRedirectMode -ne 'env' `
|
|
-and (Test-Path -LiteralPath (Join-Path $StudioHome ".venv\Scripts\python.exe"))
|
|
) {
|
|
# Old layout (~/.unsloth/studio/.venv) exists -- validate before migrating.
|
|
# Skip in env-mode so we don't blow away an unrelated .venv at the
|
|
# workspace root (e.g. user's existing project Python venv).
|
|
$OldVenv = Join-Path $StudioHome ".venv"
|
|
$OldPy = Join-Path $OldVenv "Scripts\python.exe"
|
|
substep "found legacy Studio environment, validating..."
|
|
$prevEAP2 = $ErrorActionPreference
|
|
$ErrorActionPreference = "Continue"
|
|
try {
|
|
if ($SkipTorch) {
|
|
& $OldPy -c "import sys; print(sys.executable)" 2>$null | Out-Null
|
|
} else {
|
|
& $OldPy -c "import torch; A = torch.ones((2,2)); B = A + A" 2>$null | Out-Null
|
|
}
|
|
$legacyOk = ($LASTEXITCODE -eq 0)
|
|
} catch { $legacyOk = $false }
|
|
$ErrorActionPreference = $prevEAP2
|
|
if ($legacyOk) {
|
|
substep "legacy environment is healthy -- migrating..."
|
|
Move-Item -LiteralPath $OldVenv -Destination $VenvDir -Force
|
|
substep "moved .venv -> unsloth_studio"
|
|
$_Migrated = $true
|
|
} else {
|
|
substep "legacy environment failed validation -- creating fresh environment" "Yellow"
|
|
$invalidVenv = Join-Path $StudioHome (".venv.invalid.{0}.{1}" -f (Get-Date -Format "yyyyMMddHHmmss"), $PID)
|
|
Move-Item -LiteralPath $OldVenv -Destination $invalidVenv -Force -ErrorAction SilentlyContinue
|
|
}
|
|
} elseif (
|
|
$StudioRedirectMode -ne 'env' `
|
|
-and (Test-Path -LiteralPath (Join-Path $env:USERPROFILE "unsloth_studio\Scripts\python.exe"))
|
|
) {
|
|
# CWD-relative venv from old install.ps1 -> migrate to absolute path.
|
|
# Skip in env-mode so we don't relocate the default-install venv into
|
|
# the workspace root.
|
|
$CwdVenv = Join-Path $env:USERPROFILE "unsloth_studio"
|
|
substep "found CWD-relative Studio environment, migrating to $VenvDir..."
|
|
Move-Item -LiteralPath $CwdVenv -Destination $VenvDir -Force
|
|
substep "moved ~/unsloth_studio -> ~/.unsloth/studio/unsloth_studio"
|
|
$_Migrated = $true
|
|
}
|
|
|
|
if (-not (Test-Path -LiteralPath $VenvPython)) {
|
|
step "venv" "creating Python $($DetectedPython.Version) virtual environment"
|
|
substep "$VenvDir"
|
|
$venvExit = Invoke-InstallCommand { uv venv $VenvDir --python "$($DetectedPython.Path)" }
|
|
if ($venvExit -ne 0) {
|
|
Write-Host "[ERROR] Failed to create virtual environment (exit code $venvExit)" -ForegroundColor Red
|
|
return (Exit-InstallFailure "Failed to create virtual environment (exit code $venvExit)" $venvExit)
|
|
}
|
|
} else {
|
|
step "venv" "using migrated environment"
|
|
substep "$VenvDir"
|
|
}
|
|
|
|
# Mark the freshly-created venv as Studio-owned so a partial install can be
|
|
# repaired by re-running install.ps1; the env-mode deletion guard above
|
|
# accepts this marker as the primary sentinel.
|
|
if (Test-Path -LiteralPath $VenvDir -PathType Container) {
|
|
try { [System.IO.File]::WriteAllText((Join-Path $VenvDir ".unsloth-studio-owned"), "") } catch {}
|
|
}
|
|
|
|
# ── Detect GPU (robust: PATH + hardcoded fallback paths, mirrors setup.ps1) ──
|
|
$HasNvidiaSmi = $false
|
|
$NvidiaSmiExe = $null
|
|
try {
|
|
$nvSmiCmd = Get-Command nvidia-smi -ErrorAction SilentlyContinue
|
|
if ($nvSmiCmd) {
|
|
& $nvSmiCmd.Source *> $null
|
|
if ($LASTEXITCODE -eq 0) { $HasNvidiaSmi = $true; $NvidiaSmiExe = $nvSmiCmd.Source }
|
|
}
|
|
} catch {}
|
|
if (-not $HasNvidiaSmi) {
|
|
foreach ($p in @(
|
|
"$env:ProgramFiles\NVIDIA Corporation\NVSMI\nvidia-smi.exe",
|
|
"$env:SystemRoot\System32\nvidia-smi.exe"
|
|
)) {
|
|
if (Test-Path $p) {
|
|
try {
|
|
& $p *> $null
|
|
if ($LASTEXITCODE -eq 0) { $HasNvidiaSmi = $true; $NvidiaSmiExe = $p; break }
|
|
} catch {}
|
|
}
|
|
}
|
|
}
|
|
if ($HasNvidiaSmi) {
|
|
step "gpu" "NVIDIA GPU detected"
|
|
} else {
|
|
step "gpu" "none (chat-only / GGUF)" "Yellow"
|
|
substep "Training and GPU inference require an NVIDIA GPU with drivers installed." "Yellow"
|
|
}
|
|
|
|
# ── Choose the correct PyTorch index URL based on driver CUDA version ──
|
|
# Mirrors Get-PytorchCudaTag in setup.ps1.
|
|
function Get-TorchIndexUrl {
|
|
$baseUrl = if ($env:UNSLOTH_PYTORCH_MIRROR) { $env:UNSLOTH_PYTORCH_MIRROR.TrimEnd('/') } else { "https://download.pytorch.org/whl" }
|
|
if (-not $NvidiaSmiExe) { return "$baseUrl/cpu" }
|
|
try {
|
|
$output = & $NvidiaSmiExe 2>&1 | Out-String
|
|
if ($output -match 'CUDA Version:\s+(\d+)\.(\d+)') {
|
|
$major = [int]$Matches[1]; $minor = [int]$Matches[2]
|
|
if ($major -ge 13) { return "$baseUrl/cu130" }
|
|
if ($major -eq 12 -and $minor -ge 8) { return "$baseUrl/cu128" }
|
|
if ($major -eq 12 -and $minor -ge 6) { return "$baseUrl/cu126" }
|
|
if ($major -ge 12) { return "$baseUrl/cu124" }
|
|
if ($major -ge 11) { return "$baseUrl/cu118" }
|
|
return "$baseUrl/cpu"
|
|
}
|
|
} catch {}
|
|
substep "could not determine CUDA version from nvidia-smi, defaulting to cu126" "Yellow"
|
|
return "$baseUrl/cu126"
|
|
}
|
|
$TorchIndexUrl = Get-TorchIndexUrl
|
|
$TorchIndexFamily = Get-TauriTorchIndexFamily $TorchIndexUrl
|
|
$GpuBranch = Get-TauriGpuBranch $TorchIndexFamily
|
|
Write-TauriDiag -GpuBranch $GpuBranch -TorchIndexFamily $TorchIndexFamily -PythonVersionForDiag $DetectedPython.Version
|
|
|
|
# ── Print CPU-only hint when no GPU detected ──
|
|
if (-not $SkipTorch -and $TorchIndexUrl -like "*/cpu") {
|
|
Write-Host ""
|
|
substep "No NVIDIA GPU detected." "Yellow"
|
|
substep "Installing CPU-only PyTorch. If you only need GGUF chat/inference," "Yellow"
|
|
substep "re-run with --no-torch for a faster, lighter install:" "Yellow"
|
|
substep ".\install.ps1 --no-torch" "Yellow"
|
|
Write-Host ""
|
|
}
|
|
|
|
# ── Install PyTorch first, then unsloth separately ──
|
|
#
|
|
# Why two steps?
|
|
# `uv pip install unsloth --torch-backend=cpu` on Windows resolves to
|
|
# unsloth==2024.8 (a pre-CLI release with no unsloth.exe) because the
|
|
# cpu-only solver cannot satisfy newer unsloth's dependencies.
|
|
# Installing torch first from the explicit CUDA index, then upgrading
|
|
# unsloth in a second step, avoids this solver dead-end.
|
|
#
|
|
# Why --upgrade-package instead of --upgrade?
|
|
# `--upgrade unsloth` re-resolves ALL dependencies including torch,
|
|
# pulling torch from default PyPI and stripping the +cuXXX suffix
|
|
# that step 1 installed (e.g. torch 2.5.1+cu124 -> 2.10.0 with no
|
|
# CUDA suffix). `--upgrade-package unsloth` upgrades ONLY unsloth
|
|
# to the latest version while preserving the already-pinned torch
|
|
# CUDA wheels. Missing dependencies (transformers, trl, peft, etc.)
|
|
# are still pulled in because they are new, not upgrades.
|
|
#
|
|
# ── Helper: find no-torch-runtime.txt ──
|
|
function Find-NoTorchRuntimeFile {
|
|
if ($StudioLocalInstall -and (Test-Path (Join-Path $RepoRoot "studio\backend\requirements\no-torch-runtime.txt"))) {
|
|
return Join-Path $RepoRoot "studio\backend\requirements\no-torch-runtime.txt"
|
|
}
|
|
$installed = Get-ChildItem -LiteralPath $VenvDir -Recurse -Filter "no-torch-runtime.txt" -ErrorAction SilentlyContinue |
|
|
Where-Object { $_.FullName -like "*studio*backend*requirements*no-torch-runtime.txt" } |
|
|
Select-Object -ExpandProperty FullName -First 1
|
|
return $installed
|
|
}
|
|
|
|
if ($_Migrated) {
|
|
# Migrated env: force-reinstall unsloth+unsloth-zoo to ensure clean state
|
|
# in the new venv location, while preserving existing torch/CUDA
|
|
Write-TauriLog "STEP" "Installing unsloth"
|
|
substep "upgrading unsloth in migrated environment..."
|
|
if ($SkipTorch) {
|
|
# No-torch: install unsloth + unsloth-zoo with --no-deps, then
|
|
# runtime deps (typer, safetensors, transformers, etc.) with --no-deps.
|
|
$baseInstallExit = Invoke-InstallCommand { uv pip install --python $VenvPython --no-deps --reinstall-package unsloth --reinstall-package unsloth-zoo "unsloth>=2026.5.4" unsloth-zoo }
|
|
if ($baseInstallExit -eq 0) {
|
|
$NoTorchReq = Find-NoTorchRuntimeFile
|
|
if ($NoTorchReq) {
|
|
$baseInstallExit = Invoke-InstallCommand { uv pip install --python $VenvPython --no-deps -r $NoTorchReq }
|
|
}
|
|
}
|
|
} else {
|
|
$baseInstallExit = Invoke-InstallCommand { uv pip install --python $VenvPython --reinstall-package unsloth --reinstall-package unsloth-zoo "unsloth>=2026.5.4" unsloth-zoo }
|
|
}
|
|
if ($baseInstallExit -ne 0) {
|
|
Write-Host "[ERROR] Failed to install unsloth (exit code $baseInstallExit)" -ForegroundColor Red
|
|
return (Exit-InstallFailure "Failed to install unsloth (exit code $baseInstallExit)" $baseInstallExit)
|
|
}
|
|
if ($StudioLocalInstall) {
|
|
substep "overlaying local repo (editable)..."
|
|
$overlayExit = Invoke-InstallCommand { uv pip install --python $VenvPython -e $RepoRoot --no-deps }
|
|
if ($overlayExit -ne 0) {
|
|
Write-Host "[ERROR] Failed to overlay local repo (exit code $overlayExit)" -ForegroundColor Red
|
|
return (Exit-InstallFailure "Failed to overlay local repo (exit code $overlayExit)" $overlayExit)
|
|
}
|
|
substep "overlaying unsloth-zoo from git main..."
|
|
$zooOverlayExit = Invoke-InstallCommand { uv pip install --python $VenvPython --no-deps --reinstall-package unsloth-zoo "unsloth-zoo @ git+https://github.com/unslothai/unsloth-zoo" }
|
|
if ($zooOverlayExit -ne 0) {
|
|
Write-Host "[ERROR] Failed to overlay unsloth-zoo (exit code $zooOverlayExit)" -ForegroundColor Red
|
|
return (Exit-InstallFailure "Failed to overlay unsloth-zoo (exit code $zooOverlayExit)" $zooOverlayExit)
|
|
}
|
|
}
|
|
} elseif ($TorchIndexUrl) {
|
|
if ($SkipTorch) {
|
|
substep "skipping PyTorch (--no-torch flag set)." "Yellow"
|
|
} else {
|
|
Write-TauriLog "STEP" "Installing PyTorch"
|
|
substep "installing PyTorch ($TorchIndexUrl)..."
|
|
$torchInstallExit = Invoke-InstallCommand { uv pip install --python $VenvPython "torch>=2.4,<2.11.0" torchvision torchaudio --index-url $TorchIndexUrl }
|
|
if ($torchInstallExit -ne 0) {
|
|
Write-Host "[ERROR] Failed to install PyTorch (exit code $torchInstallExit)" -ForegroundColor Red
|
|
return (Exit-InstallFailure "Failed to install PyTorch (exit code $torchInstallExit)" $torchInstallExit)
|
|
}
|
|
}
|
|
|
|
Write-TauriLog "STEP" "Installing unsloth"
|
|
substep "installing unsloth (this may take a few minutes)..."
|
|
if ($SkipTorch) {
|
|
# No-torch: install unsloth + unsloth-zoo with --no-deps, then
|
|
# runtime deps (typer, safetensors, transformers, etc.) with --no-deps.
|
|
$baseInstallExit = Invoke-InstallCommand { uv pip install --python $VenvPython --no-deps --upgrade-package unsloth --upgrade-package unsloth-zoo "unsloth>=2026.5.4" unsloth-zoo }
|
|
if ($baseInstallExit -eq 0) {
|
|
$NoTorchReq = Find-NoTorchRuntimeFile
|
|
if ($NoTorchReq) {
|
|
$baseInstallExit = Invoke-InstallCommand { uv pip install --python $VenvPython --no-deps -r $NoTorchReq }
|
|
}
|
|
}
|
|
} elseif ($StudioLocalInstall) {
|
|
$baseInstallExit = Invoke-InstallCommand { uv pip install --python $VenvPython --upgrade-package unsloth "unsloth>=2026.5.4" unsloth-zoo }
|
|
} else {
|
|
$baseInstallExit = Invoke-InstallCommand { uv pip install --python $VenvPython --upgrade-package unsloth -- "$PackageName" }
|
|
}
|
|
if ($baseInstallExit -ne 0) {
|
|
Write-Host "[ERROR] Failed to install unsloth (exit code $baseInstallExit)" -ForegroundColor Red
|
|
return (Exit-InstallFailure "Failed to install unsloth (exit code $baseInstallExit)" $baseInstallExit)
|
|
}
|
|
|
|
if ($StudioLocalInstall) {
|
|
substep "overlaying local repo (editable)..."
|
|
$overlayExit = Invoke-InstallCommand { uv pip install --python $VenvPython -e $RepoRoot --no-deps }
|
|
if ($overlayExit -ne 0) {
|
|
Write-Host "[ERROR] Failed to overlay local repo (exit code $overlayExit)" -ForegroundColor Red
|
|
return (Exit-InstallFailure "Failed to overlay local repo (exit code $overlayExit)" $overlayExit)
|
|
}
|
|
substep "overlaying unsloth-zoo from git main..."
|
|
$zooOverlayExit = Invoke-InstallCommand { uv pip install --python $VenvPython --no-deps --reinstall-package unsloth-zoo "unsloth-zoo @ git+https://github.com/unslothai/unsloth-zoo" }
|
|
if ($zooOverlayExit -ne 0) {
|
|
Write-Host "[ERROR] Failed to overlay unsloth-zoo (exit code $zooOverlayExit)" -ForegroundColor Red
|
|
return (Exit-InstallFailure "Failed to overlay unsloth-zoo (exit code $zooOverlayExit)" $zooOverlayExit)
|
|
}
|
|
}
|
|
} else {
|
|
# Fallback: GPU detection failed to produce a URL -- let uv resolve torch
|
|
Write-TauriLog "STEP" "Installing unsloth"
|
|
substep "installing unsloth (this may take a few minutes)..."
|
|
if ($StudioLocalInstall) {
|
|
$baseInstallExit = Invoke-InstallCommand { uv pip install --python $VenvPython unsloth-zoo "unsloth>=2026.5.4" --torch-backend=auto }
|
|
if ($baseInstallExit -ne 0) {
|
|
Write-Host "[ERROR] Failed to install unsloth (exit code $baseInstallExit)" -ForegroundColor Red
|
|
return (Exit-InstallFailure "Failed to install unsloth (exit code $baseInstallExit)" $baseInstallExit)
|
|
}
|
|
substep "overlaying local repo (editable)..."
|
|
$overlayExit = Invoke-InstallCommand { uv pip install --python $VenvPython -e $RepoRoot --no-deps }
|
|
if ($overlayExit -ne 0) {
|
|
Write-Host "[ERROR] Failed to overlay local repo (exit code $overlayExit)" -ForegroundColor Red
|
|
return (Exit-InstallFailure "Failed to overlay local repo (exit code $overlayExit)" $overlayExit)
|
|
}
|
|
substep "overlaying unsloth-zoo from git main..."
|
|
$zooOverlayExit = Invoke-InstallCommand { uv pip install --python $VenvPython --no-deps --reinstall-package unsloth-zoo "unsloth-zoo @ git+https://github.com/unslothai/unsloth-zoo" }
|
|
if ($zooOverlayExit -ne 0) {
|
|
Write-Host "[ERROR] Failed to overlay unsloth-zoo (exit code $zooOverlayExit)" -ForegroundColor Red
|
|
return (Exit-InstallFailure "Failed to overlay unsloth-zoo (exit code $zooOverlayExit)" $zooOverlayExit)
|
|
}
|
|
} else {
|
|
$baseInstallExit = Invoke-InstallCommand { uv pip install --python $VenvPython --torch-backend=auto -- "$PackageName" }
|
|
if ($baseInstallExit -ne 0) {
|
|
Write-Host "[ERROR] Failed to install unsloth (exit code $baseInstallExit)" -ForegroundColor Red
|
|
return (Exit-InstallFailure "Failed to install unsloth (exit code $baseInstallExit)" $baseInstallExit)
|
|
}
|
|
}
|
|
}
|
|
|
|
# Overlay Tauri-bundled studio fixes that may be ahead of PyPI. Skipped
|
|
# for --local: the editable install above already makes _PACKAGE_ROOT in
|
|
# unsloth_cli/commands/studio.py resolve to the repo (PEP 660 __file__).
|
|
# Source paths match the Tauri bundle layout in studio/src-tauri/tauri.conf.json,
|
|
# which bundles install_python_stack.py at the bundle root next to install.ps1.
|
|
if ($TauriMode) {
|
|
$rawPath = if ($PSCommandPath) { $PSCommandPath } else { $MyInvocation.ScriptName }
|
|
if ($rawPath) {
|
|
# Strip leading \\?\ extended-length prefix if the launcher passed one.
|
|
$scriptDir = Split-Path -Parent ($rawPath -replace '^\\\\\?\\', '')
|
|
$overlayMap = [ordered]@{
|
|
"install_python_stack.py" = "Lib\site-packages\studio\install_python_stack.py"
|
|
}
|
|
foreach ($rel in $overlayMap.Keys) {
|
|
$src = Join-Path $scriptDir $rel
|
|
$dst = Join-Path $VenvDir $overlayMap[$rel]
|
|
# -LiteralPath: $VenvDir derives from $StudioHome which may
|
|
# contain [ ] * ? when the user overrode UNSLOTH_STUDIO_HOME.
|
|
if (-not (Test-Path -LiteralPath $src)) { continue }
|
|
$dstParent = Split-Path -Parent $dst
|
|
if (-not (Test-Path -LiteralPath $dstParent)) {
|
|
Write-Host "[WARN] Overlay target dir missing: $dstParent; studio setup may use stale bundled file" -ForegroundColor Yellow
|
|
continue
|
|
}
|
|
try {
|
|
if (-not (Test-Path -LiteralPath $dst)) {
|
|
# Backfill: target file missing but parent dir exists.
|
|
Copy-Item -LiteralPath $src -Destination $dst -Force
|
|
substep ("backfilled bundled " + (Split-Path -Leaf $rel))
|
|
} else {
|
|
# Hash-compare so re-runs are no-ops when files already match.
|
|
$srcHash = (Get-FileHash -LiteralPath $src -Algorithm SHA256).Hash
|
|
$dstHash = (Get-FileHash -LiteralPath $dst -Algorithm SHA256).Hash
|
|
if ($srcHash -ne $dstHash) {
|
|
Copy-Item -LiteralPath $src -Destination $dst -Force
|
|
substep ("applied bundled " + (Split-Path -Leaf $rel))
|
|
}
|
|
}
|
|
} catch {
|
|
Write-Host "[WARN] Could not overlay $($rel): $($_.Exception.Message); studio setup may use stale bundled file" -ForegroundColor Yellow
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
# ── Run studio setup ──
|
|
# setup.ps1 will handle installing Git, CMake, Visual Studio Build Tools,
|
|
# CUDA Toolkit, Node.js, and other dependencies automatically via winget.
|
|
Write-TauriLog "STEP" "Running studio setup"
|
|
step "setup" "running unsloth studio setup..."
|
|
$UnslothExe = Join-Path $VenvDir "Scripts\unsloth.exe"
|
|
if (-not (Test-Path -LiteralPath $UnslothExe)) {
|
|
Write-TauriLog "ERROR" "unsloth CLI was not installed correctly"
|
|
Write-Host "[ERROR] unsloth CLI was not installed correctly." -ForegroundColor Red
|
|
Write-Host " Expected: $UnslothExe" -ForegroundColor Yellow
|
|
Write-Host " This usually means an older unsloth version was installed that does not include the Studio CLI." -ForegroundColor Yellow
|
|
Write-Host " Try re-running the installer or see: https://github.com/unslothai/unsloth?tab=readme-ov-file#-quickstart" -ForegroundColor Yellow
|
|
return (Exit-InstallFailure "unsloth CLI was not installed correctly")
|
|
}
|
|
# Tell setup.ps1 to skip base package installation (install.ps1 already did it)
|
|
$env:SKIP_STUDIO_BASE = "1"
|
|
$env:STUDIO_PACKAGE_NAME = $PackageName
|
|
$env:UNSLOTH_NO_TORCH = if ($SkipTorch) { "true" } else { "false" }
|
|
# Tauri desktop app bundles its own frontend — skip Node/npm/frontend build
|
|
$env:SKIP_STUDIO_FRONTEND = if ($TauriMode) { "1" } else { "0" }
|
|
# Always set STUDIO_LOCAL_INSTALL explicitly to avoid stale values from
|
|
# a previous --local run in the same PowerShell session.
|
|
if ($StudioLocalInstall) {
|
|
$env:STUDIO_LOCAL_INSTALL = "1"
|
|
$env:STUDIO_LOCAL_REPO = $RepoRoot
|
|
} else {
|
|
$env:STUDIO_LOCAL_INSTALL = "0"
|
|
Remove-Item Env:STUDIO_LOCAL_REPO -ErrorAction SilentlyContinue
|
|
}
|
|
# Use 'studio setup' (not 'studio update') because 'update' pops
|
|
# SKIP_STUDIO_BASE, which would cause redundant package reinstallation
|
|
# and bypass the fast-path version check from PR #4667.
|
|
# Propagate UNSLOTH_STUDIO_HOME only for env-override installs; otherwise
|
|
# an inherited value would put llama.cpp in the wrong place.
|
|
$previousUnslothStudioHome = $env:UNSLOTH_STUDIO_HOME
|
|
$hadPreviousUnslothStudioHome = ($null -ne $previousUnslothStudioHome)
|
|
if ($StudioRedirectMode -eq 'env') {
|
|
$env:UNSLOTH_STUDIO_HOME = $StudioHome
|
|
} else {
|
|
Remove-Item Env:UNSLOTH_STUDIO_HOME -ErrorAction SilentlyContinue
|
|
}
|
|
$studioArgs = @('studio', 'setup')
|
|
if ($script:UnslothVerbose) { $studioArgs += '--verbose' }
|
|
$env:UNSLOTH_INSTALL_ROLLBACK_MANAGED = "1"
|
|
try {
|
|
& $UnslothExe @studioArgs
|
|
$setupExit = $LASTEXITCODE
|
|
} finally {
|
|
if ($hadPreviousUnslothStudioHome) {
|
|
$env:UNSLOTH_STUDIO_HOME = $previousUnslothStudioHome
|
|
} else {
|
|
Remove-Item Env:UNSLOTH_STUDIO_HOME -ErrorAction SilentlyContinue
|
|
}
|
|
Remove-Item Env:UNSLOTH_INSTALL_ROLLBACK_MANAGED -ErrorAction SilentlyContinue
|
|
}
|
|
if ($setupExit -ne 0) {
|
|
Write-Host "[ERROR] unsloth studio setup failed (exit code $setupExit)" -ForegroundColor Red
|
|
return (Exit-InstallFailure "unsloth studio setup failed (exit code $setupExit)" $setupExit)
|
|
}
|
|
|
|
# ── Expose `unsloth` via a shim dir containing only unsloth.exe ──
|
|
# We do NOT add the venv Scripts dir to PATH (it also holds python.exe
|
|
# and pip.exe, which would hijack the user's system interpreter).
|
|
# Hardlink preferred; falls back to copy if cross-volume or non-NTFS.
|
|
#
|
|
# Remove the legacy venv Scripts PATH entry that older installers wrote.
|
|
$LegacyScriptsDir = Join-Path $VenvDir "Scripts"
|
|
try {
|
|
$legacyKey = [Microsoft.Win32.Registry]::CurrentUser.CreateSubKey('Environment')
|
|
try {
|
|
$rawPath = $legacyKey.GetValue('Path', '', [Microsoft.Win32.RegistryValueOptions]::DoNotExpandEnvironmentNames)
|
|
if ($rawPath) {
|
|
[string[]]$pathEntries = $rawPath -split ';'
|
|
$normalLegacy = $LegacyScriptsDir.Trim().Trim('"').TrimEnd('\').ToLowerInvariant()
|
|
$expNormalLegacy = [Environment]::ExpandEnvironmentVariables($LegacyScriptsDir).Trim().Trim('"').TrimEnd('\').ToLowerInvariant()
|
|
$filtered = @($pathEntries | Where-Object {
|
|
$stripped = $_.Trim().Trim('"')
|
|
$rawNorm = $stripped.TrimEnd('\').ToLowerInvariant()
|
|
$expNorm = [Environment]::ExpandEnvironmentVariables($stripped).TrimEnd('\').ToLowerInvariant()
|
|
($rawNorm -ne $normalLegacy -and $rawNorm -ne $expNormalLegacy) -and
|
|
($expNorm -ne $normalLegacy -and $expNorm -ne $expNormalLegacy)
|
|
})
|
|
$cleanedPath = $filtered -join ';'
|
|
if ($cleanedPath -ne $rawPath) {
|
|
$legacyKey.SetValue('Path', $cleanedPath, [Microsoft.Win32.RegistryValueKind]::ExpandString)
|
|
try {
|
|
$d = "UnslothPathRefresh_$([guid]::NewGuid().ToString('N').Substring(0,8))"
|
|
[Environment]::SetEnvironmentVariable($d, '1', 'User')
|
|
[Environment]::SetEnvironmentVariable($d, [NullString]::Value, 'User')
|
|
} catch { }
|
|
}
|
|
}
|
|
} finally {
|
|
$legacyKey.Close()
|
|
}
|
|
} catch { }
|
|
$ShimDir = Join-Path $StudioHome "bin"
|
|
[System.IO.Directory]::CreateDirectory($ShimDir) | Out-Null
|
|
$ShimExe = Join-Path $ShimDir "unsloth.exe"
|
|
# Fatal preflight outside the lock-handling try/catch -- a directory at
|
|
# the shim path must not be downgraded to "Continuing with the existing
|
|
# launcher", or the install finishes with no usable shim.
|
|
if (Test-Path -LiteralPath $ShimExe -PathType Container) {
|
|
Write-Host "[ERROR] Cannot create unsloth launcher: $ShimExe is a directory." -ForegroundColor Red
|
|
Write-Host " Move or remove it manually, then re-run the installer." -ForegroundColor Yellow
|
|
throw "Cannot create unsloth launcher: $ShimExe is a directory."
|
|
}
|
|
# try/catch: if unsloth.exe is locked (Studio running), keep the old shim.
|
|
$shimUpdated = $false
|
|
try {
|
|
if (Test-Path -LiteralPath $ShimExe) { Remove-Item -LiteralPath $ShimExe -Force -ErrorAction Stop }
|
|
try {
|
|
# New-Item -ItemType HardLink does NOT accept -LiteralPath in any
|
|
# PowerShell version, so use -Path. Wildcards in $ShimExe (e.g.
|
|
# brackets in custom roots) glob-expand here and fall through to
|
|
# the Copy-Item -LiteralPath fallback below.
|
|
New-Item -ItemType HardLink -Path $ShimExe -Target $UnslothExe -ErrorAction Stop | Out-Null
|
|
} catch {
|
|
Copy-Item -LiteralPath $UnslothExe -Destination $ShimExe -Force -ErrorAction Stop # fallback: copy
|
|
}
|
|
$shimUpdated = $true
|
|
} catch {
|
|
if (Test-Path -LiteralPath $ShimExe) {
|
|
Write-Host "[WARN] Could not refresh unsloth launcher at $ShimExe." -ForegroundColor Yellow
|
|
Write-Host " This usually means a running 'unsloth studio' process still holds the file open." -ForegroundColor Yellow
|
|
Write-Host " Close Studio and re-run the installer to pick up the latest launcher." -ForegroundColor Yellow
|
|
Write-Host " Continuing with the existing launcher." -ForegroundColor Yellow
|
|
} else {
|
|
Write-Host "[WARN] Could not create unsloth launcher at $ShimExe" -ForegroundColor Yellow
|
|
Write-Host " $($_.Exception.Message)" -ForegroundColor Yellow
|
|
Write-Host " Launch unsloth studio directly via '$UnslothExe' until the next successful install." -ForegroundColor Yellow
|
|
}
|
|
}
|
|
# Add to PATH only when launcher exists. Env-mode: session-only export,
|
|
# no registry change (workspace path may be deleted later).
|
|
$pathAdded = $false
|
|
if (Test-Path -LiteralPath $ShimExe) {
|
|
if ($StudioRedirectMode -ne 'env') {
|
|
$pathAdded = Add-ToUserPath -Directory $ShimDir -Position 'Prepend'
|
|
}
|
|
}
|
|
if ($shimUpdated -and $pathAdded) {
|
|
step "path" "added unsloth launcher to PATH"
|
|
}
|
|
Refresh-SessionPath # sync current session with registry
|
|
Complete-StudioVenvRollback
|
|
|
|
# Env-mode session export AFTER Refresh-SessionPath; otherwise a legacy
|
|
# User PATH entry (Machine > User > current $env:Path) would win.
|
|
if ($StudioRedirectMode -eq 'env' -and (Test-Path -LiteralPath $ShimExe)) {
|
|
$env:Path = "$ShimDir;$env:Path"
|
|
step "path" "exported $ShimDir for this session (no registry PATH change in env-override mode)"
|
|
}
|
|
|
|
# ── Tauri mode: done, skip shortcuts and auto-launch ──
|
|
if ($TauriMode) {
|
|
Write-TauriLog "DONE" ""
|
|
return
|
|
}
|
|
|
|
# New-StudioShortcuts gates the .lnk shortcuts on env-mode internally.
|
|
New-StudioShortcuts -UnslothExePath $UnslothExe
|
|
|
|
# In interactive terminals, ask the user before starting Studio.
|
|
# In non-interactive environments (CI, Docker) just print instructions.
|
|
$IsInteractive = [Environment]::UserInteractive -and (-not [Console]::IsInputRedirected)
|
|
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
|
|
} else {
|
|
step "launch" "to start later, run:"
|
|
substep "unsloth studio -p 8888"
|
|
substep "(add -H 0.0.0.0 to allow network / cloud access)"
|
|
Write-Host ""
|
|
}
|
|
} else {
|
|
step "launch" "manual commands:"
|
|
# Single-quote the printed paths so $-vars / backticks in custom roots
|
|
# do not reparse when the user pastes the command.
|
|
$_actLiteral = "'" + ((Join-Path $VenvDir "Scripts\Activate.ps1") -replace "'", "''") + "'"
|
|
if ($StudioRedirectMode -eq 'env') {
|
|
# Env-mode skips registry PATH; print the absolute shim path.
|
|
$_shim = Join-Path $StudioHome "bin\unsloth.exe"
|
|
$_shimLiteral = "'" + ($_shim -replace "'", "''") + "'"
|
|
substep "& $_shimLiteral studio -p 8888"
|
|
substep "or activate env first:"
|
|
substep "& $_actLiteral"
|
|
substep "unsloth studio -p 8888"
|
|
} else {
|
|
substep "& $_actLiteral"
|
|
substep "unsloth studio -p 8888"
|
|
}
|
|
substep "(add -H 0.0.0.0 to allow network / cloud access)"
|
|
Write-Host ""
|
|
}
|
|
}
|
|
|
|
Install-UnslothStudio @args
|