install.ps1
Rama main · réplica de lectura del repositorio original.
# Unsloth Studio Installer for Windows PowerShell
#
# Usage, options and the web one-liner: see "Unsloth Studio (web UI)" in the README
# (https://github.com/unslothai/unsloth#unsloth-studio-web-ui). Not repeated here: nothing reads
# this header from inside the script, and the whole file is scanned before any of it runs.
# Why several things below are written the long way: tests/studio/test_installer_av_shapes.py (AV_SHAPES_RECORD)
#
# The web entry point cannot forward arguments, so it takes options as environment variables set
# beforehand (UNSLOTH_NO_TORCH, UNSLOTH_SKIP_AUTOSTART, UNSLOTH_ISOLATE_UV_CACHE,
# UNSLOTH_INSTALL_NO_ROLLBACK, UNSLOTH_PYTHON, UNSLOTH_STUDIO_HOME); a local run takes the
# equivalent flags (--no-torch, --skip-autostart, --isolated-uv-cache, --no-rollback,
# --python, --local).
#
# Install dir priority: UNSLOTH_STUDIO_HOME > STUDIO_HOME (alias) > UNSLOTH_HOME/studio > $USERPROFILE\.unsloth\studio
#
# SPDX-License-Identifier: AGPL-3.0-only
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
# Remove-Item can throw a terminating error for an unresolvable path that -ErrorAction
# cannot suppress, so guard and catch everything (temp names can be 8.3 aliases).
# Top level, NOT inside Install-UnslothStudio: the outer finally runs after that function
# returns (tests/studio/test_unsloth_torch_override.ps1 pins the placement).
function Remove-UnslothTempFileQuietly {
param([string]$Path)
if (-not $Path) { return }
try {
if (-not (Test-Path -LiteralPath $Path -ErrorAction SilentlyContinue)) { return }
Remove-Item -LiteralPath $Path -Force -ErrorAction Stop
} catch { }
}
function Install-UnslothStudio {
$ErrorActionPreference = "Stop"
# First: under `irm | iex` the script scope is the caller's session.
$script:StudioUvMarkerSaved = $false
$script:StudioUvMarkerExisted = $false
$script:StudioUvMarkerPrevious = $null
# One flag for both rollbacks, so an interruption cannot restore only half.
$script:StudioInstallCommitted = $false
# The user's PowerShell profile has already run, and the piped web entry cannot re-launch
# with -NoProfile, so each way a profile can reach in is cut below. Strict mode Off: this
# script reads legitimately unset variables. Scoped to here and below.
Set-StrictMode -Off
# A profile setting 'None' is fatal on PowerShell 7, which preloads no modules. First,
# because the proxy handoff below needs ConvertTo-Json.
$PSModuleAutoLoadingPreference = 'All'
# Keep proxy keys: on locked-down hosts they may be the only route out. IsMatch, not
# -match, so no $Matches leaks.
$_UnslothKeptDefaults = @{}
foreach ($_UnslothDefaultKey in @($PSDefaultParameterValues.Keys)) {
# IgnoreCase: 'invoke-webrequest:proxy' binds the same parameter.
if ($_UnslothDefaultKey -is [string] -and
[regex]::IsMatch(
$_UnslothDefaultKey,
':Proxy(Credential|UseDefaultCredentials)?$',
[System.Text.RegularExpressions.RegexOptions]::IgnoreCase)) {
$_UnslothKeptDefaults[$_UnslothDefaultKey] = $PSDefaultParameterValues[$_UnslothDefaultKey]
}
}
# A profile default like 'Start-Process:WindowStyle' would rebind every launch here.
# No scope qualifier: shadows the caller's table for this scope only.
$PSDefaultParameterValues = $_UnslothKeptDefaults
# 5.1 redraws the Invoke-WebRequest progress bar on every read, which dominates download
# time (41s vs 0.08s for python.org's installer). Unqualified, so the caller keeps theirs.
$ProgressPreference = 'SilentlyContinue'
# Kept proxies go to studio/setup.ps1 as JSON in _UNSLOTH_PS_PROXY_DEFAULTS (variables do
# not cross processes). Credentials do not travel.
$_UnslothProxyHandoff = @{}
foreach ($_UnslothDefaultKey in @($_UnslothKeptDefaults.Keys)) {
$_UnslothDefaultValue = $_UnslothKeptDefaults[$_UnslothDefaultKey]
# [uri] is the form the parameter actually takes and serializes to its own string.
if ($_UnslothDefaultValue -is [uri]) {
$_UnslothProxyHandoff[$_UnslothDefaultKey] = $_UnslothDefaultValue.AbsoluteUri
} elseif ($_UnslothDefaultValue -is [string] -or $_UnslothDefaultValue -is [bool]) {
$_UnslothProxyHandoff[$_UnslothDefaultKey] = $_UnslothDefaultValue
} elseif ($_UnslothDefaultValue -is [scriptblock]) {
# Evaluate a script-block default here and hand over the result, never the code.
try {
$_UnslothDefaultResolved = & $_UnslothDefaultValue
if ($_UnslothDefaultResolved -is [uri]) {
$_UnslothProxyHandoff[$_UnslothDefaultKey] = $_UnslothDefaultResolved.AbsoluteUri
} elseif ($_UnslothDefaultResolved -is [string] -or
$_UnslothDefaultResolved -is [bool]) {
$_UnslothProxyHandoff[$_UnslothDefaultKey] = $_UnslothDefaultResolved
}
} catch { }
}
}
# Function-local: under a piped run this is the caller's session and the value may carry
# credentials. Module-qualified so a profile alias cannot replace ConvertTo-Json.
$UnslothProxyHandoffJson =
if ($_UnslothProxyHandoff.Count -gt 0) {
$_UnslothProxyHandoff | Microsoft.PowerShell.Utility\ConvertTo-Json -Compress
}
else { $null }
# PowerShell 7 only: if a profile enables it, native non-zero exits would throw past
# Exit-InstallFailure.
$PSNativeCommandUseErrorActionPreference = $false
# Reset per invocation: under `irm | iex` $script: is the caller's session.
$script:UvExe = 'uv'
$script:UvInstallDestDir = $null
$script:StudioPrivateUvDir = $null
# Same reason, and this one caches a decision about the machine: a policy added or
# removed between two runs in one console, or a replaced launcher under a hash rule,
# would otherwise be answered from the first run's probe.
$script:ShimLaunchBlockedCache = $null
$script:ShimLaunchBlockedPath = $null
$script:UnslothVerbose = ($env:UNSLOTH_VERBOSE -eq "1")
# Same fix as studio/setup.ps1: via Tauri -> Rust -> powershell.exe, 5.1 inherits
# PowerShell 7's module paths first and cannot load its own modules. Deliberately not
# restored: an interactive run ends with Unsloth in the foreground.
if ($PSVersionTable.PSEdition -ne 'Core' -and $env:SystemRoot) {
$_UnslothSystemModules = Join-Path $env:SystemRoot 'System32\WindowsPowerShell\v1.0\Modules'
if (Test-Path $_UnslothSystemModules) {
# Prepended: the problem is precedence, not absence.
$_UnslothKept = @(
$env:PSModulePath -split ';' |
Where-Object { $_ -and ($_ -ne $_UnslothSystemModules) }
)
$env:PSModulePath = (@($_UnslothSystemModules) + $_UnslothKept) -join ';'
}
}
# Same UTF-8 invariant as studio/setup.ps1; must precede the first write.
$_UnslothUtf8NoBom = New-Object System.Text.UTF8Encoding $false
try {
[Console]::OutputEncoding = $_UnslothUtf8NoBom
} catch {
# No console: the setter drops the cached writer before throwing, so bind UTF-8 writers.
try {
$_UnslothStdout = New-Object System.IO.StreamWriter -ArgumentList ([Console]::OpenStandardOutput()), $_UnslothUtf8NoBom
$_UnslothStdout.AutoFlush = $true
[Console]::SetOut($_UnslothStdout)
$_UnslothStderr = New-Object System.IO.StreamWriter -ArgumentList ([Console]::OpenStandardError()), $_UnslothUtf8NoBom
$_UnslothStderr.AutoFlush = $true
[Console]::SetError($_UnslothStderr)
} catch { }
}
$OutputEncoding = $_UnslothUtf8NoBom
$env:PYTHONUTF8 = '1'
$env:PYTHONIOENCODING = 'utf-8'
# Resolved once: it picks the sink in Write-StudioLine. Same probe as studio/setup.ps1.
$script:StudioStdoutRedirected = $false
try { $script:StudioStdoutRedirected = [Console]::IsOutputRedirected } catch { }
# 5.1's Write-Host uses the OEM code page, and the desktop app decodes the pipe as UTF-8,
# so write to the console handle when redirected and Write-Host (colour) when interactive.
# Defined before the first write.
function Write-StudioLine {
param([string]$Message = "", [string]$ForegroundColor)
if ($script:StudioStdoutRedirected) {
try { [Console]::Out.WriteLine($Message); [Console]::Out.Flush() } catch {}
return
}
if ($PSBoundParameters.ContainsKey('ForegroundColor')) {
Write-Host $Message -ForegroundColor $ForegroundColor
} else {
Write-Host $Message
}
}
# ── Tauri structured output ──
function Write-TauriLog {
param([string]$Tag, [string]$Message)
if ($TauriMode) {
Write-StudioLine "[TAURI:$Tag] $Message"
}
}
# Mirrors _uv_download_markers in install.sh; only what was opened is closed.
$script:UvDownloadMarkerMinBytes = 52428800
if ($env:UNSLOTH_DL_MARKER_MIN_BYTES -match '^\d+$') {
$script:UvDownloadMarkerMinBytes = [long]$env:UNSLOTH_DL_MARKER_MIN_BYTES
}
$script:UvAnnouncedDownloads = @{}
function Write-UvDownloadMarker {
param([string]$Line)
if (-not $TauriMode) { return }
if ($Line -match '(?:^|\s)Downloading (\S+) \(([0-9.]+)(KiB|MiB|GiB)\)\s*$') {
$unit = @{ KiB = 1024L; MiB = 1048576L; GiB = 1073741824L }[$Matches[3]]
if ([double]$Matches[2] * $unit -ge $script:UvDownloadMarkerMinBytes) {
$script:UvAnnouncedDownloads[$Matches[1]] = $true
Write-TauriLog "DL" "$($Matches[1]) $($Matches[2])$($Matches[3])"
}
} elseif ($Line -match '(?:^|\s)Downloaded (\S+)\s*$') {
# ContainsKey, not Remove's return: Hashtable.Remove is void.
if ($script:UvAnnouncedDownloads.ContainsKey($Matches[1])) {
$script:UvAnnouncedDownloads.Remove($Matches[1])
Write-TauriLog "DL_DONE" $Matches[1]
}
}
}
function Clear-TauriInstallError {
param([string]$Message)
if ($TauriMode) {
Write-TauriLog "ERROR_CLEAR" $Message
[Console]::Error.WriteLine("[TAURI:ERROR_CLEAR] $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_.-]', '_') }
}
}
# Machine arch; Get-TauriDiagArch above reports the process. An emulated x64 shell on
# ARM64 reports AMD64, but PROCESSOR_ARCHITEW6432 is ARM64 in exactly that case.
function Get-HostMachineArch {
$osArch = ""
try { $osArch = [System.Runtime.InteropServices.RuntimeInformation]::OSArchitecture.ToString() } catch { $osArch = "" }
# Machine scope first: emulated x64 says AMD64 per-process (ARCHITEW6432 is WOW64-only).
$machineArch = ""
try { $machineArch = [string][Environment]::GetEnvironmentVariable("PROCESSOR_ARCHITECTURE", "Machine") } catch { $machineArch = "" }
$signals = @($machineArch, [string]$env:PROCESSOR_ARCHITEW6432, [string]$env:PROCESSOR_ARCHITECTURE, $osArch)
foreach ($s in $signals) {
if ($s.ToLowerInvariant() -eq "arm64") { return "arm64" }
}
foreach ($s in $signals) {
if ([string]::IsNullOrWhiteSpace($s)) { continue }
switch ($s.ToLowerInvariant()) {
"amd64" { return "x86_64" }
"x64" { return "x86_64" }
"x86" { return "x86" }
}
}
return "unknown"
}
# WoA + NVIDIA: emulated triton cannot target the Blackwell SM. Probed for a win_arm64 CUDA wheel, never by arch.
$script:WoaNativeCudaTorch = $false
$script:WoaTorchIndexUrl = $null
$script:WoaNvidiaTorchIndexUrls = if ($env:UNSLOTH_WOA_TORCH_INDEX_URL) {
@($env:UNSLOTH_WOA_TORCH_INDEX_URL.Trim().TrimEnd('/'))
} else {
@(
"https://pypi.nvidia.com/nvtorch_oot",
"https://pypi.nvidia.com/nvtorch_oot_nightly"
)
}
$script:WoaTorchAudio = $false
# Wheels PyPI lacks for win_arm64 (pyarrow, sqlite-vec), built and signed by
# .github/workflows/woa-wheelhouse.yml. UNSLOTH_WOA_WHEELHOUSE overrides.
$script:WoaWheelhouse = if ($env:UNSLOTH_WOA_WHEELHOUSE) {
$env:UNSLOTH_WOA_WHEELHOUSE.Trim().TrimEnd('/')
} else {
"https://github.com/unslothai/unsloth/releases/download/Windows-ARM64"
}
# AVAILABLE, not missing: a name absent here is EXCLUDED on ARM64 by the drop list below.
$script:WoaPyPIProvided = @{}
$script:WoaPyPIMatchedVersion = $null
$script:WoaPyarrowWheel = $null
$script:WoaPyarrowSource = $null # "pypi" | "wheelhouse" | "local"
$script:WoaPyarrowWheelName = $null # the wheel the pin is written from
function Test-ZipArchiveReadable {
param([string]$Path)
try {
Add-Type -AssemblyName System.IO.Compression.FileSystem -ErrorAction SilentlyContinue
$zip = [System.IO.Compression.ZipFile]::OpenRead($Path)
} catch { return $false }
try { return ($zip.Entries.Count -gt 0) } catch { return $false } finally { $zip.Dispose() }
}
# NVIDIA ships bare .bin artifacts; uv/pip need the tags in the filename, so rebuild it.
function Get-WheelFileNameFromArchive {
param([string]$Path)
try {
Add-Type -AssemblyName System.IO.Compression.FileSystem -ErrorAction SilentlyContinue
$zip = [System.IO.Compression.ZipFile]::OpenRead($Path)
} catch { return $null }
try {
$wheelEntry = $zip.Entries | Where-Object { $_.FullName -match '^[^/]+\.dist-info/WHEEL$' } | Select-Object -First 1
if (-not $wheelEntry) { return $null }
$distInfo = ($wheelEntry.FullName -split '/')[0] -replace '\.dist-info$', ''
$reader = New-Object System.IO.StreamReader($wheelEntry.Open())
try { $content = $reader.ReadToEnd() } finally { $reader.Dispose() }
if ($content -notmatch '(?m)^Tag:\s*(\S+)\s*$') { return $null }
return "$distInfo-$($Matches[1]).whl"
} catch {
return $null
} finally {
$zip.Dispose()
}
}
# "$base/$leaf" would put the leaf inside a mirror's ?token=.
function Join-UrlPath {
param([string]$Base, [string]$Path)
if ([string]::IsNullOrWhiteSpace($Base)) { return $Path }
$cut = $Base.IndexOfAny([char[]]('?', '#'))
if ($cut -lt 0) { return "$($Base.TrimEnd('/'))/$Path" }
$head = $Base.Substring(0, $cut).TrimEnd('/')
$tail = $Base.Substring($cut)
return "$head/$Path$tail"
}
# Userinfo AND query/fragment, so an authenticated pin never leaks. Mirrors the Python side.
function Remove-IndexUrlCredentials {
param([string]$Url)
# Ordinal, not culture-aware: th-TH treats "://" as ignorable and crashes Substring (#7279).
$sep = $Url.IndexOf('://', [System.StringComparison]::Ordinal)
if ($sep -lt 0) { return $Url }
$scheme = $Url.Substring(0, $sep)
$rest = $Url.Substring($sep + 3)
$q = $rest.IndexOfAny([char[]]('?', '#'))
if ($q -ge 0) { $rest = $rest.Substring(0, $q) }
$slash = $rest.IndexOf('/', [System.StringComparison]::Ordinal)
$authority = if ($slash -ge 0) { $rest.Substring(0, $slash) } else { $rest }
$at = $authority.LastIndexOf('@', [System.StringComparison]::Ordinal)
$host_ = if ($at -ge 0) { $authority.Substring($at + 1) } else { $authority }
if ($slash -ge 0) { return "${scheme}://${host_}$($rest.Substring($slash))" }
return "${scheme}://${host_}"
}
function Test-WoaWheelhouseIsLocal {
param([string]$Value)
if (-not $Value) { return $false }
if ($Value -match '^[A-Za-z][A-Za-z0-9+.-]*://') { return $false }
try { return (Test-Path -LiteralPath $Value -PathType Container) } catch { return $false }
}
# uv splits UV_OVERRIDE on whitespace, so a spaced path breaks every later uv call (#6503).
function Get-UvSafePath {
param([string]$Path)
if (-not $Path -or -not $Path.Contains(" ")) { return $Path }
try {
$fso = New-Object -ComObject Scripting.FileSystemObject
$short = if (Test-Path -LiteralPath $Path -PathType Container) {
$fso.GetFolder($Path).ShortPath
} else {
$fso.GetFile($Path).ShortPath
}
# A space-free alias is not necessarily a name that resolves (#11290). This value
# reaches UV_OVERRIDE and --find-links, so a bogus one breaks every later uv call.
if ($short -and -not $short.Contains(" ") -and (Test-Path -LiteralPath $short -ErrorAction SilentlyContinue)) { return $short }
} catch {}
return $Path
}
# uv accepts no quoting in UV_OVERRIDE (0.10.7), so 8.3 is the only mitigation, and a volume can disable it.
$script:WoaResolverPathsOk = $null
function Test-WoaResolverPathsUsable {
if ($null -ne $script:WoaResolverPathsOk) { return $script:WoaResolverPathsOk }
try { New-Item -ItemType Directory -Force -Path $StudioHome -ErrorAction Stop | Out-Null } catch {}
$script:WoaResolverPathsOk = -not ((Get-UvSafePath $StudioHome) -match '\s')
if (-not $script:WoaResolverPathsOk) {
substep "windows on arm: $StudioHome contains a space and this volume has no 8.3" "Yellow"
substep "short name for it, which uv cannot read -- using the x64 stack instead." "Yellow"
substep "Set UNSLOTH_STUDIO_HOME to a path without spaces for the native install." "Yellow"
}
return $script:WoaResolverPathsOk
}
# PEP 425 fields, not a substring: "*cp313-cp313*" also matches cp313-cp313t. Fields are "."-sets.
function Test-WoaWheelTags {
param([string]$Name, [string]$PyTag, [string]$AbiTag)
if (-not $Name) { return $false }
$stem = $Name -replace '(?i)\.whl$', ''
$fields = $stem -split '-'
if ($fields.Count -lt 5) { return $false }
$pyTags = $fields[$fields.Count - 3] -split '\.'
$abiTags = $fields[$fields.Count - 2] -split '\.'
return (($pyTags -contains $PyTag) -and ($abiTags -contains $AbiTag))
}
# PEP 440 to the depth these floors use; a pre-release sorts BELOW its release (21.0.0rc1 fails >=21.0.0).
function Test-WoaVersionAtLeast {
param([string]$Version, [string]$Floor)
$parse = {
param([string]$v)
if ($v -notmatch '^\s*v?(\d+(\.\d+)*)') { return $null }
$release = $Matches[1] -split '\.' | ForEach-Object { [int]$_ }
$post = 0
if ($v -match '\.post(\d+)') { $post = [int]$Matches[1] }
# a/b/rc only, and only off the release, so ".post1rc1" is not a pre-release of it.
$pre = [bool]($v -match '(?i)^\s*v?\d+(\.\d+)*(a|b|rc)\d')
# PEP 440 hangs .devN off what precedes it, so 0.0.22.post7.dev0 is below 0.0.22.post7.
$dev = [int]::MaxValue
if ($v -match '(?i)\.dev(\d+)') { $dev = [int]$Matches[1] }
return @{ Release = @($release); Post = $post; Pre = $pre; Dev = $dev }
}
$a = & $parse $Version
$b = & $parse $Floor
if ($null -eq $a -or $null -eq $b) { return $false }
$width = [Math]::Max($a.Release.Count, $b.Release.Count)
for ($i = 0; $i -lt $width; $i++) {
$x = if ($i -lt $a.Release.Count) { $a.Release[$i] } else { 0 }
$y = if ($i -lt $b.Release.Count) { $b.Release[$i] } else { 0 }
if ($x -ne $y) { return ($x -gt $y) }
}
if ($a.Pre -ne $b.Pre) { return $b.Pre }
if ($a.Post -ne $b.Post) { return ($a.Post -gt $b.Post) }
if ($a.Dev -ne $b.Dev) { return ($a.Dev -gt $b.Dev) }
return $true
}
# The wheelhouse may BE the managed directory (offline reuse), and Copy-Item refuses a self-copy.
function Test-WoaSamePath {
param([string]$A, [string]$B)
if (-not $A -or -not $B) { return $false }
try {
$a = [System.IO.Path]::GetFullPath($A).TrimEnd('\', '/')
$b = [System.IO.Path]::GetFullPath($B).TrimEnd('\', '/')
} catch { return $false }
return $a.Equals($b, [System.StringComparison]::OrdinalIgnoreCase)
}
# uv and pip resolve -r/-c/-f against the file CONTAINING the line, so a moved line must rebase.
function Resolve-WoaOverrideLine {
param([string]$Line, [string]$BaseDir)
if (-not $BaseDir -or $Line -match '^\s*(#|$)') { return $Line }
# pip's inline comment is whitespace then "#": split off, or the rebase reads it as part of the path.
$comment = ""
if ($Line -match '^(.*?)(\s+#.*)$') { $Line = $Matches[1]; $comment = $Matches[2] }
$abs = {
param([string]$p)
if (-not $p -or $p -match '^[A-Za-z][A-Za-z0-9+.-]*://' -or [System.IO.Path]::IsPathRooted($p)) { return $p }
try { return [System.IO.Path]::GetFullPath([System.IO.Path]::Combine($BaseDir, $p)) } catch { return $p }
}
if ($Line -match '^(\s*)(-r|--requirement|-c|--constraint|-f|--find-links)([=\s]+)(.+?)(\s*)$') {
# Copied out FIRST: the -match below replaces $Matches, dropping the option token.
$lead = $Matches[1]; $opt = $Matches[2]; $sep = $Matches[3]
$bare = $Matches[4].Trim('"').Trim("'"); $tail = $Matches[5]
$rebased = & $abs $bare
if ($rebased -match '\s') { $rebased = '"' + $rebased + '"' }
return "$lead$opt$sep$rebased$tail$comment"
}
# -e / --editable names a path too. Extras split off first, or GetFullPath folds ".[dev]" into the parent and leaves a directory nobody has.
if ($Line -match '^(\s*)(-e|--editable)([=\s]+)(.+?)(\s*)$') {
$lead = $Matches[1]; $opt = $Matches[2]; $sep = $Matches[3]
$bare = $Matches[4].Trim('"').Trim("'"); $tail = $Matches[5]
$extras = ""
if ($bare -match '^(.*?)(\[[^\]]*\])$') { $bare = $Matches[1]; $extras = $Matches[2] }
$rebased = (& $abs $bare) + $extras
if ($rebased -match '\s') { $rebased = '"' + $rebased + '"' }
return "$lead$opt$sep$rebased$tail$comment"
}
if ($Line -match '^(\s*[^\s@]+\s*@\s*)(.+?)(\s*)$') {
$head = $Matches[1]; $target = $Matches[2]; $tail = $Matches[3]
# PEP 508 separates a marker from a URL with whitespace before the ";": kept aside, or it would be rebased as part of the path.
$marker = ""
if ($target -match '^(.*?)(\s+;.*)$') { $target = $Matches[1]; $marker = $Matches[2] }
if ($target -match '^file:(?!//)(.*)$') {
$rebasedPath = & $abs $Matches[1]
$uri = try { (New-Object System.Uri -ArgumentList @($rebasedPath, [System.UriKind]::Absolute)).AbsoluteUri } catch { "file:" + $rebasedPath }
return "$head$uri$marker$tail$comment"
}
return "$Line$comment"
}
if ($Line -match '^(\s*)([^\s#;]+\.(?:whl|tar\.gz|zip))(\s*.*)$') {
$lead = $Matches[1]; $path = $Matches[2]; $rest = $Matches[3]
if ($path -match '[\\/]') { return "$lead" + (& $abs $path) + "$rest$comment" }
}
# A bare local directory is a requirement to pip and uv both; the leading dot segment is what tells it from a package name, which may not start with one.
if ($Line -match '^(\s*)(\.{1,2}[^\s#;]*)(\s*(?:[;#].*)?)$') {
$lead = $Matches[1]; $path = $Matches[2]; $rest = $Matches[3]
$extras = ""
if ($path -match '^(.*?)(\[[^\]]*\])$') { $path = $Matches[1]; $extras = $Matches[2] }
return "$lead" + (& $abs $path) + "$extras$rest$comment"
}
return "$Line$comment"
}
# Which index a uv resolve uses, from uv.toml / pyproject.toml. Quote-aware, and a subset parser because PS 5.1 has no TOML reader.
function Remove-WoaTomlComment {
param([string]$Line)
$inD = $false; $inS = $false
for ($i = 0; $i -lt $Line.Length; $i++) {
$c = $Line[$i]
if ($inD) {
if ($c -eq '\') { $i++; continue }
if ($c -eq '"') { $inD = $false }
continue
}
if ($inS) { if ($c -eq "'") { $inS = $false }; continue }
if ($c -eq '"') { $inD = $true; continue }
if ($c -eq "'") { $inS = $true; continue }
if ($c -eq '#') { return $Line.Substring(0, $i) }
}
return $Line
}
function Split-WoaTomlKey {
param([string]$Text)
$parts = @()
$i = 0
while ($i -lt $Text.Length) {
while ($i -lt $Text.Length -and [char]::IsWhiteSpace($Text[$i])) { $i++ }
if ($i -ge $Text.Length) { return $null }
$c = $Text[$i]
if ($c -eq '"' -or $c -eq "'") {
$q = $c; $i++; $sb = ""
while ($i -lt $Text.Length -and $Text[$i] -ne $q) {
if ($q -eq '"' -and $Text[$i] -eq '\' -and ($i + 1) -lt $Text.Length) { $i++ }
$sb += $Text[$i]; $i++
}
if ($i -ge $Text.Length) { return $null }
$i++
$parts += $sb
} else {
$sb = ""
while ($i -lt $Text.Length -and ([string]$Text[$i]) -match '[A-Za-z0-9_-]') { $sb += $Text[$i]; $i++ }
if (-not $sb) { return $null }
$parts += $sb
}
while ($i -lt $Text.Length -and [char]::IsWhiteSpace($Text[$i])) { $i++ }
if ($i -lt $Text.Length) {
if ($Text[$i] -ne '.') { return $null }
$i++
}
}
if ($parts.Count -eq 0) { return $null }
return ,$parts
}
# uv's inline spelling of [[index]]: `index = [{ url = "...", default = true }]`. Returns @{ DefaultUrl; Extras }, or $null on ANY doubt; only a single-line, brace-balanced array of FLAT inline tables is read.
# The brace characters below are built from code points: the parity tests extract a function by counting braces, so a literal one inside a string would make this body un-tested.
function Read-WoaUvInlineIndexArray {
param([string]$Value)
$lb = [char]0x7B; $rb = [char]0x7D
$v = ([string]$Value).Trim()
if ($v -notmatch '^\[(.*)\]$') { return $null }
$inner = $Matches[1].Trim()
$result = @{ DefaultUrl = $null; Extras = @() }
if (-not $inner) { return $result }
$groups = @()
$depth = 0; $start = -1; $inD = $false; $inS = $false
for ($i = 0; $i -lt $inner.Length; $i++) {
$c = $inner[$i]
if ($inD) { if ($c -eq '\') { $i++ } elseif ($c -eq '"') { $inD = $false }; continue }
if ($inS) { if ($c -eq "'") { $inS = $false }; continue }
if (($c -eq '"' -or $c -eq "'") -and $depth -eq 0) { return $null }
if ($c -eq '"') { $inD = $true; continue }
if ($c -eq "'") { $inS = $true; continue }
if ($c -eq $lb) { if ($depth -eq 0) { $start = $i + 1 }; $depth++; continue }
if ($c -eq $rb) {
$depth--
if ($depth -lt 0) { return $null }
if ($depth -eq 0) { $groups += $inner.Substring($start, $i - $start); $start = -1 }
continue
}
if ($depth -eq 0 -and ([string]$c) -notmatch '[\s,]') { return $null }
}
if ($depth -ne 0 -or $inD -or $inS -or -not $groups.Count) { return $null }
foreach ($g in $groups) {
if ($g.IndexOf($lb) -ge 0 -or $g.IndexOf('[') -ge 0) { return $null }
$url = $null; $isDefault = $false; $isExplicit = $false
foreach ($pair in ($g -split ',')) {
$pair = $pair.Trim()
if (-not $pair) { continue }
$eq = $pair.IndexOf('=')
if ($eq -lt 1) { return $null }
$k = $pair.Substring(0, $eq).Trim().Trim('"').Trim("'").ToLowerInvariant()
$raw = $pair.Substring($eq + 1).Trim()
$str = if ($raw -match '^"(.*)"$' -or $raw -match "^'(.*)'$") { $Matches[1] } else { $null }
$bool = if ($raw -match '^(true|false)$') { $raw -eq 'true' } else { $null }
if ($k -eq 'url') { if (-not $str) { return $null }; $url = $str }
elseif ($k -eq 'default') { if ($null -eq $bool) { return $null }; $isDefault = $bool }
# uv: an explicit index serves only packages pinned to it via [tool.uv.sources], so it is neither the default nor an extra.
elseif ($k -eq 'explicit') { if ($null -eq $bool) { return $null }; $isExplicit = $bool }
}
if (-not $url) { return $null }
# explicit AND default also removes PyPI as the default (uv docs): not modelled, so doubt.
if ($isExplicit) { if ($isDefault) { return $null }; continue }
if ($isDefault) { if (-not $result.DefaultUrl) { $result.DefaultUrl = $url } }
else { $result.Extras += $url }
}
return $result
}
function Read-WoaUvTomlIndexKeys {
param([string]$Path, [string]$Top)
try { $lines = [System.IO.File]::ReadAllLines($Path) } catch { return $null }
# uv pip (0.10.7): [pip] scalars beat top-level, [[index]] default = true beats both. Ranked at the end.
$topScope = @{ NoIndex = $null; IndexUrl = $null; Extras = @() }
$pipScope = @{ NoIndex = $null; IndexUrl = $null; Extras = @() }
$section = ""
# A hashtable, because an assignment inside the $flush script block would be local to it.
$inIndex = $false; $idxUrl = $null; $idxDefault = $false; $idxExplicit = $false; $entry = @{ DefaultUrl = $null; Extras = @(); Doubt = $false }
$indexTable = if ($Top) { "$Top.index" } else { "index" }
$pipTable = if ($Top) { "$Top.pip" } else { "pip" }
$flush = {
if ($inIndex -and $idxUrl) {
# Explicit entries are skipped, as in the inline reader; explicit AND default is doubt.
if ($idxExplicit) { if ($idxDefault) { $entry.Doubt = $true } }
elseif ($idxDefault) { if (-not $entry.DefaultUrl) { $entry.DefaultUrl = $idxUrl } }
else { $entry.Extras += $idxUrl }
}
}
foreach ($raw in $lines) {
$line = (Remove-WoaTomlComment $raw).Trim()
if (-not $line) { continue }
if ($line -match '^\[\[(.+?)\]\]$') {
& $flush
$section = $Matches[1].Trim(); $inIndex = ($section -eq $indexTable); $idxUrl = $null; $idxDefault = $false; $idxExplicit = $false
continue
}
if ($line -match '^\[(.+?)\]$') { & $flush; $section = $Matches[1].Trim(); $inIndex = $false; continue }
$eq = -1; $inD = $false; $inS = $false
for ($i = 0; $i -lt $line.Length; $i++) {
$c = $line[$i]
if ($inD) { if ($c -eq '\') { $i++ } elseif ($c -eq '"') { $inD = $false }; continue }
if ($inS) { if ($c -eq "'") { $inS = $false }; continue }
if ($c -eq '"') { $inD = $true; continue }
if ($c -eq "'") { $inS = $true; continue }
if ($c -eq '=') { $eq = $i; break }
}
if ($eq -lt 1) { continue }
$parts = Split-WoaTomlKey $line.Substring(0, $eq)
if (-not $parts) { continue }
$key = $parts[-1]
$prefix = if ($parts.Count -gt 1) { ($parts[0..($parts.Count - 2)] -join '.') } else { "" }
$val = $line.Substring($eq + 1).Trim()
if (-not $val) { continue }
$str = if ($val -match '^"(.*)"$' -or $val -match "^'(.*)'$") { $Matches[1] } else { $null }
$bool = if ($val -match '^(true|false)$') { $val -eq 'true' } else { $null }
if ($inIndex) {
if (-not $prefix) {
if ($key -eq 'url' -and $str) { $idxUrl = $str }
if ($key -eq 'default' -and $null -ne $bool) { $idxDefault = $bool }
if ($key -eq 'explicit' -and $null -ne $bool) { $idxExplicit = $bool }
}
continue
}
$scopeSection = if ($prefix) { if ($section) { "$section.$prefix" } else { $prefix } } else { $section }
$scope = if ($scopeSection -eq $Top) { $topScope } elseif ($scopeSection -eq $pipTable) { $pipScope } else { $null }
if ($null -eq $scope) { continue }
if ($key -eq 'no-index' -and $null -ne $bool -and $null -eq $scope.NoIndex) { $scope.NoIndex = $bool }
if (($key -eq 'default-index' -or $key -eq 'index-url') -and $str -and -not $scope.IndexUrl) { $scope.IndexUrl = $str }
if ($key -eq 'extra-index-url') {
if ($val -match '^\[(.*)\]$') {
foreach ($m in [regex]::Matches($Matches[1], ('"([^"]*)"' + "|'([^']*)'"))) {
$scope.Extras += $(if ($m.Groups[1].Success) { $m.Groups[1].Value } else { $m.Groups[2].Value })
}
} elseif ($str) { $scope.Extras += $str }
}
if ($key -eq 'index') {
$inline = Read-WoaUvInlineIndexArray -Value $val
if ($null -eq $inline) { return $null }
if ($inline.DefaultUrl -and -not $entry.DefaultUrl) { $entry.DefaultUrl = $inline.DefaultUrl }
$entry.Extras += @($inline.Extras)
}
}
& $flush
if ($entry.Doubt) { return $null }
$noIndex = if ($null -ne $pipScope.NoIndex) { $pipScope.NoIndex } else { $topScope.NoIndex }
$defaultIndex = if ($entry.DefaultUrl) { $entry.DefaultUrl } elseif ($pipScope.IndexUrl) { $pipScope.IndexUrl } else { $topScope.IndexUrl }
$extras = @($entry.Extras) + @($pipScope.Extras) + @($topScope.Extras)
return @{ NoIndex = $noIndex; DefaultIndex = $defaultIndex; ExtraIndexes = @($extras | Where-Object { $_ }) }
}
# uv's boolish set (uv 0.10.7 parse_boolish_environment_variable): y/yes/t/true/on/1 vs
# n/no/f/false/off/0, case-insensitive. Trimmed to match setup.sh and
# install_python_stack.py. ToLowerInvariant for Turkish locales.
function Test-UvEnvFlag {
param([string]$Name)
$value = [string][Environment]::GetEnvironmentVariable($Name)
return (@("1", "t", "true", "y", "yes", "on") -contains $value.Trim().ToLowerInvariant())
}
# pip's own rule for PIP_NO_INDEX (strtobool; empty means unset). Same literals as uv's
# today, restated so either can change independently.
function Test-PipEnvFlag {
param([string]$Name)
$value = [string][Environment]::GetEnvironmentVariable($Name)
return (@("1", "t", "true", "y", "yes", "on") -contains $value.Trim().ToLowerInvariant())
}
# UV_NO_INDEX is OURS: uv defines no such environment variable (--no-index is a
# command-line flag only). Read with uv's boolish set for consistency with UV_OFFLINE/UV_NO_CONFIG;
# not turned into --no-index, which would change behaviour for existing users.
function Test-NoIndexRequested {
return (Test-UvEnvFlag "UV_NO_INDEX")
}
function Get-WoaUvConfigIndexPolicy {
$result = @{ NoIndex = $false; DefaultIndex = $null; Unreadable = $false; UnreadablePath = $null; ExtraIndexes = @() }
if (Test-UvEnvFlag "UV_NO_CONFIG") { return $result }
$files = @()
$cfgFile = [string][Environment]::GetEnvironmentVariable("UV_CONFIG_FILE")
if ($cfgFile) {
$files += @{ Path = $cfgFile; Top = "" }
} else {
$dir = (Get-Location).Path
while ($dir) {
$u = Join-Path $dir "uv.toml"; $pp = Join-Path $dir "pyproject.toml"
if (Test-Path -LiteralPath $u -PathType Leaf) { $files += @{ Path = $u; Top = "" }; break }
if (Test-Path -LiteralPath $pp -PathType Leaf) {
$txt = try { [System.IO.File]::ReadAllText($pp) } catch { "" }
if ($txt -match '(?m)^\s*\[+tool\.uv(\.|\])') { $files += @{ Path = $pp; Top = "tool.uv" }; break }
}
$parent = Split-Path -Parent $dir
if (-not $parent -or $parent -eq $dir) { break }
$dir = $parent
}
if ($env:APPDATA) { $files += @{ Path = (Join-Path $env:APPDATA "uv\uv.toml"); Top = "" } }
if ($env:ProgramData) { $files += @{ Path = (Join-Path $env:ProgramData "uv\uv.toml"); Top = "" } }
}
$noIndexSet = $false
foreach ($f in $files) {
if (-not (Test-Path -LiteralPath $f.Path -PathType Leaf)) { continue }
$policy = Read-WoaUvTomlIndexKeys -Path $f.Path -Top $f.Top
if ($null -eq $policy) {
$result.Unreadable = $true
if (-not $result.UnreadablePath) { $result.UnreadablePath = $f.Path }
continue
}
if (-not $noIndexSet -and $null -ne $policy.NoIndex) { $result.NoIndex = $policy.NoIndex; $noIndexSet = $true }
if (-not $result.DefaultIndex -and $policy.DefaultIndex) { $result.DefaultIndex = $policy.DefaultIndex }
$result.ExtraIndexes = @($result.ExtraIndexes) + @($policy.ExtraIndexes)
}
return $result
}
# The host, not a substring: "https://pypi.org.corp.example/simple" is not public PyPI.
function Test-WoaUrlIsPublicPyPI {
param([string]$Url)
if (-not $Url) { return $false }
try { $u = [System.Uri]$Url.Trim() } catch { return $false }
if (-not $u.IsAbsoluteUri) { return $false }
return ($u.Host.ToLowerInvariant() -eq "pypi.org")
}
# This script resolves with uv, so uv's policy is the one that counts: the UV_* variables uv actually defines, and its configuration files. pip's PIP_* variables are pip's alone; uv never reads them. UV_NO_INDEX is neither -- see Test-NoIndexRequested.
function Test-WoaResolveReachesPyPI {
# UV_OFFLINE really stops uv; UV_NO_INDEX is our convention and uv ignores it.
if (Test-UvEnvFlag "UV_OFFLINE") { return $false }
if (Test-NoIndexRequested) { return $false }
$extraIsPyPI = $false
foreach ($name in @("UV_INDEX", "UV_EXTRA_INDEX_URL")) {
$list = [string][Environment]::GetEnvironmentVariable($name)
foreach ($u in ($list -split '\s+' | Where-Object { $_ })) {
if (Test-WoaUrlIsPublicPyPI $u) { $extraIsPyPI = $true }
}
}
foreach ($name in @("UV_DEFAULT_INDEX", "UV_INDEX_URL")) {
$url = [string][Environment]::GetEnvironmentVariable($name)
if ($url -and ($url.Trim())) { return ($extraIsPyPI -or (Test-WoaUrlIsPublicPyPI $url)) }
}
# Doubt resolves to "not PyPI": that answer keeps a wheel, the other loses the package.
$cfg = Get-WoaUvConfigIndexPolicy
if ($cfg.Unreadable -or $cfg.NoIndex) { return $false }
foreach ($u in @($cfg.ExtraIndexes)) { if (Test-WoaUrlIsPublicPyPI $u) { $extraIsPyPI = $true } }
if ($cfg.DefaultIndex -and -not (Test-WoaUrlIsPublicPyPI $cfg.DefaultIndex)) { return $extraIsPyPI }
return $true
}
# True when uv's own config decides the indexes and we could not read it: fatal where the caller scrubs UV_* and sets UV_NO_CONFIG, because the trio index would be the only source left.
function Test-WoaUvIndexPolicyUnreadable {
foreach ($name in @("UV_NO_INDEX", "UV_DEFAULT_INDEX", "UV_INDEX_URL")) {
$v = [string][Environment]::GetEnvironmentVariable($name)
if ($v -and $v.Trim()) { return $false }
}
return [bool](Get-WoaUvConfigIndexPolicy).Unreadable
}
# The index beside the CUDA one for torch's shared dependencies. Invoke-InstallCommand clears the inherited index settings whenever --default-index is passed, so the caller's policy is restated here.
function Get-WoaDependencyIndexArgs {
param([string]$Resolver = "uv")
$pip = ($Resolver -eq "pip")
$defaultNames = if ($pip) { @("PIP_INDEX_URL") } else { @("UV_DEFAULT_INDEX", "UV_INDEX_URL") }
$extraNames = if ($pip) { @("PIP_EXTRA_INDEX_URL") } else { @("UV_INDEX", "UV_EXTRA_INDEX_URL") }
# Each variable by its owner's rule, and they are not symmetric. PIP_NO_INDEX is
# pip's and pip really reads it, so naming no index here matches what pip will
# then do. UV_NO_INDEX is ours alone, so that arm is us honouring the operator.
$noIndexRequested = if ($pip) { Test-PipEnvFlag "PIP_NO_INDEX" } else { Test-NoIndexRequested }
if ($noIndexRequested) { return @() }
$default = $null
foreach ($name in $defaultNames) {
$url = [string][Environment]::GetEnvironmentVariable($name)
if ($url -and $url.Trim()) { $default = $url.Trim(); break }
}
$extras = @()
foreach ($name in $extraNames) {
$list = [string][Environment]::GetEnvironmentVariable($name)
foreach ($u in ($list -split '\s+' | Where-Object { $_ })) { $extras += $u }
}
if (-not $pip -and (-not $default -or -not $extras)) {
$cfg = Get-WoaUvConfigIndexPolicy
if ($cfg.NoIndex) { return @() }
# An unreadable policy with no env default: name nothing, or the PyPI fallback below would silently override whatever mirror the file configured.
if ($cfg.Unreadable -and -not $default) { return @() }
if (-not $default -and $cfg.DefaultIndex) { $default = $cfg.DefaultIndex }
if (-not $extras) { $extras = @($cfg.ExtraIndexes) }
}
if (-not $default) { $default = "https://pypi.org/simple" }
$indexArgs = @()
foreach ($u in @(@($default) + @($extras) | Where-Object { $_ } | Select-Object -Unique)) {
$indexArgs += @("--extra-index-url", $u)
}
return $indexArgs
}
# Exact tags, or a wheel that does not care (cp38-abi3, py3-none). Free-threaded has no abi3.
function Test-WoaWheelTagsUsable {
param([string]$Name, [string]$PyTag, [string]$AbiTag = "")
if (-not $AbiTag) { $AbiTag = $PyTag }
if (Test-WoaWheelTags -Name $Name -PyTag $PyTag -AbiTag $AbiTag) { return $true }
$fields = ($Name -replace '(?i)\.whl$', '') -split '-'
if ($fields.Count -lt 5) { return $false }
# Free-threaded CPython has no stable ABI (CPython #111506), so abi3 is not an option there.
if ($AbiTag -like "*t") { return $false }
$abiTags = $fields[$fields.Count - 2] -split '\.'
$minor = 0
if ($PyTag -match '^cp3(\d+)$') { $minor = [int]$Matches[1] }
foreach ($pyTag in ($fields[$fields.Count - 3] -split '\.')) {
# abi3 is forward compatible only from what it was built against: cp314-abi3 fails on cp313.
if (($abiTags -contains "abi3") -and ($pyTag -match '^cp3(\d+)$')) {
if ([int]$Matches[1] -le $minor) { return $true }
}
if ($abiTags -contains "none") {
if ($pyTag -match '^py3(\d*)$') {
if ([string]::IsNullOrEmpty($Matches[1]) -or ([int]$Matches[1] -le $minor)) { return $true }
}
if ($pyTag -match '^cp3(\d+)$') {
if ([int]$Matches[1] -le $minor) { return $true }
}
}
}
return $false
}
function Test-WoaPyPIWheel {
param([string]$Project, [string]$PyTag, [string]$AbiTag = "", [string]$Floor = "", [switch]$AllowAgnostic, [switch]$Exact)
if (-not $Project -or -not $PyTag) { return $false }
if (-not $AbiTag) { $AbiTag = $PyTag }
# pypi.org/simple normalises the project name; a wheel filename carries the underscored form.
$slug = ($Project -replace '_', '-').ToLowerInvariant()
try {
$body = [string](Invoke-RestMethod -Uri "https://pypi.org/simple/$slug/" -UseBasicParsing -TimeoutSec 20)
} catch { return $false }
foreach ($match in [regex]::Matches($body, "[A-Za-z0-9_.+-]+win_arm64\.whl")) {
$name = $match.Value
$tagsOk = if ($AllowAgnostic) { Test-WoaWheelTagsUsable -Name $name -PyTag $PyTag -AbiTag $AbiTag }
else { Test-WoaWheelTags -Name $name -PyTag $PyTag -AbiTag $AbiTag }
if (-not $tagsOk) { continue }
$fields = ($name -replace '(?i)\.whl$', '') -split '-'
if ($fields.Count -lt 5) { continue }
if (($fields[0] -replace '_', '-').ToLowerInvariant() -ne $slug) { continue }
if (-not $Floor) { $script:WoaPyPIMatchedVersion = $fields[1]; return $true }
# -Exact: the same version, so an exact pin the wheelhouse copy satisfies is never lost to a newer upstream release it does not.
$ok = if ($Exact) {
(Test-WoaVersionAtLeast -Version $fields[1] -Floor $Floor) -and (Test-WoaVersionAtLeast -Version $Floor -Floor $fields[1])
} else { Test-WoaVersionAtLeast -Version $fields[1] -Floor $Floor }
if ($ok) {
$script:WoaPyPIMatchedVersion = $fields[1]
return $true
}
}
return $false
}
# Ours is first in UV_FIND_LINKS and wins the tie; an upstream BEHIND it leaves ours staged.
function Test-WoaWheelhouseWheelIsRedundant {
param([string]$Name, [string]$PyTag, [string]$AbiTag = "")
if ($Name -notlike "*win_arm64*") { return $false }
$fields = ($Name -replace '(?i)\.whl$', '') -split '-'
if ($fields.Count -lt 5) { return $false }
if (-not (Test-WoaResolveReachesPyPI)) { return $false }
if (-not (Test-WoaWheelTagsUsable -Name $Name -PyTag $PyTag -AbiTag $AbiTag)) { return $false }
return (Test-WoaPyPIWheel -Project $fields[0] -PyTag $PyTag -AbiTag $AbiTag -Floor $fields[1] -AllowAgnostic -Exact)
}
function Test-WoaWheelAvailable {
param([string]$Project, [string]$PythonMinor, [string]$AbiTag = "")
$tag = "cp" + ($PythonMinor -replace '\.', '')
if (-not $AbiTag) { $AbiTag = $tag }
$pattern = "$Project-[^`"'<>\s]*?win_arm64\.whl"
if ((Test-WoaResolveReachesPyPI) -and (Test-WoaPyPIWheel -Project $Project -PyTag $tag -AbiTag $AbiTag)) { return $true }
if (-not $script:WoaWheelhouse) { return $false }
if (Test-WoaWheelhouseIsLocal $script:WoaWheelhouse) {
$local = Get-ChildItem -LiteralPath $script:WoaWheelhouse -Filter "$Project-*win_arm64.whl" -ErrorAction SilentlyContinue |
Where-Object { Test-WoaWheelTags -Name $_.Name -PyTag $tag -AbiTag $AbiTag } | Select-Object -First 1
return [bool]$local
}
try {
$index = [string](Invoke-RestMethod -Uri (Join-UrlPath $script:WoaWheelhouse "index.txt") -UseBasicParsing -TimeoutSec 20)
foreach ($match in [regex]::Matches($index, $pattern)) {
if (Test-WoaWheelTags -Name $match.Value -PyTag $tag -AbiTag $AbiTag) { return $true }
}
} catch {}
return $false
}
# constraints.txt floors ARM64 pyarrow at 21.0.0 and staging pins exactly, so a 19.x cannot resolve.
$script:WoaPyarrowFloor = "21.0.0"
function Test-WoaPyarrowWheelUsable {
param([string]$Name, [string]$PyTag, [string]$AbiTag)
# Usable, not exact: pyarrow's coming win_arm64 wheel is cp311-abi3 (apache/arrow#48539).
if (-not (Test-WoaWheelTagsUsable -Name $Name -PyTag $PyTag -AbiTag $AbiTag)) { return $false }
$fields = ($Name -replace '(?i)\.whl$', '') -split '-'
if ($fields.Count -lt 5) { return $false }
return (Test-WoaVersionAtLeast -Version $fields[1] -Floor $script:WoaPyarrowFloor)
}
# uv's --overrides beat a spec named on the command line (0.10.7): drop the trio only, here only.
function New-WoaTorchStepOverrideValue {
param([string]$Value, [string]$Dir = "")
$result = @{ Value = $null; Temps = @() }
if (-not $Value) { return $result }
$files = @()
foreach ($entry in ($Value -split '\s+' | Where-Object { $_ })) {
$path = $entry.Trim('"').Trim("'")
if (-not (Test-Path -LiteralPath $path -PathType Leaf)) { continue }
$kept = @()
$dropped = $false
foreach ($line in (Get-WoaRequirementEntries -Path $path)) {
$name = ((($line.Line -split '[\s<>=!~;@\[]', 2)[0]).Trim() -replace '[-_.]+', '-').ToLowerInvariant()
if (@("torch", "torchvision", "torchaudio") -contains $name) { $dropped = $true; continue }
$kept += (Resolve-WoaOverrideLine -Line $line.Line -BaseDir $line.BaseDir)
}
if (-not $dropped) { $files += $path; continue }
# Flattened, because a line dropped from an include cannot come along by reference. Recorded for deletion: it can carry an authenticated URL.
$tmp = if ($Dir -and (Test-Path -LiteralPath $Dir -PathType Container)) {
Join-Path $Dir ("torch-step-" + [System.IO.Path]::GetRandomFileName() + ".txt")
} else { [System.IO.Path]::GetTempFileName() }
[System.IO.File]::WriteAllLines($tmp, [string[]]$kept, (New-Object System.Text.UTF8Encoding($false)))
$result.Temps += $tmp
$files += $tmp
}
if (-not $files) { $result.Value = ""; return $result }
$safe = foreach ($f in $files) { Get-UvSafePath $f }
$result.Value = (($safe | ForEach-Object { if ($_ -match '\s') { '"' + $_ + '"' } else { $_ } }) -join " ")
return $result
}
function Get-WoaRequirementEntries {
param([string]$Path, $Seen = $null, [int]$Depth = 0)
if ($Depth -gt 8) { return @() }
if ($null -eq $Seen) { $Seen = New-Object 'System.Collections.Generic.HashSet[string]' ([System.StringComparer]::OrdinalIgnoreCase) }
if (-not $Path -or -not (Test-Path -LiteralPath $Path -PathType Leaf)) { return @() }
try { $full = Convert-Path -LiteralPath $Path } catch { return @() }
if (-not $Seen.Add($full)) { return @() }
try { $lines = [System.IO.File]::ReadAllLines($full) } catch { return @() }
$dir = [System.IO.Path]::GetDirectoryName($full)
$entries = @()
foreach ($line in $lines) {
if ($line -match '^\s*(?:-r|--requirement)[=\s]+(.+?)\s*$') {
# pip needs whitespace before an inline "#": "-r a#b.txt" keeps its hash.
$nested = ($Matches[1] -replace '\s+#.*$', '').Trim().Trim('"', "'")
if (-not [System.IO.Path]::IsPathRooted($nested)) { $nested = Join-Path $dir $nested }
$entries += @(Get-WoaRequirementEntries -Path $nested -Seen $Seen -Depth ($Depth + 1))
continue
}
$entries += , @{ Line = $line; BaseDir = $dir }
}
return $entries
}
# UNSLOTH_PYARROW_WHEEL, then PyPI, then the wheelhouse. "" leaves native unselected.
function Get-WoaPyarrowSource {
param([string]$PythonMinor, [string]$AbiTag = "")
$tag = "cp" + ($PythonMinor -replace '\.', '')
if (-not $AbiTag) { $AbiTag = $tag }
$script:WoaPyarrowWheelName = $null
if ($env:UNSLOTH_PYARROW_WHEEL) {
$_paWheel = $env:UNSLOTH_PYARROW_WHEEL
if (Test-Path -LiteralPath $_paWheel -PathType Leaf) {
$_paName = Split-Path -Leaf $_paWheel
if ($_paName -notlike "*.whl") {
$_paFromArchive = Get-WheelFileNameFromArchive -Path $_paWheel
if ($_paFromArchive) { $_paName = $_paFromArchive }
}
if (($_paName -like "pyarrow-*") -and (Test-WoaPyarrowWheelUsable -Name $_paName -PyTag $tag -AbiTag $AbiTag) -and
($_paName -like "*win_arm64.whl")) {
# The whole archive: a truncated download still starts with "PK".
if (Test-ZipArchiveReadable -Path $_paWheel) { return "local" }
substep "windows on arm: UNSLOTH_PYARROW_WHEEL is not a readable wheel archive -- ignoring it." "Yellow"
} else {
substep "windows on arm: UNSLOTH_PYARROW_WHEEL is not a $tag win_arm64 pyarrow >= $($script:WoaPyarrowFloor) wheel -- ignoring it." "Yellow"
}
} else {
substep "windows on arm: UNSLOTH_PYARROW_WHEEL does not exist -- ignoring it." "Yellow"
}
}
if (Test-WoaResolveReachesPyPI) { try {
$body = [string](Invoke-RestMethod -Uri "https://pypi.org/simple/pyarrow/" -UseBasicParsing -TimeoutSec 20)
foreach ($match in [regex]::Matches($body, 'pyarrow-[^"''<>\s]*?win_arm64\.whl')) {
if (Test-WoaPyarrowWheelUsable -Name $match.Value -PyTag $tag -AbiTag $AbiTag) {
# Recorded so the override pins THIS wheel: a newer sdist satisfies >=21.0.0.
$script:WoaPyarrowWheelName = $match.Value
return "pypi"
}
}
} catch {} }
if (Test-WoaWheelhouseIsLocal $script:WoaWheelhouse) {
$local = Get-ChildItem -LiteralPath $script:WoaWheelhouse -Filter "pyarrow-*win_arm64.whl" -ErrorAction SilentlyContinue |
Where-Object {
(Test-WoaPyarrowWheelUsable -Name $_.Name -PyTag $tag -AbiTag $AbiTag) -and
(Test-ZipArchiveReadable -Path $_.FullName)
} | Select-Object -First 1
if ($local) { return "wheelhouse" }
return ""
}
try {
$index = [string](Invoke-RestMethod -Uri (Join-UrlPath $script:WoaWheelhouse "index.txt") -UseBasicParsing -TimeoutSec 20)
foreach ($match in [regex]::Matches($index, 'pyarrow-[^"''<>\s]*?win_arm64\.whl')) {
if (Test-WoaPyarrowWheelUsable -Name $match.Value -PyTag $tag -AbiTag $AbiTag) { return "wheelhouse" }
}
} catch {}
return ""
}
# ── Bound nvidia-smi so a wedged driver cannot hang the installer. ──
function Invoke-NvidiaSmiBounded {
param(
[Parameter(Mandatory = $true, Position = 0)][string]$Exe,
[Parameter(Position = 1)][string[]]$SmiArgs = @(),
[int]$TimeoutSec = 10,
# Driver warnings on stderr would corrupt machine-readable --query-gpu output; the human-readable probes keep the default merge.
[switch]$StdoutOnly
)
try {
$psi = New-Object System.Diagnostics.ProcessStartInfo
$psi.FileName = $Exe
$psi.Arguments = ($SmiArgs -join ' ')
$psi.UseShellExecute = $false
$psi.RedirectStandardOutput = $true
$psi.RedirectStandardError = $true
$psi.CreateNoWindow = $true
$proc = [System.Diagnostics.Process]::Start($psi)
$outTask = $proc.StandardOutput.ReadToEndAsync()
$errTask = $proc.StandardError.ReadToEndAsync()
if (-not $proc.WaitForExit($TimeoutSec * 1000)) {
try { $proc.Kill() } catch {}
$global:LASTEXITCODE = 124
return ""
}
$global:LASTEXITCODE = $proc.ExitCode
if ($StdoutOnly) { return $outTask.Result }
return ($outTask.Result + "`n" + $errTask.Result)
} catch {
$global:LASTEXITCODE = 1
return ""
}
}
# ── BEGIN SHARED WITH studio/setup.ps1 (Get-NvidiaSmiCandidatePaths) ──
# Every directory nvidia-smi might be in, in the order worth trying, filtered to the ones that
# actually hold the binary. Callers still run their own Test-NvidiaSmiHasGpu over the result:
# existing is not the same as answering.
#
# Only two locations were searched before, and each addition below is a host where a real NVIDIA
# GPU was reported absent, which sends the installer to CPU-only PyTorch:
#
# SysNative From a 32-bit process "System32" is redirected to SysWOW64, which has no
# nvidia-smi. SysNative is the alias that reaches the real System32.
# ProgramW64 In that same process $env:ProgramFiles is "Program Files (x86)".
# ProgramW6432 is the 64-bit Program Files whatever the process bitness.
# x86 Not the driver's own location, but a machine that once had a 32-bit
# toolkit can carry a working copy there.
# DriverStore Where the driver package itself lives. Present on hosts where the copy
# into System32 did not happen, which is the #9255 population.
#
# Nothing is removed. One ordering does change, and deliberately: the two call sites disagreed
# with each other before, one trying System32 first and the other the NVSMI directory first, and
# a single list cannot keep both. System32 wins, because that is where the current driver puts
# its copy; NVSMI is the legacy location and a machine carrying both can have an older binary
# there reporting an older CUDA version. This only affects a host that has both, and only in
# which of two working binaries answers.
function Get-NvidiaSmiCandidatePaths {
$dirs = @()
# Current driver locations first, in BOTH process bitnesses, before any legacy one.
# Both bitness spellings before $env:ProgramFiles, which in a 32-bit process is the x86 tree
# where a stale toolkit copy can sit and answer first.
if ($env:SystemRoot) { $dirs += (Join-Path $env:SystemRoot "System32") }
if ($env:SystemRoot) { $dirs += (Join-Path $env:SystemRoot "SysNative") }
if ($env:ProgramW6432) { $dirs += (Join-Path $env:ProgramW6432 "NVIDIA Corporation\NVSMI") }
if ($env:ProgramFiles) { $dirs += (Join-Path $env:ProgramFiles "NVIDIA Corporation\NVSMI") }
$pfx86 = ${env:ProgramFiles(x86)}
if ($pfx86) { $dirs += (Join-Path $pfx86 "NVIDIA Corporation\NVSMI") }
# Bounded on purpose. FileRepository holds every driver package the machine has ever had.
#
# The bound is applied to directories that ACTUALLY HOLD the binary, not to the nv* matches.
# NVIDIA ships several packages whose names start nv (HD audio, the virtual audio device,
# the network service), so a host with eight of those newer than the display driver would
# otherwise spend the whole allowance on directories with no nvidia-smi in them and report
# the machine as having none. Test-Path is a file existence check, not a process spawn; the
# bound that matters is on the candidates handed back, since each of those costs a probe.
try {
$repos = @()
if ($env:SystemRoot) {
# Both spellings, same WOW64 reason as above. At most one resolves in any given process, so
# this is not a doubled scan.
$repos += (Join-Path $env:SystemRoot "System32\DriverStore\FileRepository")
$repos += (Join-Path $env:SystemRoot "SysNative\DriverStore\FileRepository")
}
$packages = @()
foreach ($repo in $repos) {
if (-not (Test-Path -LiteralPath $repo -PathType Container)) { continue }
$packages += @(Get-ChildItem -LiteralPath $repo -Directory -Filter "nv*" -ErrorAction SilentlyContinue |
Where-Object { Test-Path -LiteralPath (Join-Path $_.FullName "nvidia-smi.exe") -PathType Leaf })
}
# The bound is on the whole DriverStore contribution rather than per root, so adding
# the second spelling cannot double the number of probes this hands back.
foreach ($dir in @($packages | Sort-Object LastWriteTime -Descending | Select-Object -First 8)) {
$dirs += $dir.FullName
}
} catch {}
$paths = @()
$seen = @{}
foreach ($dir in $dirs) {
if ([string]::IsNullOrWhiteSpace($dir)) { continue }
$candidate = Join-Path $dir "nvidia-smi.exe"
# Case-insensitive, because the same directory reached two ways must not be probed twice:
# each probe is a bounded process spawn with a wall-clock timeout behind it.
$key = $candidate.ToLowerInvariant()
if ($seen.ContainsKey($key)) { continue }
$seen[$key] = $true
if (Test-Path -LiteralPath $candidate -PathType Leaf) { $paths += $candidate }
}
return $paths
}
# ── END SHARED WITH studio/setup.ps1 (Get-NvidiaSmiCandidatePaths) ──
# One list, because the presence probe and the driver-version probe must agree: a host only the first finds reports a GPU with no driver version, and the CUDA-major guard is then skipped.
# Reset per run: under `irm | iex` the script scope IS the caller's session, so a second
# invocation in one shell would otherwise reuse the first run's answer.
$script:WoaNvidiaSmiProbed = $false
$script:WoaNvidiaSmiPath = $null
function Get-WoaNvidiaSmiPath {
# Memoised: both callers probe, and each candidate costs a bounded process spawn.
if ($script:WoaNvidiaSmiProbed) { return $script:WoaNvidiaSmiPath }
$script:WoaNvidiaSmiProbed = $true
$exe = $null
try { $exe = (Get-Command nvidia-smi -ErrorAction SilentlyContinue).Source } catch { $exe = $null }
if ($exe) { $script:WoaNvidiaSmiPath = $exe; return $exe }
$found = @(Get-NvidiaSmiCandidatePaths)
if ($found.Count -eq 0) { return $null }
# The first candidate that ANSWERS: a broken copy would decline the whole ARM64 CUDA route.
# Soft and hard budgets as in the main detection loop; only the hard one may end empty.
$woaDeadline = (Get-Date).AddSeconds(30)
$woaHardDeadline = (Get-Date).AddSeconds(60)
$woaListing = $null
foreach ($p in $found) {
if ($null -ne $woaListing -and (Get-Date) -gt $woaDeadline) { break }
if ((Get-Date) -gt $woaHardDeadline) { break }
try {
$listing = Invoke-NvidiaSmiBounded $p @('-L')
if (-not ($LASTEXITCODE -eq 0 -and $listing -match '(?m)^\s*GPU\s+\d+')) { continue }
if (-not $woaListing) { $woaListing = $p }
# Prefer a candidate that also reports the CUDA version: without it the CUDA-major guard
# in Initialize-WoaNativeCudaTorch is skipped.
if ((Get-Date) -gt $woaDeadline) { break }
$banner = Invoke-NvidiaSmiBounded $p
if ($banner -match 'CUDA(?: UMD)? Version:\s+\d+\.\d+') {
$script:WoaNvidiaSmiPath = $p
return $p
}
} catch {}
}
# A GPU listing without a version still beats no answer.
if ($woaListing) {
$script:WoaNvidiaSmiPath = $woaListing
return $woaListing
}
# Nothing answered: fall back to the first that exists.
$script:WoaNvidiaSmiPath = $found[0]
return $found[0]
}
function Test-WoaNvidiaPresent {
$exe = Get-WoaNvidiaSmiPath
if (-not $exe) { return $false }
$listing = Invoke-NvidiaSmiBounded $exe @('-L')
return ($LASTEXITCODE -eq 0 -and $listing -match '(?m)^\s*GPU\s+\d+')
}
# The driver's CUDA version as @(major, minor), or $null when nvidia-smi does not say.
function Get-WoaDriverCudaVersion {
$exe = Get-WoaNvidiaSmiPath
if (-not $exe) { return $null }
$out = Invoke-NvidiaSmiBounded $exe
# Newer drivers print "CUDA UMD Version"; accept both spellings.
if ($out -notmatch 'CUDA(?: UMD)? Version:\s+(\d+)\.(\d+)') { return $null }
return @([int]$Matches[1], [int]$Matches[2])
}
function Get-WoaDriverCudaLeaf {
$v = Get-WoaDriverCudaVersion
if (-not $v) { return $null }
$major = [int]$v[0]; $minor = [int]$v[1]
if ($major -ge 13) { return "cu130" }
if ($major -eq 12 -and $minor -ge 8) { return "cu128" }
if ($major -eq 12 -and $minor -ge 6) { return "cu126" }
if ($major -ge 12) { return "cu124" }
if ($major -ge 11) { return "cu118" }
return $null
}
# A free-threaded build installs cp313t, not cp313. Unknown answers $false, keeping the GIL path.
function Test-PythonFreeThreaded {
param([string]$PythonExe)
if (-not $PythonExe -or -not (Test-Path -LiteralPath $PythonExe -PathType Leaf)) { return $false }
$out = ""
try {
$out = (& $PythonExe -c "import sysconfig; print(1 if sysconfig.get_config_var('Py_GIL_DISABLED') else 0)" 2>$null | Out-String).Trim()
} catch { return $false }
return ($out -eq "1")
}
function Get-WoaAbiTag {
param([string]$PythonMinor, [bool]$FreeThreaded)
$tag = "cp" + ($PythonMinor -replace '\.', '')
if ($FreeThreaded) { return "${tag}t" }
return $tag
}
# CUDA is established POSITIVELY by requiring +cuNNN: PyPI's own win_arm64 torch carries no local version, and the stamp and +cuXXX tag identify the torch BUILD a companion belongs to.
function Test-WoaWheelPairsWithTorch {
param([string]$TorchVersion, [string]$OtherVersion, [string]$Project = "torchvision")
if (-not $TorchVersion -or -not $OtherVersion) { return $false }
$stamp = {
param($v)
$m = [regex]::Match($v, '(?i)\.dev(\d+)')
if ($m.Success) { return $m.Groups[1].Value } else { return "" }
}
$local = {
param($v)
$parts = $v -split '\+', 2
if ($parts.Count -eq 2) { return $parts[1].ToLowerInvariant() } else { return "" }
}
if ((& $stamp $TorchVersion) -ne (& $stamp $OtherVersion)) { return $false }
if ((& $local $TorchVersion) -ne (& $local $OtherVersion)) { return $false }
if (& $stamp $TorchVersion) { return $true }
# Stable releases share an empty stamp, so they pair by offset: torchvision 0.(M+15) to 2.M.
$rel = {
param($v)
$m = [regex]::Match($v, '^(\d+)\.(\d+)')
if ($m.Success) { return @([int]$m.Groups[1].Value, [int]$m.Groups[2].Value) } else { return $null }
}
$t = & $rel $TorchVersion; $o = & $rel $OtherVersion
if (-not $t -or -not $o) { return $false }
switch (($Project -replace '[-_.]+', '-').ToLowerInvariant()) {
"torchvision" { return (($t[0] -eq 2) -and ($o[0] -eq 0) -and ($o[1] -eq ($t[1] + 15))) }
"torchaudio" { return (($o[0] -eq $t[0]) -and ($o[1] -eq $t[1])) }
default { return $true }
}
}
function Get-WoaCudaWheelVersion {
param([string]$IndexUrl, [string]$PythonMinor, [string]$Project = "torch", [string]$AbiTag = "",
[string]$PairWith = "", [string]$Below = "")
if ([string]::IsNullOrWhiteSpace($IndexUrl) -or [string]::IsNullOrWhiteSpace($PythonMinor)) { return $null }
$tag = "cp" + ($PythonMinor -replace '\.', '')
if (-not $AbiTag) { $AbiTag = $tag }
$projectUrl = Join-UrlPath $IndexUrl "$Project/"
try {
$body = [string](Invoke-RestMethod -Uri $projectUrl -UseBasicParsing -TimeoutSec 20)
} catch {
return $null
}
if ([string]::IsNullOrWhiteSpace($body)) { return $null }
$best = $null
$bestKey = $null
# PEP 440 order within one numeric release: dev < a < b < rc < final, then by number.
$prerelease = {
param([string]$v)
$r = ($v -split '\+', 2)[0]
if ($r -match '\.dev(\d+)') { return @(0, [long]$Matches[1]) }
if ($r -match '(?i)(a|b|rc)(\d+)') { return @(@{ a = 1; b = 2; rc = 3 }[$Matches[1].ToLowerInvariant()], [long]$Matches[2]) }
return @(4, [long]0)
}
# -Below: only versions ranked strictly under it, so a caller can walk back from an unpaired newest.
$belowKey = $null; $belowRank = $null
if ($Below) {
$belowNumeric = [regex]::Match((($Below -split '\+', 2)[0]), '^\d+(\.\d+){0,2}').Value
try { $belowKey = [version]$belowNumeric } catch { return $null }
$belowRank = & $prerelease $Below
}
foreach ($match in [regex]::Matches($body, "$Project-[^`"'<>\s]*?win_arm64\.whl")) {
$name = $match.Value
try { $name = [System.Uri]::UnescapeDataString($name) } catch {}
if (-not (Test-WoaWheelTags -Name $name -PyTag $tag -AbiTag $AbiTag)) { continue }
if ($name -notmatch '\+cu[0-9]+') { continue }
$version = ($name -split '-')[1]
if ($PairWith -and -not (Test-WoaWheelPairsWithTorch -TorchVersion $PairWith -OtherVersion $version -Project $Project)) { continue }
# Numeric release only: a .dev suffix must not make a newer line look older.
$release = ($version -split '\+', 2)[0]
$numeric = [regex]::Match($release, '^\d+(\.\d+){0,2}').Value
$key = $null
try { $key = [version]$numeric } catch { continue }
if ($belowKey) {
if ($key -gt $belowKey) { continue }
if ($key -eq $belowKey) {
$rank = & $prerelease $version
if (($rank[0] -gt $belowRank[0]) -or (($rank[0] -eq $belowRank[0]) -and ($rank[1] -ge $belowRank[1]))) { continue }
}
}
if ($null -eq $bestKey -or $key -gt $bestKey) {
$bestKey = $key; $best = $version
} elseif ($key -eq $bestKey) {
$rankNew = & $prerelease $version; $rankBest = & $prerelease $best
if (($rankNew[0] -gt $rankBest[0]) -or (($rankNew[0] -eq $rankBest[0]) -and ($rankNew[1] -gt $rankBest[1]))) { $best = $version }
}
}
return $best
}
function Test-WoaCudaWheel {
param([string]$IndexUrl, [string]$PythonMinor, [string]$Project = "torch", [string]$AbiTag = "")
return [bool](Get-WoaCudaWheelVersion -IndexUrl $IndexUrl -PythonMinor $PythonMinor -Project $Project -AbiTag $AbiTag)
}
function Test-WoaAudioMatchesTorch {
param([string]$TorchVersion, [string]$AudioVersion)
if (-not $TorchVersion -or -not $AudioVersion) { return $false }
$shorten = {
param($v)
[regex]::Match((($v -split '\+', 2)[0]), '^\d+\.\d+').Value
}
$a = & $shorten $TorchVersion
$b = & $shorten $AudioVersion
return ($a -and $b -and $a -eq $b)
}
function Initialize-WoaNativeCudaTorch {
param([string]$PythonMinor, [bool]$FreeThreaded = $false)
# ABI, not just minor: cp313-cp313 for a 3.13t interpreter finds GIL wheels it cannot install.
$_woaAbiTag = Get-WoaAbiTag -PythonMinor $PythonMinor -FreeThreaded $FreeThreaded
$script:WoaFreeThreaded = $FreeThreaded
$script:WoaNativeCudaTorch = $false
$script:WoaTorchIndexUrl = $null
$script:WoaTorchAudio = $false
if ((Get-HostMachineArch) -ne "arm64") { return }
if ($SkipTorch) { return }
if ($env:UNSLOTH_WOA_NATIVE -and $env:UNSLOTH_WOA_NATIVE.Trim().ToLowerInvariant() -in @("0", "false", "no", "off")) {
substep "windows on arm: UNSLOTH_WOA_NATIVE is off -- using the x64 path." "Yellow"
return
}
if (-not (Test-WoaNvidiaPresent)) { return }
if (-not (Test-WoaResolverPathsUsable)) { return }
$pinned = @()
if (-not [string]::IsNullOrWhiteSpace($env:UNSLOTH_TORCH_INDEX_URL)) {
$pinned += $env:UNSLOTH_TORCH_INDEX_URL.Trim().TrimEnd('/')
} elseif (-not [string]::IsNullOrWhiteSpace($env:UNSLOTH_TORCH_INDEX_FAMILY)) {
$mirror = if ($env:UNSLOTH_PYTORCH_MIRROR) { $env:UNSLOTH_PYTORCH_MIRROR.TrimEnd('/') } else { "https://download.pytorch.org/whl" }
$pinned += "$mirror/$($env:UNSLOTH_TORCH_INDEX_FAMILY.Trim().Trim('/'))"
}
$candidates = @()
if ($pinned.Count -gt 0) {
$candidates = $pinned
} else {
$mirror = if ($env:UNSLOTH_PYTORCH_MIRROR) { $env:UNSLOTH_PYTORCH_MIRROR.TrimEnd('/') } else { "https://download.pytorch.org/whl" }
$driverLeaf = Get-WoaDriverCudaLeaf
if ($driverLeaf) { $candidates += "$mirror/$driverLeaf" }
$candidates += $script:WoaNvidiaTorchIndexUrls
}
$torchIndex = $null
$_woaTorchVersion = $null
$_woaVisionVersion = $null
$_woaUnpairedIndex = $null
foreach ($candidate in $candidates) {
if (-not (Test-WoaCudaWheel -IndexUrl $candidate -PythonMinor $PythonMinor -AbiTag $_woaAbiTag -Project "torch")) { continue }
$_woaTorchVersion = Get-WoaCudaWheelVersion -IndexUrl $candidate -PythonMinor $PythonMinor -AbiTag $_woaAbiTag
# An index qualifies only with a torchvision build PAIRED to that torch, or a lagging nightly or partial mirror leaves torchvision resolving against a torch it was not built for.
# The newest torch is not the only candidate: a nightly torchvision lags by days, so the newest COMPLETE pair is searched for, a few versions deep.
$_woaVisionVersion = $null
$_woaBacktracks = 0
while ($_woaTorchVersion) {
$_woaVisionVersion = Get-WoaCudaWheelVersion -IndexUrl $candidate -PythonMinor $PythonMinor -Project "torchvision" -AbiTag $_woaAbiTag -PairWith $_woaTorchVersion
if ($_woaVisionVersion -or $_woaBacktracks -ge 5) { break }
$_woaOlderTorch = Get-WoaCudaWheelVersion -IndexUrl $candidate -PythonMinor $PythonMinor -AbiTag $_woaAbiTag -Below $_woaTorchVersion
if (-not $_woaOlderTorch) { break }
substep "windows on arm: $(Remove-IndexUrlCredentials $candidate) publishes torch $_woaTorchVersion but no torchvision paired with it; trying torch $_woaOlderTorch." "Yellow"
$_woaTorchVersion = $_woaOlderTorch
$_woaBacktracks++
}
if (-not $_woaVisionVersion) {
substep "windows on arm: $(Remove-IndexUrlCredentials $candidate) publishes torch $_woaTorchVersion but no torchvision paired with it; trying the next index." "Yellow"
if (-not $_woaUnpairedIndex) { $_woaUnpairedIndex = $candidate }
continue
}
$torchIndex = $candidate
break
}
if (-not $torchIndex) {
if ($_woaUnpairedIndex) {
substep "windows on arm: no index pairs a torchvision with its win_arm64 CUDA torch; using the x64 stack instead." "Yellow"
}
return
}
# A CUDA 13 runtime does not run on a CUDA 12 driver (no forward compatibility on Windows), so the wheel's major is checked against the driver's.
$_woaDriver = Get-WoaDriverCudaVersion
if ($_woaDriver -and $_woaTorchVersion -match '\+cu(\d+)') {
$_woaWheelMajor = [int]($Matches[1].Substring(0, $Matches[1].Length - 1))
if ($_woaWheelMajor -gt [int]$_woaDriver[0]) {
substep "windows on arm: $(Remove-IndexUrlCredentials $torchIndex) publishes torch $_woaTorchVersion (CUDA $_woaWheelMajor), but this driver supports CUDA $($_woaDriver[0]).$($_woaDriver[1])." "Yellow"
substep "using the x64 stack instead. Update the NVIDIA driver for the native install." "Yellow"
return
}
}
# torch alone is not enough: datasets needs pyarrow, unbuildable here. Both decided up front.
$pyarrowSource = Get-WoaPyarrowSource -PythonMinor $PythonMinor -AbiTag $_woaAbiTag
if (-not $pyarrowSource) {
substep "windows on arm: native CUDA torch is available, but no win_arm64 pyarrow wheel could be found." "Yellow"
substep "using the x64 stack instead. Set UNSLOTH_PYARROW_WHEEL to a win_arm64 pyarrow wheel for the native install." "Yellow"
return
}
# Free-threaded only: constraints.txt's win_arm64 av wheel is cp311-abi3 (CPython #111506).
if ($FreeThreaded -and -not (Test-WoaWheelAvailable -Project "av" -PythonMinor $PythonMinor -AbiTag $_woaAbiTag)) {
substep "windows on arm: no win_arm64 av (PyAV) wheel for this free-threaded interpreter ($_woaAbiTag)." "Yellow"
substep "using the x64 stack instead. A GIL build of the same Python takes the native path." "Yellow"
return
}
$script:WoaNativeCudaTorch = $true
$script:WoaTorchIndexUrl = $torchIndex
$script:WoaPyarrowSource = $pyarrowSource
# Only when the audio wheel PAIRS with the torch one: 2.11 dropped its exact torch pin.
$_woaAudioVersion = Get-WoaCudaWheelVersion -IndexUrl $torchIndex -PythonMinor $PythonMinor -Project "torchaudio" -AbiTag $_woaAbiTag -PairWith $_woaTorchVersion
$script:WoaTorchAudio = Test-WoaAudioMatchesTorch -TorchVersion $_woaTorchVersion -AudioVersion $_woaAudioVersion
# Kept so the install pins what the probe SELECTED: unsafe-best-match spans ALL indexes.
$script:WoaTorchWheelVersion = $_woaTorchVersion
$script:WoaAudioWheelVersion = $_woaAudioVersion
$script:WoaVisionWheelVersion = $_woaVisionVersion
# Read off the wheel, not the URL: a mirror of the prerelease channel need not say "nightly".
$script:WoaTorchIsPrerelease = [bool]($_woaTorchVersion -match '(?i)\d(a|b|rc)\d|\.dev\d')
if ($_woaAudioVersion -and -not $script:WoaTorchAudio) {
substep "windows on arm: this index has torchaudio $_woaAudioVersion but torch $_woaTorchVersion; skipping torchaudio."
}
}
function Get-TauriTorchIndexFamily {
param([string]$TorchIndexUrl)
if ($SkipTorch) { return "none" }
if ([string]::IsNullOrWhiteSpace($TorchIndexUrl)) { return "none" }
# Drop query/fragment first so a token-authenticated pin classifies by family.
$leaf = (($TorchIndexUrl -split '[?#]', 2)[0].TrimEnd('/') -split '/')[-1].ToLowerInvariant()
if (@("cpu", "xpu", "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" }
# Require a digit after "cu" so /current or /custom isn't branded CUDA (parity ^cu[0-9]).
if ($TorchIndexFamily -match '^cu[0-9]') { return "cuda" }
if ($TorchIndexFamily -like "rocm*") { return "rocm" }
if ($TorchIndexFamily -eq "xpu") { return "xpu" }
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 }
# Name a full disk here, since big writes exit through this function (#11313); below
# 64 MB is the cause. Folded into $Message because --tauri shows only that line.
$_diskSuffix = ""
try {
# Probed: early exits happen before the helper is defined.
if ($StudioHome -and (Get-Command Get-StudioFreeSpaceBytes -CommandType Function -ErrorAction SilentlyContinue)) {
$_diskFree = Get-StudioFreeSpaceBytes -Path $StudioHome
if ($null -ne $_diskFree -and $_diskFree -lt 64MB) {
$_diskMb = [math]::Round($_diskFree / 1MB)
# Advice depends on what happened to the old environment (same four cases as install.sh).
$_diskRemedy = if ($script:StudioVenvDiscardLeftover) {
"Free some space and re-run. The previous environment could not be removed and is still at $($script:StudioVenvDiscardLeftover); deleting it will reclaim that space."
} elseif ($script:StudioVenvDiscardSucceeded) {
"Free some space and re-run. The previous environment was already discarded by --no-rollback, so the installer has nothing further of its own to reclaim."
} elseif ($script:StudioNoRollback) {
"Free some space and re-run."
} else {
"Free some space and re-run. --no-rollback (UNSLOTH_INSTALL_NO_ROLLBACK=1) drops the previous environment instead of keeping a copy of it during the install."
}
$_diskSuffix = ": $StudioHome has only $_diskMb MB free, so the disk is full, which is very likely the cause. $_diskRemedy"
if (-not $TauriMode) {
Write-StudioLine " $StudioHome has only $_diskMb MB free -- the disk is full, which is very likely the cause." -ForegroundColor Red
Write-StudioLine " $_diskRemedy" -ForegroundColor Red
}
}
}
} catch { $_diskSuffix = "" }
$Message = "$Message$_diskSuffix"
Write-TauriLog "ERROR_DEFAULT" $Message
# Under `irm | iex` the session survives a failure; a leaked handoff would re-pin it.
Remove-Item Env:UNSLOTH_KEPT_TORCH -ErrorAction SilentlyContinue
if (Get-Command Restore-StudioVenvRollback -CommandType Function -ErrorAction SilentlyContinue) {
Restore-StudioVenvRollback
}
# Separate from the venv rollback: an install can fail before one is in flight.
if (Get-Command Restore-StudioUvCacheMarker -CommandType Function -ErrorAction SilentlyContinue) {
Restore-StudioUvCacheMarker -StudioRoot $StudioHome
}
# Most failures return before the lock try/finally, and under `irm | iex` these are the
# caller's variables. Probed like above.
if (Get-Command Restore-StudioTempEnvironment -CommandType Function -ErrorAction SilentlyContinue) {
Restore-StudioTempEnvironment
}
if ($TauriMode) {
exit $Code
}
throw $Message
}
# ── Usable temporary storage ──
# Usable temporary storage. Windows never checks TMP/TEMP exist or are writable
# (#9140), and downloads stage there. If it cannot hold a file, point BOTH at a
# directory we own; children and GetTempPath() read the process environment.
function Test-StudioDirectoryUsable {
param(
[string]$Path,
# Only for a directory we OWN: -Force would materialize a stale inherited TMP path.
[switch]$CreateIfMissing
)
if ([string]::IsNullOrWhiteSpace($Path)) { return $false }
try {
if (-not (Test-Path -LiteralPath $Path -PathType Container)) {
if (-not $CreateIfMissing) { return $false }
New-Item -ItemType Directory -Path $Path -Force -ErrorAction Stop | Out-Null
}
# Remove probes earlier runs could not delete.
try {
$cutoff = (Get-Date).AddDays(-1)
foreach ($old in @(Get-ChildItem -LiteralPath $Path -File -Filter "unsloth-probe-*.tmp" -ErrorAction Stop)) {
# Shape, not prefix: this is the host's temp directory.
if ($old.Name -notmatch '^unsloth-probe-[0-9a-f]{8}\.tmp$') { continue }
if ($old.LastWriteTime -lt $cutoff) {
Remove-Item -LiteralPath $old.FullName -Force -ErrorAction SilentlyContinue
}
}
} catch {}
# Write, read back, delete: Test-Path misses the failures that matter.
$probe = Join-Path $Path ("unsloth-probe-" + [guid]::NewGuid().ToString('N').Substring(0, 8) + ".tmp")
[System.IO.File]::WriteAllText($probe, "unsloth")
$readBack = [System.IO.File]::ReadAllText($probe)
# Deletion must work and be verified (the #9140 shape); retry briefly for scanners.
$probeGone = $false
foreach ($attempt in 1..3) {
Remove-Item -LiteralPath $probe -Force -ErrorAction SilentlyContinue
if (-not [System.IO.File]::Exists($probe)) { $probeGone = $true; break }
Start-Sleep -Milliseconds 100
}
if (-not $probeGone) { return $false }
return ($readBack -eq "unsloth")
} catch {
return $false
}
}
function Remove-StudioStalePrivateTempDirectories {
param([Parameter(Mandatory = $true)][string]$Root)
# These outlive the install (autostarted Unsloth's %TEMP%), so prune by age.
try {
$cutoff = (Get-Date).AddDays(-1)
foreach ($stale in @(Get-ChildItem -LiteralPath $Root -Directory -Filter "ust-*" -ErrorAction Stop)) {
# SHAPE, not prefix, before a recursive delete: "ust-" + PID + "-" + 8 hex, as
# scripts/uninstall.ps1 requires.
if ($stale.Name -notmatch '^ust-[0-9]+-[0-9a-f]{8}$') { continue }
if ($stale.LastWriteTime -ge $cutoff) { continue }
# Reparse points are never ours: unlink without following. Not Remove-Item, which can
# throw unsuppressibly or walk through a junction on 5.1.
if ($stale.Attributes -band [System.IO.FileAttributes]::ReparsePoint) {
try { [System.IO.Directory]::Delete($stale.FullName, $false) } catch {}
continue
}
# owner.pid names the process that inherited this dir; if it is alive, keep the dir.
$ownerPid = 0
$ownerFile = Join-Path $stale.FullName "owner.pid"
$recorded = $null
try {
if ([System.IO.File]::Exists($ownerFile)) {
$recorded = [System.IO.File]::ReadAllText($ownerFile).Trim()
}
} catch { $recorded = $null }
if (-not [string]::IsNullOrWhiteSpace($recorded)) {
$null = [int]::TryParse($recorded, [ref]$ownerPid)
}
# A recorded owner proves more than the installer PID in the name.
$ownerRecorded = ($ownerPid -gt 0)
if ($ownerPid -le 0) {
$null = [int]::TryParse(($stale.Name -split '-')[1], [ref]$ownerPid)
}
# No recorded owner: collect only after a week with no new entries.
if (-not $ownerRecorded -and $stale.LastWriteTime -ge (Get-Date).AddDays(-7)) {
continue
}
if ($ownerPid -gt 0) {
$ownerLives = $true
try {
$null = Get-Process -Id $ownerPid -ErrorAction Stop
} catch [Microsoft.PowerShell.Commands.ProcessCommandException] {
# Only this failure means "abandoned"; others keep the directory.
$ownerLives = $false
} catch {
$ownerLives = $true
}
if ($ownerLives) { continue }
}
Remove-Item -LiteralPath $stale.FullName -Recurse -Force -ErrorAction SilentlyContinue
}
} catch {}
}
function Set-StudioPrivateTempOwner {
param([Parameter(Mandatory = $true)][int]$OwnerProcessId)
# Only meaningful if this run redirected the temp; otherwise it is the host's.
if ($null -eq $script:StudioTempOverride) { return }
# Only a directory this run created; never litter the host's temp.
if (-not $script:StudioTempOverride.Owned) { return }
$owned = $script:StudioTempOverride.Path
if ([string]::IsNullOrWhiteSpace($owned)) { return }
try {
[System.IO.File]::WriteAllText((Join-Path $owned "owner.pid"), [string]$OwnerProcessId)
} catch {}
}
function Get-StudioPrivateTempRoots {
# Only under paths scripts/uninstall.ps1 already reclaims.
$roots = @()
if (-not [string]::IsNullOrWhiteSpace($env:LOCALAPPDATA)) {
$roots += (Join-Path $env:LOCALAPPDATA "Unsloth Studio\temp")
}
try {
$localAppData = [Environment]::GetFolderPath("LocalApplicationData")
if (-not [string]::IsNullOrWhiteSpace($localAppData)) {
$roots += (Join-Path $localAppData "Unsloth Studio\temp")
}
} catch {}
if (-not [string]::IsNullOrWhiteSpace($env:USERPROFILE)) {
$roots += (Join-Path $env:USERPROFILE ".unsloth\.cache\temp")
}
return $roots
}
function New-StudioPrivateTempDirectory {
foreach ($root in @(Get-StudioPrivateTempRoots)) {
# Short leaf: 5.1's .NET Framework compiler is bound by the legacy path limit.
$leaf = "ust-" + $PID + "-" + [guid]::NewGuid().ToString('N').Substring(0, 8)
$candidate = Join-Path $root $leaf
# Only directories absent BEFORE the probe may be unwound; pre-provisioned ones are
# configuration we did not create.
$preAbsent = @{}
$walk = $candidate
for ($seen = 0; $seen -lt 4; $seen++) {
if ([string]::IsNullOrEmpty($walk)) { break }
$preAbsent[$walk] = (-not (Test-Path -LiteralPath $walk))
$walk = [System.IO.Path]::GetDirectoryName($walk)
}
if (Test-StudioDirectoryUsable -Path $candidate -CreateIfMissing) {
Remove-StudioStalePrivateTempDirectories -Root $root
return $candidate
}
# A failed root leaves the -Force chain behind; walk back up through our own empty
# components only, never ~\.unsloth itself.
$ours = @("temp", "Unsloth Studio", ".cache")
$unwind = $candidate
for ($depth = 0; $depth -lt 4; $depth++) {
try {
if (-not $preAbsent[$unwind]) { break }
if (-not (Test-Path -LiteralPath $unwind -PathType Container)) { break }
$item = Get-Item -LiteralPath $unwind -Force -ErrorAction Stop
# A relocation junction is somebody's configuration; leave it and stop.
if ($item.Attributes -band [System.IO.FileAttributes]::ReparsePoint) { break }
if (@(Get-ChildItem -LiteralPath $unwind -Force -ErrorAction Stop).Count -gt 0) { break }
[System.IO.Directory]::Delete($unwind, $false)
} catch { break }
$unwind = [System.IO.Path]::GetDirectoryName($unwind)
if ([string]::IsNullOrEmpty($unwind)) { break }
if ($ours -notcontains [System.IO.Path]::GetFileName($unwind)) { break }
}
}
return $null
}
$script:StudioTempOverride = $null
$script:StudioTempChecked = $false
function Initialize-StudioTempEnvironment {
if ($script:StudioTempChecked) { return }
$script:StudioTempChecked = $true
# TMP wins over TEMP. IsNullOrEmpty, not IsNullOrWhiteSpace: GetTempPath takes the first
# non-empty one, so a whitespace TMP is what Windows uses.
$inherited = if (-not [string]::IsNullOrEmpty($env:TMP)) { $env:TMP } else { $env:TEMP }
# Resolve BEFORE probing: Test-Path and .NET resolve relative paths differently. Leave
# whitespace-only as is so the probe rejects it.
$absolute = $inherited
if (-not [string]::IsNullOrWhiteSpace($inherited)) {
try { $absolute = [System.IO.Path]::GetFullPath($inherited) } catch { $absolute = $inherited }
}
if (Test-StudioDirectoryUsable -Path $absolute) {
# The allocator is the only sweeper, so sweep here too when the temp is healthy.
foreach ($root in @(Get-StudioPrivateTempRoots)) {
Remove-StudioStalePrivateTempDirectories -Root $root
}
# Pin the absolute spelling: a relative value could later name somewhere else.
if (-not [string]::Equals($absolute, $inherited, [System.StringComparison]::Ordinal)) {
$script:StudioTempOverride = [pscustomobject]@{
TmpSet = ($null -ne $env:TMP)
TmpValue = $env:TMP
TempSet = ($null -ne $env:TEMP)
TempValue = $env:TEMP
Path = $absolute
Owned = $false
}
$env:TMP = $absolute
$env:TEMP = $absolute
}
return
}
$private = New-StudioPrivateTempDirectory
if (-not $private) {
Write-StudioLine "[WARN] No writable temporary directory was found; downloads may fail." -ForegroundColor Yellow
return
}
# Absent is not empty: restoring an absent variable as "" changes children's temp.
$script:StudioTempOverride = [pscustomobject]@{
TmpSet = ($null -ne $env:TMP)
TmpValue = $env:TMP
TempSet = ($null -ne $env:TEMP)
TempValue = $env:TEMP
Path = $private
Owned = $true
}
$env:TMP = $private
$env:TEMP = $private
Write-StudioLine "[WARN] The inherited temporary directory is not usable; this install will use its own." -ForegroundColor Yellow
}
function Restore-StudioTempEnvironment {
$override = $script:StudioTempOverride
if ($null -eq $override) { return }
$script:StudioTempOverride = $null
if ($override.TmpSet) { $env:TMP = $override.TmpValue }
else { Remove-Item Env:\TMP -ErrorAction SilentlyContinue }
if ($override.TempSet) { $env:TEMP = $override.TempValue }
else { Remove-Item Env:\TEMP -ErrorAction SilentlyContinue }
# The directory stays: an autostarted Unsloth inherited it as %TEMP%.
}
# ── Parse flags ──
$StudioLocalInstall = $false
$PackageName = "unsloth"
$RepoRoot = ""
$TauriMode = $false
$SkipTorch = $false
$SkipAutostart = $false
$IsolateUvCache = $false
# Script-scoped: tests/studio/test_install_rollback_lifecycle.ps1 extracts
# Start-StudioVenvRollback on its own.
$script:StudioNoRollback = $false
# Initialised here, before the cache notice that sets it (the later reset would clear it).
# Reset per run: under `irm | iex` the script scope is the caller's session.
$script:StudioVolumeList = $null
# Set by the --no-rollback discard when the environment it is about to delete reports XPU,
# read by the Intel scan once that tree is gone.
$script:StudioPreservedXpuVerdict = $false
$ShortcutsOnly = $false
$WithLlamaCppDir = ""
$argList = $args
$PythonRequestArg = $null
for ($i = 0; $i -lt $argList.Count; $i++) {
if ([string]$argList[$i] -like "--python=*") {
$PythonRequestArg = ([string]$argList[$i]).Substring(9)
continue
}
switch ($argList[$i]) {
"--local" { $StudioLocalInstall = $true }
"--tauri" { $TauriMode = $true }
"--no-torch" { $SkipTorch = $true }
"--isolated-uv-cache" { $IsolateUvCache = $true }
"--no-rollback" { $script:StudioNoRollback = $true }
"--verbose" { $script:UnslothVerbose = $true }
"-v" { $script:UnslothVerbose = $true }
"--shortcuts-only" { $ShortcutsOnly = $true }
"--package" {
$i++
if ($i -ge $argList.Count) {
Write-StudioLine "[ERROR] --package requires an argument." -ForegroundColor Red
return (Exit-InstallFailure "--package requires an argument.")
}
$PackageName = $argList[$i]
}
"--python" {
$i++
if ($i -ge $argList.Count) {
Write-StudioLine "[ERROR] --python requires a version argument, e.g. --python 3.13." -ForegroundColor Red
return (Exit-InstallFailure "--python requires a version argument.")
}
$PythonRequestArg = [string]$argList[$i]
}
"--with-llama-cpp-dir" {
$i++
if ($i -ge $argList.Count) {
Write-StudioLine "[ERROR] --with-llama-cpp-dir requires a path argument." -ForegroundColor Red
return (Exit-InstallFailure "--with-llama-cpp-dir requires a path argument.")
}
$WithLlamaCppDir = $argList[$i]
}
}
}
# Env-var equivalent for web installs; an explicit flag still wins.
if ($env:UNSLOTH_NO_TORCH -in @('1', 'true', 'yes', 'on')) { $SkipTorch = $true }
if ($env:UNSLOTH_SKIP_AUTOSTART -in @('1', 'true', 'yes', 'on')) { $SkipAutostart = $true }
if ($env:UNSLOTH_ISOLATE_UV_CACHE -in @('1', 'true', 'yes', 'on')) { $IsolateUvCache = $true }
if ($env:UNSLOTH_INSTALL_NO_ROLLBACK -in @('1', 'true', 'yes', 'on')) { $script:StudioNoRollback = $true }
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-StudioLine "[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-StudioLine "[ERROR] --package name contains invalid characters (allowed: a-z A-Z 0-9 . _ -)" -ForegroundColor Red
return (Exit-InstallFailure "--package name contains invalid characters")
}
# Always a uv-managed CPython: --python > UNSLOTH_PYTHON > the existing environment's minor > 3.13.
$PythonExplicitRequest = if ($null -ne $PythonRequestArg) { $PythonRequestArg.Trim() } elseif ($env:UNSLOTH_PYTHON) { $env:UNSLOTH_PYTHON.Trim() } else { "" }
if (($null -ne $PythonRequestArg -or $PythonExplicitRequest) -and $PythonExplicitRequest -notmatch '^3\.1[1-3](\.\d+)?$') {
Write-StudioLine "[ERROR] Unsupported Python request '$PythonExplicitRequest': use 3.11, 3.12 or 3.13 (optionally with a patch, e.g. 3.13.9)." -ForegroundColor Red
return (Exit-InstallFailure "Unsupported Python request '$PythonExplicitRequest'")
}
$PythonPatchRequest = if ($PythonExplicitRequest -match '^3\.1[1-3]\.\d+$') { $PythonExplicitRequest } else { "" }
$PythonVersion = if ($PythonExplicitRequest) { (($PythonExplicitRequest -split '\.')[0..1]) -join '.' } else { "3.13" }
# Patch releases the stack cannot run; mirrors PYTHON_SKIP in install.sh.
$PythonSkip = @("3.13.8")
# It cannot `import torch`, which a -NoTorch install never does.
if ($SkipTorch) { $PythonSkip = @() }
if ($PythonPatchRequest -and ($PythonSkip -contains $PythonPatchRequest)) {
Write-StudioLine "[ERROR] Python $PythonPatchRequest cannot import torch; request $PythonVersion or another patch." -ForegroundColor Red
return (Exit-InstallFailure "Python $PythonPatchRequest cannot import torch")
}
# An elevated run writes the install root as Administrators and the same account cannot
# read it back afterwards. Twin in studio/setup.ps1.
function Get-ElevationState {
try {
$identity = [System.Security.Principal.WindowsIdentity]::GetCurrent()
$principal = New-Object System.Security.Principal.WindowsPrincipal($identity)
if ($principal.IsInRole([System.Security.Principal.WindowsBuiltInRole]::Administrator)) {
return "true"
}
return "false"
} catch {
return "unknown"
}
}
# Recorded either way; install.rs record_diag_marker puts it in the support report.
function Write-ElevationNotice {
param(
[Parameter(Mandatory = $true)][string]$State,
[Parameter(Mandatory = $true)][AllowEmptyString()][string]$Root,
# UNSLOTH_TAURI_MODE is not assigned until the setup call far below; --tauri is.
[switch]$Tauri
)
if ($Tauri) {
[Console]::Out.WriteLine("[TAURI:DIAG] elevated=$State")
[Console]::Out.Flush()
}
if ($State -ne "true") { return }
Write-StudioLine " [WARNING] Running as administrator. Unsloth does not need this." -ForegroundColor Yellow
Write-StudioLine " Anything written to $Root will be owned by Administrators," -ForegroundColor Yellow
Write-StudioLine " and your normal account will not be able to read it afterwards." -ForegroundColor Yellow
Write-StudioLine " That folder outlives an uninstall, so a later install, setup or" -ForegroundColor Yellow
Write-StudioLine " update run normally fails on it." -ForegroundColor Yellow
Write-StudioLine " Stop and start again without 'Run as administrator'." -ForegroundColor Yellow
Write-StudioLine ""
}
function Get-CanonicalRootPath {
param([Parameter(Mandatory = $true)][AllowEmptyString()][string]$Path)
if ([string]::IsNullOrWhiteSpace($Path)) { return "" }
$_p = $Path.Trim()
if (($_p -eq "~" -or $_p -like "~/*" -or $_p -like "~\*") -and
-not [string]::IsNullOrWhiteSpace($env:USERPROFILE)) {
# A bare "~" leaves an empty child path, which Join-Path rejects on PS 5.1.
$_rest = $_p.Substring(1).TrimStart('/', '\')
$_p = if ($_rest) { Join-Path $env:USERPROFILE $_rest } else { $env:USERPROFILE }
}
# GetFullPath anchors a relative path to [Environment]::CurrentDirectory, which
# Set-Location does not move, so it disagreed with the Resolve-Path based resolver
# (PowerShell#10278, by design). It stays as the fallback, never the first answer.
try {
$_p = $ExecutionContext.SessionState.Path.GetUnresolvedProviderPathFromPSPath($_p)
} catch {
try { $_p = [System.IO.Path]::GetFullPath($_p) } catch { }
}
return $_p.TrimEnd('\', '/')
}
# Warn before the resolver below creates and probes the root, so mirror its precedence
# rather than reuse the unassigned $StudioHome. USERPROFILE can be unset (service, CI).
$UnslothRoot = if ([string]::IsNullOrWhiteSpace($env:USERPROFILE)) {
'%USERPROFILE%\.unsloth'
} else {
Join-Path $env:USERPROFILE ".unsloth"
}
$ElevationRoot = if (-not [string]::IsNullOrWhiteSpace($env:UNSLOTH_STUDIO_HOME)) {
$env:UNSLOTH_STUDIO_HOME.Trim()
} elseif (-not [string]::IsNullOrWhiteSpace($env:STUDIO_HOME)) {
$env:STUDIO_HOME.Trim()
} elseif (-not [string]::IsNullOrWhiteSpace($env:UNSLOTH_HOME)) {
$env:UNSLOTH_HOME.Trim()
} else {
$UnslothRoot
}
# A legacy-equal override is not a custom root: llama.cpp and node stay siblings of
# studio under ~/.unsloth, so name that parent.
if ((Get-CanonicalRootPath $ElevationRoot) -ieq
(Get-CanonicalRootPath (Join-Path $UnslothRoot "studio"))) {
$ElevationRoot = $UnslothRoot
}
Write-ElevationNotice -State (Get-ElevationState) -Root $ElevationRoot -Tauri:$TauriMode
# Whitespace-only == unset (matches the Python resolvers' .strip()).
$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()
} elseif (-not [string]::IsNullOrWhiteSpace($env:UNSLOTH_HOME)) {
$envOverrideVar = "UNSLOTH_HOME (studio child)"
$envOverride = Join-Path $env:UNSLOTH_HOME.Trim() "studio"
}
$defaultProfile = $null
try { $defaultProfile = [Environment]::GetFolderPath("UserProfile") } catch {}
$tauriProfile = if ($defaultProfile) { $defaultProfile } else { $env:USERPROFILE }
# tests/studio/test_installer_av_shapes.py (AV_SHAPES_RECORD)
$script:StudioFinalPathWarned = $false
$script:StudioInstallIsFresh = $null
function Write-StudioFinalPathDegraded {
param([string]$Reason)
if ($script:StudioFinalPathWarned) { return }
$script:StudioFinalPathWarned = $true
if ($script:StudioInstallIsFresh -eq $true) {
Write-Verbose "Unsloth: path identity is inexact ($Reason); first install, taking every runtime lock."
return
}
# Only claim that the installer can continue.
Write-StudioLine "[WARN] Could not resolve a path exactly ($Reason)." -ForegroundColor Yellow
Write-StudioLine " Continuing with the lexical resolver, which cannot recover a path's" -ForegroundColor Yellow
Write-StudioLine " stored casing or expand an 8.3 name, so paths are compared as written." -ForegroundColor Yellow
Write-StudioLine " Unsloth will take every runtime lock it might need rather than assume" -ForegroundColor Yellow
Write-StudioLine " two spellings are different directories." -ForegroundColor Yellow
}
function Resolve-StudioLinkTarget {
param([Parameter(Mandatory = $true)][string]$Path)
$item = $null
try { $item = Get-Item -LiteralPath $Path -Force -ErrorAction Stop } catch { return $null }
$target = $null
# PowerShell 7 walks the whole chain; 5.1 exposes only the raw reparse target,
# relative for a relative symlink and still carrying a junction's NT prefix.
if ($item.PSObject.Methods.Name -contains 'ResolveLinkTarget') {
try {
$final = $item.ResolveLinkTarget($true)
if ($final) { $target = [string]$final.FullName }
} catch { $target = $null }
}
if ([string]::IsNullOrWhiteSpace($target)) {
$raw = $null
try { $raw = $item.Target } catch { $raw = $null }
# 5.1 hands this back as a COLLECTION, not a string, and not always an
# [array], so unwrap anything that is not already a string.
if ($null -ne $raw -and $raw -isnot [string]) {
$raw = @($raw) | Select-Object -First 1
}
if (-not [string]::IsNullOrWhiteSpace($raw)) { $target = [string]$raw }
}
if ([string]::IsNullOrWhiteSpace($target)) { return $null }
if ($target.StartsWith('\??\')) {
$target = $target.Substring(4)
# \??\UNC\server\share is the device spelling of \\server\share; left as is it reads as
# relative and yields a wrong mutex identity.
if ($target.StartsWith('UNC\', [System.StringComparison]::OrdinalIgnoreCase)) {
$target = '\\' + $target.Substring(4)
} elseif ($target.StartsWith('Volume{', [System.StringComparison]::OrdinalIgnoreCase)) {
# Same for \??\Volume{GUID}\...: \\?\ keeps naming the volume.
$target = '\\?\' + $target
}
}
# A drive-less "\real" resolves on the LINK's volume, not the process's current drive.
if ($target.Length -ge 1 -and ($target[0] -eq '\' -or $target[0] -eq '/') -and
-not ($target.Length -ge 2 -and ($target[1] -eq '\' -or $target[1] -eq '/'))) {
$linkRoot = $null
try { $linkRoot = [System.IO.Path]::GetPathRoot([System.IO.Path]::GetFullPath($Path)) } catch { $linkRoot = $null }
# Empty for a volume-GUID spelling; leaving the target alone beats guessing.
if (-not [string]::IsNullOrEmpty($linkRoot)) {
try { $target = [System.IO.Path]::Combine($linkRoot, $target.TrimStart('\', '/')) } catch {}
}
}
try {
if (-not [System.IO.Path]::IsPathRooted($target)) {
$parent = [System.IO.Path]::GetDirectoryName($Path)
if ([string]::IsNullOrEmpty($parent)) { return $null }
$target = [System.IO.Path]::Combine($parent, $target)
}
$target = [System.IO.Path]::GetFullPath($target)
} catch { return $null }
# Compare like against like: $target went through GetFullPath, so $Path must
# too, or a relative spelling misses the self-reference guard and loops.
$self = $Path
try { $self = [System.IO.Path]::GetFullPath($Path) } catch { $self = $Path }
if ([string]::Equals(
$target.TrimEnd('\', '/'), $self.TrimEnd('\', '/'), [System.StringComparison]::OrdinalIgnoreCase
)) {
return $null
}
return $target
}
# Compiler-free stand-in for the native resolver: normalized, not exact (no 8.3
# expansion or casing). Never throws.
$script:StudioSubstMap = $null
function Get-StudioSubstTarget {
param([Parameter(Mandatory = $true)][string]$Path)
# No 5.1 API reports SUBST mappings; `subst` with no arguments prints them, read once.
if ($null -eq $script:StudioSubstMap) {
$script:StudioSubstMap = @{}
try {
foreach ($line in @(& "$env:SystemRoot\System32\subst.exe" 2>$null)) {
$m = [regex]::Match([string]$line, '^([A-Za-z]):\\?:\s*=>\s*(\S.*)$')
if ($m.Success) {
$script:StudioSubstMap[$m.Groups[1].Value.ToUpperInvariant()] =
$m.Groups[2].Value.TrimEnd()
}
}
} catch {}
}
if ($script:StudioSubstMap.Count -eq 0) { return $null }
if ($Path.Length -lt 2 -or $Path[1] -ne ':') { return $null }
$letter = ([string]$Path[0]).ToUpperInvariant()
if (-not $script:StudioSubstMap.ContainsKey($letter)) { return $null }
$tail = $Path.Substring(2).TrimStart('\', '/')
$target = $script:StudioSubstMap[$letter]
if ([string]::IsNullOrWhiteSpace($tail)) { return $target }
try { return [System.IO.Path]::Combine($target, $tail) } catch { return $null }
}
function Get-StudioLexicalPath {
param([Parameter(Mandatory = $true)][string]$Path)
$current = $null
try { $current = [System.IO.Path]::GetFullPath($Path) } catch { return $Path }
# Fold SUBST first: the Python runtime gate resolves it, and both must name one mutex.
$substituted = Get-StudioSubstTarget -Path $current
if ($substituted) {
try { $current = [System.IO.Path]::GetFullPath($substituted) } catch {}
}
# Hashtable, not a generic HashSet: already case-insensitive, and a
# locked-down host (the kind that got here) can forbid generic types.
$visited = @{}
# Links on parent components are ordinary; walk from the root, capped against loops.
for ($hop = 0; $hop -lt 32; $hop++) {
if ($visited.ContainsKey($current)) { break }
$visited[$current] = $true
$root = ""
try { $root = [System.IO.Path]::GetPathRoot($current) } catch { break }
if ([string]::IsNullOrEmpty($root) -or $current.Length -lt $root.Length) { break }
$walked = $root
$rewritten = $null
$tail = $current.Substring($root.Length)
# [char[]] on purpose: an untyped array binds Split's single-string
# overload instead, which splits on nothing.
foreach ($segment in $tail.Split([char[]]@('\', '/'), [System.StringSplitOptions]::RemoveEmptyEntries)) {
$walked = [System.IO.Path]::Combine($walked, $segment)
$target = Resolve-StudioLinkTarget -Path $walked
if ($target) {
$remainder = $current.Substring($walked.Length).TrimStart('\', '/')
$rewritten = if ($remainder) { [System.IO.Path]::Combine($target, $remainder) } else { $target }
break
}
}
if (-not $rewritten) { break }
try { $current = [System.IO.Path]::GetFullPath($rewritten) } catch { $current = $rewritten; break }
}
try {
$provider = (Resolve-Path -LiteralPath $current -ErrorAction Stop).ProviderPath
if (-not [string]::IsNullOrWhiteSpace($provider)) { $current = $provider }
} catch {}
return $current
}
# Runs before the install lock: find an existing Python only. Path.resolve matches what
# unsloth_cli/_studio_runtime_gate.py hashes, so both sides name the same lock.
$script:StudioEarlyPythonProbed = $false
$script:StudioEarlyPython = $null
$script:StudioEarlyPythonProbedWithoutVenv = $false
function Test-StudioSddlRightsAreWrite {
param([string]$Rights)
if ([string]::IsNullOrWhiteSpace($Rights)) { return $true }
$text = "$Rights".Trim().ToUpper()
if ($text -like "0X*") {
$mask = [long]0
foreach ($ch in $text.Substring(2).ToCharArray()) {
$digit = "0123456789ABCDEF".IndexOf($ch)
if ($digit -lt 0) { return $true }
$mask = ($mask * 16) + $digit
}
return (($mask -band 0x500D0156) -ne 0)
}
foreach ($alias in @("GA", "GW", "WD", "WO", "SD", "DT", "FA", "FW", "KA", "KW",
"CC", "DC", "WP", "SW")) {
if ($text.Contains($alias)) { return $true }
}
return $false
}
function Test-StudioSddlPrincipalIsAdminOnly {
param([string]$Principal)
if ([string]::IsNullOrWhiteSpace($Principal)) { return $false }
$who = "$Principal".Trim().ToUpper()
return (@(
"BA", "SY", "S-1-5-32-544", "S-1-5-18",
"S-1-5-80-956008885-3418522649-1831038044-1853292631-2271478464"
) -contains $who)
}
function Test-StudioSddlWritableByNonAdmin {
param([string]$Sddl)
if ([string]::IsNullOrWhiteSpace($Sddl)) { return $true }
$text = "$Sddl".Trim()
if (-not ($text -match '^O:([A-Za-z0-9\-]+?)(?:G:|D:|S:|$)')) { return $true }
if (-not (Test-StudioSddlPrincipalIsAdminOnly -Principal $Matches[1])) { return $true }
$daclAt = $text.IndexOf("D:")
if ($daclAt -lt 0) { return $true }
$dacl = $text.Substring($daclAt)
$saclAt = $dacl.IndexOf("S:")
if ($saclAt -ge 0) { $dacl = $dacl.Substring(0, $saclAt) }
$seen = 0
foreach ($chunk in ($dacl -split '\)')) {
$open = $chunk.IndexOf("(")
if ($open -lt 0) { continue }
$seen++
$fields = $chunk.Substring($open + 1) -split ';'
if ($fields.Count -lt 6) { return $true }
$type = "$($fields[0])".Trim().ToUpper()
$flags = "$($fields[1])".Trim().ToUpper()
if (@("D", "OD", "XD") -contains $type) { continue }
if ($flags.Contains("IO")) { continue }
if (-not (Test-StudioSddlRightsAreWrite -Rights $fields[2])) { continue }
if (-not (Test-StudioSddlPrincipalIsAdminOnly -Principal $fields[5])) { return $true }
}
# A DACL with no ACEs in it is not evidence of anything, and neither is one this did not
# manage to split.
if ($seen -eq 0) { return $true }
return $false
}
function Test-StudioDirectoryIsAdminOnly {
param([string]$Path)
if ([string]::IsNullOrWhiteSpace($Path)) { return $false }
$sddl = ""
try {
# Select-Object -ExpandProperty, not a property read: Constrained Language Mode refuses
# property access on types outside its allowed list, and the security descriptor is one.
$sddl = "$(Get-Acl -LiteralPath $Path -ErrorAction Stop |
Select-Object -ExpandProperty Sddl)"
} catch { return $false }
if ([string]::IsNullOrWhiteSpace($sddl)) { return $false }
return (-not (Test-StudioSddlWritableByNonAdmin -Sddl $sddl))
}
function Get-StudioLexicalParent {
param([string]$Path)
$trimmed = "$Path".TrimEnd('\', '/')
$cut = $trimmed.LastIndexOfAny(@([char]92, [char]47))
if ($cut -lt 0) { return "" }
return $trimmed.Substring(0, $cut)
}
function Test-StudioPathUnderAdminRoot {
param([string]$Path)
if ([string]::IsNullOrWhiteSpace($Path)) { return $false }
# $env: rather than [Environment]::GetEnvironmentVariable: System.Environment is not on
# Constrained Language Mode's allowed type list, and this helper runs on hosts under it.
$roots = @()
foreach ($value in @($env:SystemRoot, $env:ProgramFiles, $env:ProgramW6432, ${env:ProgramFiles(x86)})) {
if (-not [string]::IsNullOrWhiteSpace($value)) { $roots += "$value".TrimEnd('\', '/') }
}
$matchedRoot = ""
foreach ($root in $roots) {
$escapedRoot = $root -replace '([\[\]\*\?])', '`$1'
if (("$Path" -like ($escapedRoot + "\*")) -or ("$Path" -like ($escapedRoot + "/*"))) {
$matchedRoot = $root
break
}
}
if ([string]::IsNullOrWhiteSpace($matchedRoot)) { return $false }
$windowsRoot = "$($env:SystemRoot)".TrimEnd('\', '/')
if (-not [string]::IsNullOrWhiteSpace($windowsRoot)) {
foreach ($leaf in @(
"Temp", "Tasks", "Tracing", "Registration\CRMLog", "debug\WIA",
"System32\Tasks", "System32\spool\drivers\color", "System32\spool\PRINTERS",
"System32\spool\SERVERS", "System32\com\dmp", "System32\FxsTmp",
"SysWOW64\Tasks", "SysWOW64\com\dmp", "SysWOW64\FxsTmp"
)) {
$writable = $windowsRoot + "\" + $leaf
$escapedWritable = $writable -replace '([\[\]\*\?])', '`$1'
if (("$Path" -like ($escapedWritable + "\*")) -or
("$Path" -like ($escapedWritable + "/*"))) { return $false }
}
}
$current = Get-StudioLexicalParent -Path "$Path"
$guard = 0
while (-not [string]::IsNullOrWhiteSpace($current)) {
$guard++
if ($guard -gt 64) { return $false }
if (-not (Test-StudioDirectoryIsAdminOnly -Path $current)) { return $false }
if ("$current".TrimEnd('\', '/') -eq $matchedRoot) { return $true }
$next = Get-StudioLexicalParent -Path "$current"
if ("$next" -eq "$current") { return $false }
$current = $next
}
return $false
}
function Test-StudioInterpreterFileIsAdminOnly {
param([string]$Path)
if ([string]::IsNullOrWhiteSpace($Path)) { return $false }
try {
$item = Get-Item -LiteralPath $Path -Force -ErrorAction Stop |
Select-Object -Property Attributes, LinkType
if ($null -eq $item) { return $false }
if ("$($item.Attributes)" -match 'ReparsePoint') { return $false }
if (-not [string]::IsNullOrWhiteSpace("$($item.LinkType)")) { return $false }
} catch { return $false }
return (Test-StudioDirectoryIsAdminOnly -Path $Path)
}
function Get-StudioEarlyPython {
# A miss taken before $VenvDir existed (--tauri) is re-probed once it does.
$venvDirValue = $null
try { $venvDirValue = Get-Variable -Name VenvDir -ValueOnly -ErrorAction SilentlyContinue } catch {}
$venvKnown = -not [string]::IsNullOrWhiteSpace($venvDirValue)
if ($script:StudioEarlyPythonProbed) {
if (-not ($script:StudioEarlyPythonProbedWithoutVenv -and $venvKnown -and
[string]::IsNullOrWhiteSpace($script:StudioEarlyPython))) {
return $script:StudioEarlyPython
}
}
$script:StudioEarlyPythonProbed = $true
$script:StudioEarlyPythonProbedWithoutVenv = (-not $venvKnown)
if ("$($env:UNSLOTH_EARLY_PYTHON_PROBE)".Trim() -eq "0") { return $null }
$candidates = @()
$venvCandidates = @()
if ($venvKnown) {
$venvCandidates = @((Join-Path $venvDirValue "Scripts\python.exe"), (Join-Path $venvDirValue "bin/python3"))
$candidates += $venvCandidates
}
foreach ($name in @("python3", "python")) {
try {
foreach ($cmd in @(Get-Command $name -All -CommandType Application -ErrorAction SilentlyContinue)) {
if ($cmd -and $cmd.Source) { $candidates += $cmd.Source }
}
} catch {}
}
$requireAdminRoot = $false
if ($env:OS -eq "Windows_NT") {
try { $requireAdminRoot = Test-StudioChildScriptDirectoryElevated } catch { $requireAdminRoot = $true }
}
$rejectedForWritability = $false
foreach ($candidate in $candidates) {
if ([string]::IsNullOrWhiteSpace($candidate)) { continue }
# Test-Path throws on an unreadable dir under Stop: skip this candidate only.
$isFile = $false
try { $isFile = Test-Path -LiteralPath $candidate -PathType Leaf } catch {}
if (-not $isFile) { continue }
# The venv interpreter runs here before the destination guard does, so only where that
# guard would let it run: the default layout, or a custom home that is already Unsloth's.
if ($venvCandidates -contains $candidate) {
$ours = $false
try {
$mode = Get-Variable -Name StudioRedirectMode -ValueOnly -ErrorAction Stop
$studioHomeValue = Get-Variable -Name StudioHome -ValueOnly -ErrorAction Stop
# The guard's own predicates; one not yet defined this early throws, which declines.
$ours = ($mode -ne 'env') -or
(Test-Path -LiteralPath (Join-Path $venvDirValue ".unsloth-studio-owned") -PathType Leaf) -or
(Test-Path -LiteralPath (Join-Path $studioHomeValue "share\studio.conf") -PathType Leaf) -or
(Test-Path -LiteralPath (Join-Path $studioHomeValue "bin\unsloth.exe") -PathType Leaf) -or
(Test-StudioPlainFile -Path (Join-Path $studioHomeValue ".unsloth-studio-owned")) -or
(Test-UnslothCmdShimFile (Join-Path $studioHomeValue "bin\unsloth.cmd"))
} catch { $ours = $false }
if (-not $ours) {
if ($requireAdminRoot) { $rejectedForWritability = $true }
continue
}
}
if ($requireAdminRoot -and ($venvCandidates -notcontains $candidate)) {
$adminOnly = $false
try {
$adminOnly = (Test-StudioPathUnderAdminRoot -Path $candidate) -and
(Test-StudioInterpreterFileIsAdminOnly -Path $candidate)
} catch { $adminOnly = $false }
if (-not $adminOnly) {
$rejectedForWritability = $true
continue
}
}
if ("$candidate" -match '(?i)[\\/]Microsoft[\\/]WindowsApps[\\/]') { continue }
# Probe the interpreter's own directory: $PSScriptRoot is empty when the script runs from memory.
$probeDir = $null
try { $probeDir = [System.IO.Path]::GetDirectoryName($candidate) } catch {}
if ([string]::IsNullOrWhiteSpace($probeDir)) { continue }
$probe = Invoke-StudioEarlyPython -Exe $candidate -Path $probeDir
if (-not [string]::IsNullOrWhiteSpace($probe)) {
$script:StudioEarlyPython = $candidate
return $candidate
}
}
if ($rejectedForWritability) {
Write-StudioFinalPathDegraded -Reason ("this run is elevated and the only interpreters found " +
"are in directories a standard user can write, which an elevated run must not launch")
}
return $null
}
function Invoke-StudioEarlyPython {
param(
[Parameter(Mandatory = $true)][string]$Exe,
[Parameter(Mandatory = $true)][string]$Path,
[int]$TimeoutMs = 10000
)
# The gate's _resolved_windows_path, strict so loops/dangling links raise. <3.8 does not follow links.
$script = "import pathlib,sys" + [char]10 +
"sys.exit(2) if sys.version_info < (3,8) else None" + [char]10 +
"sys.stdout.buffer.write(str(pathlib.Path(sys.argv[1]).resolve(strict=True)).encode('utf-8'))"
# Any error, incl. from Test-Path, returns $null (lexical rung).
try {
# Verbatim: Trim() would drop a trailing U+00A0, which NTFS names keep.
$answer = "$(Invoke-StudioEarlyPythonScript -Exe $Exe -Script $script -ScriptArgs @($Path) -TimeoutMs $TimeoutMs)"
if ([string]::IsNullOrWhiteSpace($answer)) { return $null }
if (-not [System.IO.Path]::IsPathRooted($answer)) { return $null }
if (-not (Test-Path -LiteralPath $answer)) { return $null }
return $answer
} catch {
return $null
}
}
function Invoke-StudioEarlyPythonScript {
param(
[Parameter(Mandatory = $true)][string]$Exe,
[Parameter(Mandatory = $true)][string]$Script,
[string[]]$ScriptArgs = @(),
[int]$TimeoutMs = 10000
)
$proc = $null
$clock = [System.Diagnostics.Stopwatch]::StartNew()
try {
$psi = New-Object System.Diagnostics.ProcessStartInfo
$psi.FileName = $Exe
# -S as well as -I: -I still imports site, so a sitecustomize could print or hang.
# -B: these probes can run before the install lock, so they must write no .pyc.
# ArgumentList is .NET Core only; Windows PowerShell 5.1 lacks it.
$argv = @("-I", "-S", "-B", "-c", $Script) + $ScriptArgs
if ($null -ne $psi.PSObject.Properties["ArgumentList"]) {
foreach ($a in $argv) { $null = $psi.ArgumentList.Add($a) }
} else {
# Quote for CommandLineToArgvW, doubling trailing backslashes so "C:\dir\" keeps its quote.
$psi.Arguments = (@($argv | ForEach-Object {
'"' + ($_ -replace '(\\+)$', '$1$1') + '"'
}) -join ' ')
}
$psi.UseShellExecute = $false
$psi.RedirectStandardOutput = $true
$psi.RedirectStandardError = $true
# Otherwise 5.1 decodes with the console codepage and corrupts non-ASCII paths.
$psi.StandardOutputEncoding = [System.Text.Encoding]::UTF8
$psi.CreateNoWindow = $true
$proc = [System.Diagnostics.Process]::Start($psi)
$stdout = $proc.StandardOutput.ReadToEndAsync()
$null = $proc.StandardError.ReadToEndAsync()
if (-not $proc.WaitForExit($TimeoutMs)) {
try { $proc.Kill() } catch {}
return $null
}
if ($proc.ExitCode -ne 0) { return $null }
# A leftover child can hold stdout open, so the deadline bounds the read too.
$left = [Math]::Max(0, $TimeoutMs - [int]$clock.ElapsedMilliseconds)
if (-not $stdout.Wait($left)) { return $null }
return "$($stdout.Result)"
} catch {
return $null
} finally {
if ($proc) { try { $proc.Dispose() } catch {} }
}
}
function Get-StudioSystem32Tool {
param([Parameter(Mandatory = $true)][string]$Name)
if ([string]::IsNullOrWhiteSpace($env:SystemRoot)) { return "" }
$candidate = Join-Path (Join-Path $env:SystemRoot "System32") $Name
if (-not (Test-Path -LiteralPath $candidate -PathType Leaf)) { return "" }
return $candidate
}
function Invoke-StudioSystem32ToolBounded {
param([string]$Exe, [string[]]$Arguments = @(), [int]$TimeoutMs = 3000)
# CLM cannot construct ProcessStartInfo. A job captures the trusted system tool's
# output through PowerShell's pipes, without redirect files in an unlabelled directory.
if ("$($ExecutionContext.SessionState.LanguageMode)" -ne "FullLanguage") {
$job = $null
try {
$seconds = ($TimeoutMs - ($TimeoutMs % 1000)) / 1000
if (($TimeoutMs % 1000) -ne 0) { $seconds++ }
if ($seconds -lt 1) { $seconds = 1 }
$job = Start-Job -ScriptBlock {
param($tool, $toolArgs)
$lines = @(& $tool @toolArgs 2>$null)
return @{ Output = ($lines -join "`n"); ExitCode = $LASTEXITCODE }
} -ArgumentList $Exe, (,$Arguments)
if (Wait-Job -Job $job -Timeout $seconds) {
return (Receive-Job -Job $job -ErrorAction SilentlyContinue | Select-Object -Last 1)
}
Stop-Job -Job $job -ErrorAction SilentlyContinue
} catch { return $null }
finally { if ($job) { Remove-Job -Job $job -Force -ErrorAction SilentlyContinue } }
return $null
}
$proc = $null
try {
# If policy forbids these types, decline. The caller treats an unreadable
# token as elevated and uses the inline probe when no private directory is safe.
$clock = [System.Diagnostics.Stopwatch]::StartNew()
$psi = New-Object System.Diagnostics.ProcessStartInfo
$psi.FileName = $Exe
if ($null -ne $psi.PSObject.Properties['ArgumentList']) {
foreach ($arg in $Arguments) { $null = $psi.ArgumentList.Add($arg) }
} else {
# The arguments here are paths and fixed switches; paths cannot contain quotes.
$psi.Arguments = (@($Arguments | ForEach-Object {
'"' + ($_ -replace '(\\+)$', '$1$1') + '"'
}) -join ' ')
}
$psi.UseShellExecute = $false
$psi.RedirectStandardOutput = $true
$psi.RedirectStandardError = $true
$psi.CreateNoWindow = $true
$proc = [System.Diagnostics.Process]::Start($psi)
$stdout = $proc.StandardOutput.ReadToEndAsync()
$null = $proc.StandardError.ReadToEndAsync()
if (-not $proc.WaitForExit($TimeoutMs)) {
try { $proc.Kill() } catch { }
return $null
}
$left = [Math]::Max(0, $TimeoutMs - [int]$clock.ElapsedMilliseconds)
if (-not $stdout.Wait($left)) { return $null }
return @{ Output = "$($stdout.Result)"; ExitCode = $proc.ExitCode }
} catch { return $null }
finally { if ($proc) { try { $proc.Dispose() } catch { } } }
}
function Test-StudioChildScriptDirectoryElevated {
$groups = ""
$whoami = Get-StudioSystem32Tool -Name "whoami.exe"
if (-not $whoami) { return $true }
try {
$result = Invoke-StudioSystem32ToolBounded -Exe $whoami -Arguments @('/groups')
if ($null -eq $result) { return $true }
$groups = "$($result.Output)"
} catch { return $true }
if ([string]::IsNullOrWhiteSpace($groups)) { return $true }
if ($groups -match "S-1-16-(12288|16384)") { return $true }
if ($groups -match "S-1-16-\d+") { return $false }
return $true
}
function New-StudioChildScriptDirectory {
$tempRoot = if ($env:TEMP) { $env:TEMP } elseif ($env:TMPDIR) { $env:TMPDIR } else { "/tmp" }
# Join-Path throws on a TEMP naming a missing drive: decline, like any other unusable root.
try { $dir = Join-Path $tempRoot ("unsloth-child-" + [guid]::NewGuid().ToString("N")) -ErrorAction Stop } catch { return "" }
$made = $false
try {
$createdPath = "$(New-Item -ItemType Directory -Path $dir -ErrorAction Stop |
Select-Object -ExpandProperty FullName)"
$made = (Test-Path -LiteralPath $dir -PathType Container)
if ((-not $made) -and $createdPath) {
Remove-Item -LiteralPath $createdPath -Recurse -Force -ErrorAction SilentlyContinue
}
} catch { }
if ((-not $made) -and $dir -match '[\[\]]') {
$escaped = $dir -replace '([\[\]])', '`$1'
try {
$null = New-Item -ItemType Directory -Path $escaped -ErrorAction Stop
$made = (Test-Path -LiteralPath $dir -PathType Container)
} catch { }
}
# Created by THIS call. New-Item without -Force throws on a directory that already exists,
# which is the point: a pre-created one carrying an attacker's ACL is refused, not adopted.
if (-not $made) { return "" }
if ($env:OS -eq "Windows_NT") {
# Until it is labelled a standard user can swap this directory for a junction: a link is
# never adopted, and cleanup never recurses through one into its target.
$isLink = { try { "$((Get-Item -LiteralPath $dir -Force -ErrorAction Stop).Attributes)" -match 'ReparsePoint' } catch { $true } }
$labelled = $false
$icacls = Get-StudioSystem32Tool -Name "icacls.exe"
if (-not $icacls) {
if (-not (& $isLink)) { try { Remove-Item -LiteralPath $dir -Recurse -Force -ErrorAction SilentlyContinue } catch { } }
return ""
}
try {
$result = Invoke-StudioSystem32ToolBounded -Exe $icacls -Arguments @($dir, '/setintegritylevel', '(OI)(CI)H')
$labelled = ($null -ne $result -and $result.ExitCode -eq 0)
if (-not $labelled -and $null -ne $result) {
$readback = Invoke-StudioSystem32ToolBounded -Exe $icacls -Arguments @($dir)
$labelled = ($null -ne $readback -and "$($readback.Output)" -match "S-1-16-12288|High Mandatory Level")
}
} catch { $labelled = $false }
if ((-not $labelled) -and (Test-StudioChildScriptDirectoryElevated)) {
if (-not (& $isLink)) { try { Remove-Item -LiteralPath $dir -Recurse -Force -ErrorAction SilentlyContinue } catch { } }
return ""
}
if (& $isLink) { return "" }
if ($labelled) {
$planted = $true
try {
$planted = @(Get-ChildItem -LiteralPath $dir -Force -ErrorAction Stop).Count -ne 0
} catch { $planted = $true }
if ($planted) {
if (-not (& $isLink)) { try { Remove-Item -LiteralPath $dir -Recurse -Force -ErrorAction SilentlyContinue } catch { } }
return ""
}
}
}
return $dir
}
# One child per distinct path: the process scan asks for the same image paths repeatedly.
$script:StudioPythonFinalPathCache = $null
function Get-StudioPythonFinalPath {
param([Parameter(Mandatory = $true)][string]$Path)
if ($null -eq $script:StudioPythonFinalPathCache) { $script:StudioPythonFinalPathCache = @{} }
if ($script:StudioPythonFinalPathCache.ContainsKey($Path)) {
return $script:StudioPythonFinalPathCache[$Path]
}
$exe = $null
try { $exe = Get-StudioEarlyPython } catch {}
# Not cached: the re-probe once $VenvDir is known may still find an interpreter.
if (-not $exe) { return $null }
$answer = Invoke-StudioEarlyPython -Exe $exe -Path $Path
$script:StudioPythonFinalPathCache[$Path] = $answer
return $answer
}
function Resolve-StudioFinalPathsInOneChild {
param([string[]]$Paths = @())
if ($null -eq $script:StudioPythonFinalPathCache) { $script:StudioPythonFinalPathCache = @{} }
$wanted = @()
foreach ($candidate in $Paths) {
if ([string]::IsNullOrWhiteSpace($candidate)) { continue }
if ($script:StudioPythonFinalPathCache.ContainsKey($candidate)) { continue }
if ($wanted -notcontains $candidate) { $wanted += $candidate }
}
if ($wanted.Count -eq 0) { return }
$exe = $null
try { $exe = Get-StudioEarlyPython } catch {}
if (-not $exe) { return }
$listDir = New-StudioChildScriptDirectory
$listFile = $null
if ($listDir) {
$listFile = Join-Path $listDir "paths.txt"
try { Set-Content -LiteralPath $listFile -Value $wanted -Encoding UTF8 -ErrorAction Stop }
catch { Remove-Item -LiteralPath $listDir -Recurse -Force -ErrorAction SilentlyContinue; $listDir = $null; $listFile = $null }
}
# Without a private directory the list rides the environment, in chunks under its 32767-character
# block, so a declined directory does not fall back to one child per process.
$batches = @()
if ($listFile) { $batches += ,@{ Args = @($listFile); Env = $null } }
else {
$chunk = ""
foreach ($candidate in $wanted) {
if ($chunk -and ($chunk.Length + $candidate.Length + 1) -gt 30000) { $batches += ,@{ Args = @(); Env = $chunk }; $chunk = "" }
$chunk = if ($chunk) { $chunk + "`n" + $candidate } else { $candidate }
}
if ($chunk) { $batches += ,@{ Args = @(); Env = $chunk } }
}
$probe = "import os,pathlib,sys" + [char]10 +
"sys.exit(2) if sys.version_info < (3,8) else None" + [char]10 +
"out=[]" + [char]10 +
"if len(sys.argv)>1:" + [char]10 +
" with open(sys.argv[1],'r',encoding='utf-8-sig') as fh: lines=fh.read().split('\n')" + [char]10 +
"else:" + [char]10 +
" lines=os.environ.get('UNSLOTH_FINAL_PATHS','').split('\n')" + [char]10 +
"for line in lines:" + [char]10 +
" p=line.rstrip('\r\n')" + [char]10 +
" if not p: continue" + [char]10 +
" try:" + [char]10 +
" out.append(p+'|'+str(pathlib.Path(p).resolve(strict=True)))" + [char]10 +
" except Exception:" + [char]10 +
" out.append(p+'|')" + [char]10 +
"sys.stdout.buffer.write('\n'.join(out).encode('utf-8'))"
$raw = ""
$savedPaths = $env:UNSLOTH_FINAL_PATHS
try {
foreach ($batch in $batches) {
if ($null -ne $batch.Env) { $env:UNSLOTH_FINAL_PATHS = $batch.Env }
try { $raw += "`n" + (Invoke-StudioEarlyPythonScript -Exe $exe -Script $probe -ScriptArgs $batch.Args -TimeoutMs 30000) } catch { }
}
} finally {
if ($null -eq $savedPaths) { Remove-Item Env:UNSLOTH_FINAL_PATHS -ErrorAction SilentlyContinue }
else { $env:UNSLOTH_FINAL_PATHS = $savedPaths }
if ($listDir) { Remove-Item -LiteralPath $listDir -Recurse -Force -ErrorAction SilentlyContinue }
}
$answers = @{}
$reported = @{}
foreach ($line in ("$raw" -split "`r?`n")) {
if ([string]::IsNullOrWhiteSpace($line)) { continue }
# The first separator, because the pipe is not legal in a Windows path so the left
# half cannot contain one. $requested, not $input: $input is an automatic variable.
$split = $line.IndexOf('|')
if ($split -lt 1) { continue }
$requested = $line.Substring(0, $split)
$answer = $line.Substring($split + 1)
$reported[$requested] = $true
if ([string]::IsNullOrWhiteSpace($answer)) { continue }
if (-not (Split-Path -IsAbsolute $answer)) { continue }
if (-not (Test-Path -LiteralPath $answer)) { continue }
$answers[$requested] = $answer
}
foreach ($candidate in $wanted) {
if ($answers.ContainsKey($candidate)) {
$script:StudioPythonFinalPathCache[$candidate] = $answers[$candidate]
} elseif ($reported.ContainsKey($candidate)) {
# Asked, and the child said it cannot be resolved. Recorded as $null rather than
# left absent, so the miss is not re-asked once per protected root.
$script:StudioPythonFinalPathCache[$candidate] = $null
}
# Neither: the child answered, but not about this path. Left uncached, so the
# single-path rung still gets its turn.
}
}
# Tell Explorer a shortcut changed via a child interpreter, where the type cannot be
# defined in this shell. Cosmetic; never fails the install.
function Invoke-StudioPythonShellIconRefresh {
param([string[]]$Paths = @(), [string]$Exe = "", [switch]$DestinationValidated)
if (-not ($env:OS -eq "Windows_NT")) { return $false }
# Elevated, a venv interpreter runs only after the destination guard; --shortcuts-only never reaches it.
if (-not $DestinationValidated) {
try { if ((Get-ElevationState) -ne "false") { return $false } } catch { return $false }
}
# Kill switch before $Exe too: it forbids any child on this host, however the path was found.
if ("$($env:UNSLOTH_EARLY_PYTHON_PROBE)".Trim() -eq "0") { return $false }
$exe = $Exe
if ([string]::IsNullOrWhiteSpace($exe)) {
try {
$exe = Get-StudioEarlyPython
} catch { return $false }
}
if (-not $exe) { return $false }
# Per-item SHCNE_UPDATEITEM is required: the global broadcast misses in-place .lnk rewrites.
# SHCNF_FLUSH (0x1000) because the child exits at once and a queued notification is lost.
$script = "import ctypes,sys" + [char]10 +
"from ctypes import wintypes" + [char]10 +
"s32=ctypes.WinDLL('shell32',use_last_error=True)" + [char]10 +
"s32.SHChangeNotify.restype=None" + [char]10 +
"s32.SHChangeNotify.argtypes=[wintypes.LONG,wintypes.UINT,wintypes.LPCWSTR,wintypes.LPCWSTR]" + [char]10 +
"for p in sys.argv[1:]:" + [char]10 +
" s32.SHChangeNotify(0x00002000,0x1005,p,None)" + [char]10 +
"s32.SHChangeNotify(0x08000000,0x1000,None,None)" + [char]10 +
"sys.stdout.write('ok')"
try {
$answer = Invoke-StudioEarlyPythonScript -Exe $exe -Script $script -ScriptArgs $Paths -TimeoutMs 10000
} catch { return $false }
return ("$answer".Trim() -eq "ok")
}
# Exact = $true means the native resolver answered, so the string is what it
# always was. Callers keying a lock on it use that to judge an inequality.
function Resolve-StudioFinalPathInfo {
param([Parameter(Mandatory = $true)][string]$Path)
$fullPath = [System.IO.Path]::GetFullPath($Path)
$fullRoot = [System.IO.Path]::GetPathRoot($fullPath)
if (-not $fullRoot -or $fullPath.Length -gt $fullRoot.Length) {
$fullPath = $fullPath.TrimEnd('\', '/')
}
$existingPath = $fullPath
$missingSegments = @()
while (-not (Test-Path -LiteralPath $existingPath)) {
$leaf = [System.IO.Path]::GetFileName($existingPath)
$parent = [System.IO.Path]::GetDirectoryName($existingPath)
if ([string]::IsNullOrEmpty($leaf) -or [string]::IsNullOrEmpty($parent)) {
return [pscustomobject]@{ Path = $fullPath; Exact = $false }
}
# A dangling link/loop reads as missing; a reparse point in the stripped tail must stay inexact.
$strippedAttributes = $null
try { $strippedAttributes = [System.IO.File]::GetAttributes($existingPath) } catch { }
if ($null -ne $strippedAttributes -and
($strippedAttributes -band [System.IO.FileAttributes]::ReparsePoint)) {
return [pscustomobject]@{ Path = $fullPath; Exact = $false }
}
$missingSegments = @($leaf) + $missingSegments
$existingPath = $parent
}
$exact = $false
$resolved = Get-StudioPythonFinalPath -Path $existingPath
if (-not [string]::IsNullOrWhiteSpace($resolved)) { $exact = $true }
if ([string]::IsNullOrEmpty($resolved)) {
$resolved = Get-StudioLexicalPath -Path $existingPath
$exact = $false
Write-StudioFinalPathDegraded -Reason "no Python interpreter was available to resolve it"
}
if ($resolved.StartsWith('\\?\UNC\', [System.StringComparison]::OrdinalIgnoreCase)) {
$resolved = '\\' + $resolved.Substring(8)
} elseif ($resolved.StartsWith('\\?\', [System.StringComparison]::OrdinalIgnoreCase)) {
# Strip every extended prefix, including \\?\Volume{GUID}\: this is hashed into the
# runtime mutex name and unsloth_cli/_studio_runtime_gate.py strips \\?\ unconditionally.
$resolved = $resolved.Substring(4)
}
foreach ($segment in $missingSegments) {
$resolved = [System.IO.Path]::Combine($resolved, $segment)
}
$resolvedRoot = [System.IO.Path]::GetPathRoot($resolved)
if ($resolvedRoot -and $resolved.Length -le $resolvedRoot.Length) {
return [pscustomobject]@{ Path = $resolvedRoot; Exact = $exact }
}
return [pscustomobject]@{ Path = $resolved.TrimEnd('\', '/'); Exact = $exact }
}
function Get-StudioFinalPath {
param([Parameter(Mandatory = $true)][string]$Path)
return (Resolve-StudioFinalPathInfo -Path $Path).Path
}
function Test-StudioSameDirectoryByProbe {
param([string]$Left, [string]$Right)
if ([string]::IsNullOrWhiteSpace($Left) -or [string]::IsNullOrWhiteSpace($Right)) { return $false }
if (-not (Test-Path -LiteralPath $Left -PathType Container)) { return $false }
if (-not (Test-Path -LiteralPath $Right -PathType Container)) { return $false }
$name = ".unsloth-path-probe-" + [guid]::NewGuid().ToString("N")
$probe = Join-Path $Left $name
try { Set-Content -LiteralPath $probe -Value "" -ErrorAction Stop } catch { return $false }
try {
return (Test-Path -LiteralPath (Join-Path $Right $name))
} finally {
Remove-Item -LiteralPath $probe -Force -ErrorAction SilentlyContinue
}
}
function Split-StudioExistingAncestor {
param([string]$Path)
if ([string]::IsNullOrWhiteSpace($Path)) { return $null }
$current = $Path
$tail = ""
# Bounded: a malformed path can leave Split-Path returning something that never shortens.
for ($hop = 0; $hop -lt 64; $hop++) {
if ([string]::IsNullOrWhiteSpace($current)) { break }
if (Test-Path -LiteralPath $current -PathType Container) {
return [pscustomobject]@{ Root = $current; Tail = $tail }
}
$leaf = Split-Path -Leaf $current
$parent = Split-Path -Parent $current
if ([string]::IsNullOrWhiteSpace($parent) -or $parent -eq $current) { break }
if ([string]::IsNullOrWhiteSpace($leaf)) { break }
$tail = if ($tail) { Join-Path $leaf $tail } else { $leaf }
$current = $parent
}
return $null
}
# Free space for the disk-full diagnosis (#11313), or $null. A volume can be mounted at a
# directory, so take the longest Win32_Volume Name prefixing the path; otherwise callers
# fall back to the drive root.
function Select-StudioVolumeForPath {
param([Parameter(Mandatory = $true)][AllowEmptyString()][string]$Path,
[Parameter(Mandatory = $true)][AllowEmptyCollection()][object[]]$Volumes)
if ([string]::IsNullOrWhiteSpace($Path)) { return $null }
# Unrooted would anchor to the current directory and invent a match.
if (-not [System.IO.Path]::IsPathRooted($Path)) { return $null }
$full = [System.IO.Path]::GetFullPath($Path)
# Append a separator so the mount point matches itself and C:\studiofoo does not match
# C:\studio.
if (-not $full.EndsWith([System.IO.Path]::DirectorySeparatorChar)) {
$full += [System.IO.Path]::DirectorySeparatorChar
}
$best = $null
foreach ($vol in $Volumes) {
if (-not $vol -or -not $vol.Name) { continue }
if (-not $full.StartsWith($vol.Name, [System.StringComparison]::OrdinalIgnoreCase)) { continue }
if ((-not $best) -or $vol.Name.Length -gt $best.Name.Length) { $best = $vol }
}
return $best
}
# Junctions lie about which volume a path is on; resolve first, lexical fallback.
function Resolve-StudioVolumeQueryPath {
param([Parameter(Mandatory = $true)][string]$Path)
try {
$final = Get-StudioFinalPath -Path $Path
# Volume{GUID}\... is not rooted, so keep the caller's path then.
if ($final -and [System.IO.Path]::IsPathRooted($final)) { return $final }
return $Path
} catch { return $Path }
}
# Out of process and bounded: a degraded WMI repository blocks CIM queries forever.
# Cached (timeouts too); -Fresh for the free-space caller.
function Get-StudioVolumeList {
param([switch]$Fresh)
if (-not $Fresh -and $null -ne $script:StudioVolumeList) { return $script:StudioVolumeList }
$script:StudioVolumeList = @()
$job = $null
try {
$job = Start-Job -ScriptBlock {
Get-CimInstance -ClassName Win32_Volume -ErrorAction SilentlyContinue |
Select-Object Name, DeviceID, FreeSpace
}
if (Wait-Job -Job $job -Timeout 15) {
$script:StudioVolumeList = @(Receive-Job -Job $job -ErrorAction SilentlyContinue |
Where-Object { $_ -and $_.Name })
} else {
Stop-Job -Job $job -ErrorAction SilentlyContinue
}
} catch {
} finally {
if ($job) { Remove-Job -Job $job -Force -ErrorAction SilentlyContinue }
}
return $script:StudioVolumeList
}
function Get-StudioMountedVolume {
param([Parameter(Mandatory = $true)][AllowEmptyString()][string]$Path,
[switch]$Fresh)
if ([string]::IsNullOrWhiteSpace($Path)) { return $null }
if (-not ($IsWindows -or $env:OS -eq "Windows_NT")) { return $null }
try {
return (Select-StudioVolumeForPath -Path (Resolve-StudioVolumeQueryPath -Path $Path) `
-Volumes (Get-StudioVolumeList -Fresh:$Fresh))
} catch { return $null }
}
# GetPathRoot on a path that does not exist yet still names the volume it would be created on.
function Get-StudioFreeSpaceBytes {
param([Parameter(Mandatory = $true)][AllowEmptyString()][string]$Path)
if ([string]::IsNullOrWhiteSpace($Path)) { return $null }
$queryPath = Resolve-StudioVolumeQueryPath -Path $Path
# -Fresh: this is the caller that wants a number, and a stale one is worse than none.
$mounted = Get-StudioMountedVolume -Path $queryPath -Fresh
if ($mounted -and $null -ne $mounted.FreeSpace) { return [int64]$mounted.FreeSpace }
try {
# The fallback needs the resolved path too: DriveInfo on the lexical one answers for
# the drive the junction lives on, not the drive it points at.
$root = [System.IO.Path]::GetPathRoot([System.IO.Path]::GetFullPath($queryPath))
if (-not $root) { return $null }
return ([System.IO.DriveInfo]::new($root)).AvailableFreeSpace
} catch { return $null }
}
# Custom Unsloth roots are not supported with --tauri (the desktop app uses
# the Windows profile folder). Pass through if the override is that same root,
# or the one the desktop chose (UNSLOTH_DESKTOP_CHOSEN_STUDIO_HOME=1).
if ($envOverride) {
if ($envOverride -eq "~" -or $envOverride -like "~/*" -or $envOverride -like "~\*") {
$envOverride = (Join-Path $env:USERPROFILE $envOverride.Substring(1).TrimStart('/','\'))
}
$envOverride = $ExecutionContext.SessionState.Path.GetUnresolvedProviderPathFromPSPath($envOverride)
}
$tauriChosenRoot = ($TauriMode -and $envOverrideVar -eq "UNSLOTH_STUDIO_HOME" -and
$env:UNSLOTH_DESKTOP_CHOSEN_STUDIO_HOME -eq "1")
if ($TauriMode -and $envOverride -and -not $tauriChosenRoot) {
$_tauriOverride = $envOverride
try {
$_tauriOverride = Get-StudioFinalPath -Path $_tauriOverride
} catch {}
$_legacyTauriRoot = Join-Path $tauriProfile ".unsloth\studio"
try {
$_legacyTauriRoot = Get-StudioFinalPath -Path $_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)
$_tauriSameRoot = $false
if ($_tauriOverride -eq $_legacyTauriRoot) {
$_tauriSameRoot = $true
} else {
$_tauriSameRoot = Test-StudioSameDirectoryByProbe -Left $_tauriOverride -Right $_legacyTauriRoot
}
if (-not $_tauriSameRoot) {
$_tauriLeft = Split-StudioExistingAncestor -Path $_tauriOverride
$_tauriRight = Split-StudioExistingAncestor -Path $_legacyTauriRoot
if ($_tauriLeft -and $_tauriRight -and [string]::Equals(
$_tauriLeft.Tail, $_tauriRight.Tail, [System.StringComparison]::OrdinalIgnoreCase)) {
$_tauriSameRoot = Test-StudioSameDirectoryByProbe -Left $_tauriLeft.Root -Right $_tauriRight.Root
}
}
if (-not $_tauriSameRoot) {
Write-StudioLine "ERROR: $envOverrideVar is not supported with --tauri." -ForegroundColor Red
Write-StudioLine " The desktop app uses the Windows profile .unsloth\studio root." -ForegroundColor Red
Write-StudioLine " Run install.ps1 without --tauri for custom-root shell installs," -ForegroundColor Yellow
Write-StudioLine " or unset the env var for default desktop installs." -ForegroundColor Yellow
# No-op today (nothing redirected TMP/TEMP yet), kept so this stays correct if that changes.
Restore-StudioTempEnvironment
throw "$envOverrideVar is not supported with --tauri."
}
}
# LOCALAPPDATA may be unset in service / CI contexts; guard Join-Path under Stop.
$defaultDataDir = if ($env:LOCALAPPDATA -and -not [string]::IsNullOrWhiteSpace($env:LOCALAPPDATA)) {
Join-Path $env:LOCALAPPDATA "Unsloth Studio"
} else { $null }
# Cleared per invocation: script scope outlives a run, and under `irm | iex` one session can
# install twice. A stale $true makes the lock skip claiming a root it just created.
$script:StudioEnvRootPath = $null
$script:StudioEnvRootExisted = $false
if ($envOverride) {
try {
# BEFORE the create: the lock later asks whether the root pre-existed, and after the
# create every env-mode root does.
$script:StudioEnvRootPath = $envOverride
$script:StudioEnvRootExisted = [System.IO.Directory]::Exists($envOverride)
# .NET API: New-Item -Path treats brackets as wildcards (no -LiteralPath on PS 5.1).
[System.IO.Directory]::CreateDirectory($envOverride) | Out-Null
$StudioHome = (Resolve-Path -LiteralPath $envOverride).Path
# Re-recorded against the resolved spelling, which is what the lock is handed.
$script:StudioEnvRootPath = $StudioHome
} catch {
Write-StudioLine "ERROR: $envOverrideVar=$envOverride cannot be created or accessed." -ForegroundColor Red
# Same as the --tauri rejection above: still before the lock finally.
Restore-StudioTempEnvironment
throw "$envOverrideVar=$envOverride cannot be created or accessed."
}
$probe = Join-Path $StudioHome (".unsloth-write-probe-" + [guid]::NewGuid())
try {
[System.IO.File]::WriteAllText($probe, "") # literal-path safe + closes handle
Remove-Item -LiteralPath $probe -Force -ErrorAction SilentlyContinue
} catch {
Write-StudioLine "ERROR: $envOverrideVar=$StudioHome is not writable." -ForegroundColor Red
Restore-StudioTempEnvironment
throw "$envOverrideVar=$StudioHome is not writable."
}
# A new custom root would inherit its drive's ACL (often writable by every account, so
# another user could swap the installed executables): give it the profile's access
# instead. An existing folder keeps its ACL.
if (-not $script:StudioEnvRootExisted) {
try {
$rootAcl = New-Object System.Security.AccessControl.DirectorySecurity
$rootAcl.SetAccessRuleProtection($true, $false)
$inherit = [System.Security.AccessControl.InheritanceFlags]"ContainerInherit, ObjectInherit"
foreach ($sid in @(
[System.Security.Principal.WindowsIdentity]::GetCurrent().User,
(New-Object System.Security.Principal.SecurityIdentifier "S-1-5-18"),
(New-Object System.Security.Principal.SecurityIdentifier "S-1-5-32-544"))) {
$rootAcl.AddAccessRule((New-Object System.Security.AccessControl.FileSystemAccessRule(
$sid, [System.Security.AccessControl.FileSystemRights]::FullControl, $inherit,
[System.Security.AccessControl.PropagationFlags]::None,
[System.Security.AccessControl.AccessControlType]::Allow)))
}
$rootInfo = New-Object System.IO.DirectoryInfo $StudioHome
if ($rootInfo.PSObject.Methods['SetAccessControl']) {
$rootInfo.SetAccessControl($rootAcl)
} else {
[System.IO.FileSystemAclExtensions]::SetAccessControl($rootInfo, $rootAcl)
}
} catch {
Write-StudioLine "[WARN] Could not restrict access to ${StudioHome}: $($_.Exception.Message)" -ForegroundColor Yellow
}
}
$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"
# Read before anything below creates them; see Write-StudioFinalPathDegraded. Every root
# the process scan protects counts, so a legacy-layout install is not taken for a fresh one.
$script:StudioInstallIsFresh = -not (
(Test-Path -LiteralPath $VenvDir) -or
(Test-Path -LiteralPath (Join-Path $StudioHome "bin\unsloth.exe")) -or
(Test-Path -LiteralPath (Join-Path $StudioHome ".venv")) -or
($env:USERPROFILE -and (Test-Path -LiteralPath (Join-Path $env:USERPROFILE "unsloth_studio"))))
# Records which uv cache this install used so an update reuses it. Absolutized against
# $PWD.Path (not the .NET process dir), as _absolutize_uv_cache_dir in install.sh.
function Resolve-StudioUvCachePath {
param([Parameter(Mandatory = $true)][AllowEmptyString()][string]$Cache)
if ([string]::IsNullOrEmpty($Cache)) { return $Cache }
if (-not [System.IO.Path]::IsPathRooted($Cache)) {
try {
$base = if (-not [string]::IsNullOrWhiteSpace($env:UV_WORKING_DIR)) {
if ([System.IO.Path]::IsPathRooted($env:UV_WORKING_DIR)) {
$env:UV_WORKING_DIR
} else { Join-Path $PWD.Path $env:UV_WORKING_DIR }
} else { $PWD.Path }
$Cache = [System.IO.Path]::GetFullPath((Join-Path $base $Cache))
} catch { return $Cache }
}
# Trim trailing separators (never past the root), as in _absolutize_uv_cache_dir.
try {
$root = [System.IO.Path]::GetPathRoot($Cache)
while ($Cache.Length -gt $root.Length -and
($Cache.EndsWith("\") -or $Cache.EndsWith("/"))) {
$Cache = $Cache.Substring(0, $Cache.Length - 1)
}
} catch { }
return $Cache
}
# Claim the root before anything lands in it, so the uninstaller need not guess. Never
# fatal. A trusted sentinel must be a regular file, never a link.
function Test-StudioPlainFile {
param([string]$Path, [string]$Container)
try {
# Check the container too: a linked parent holding a genuine marker proves nothing.
if (-not [string]::IsNullOrWhiteSpace($Container)) {
$dir = Get-Item -LiteralPath $Container -Force -ErrorAction SilentlyContinue
if ($dir -and (($dir.Attributes -band [System.IO.FileAttributes]::ReparsePoint) -ne 0)) { return $false }
}
$item = Get-Item -LiteralPath $Path -Force -ErrorAction Stop
return (-not $item.PSIsContainer -and
(($item.Attributes -band [System.IO.FileAttributes]::ReparsePoint) -eq 0))
} catch { return $false }
}
# Only a root this run may take over (empty, or with an unambiguous marker); this
# authorizes a delete. Emptiness test inline (the helper is defined later); unreadable
# counts as occupied. Defined above its earliest reader.
$script:StudioInstallLockFileName = ".unsloth-install.lock"
function Write-StudioRootOwnerMarker {
param([Parameter(Mandatory = $true)][string]$Root)
try {
$marker = Join-Path $Root ".unsloth-studio-owned"
# Already ours and the right shape: leave it. This runs twice per install, and a run
# killed between the delete and the write would lose the only proof this root is ours.
if (Test-StudioPlainFile -Path $marker) { return }
if (Test-Path -LiteralPath $Root) {
$occupied = $true
try {
# The lock's own file is not occupancy: it is created in this root before
# anything else, so counting it makes a fresh root look like somebody else's.
$occupied = @(Get-ChildItem -LiteralPath $Root -Force -ErrorAction Stop |
Where-Object { $_.Name -ne $script:StudioInstallLockFileName } |
Select-Object -First 1).Count -gt 0
} catch { $occupied = $true }
$claimable = (
$StudioRedirectMode -ne 'env' -or
(Test-StudioPlainFile -Path (Join-Path $Root "unsloth_studio\.unsloth-studio-owned") `
-Container (Join-Path $Root "unsloth_studio")) -or
(Test-StudioPlainFile -Path (Join-Path $Root "share\studio.conf") `
-Container (Join-Path $Root "share")) -or
-not $occupied
)
if (-not $claimable) { return }
} else {
# .NET API: New-Item -Path treats brackets as wildcards.
[System.IO.Directory]::CreateDirectory($Root) | Out-Null
}
# Delete first, then confirm with Get-Item -Force: WriteAllText follows a file link and
# Test-Path follows a dangling one.
Remove-Item -LiteralPath $marker -Force -ErrorAction SilentlyContinue
# CreateNew closes the check-then-write race. A dangling link is still followed (it can
# only create an empty file).
$markerStream = $null
try {
$markerStream = [System.IO.File]::Open($marker,
[System.IO.FileMode]::CreateNew,
[System.IO.FileAccess]::Write,
[System.IO.FileShare]::None)
} catch {
# No marker is fine; the venv writes its own later.
return
} finally {
if ($markerStream) { try { $markerStream.Dispose() } catch {} }
}
} catch { }
}
function Write-StudioUvCacheMarker {
param(
[Parameter(Mandatory = $true)][string]$StudioRoot,
[Parameter(Mandatory = $true)][string]$Cache
)
$markerDir = Join-Path $StudioRoot "cache"
$markerFile = Join-Path $markerDir "uv-cache-dir"
# Absolute: the update resolves this against ITS working directory.
$Cache = Resolve-StudioUvCachePath -Cache $Cache
# Remembered so a rollback can put it back.
if (-not $script:StudioUvMarkerSaved) {
# Under "Stop", Test-Path inside an ACL-denied directory throws.
$existing = Test-Path -LiteralPath $markerFile -ErrorAction SilentlyContinue
if ($existing) {
# UTF8: 5.1 decodes BOM-less files with the ANSI code page.
$previous = Get-Content -LiteralPath $markerFile -Raw -Encoding UTF8 `
-ErrorAction SilentlyContinue
# One we cannot read is one we cannot put back, so leave it alone.
if ($null -eq $previous) { return }
$script:StudioUvMarkerPrevious = $previous
$script:StudioUvMarkerExisted = $true
} else {
$script:StudioUvMarkerPrevious = $null
$script:StudioUvMarkerExisted = $false
}
$script:StudioUvMarkerSaved = $true
}
# CreateDirectory, not New-Item -Path: -Path treats [] in the root as a wildcard.
if (-not (Test-Path -LiteralPath $markerDir -PathType Container -ErrorAction SilentlyContinue)) {
try { [System.IO.Directory]::CreateDirectory($markerDir) | Out-Null } catch { }
}
# Removed first: Set-Content follows a symlink and would truncate its target.
Remove-Item -LiteralPath $markerFile -Force -ErrorAction SilentlyContinue
# And only once gone, since that removal fails non-terminatingly. Get-Item -Force
# reports the link itself; Test-Path would follow it.
if ($null -ne (Get-Item -LiteralPath $markerFile -Force -ErrorAction SilentlyContinue)) {
return
}
Set-Content -LiteralPath $markerFile -Value $Cache `
-Encoding utf8 -ErrorAction SilentlyContinue
}
function Restore-StudioUvCacheMarker {
# AllowEmptyString and the blank guard: a throw here would be reported as a
# failed environment restore.
param([Parameter(Mandatory = $true)][AllowEmptyString()][string]$StudioRoot)
if ($script:StudioInstallCommitted) { return }
if (-not $script:StudioUvMarkerSaved) { return }
if ([string]::IsNullOrWhiteSpace($StudioRoot)) { return }
$markerFile = Join-Path (Join-Path $StudioRoot "cache") "uv-cache-dir"
Remove-Item -LiteralPath $markerFile -Force -ErrorAction SilentlyContinue
$stillThere = $null -ne (Get-Item -LiteralPath $markerFile -Force -ErrorAction SilentlyContinue)
if (-not $stillThere -and $script:StudioUvMarkerExisted -and $null -ne $script:StudioUvMarkerPrevious) {
Set-Content -LiteralPath $markerFile -Value ([string]$script:StudioUvMarkerPrevious).TrimEnd("`r", "`n") `
-Encoding utf8 -ErrorAction SilentlyContinue
}
$script:StudioUvMarkerSaved = $false
}
# uv --no-cache neither reads nor writes a cache, so nothing belongs in the marker.
# Lowercased, not trimmed; clap's boolish set. Mirrors _uv_no_cache_requested.
function Test-StudioUvNoCache {
return (([string]$env:UV_NO_CACHE).ToLowerInvariant() -in @("1", "y", "yes", "t", "true", "on"))
}
# True for a name uv itself creates: <kind>-v<N>, whole suffix numeric. `archive-v0.backup`
# is not uv's. Mirrors _uv_is_bucket_name, suffix from the LAST `-v` included.
function Test-StudioUvBucketName {
param(
[Parameter(Mandatory = $true)][AllowEmptyString()][string]$Name,
# Off by default: warmth counting bytes under `Archive-V0` that uv looks for at
# `archive-v0` picks a cache that cannot serve them. Only the write probe folds.
[switch]$Fold
)
# Whole name, since the `-v` marker varies with the kind. Invariant: a Turkish
# locale dots the I in `flat-Index-v4`.
$lower = if ($Fold) { $Name.ToLowerInvariant() } else { $Name }
$at = $lower.LastIndexOf("-v")
if ($at -lt 0) { return $false }
$suffix = $lower.Substring($at + 2)
if ([string]::IsNullOrEmpty($suffix)) { return $false }
# \A and \z, not ^ and $: in .NET `$` also matches before a final newline, so
# `archive-v1<LF>` passed here while the sh helper rejected it.
if (-not ($suffix -match '\A[0-9]+\z')) { return $false }
# Every CacheBucket in uv 0.13.5 ($UvPinnedVersion) plus built-wheels; keep in step
# with install.sh on a pin bump.
return ($lower.Substring(0, $at) -in @(
"archive", "binaries", "builds", "built-wheels", "environments", "files", "flat-index",
"git", "interpreter", "osv", "python", "sdists", "simple", "wheels"))
}
# Readable is not usable: create and delete for real in the root and in EVERY
# <kind>-v<N> bucket. Mirrors the probe loop in _configure_uv_cache.
function Test-StudioUvCacheWritable {
param([Parameter(Mandatory = $true)][string]$Cache)
$probeDirs = [System.Collections.Generic.List[string]]::new()
$probeDirs.Add($Cache)
# Measured like _uv_cache_is_writable does: NTFS folds unless fsutil
# setCaseSensitiveInfo says otherwise, which is how a WSL-created tree behaves.
$fold = $false
$probeRoot = Join-Path $Cache (".unsloth-case-probe." +
[guid]::NewGuid().ToString("N").Substring(0, 8) + "-A")
try {
[System.IO.Directory]::CreateDirectory($probeRoot) | Out-Null
$fold = [System.IO.Directory]::Exists($probeRoot.Substring(0, $probeRoot.Length - 1) + "a")
} catch { }
Remove-Item -LiteralPath $probeRoot -Force -Recurse -ErrorAction SilentlyContinue
try {
foreach ($entry in [System.IO.Directory]::GetFileSystemEntries($Cache)) {
$name = [System.IO.Path]::GetFileName($entry)
# Only where a BUCKET should be. Anything else up here is not uv's to write,
# and a cache-dir on a mount point has a root-owned lost+found that must not
# condemn it.
if (-not (Test-StudioUvBucketName -Name $name -Fold:$fold)) { continue }
# A file, or a link dangling or not, is an existing path to uv's own create,
# which answers "already exists", so uv refuses it.
if (-not [System.IO.Directory]::Exists($entry)) { return $false }
$probeDirs.Add($entry)
}
} catch { return $false }
foreach ($dir in $probeDirs) {
# A generated name, not a fixed one: a predictable path can be pre-created as a link
# for the write to follow.
$probe = Join-Path $dir (".unsloth-write-probe." + [guid]::NewGuid().ToString("N").Substring(0, 8))
try { [System.IO.File]::WriteAllText($probe, "") } catch { return $false }
try { Remove-Item -LiteralPath $probe -Force -ErrorAction Stop } catch { return $false }
}
return $true
}
# Warm means package BYTES in a bucket kind uv fills (wheels-* is metadata only on
# uv 0.10). [ref] $Blocked: unreadable is not empty.
function Test-StudioUvCachePopulated {
param(
[Parameter(Mandatory = $true)][string]$Cache,
[ref]$Blocked
)
try {
$buckets = Get-ChildItem -LiteralPath $Cache -Directory -Force -ErrorAction Stop |
Where-Object {
# No -Fold: warmth is exact-case, so every name here is lowercase.
(Test-StudioUvBucketName -Name $_.Name) -and
($_.Name.Substring(0, $_.Name.LastIndexOf("-v")) -in
@("archive", "builds", "built-wheels", "wheels", "sdists"))
}
} catch {
$Blocked.Value = $true
return $false
}
foreach ($bucket in $buckets) {
# SilentlyContinue, not Stop: one denied subdirectory must not make a populated cache
# read as empty. `find` also skips and continues.
$scanErrors = $null
$entry = Get-ChildItem -LiteralPath $bucket.FullName -File -Recurse -Force `
-ErrorAction SilentlyContinue -ErrorVariable scanErrors |
Where-Object {
$_.Name -notin @("CACHEDIR.TAG", ".git", ".gitignore") -and
-not $_.Name.StartsWith(".unsloth-write-probe.") -and
$_.Extension -notin @(".lock", ".msgpack", ".http", ".rev")
} |
Select-Object -First 1
if ($null -ne $entry) { return $true }
if ($scanErrors -and $scanErrors.Count -gt 0) { $Blocked.Value = $true }
}
return $false
}
# Can we create AND fill this directory? A real create, since an existing unwritable one
# satisfies CreateDirectory. For the Studio cache, which may not exist yet, where the bucket
# probe above has nothing to walk.
function Test-StudioUvCacheRootWritable {
param([Parameter(Mandatory = $true)][string]$Cache)
try {
[System.IO.Directory]::CreateDirectory($Cache) | Out-Null
$probe = Join-Path $Cache (".unsloth-write-probe." + [guid]::NewGuid().ToString("N").Substring(0, 8))
[System.IO.File]::WriteAllText($probe, "")
} catch { return $false }
# A root can grant create and deny delete; retry, then ask whether the probe is GONE.
for ($attempt = 0; $attempt -lt 3; $attempt++) {
if ($attempt -gt 0) { Start-Sleep -Seconds 1 }
Remove-Item -LiteralPath $probe -Force -ErrorAction SilentlyContinue
if (-not [System.IO.File]::Exists($probe)) { return $true }
}
return $false
}
# Creatable AND fillable: the root helper answers for a cache that may not exist yet, the
# bucket probe for one uv has already made buckets in. The fallback and the launch repoint
# both need both, or they hand back a cache the candidate probe just rejected.
function Test-StudioUvCacheUsable {
param([Parameter(Mandatory = $true)][string]$Cache)
if (-not (Test-StudioUvCacheRootWritable -Cache $Cache)) { return $false }
return (Test-StudioUvCacheWritable -Cache $Cache)
}
# The cache THIS install last recorded, absolute, or "" when there is none. Read before the
# selector writes its own. BOM and all: PowerShell 5.1 writes -Encoding utf8 WITH a BOM and
# the update writes the same file BOM-less.
function Read-StudioUvCacheMarker {
param([Parameter(Mandatory = $true)][string]$StudioRoot)
$markerFile = Join-Path (Join-Path $StudioRoot "cache") "uv-cache-dir"
try {
$recorded = Get-Content -LiteralPath $markerFile -Raw -Encoding UTF8 -ErrorAction Stop
} catch { return "" }
if ($null -eq $recorded) { return "" }
$recorded = ([string]$recorded).Trim([char]0xFEFF).Trim()
if ([string]::IsNullOrWhiteSpace($recorded)) { return "" }
return (Resolve-StudioUvCachePath -Cache $recorded)
}
function Set-StudioUvCacheEnvironment {
param(
[Parameter(Mandatory = $true)][string]$StudioRoot,
[bool]$Isolated = $false,
[string]$UvExecutable = ""
)
$studioCache = Join-Path (Join-Path $StudioRoot "cache") "uv"
if (-not [string]::IsNullOrWhiteSpace($env:UV_CACHE_DIR)) {
$script:StudioUvCacheMode = "custom"
# Absolute before anything uses it, so every phase of one install and the
# marker name the same directory (see _absolutize_uv_cache_dir in install.sh).
$env:UV_CACHE_DIR = Resolve-StudioUvCachePath -Cache $env:UV_CACHE_DIR
if (Test-StudioUvNoCache) {
step "uv cache" "preserving custom UV_CACHE_DIR ($env:UV_CACHE_DIR); uv caching is off (UV_NO_CACHE), so nothing is recorded"
return
}
# Recorded like any other choice; a caller still outranks the marker.
Write-StudioUvCacheMarker -StudioRoot $StudioRoot -Cache $env:UV_CACHE_DIR
step "uv cache" "preserving custom UV_CACHE_DIR ($env:UV_CACHE_DIR)"
return
}
if ($Isolated) {
Set-Item -LiteralPath Env:UV_CACHE_DIR -Value $studioCache
$script:StudioUvCacheMode = "isolated"
if (-not (Test-StudioUvNoCache)) {
Write-StudioUvCacheMarker -StudioRoot $StudioRoot -Cache $studioCache
}
step "uv cache" "forced Unsloth Studio cache isolation ($studioCache); already-cached packages may download again" "Yellow"
return
}
# Nothing to select either: probing would touch a cache the caller told uv to leave alone.
if (Test-StudioUvNoCache) {
Set-Item -LiteralPath Env:UV_CACHE_DIR -Value $studioCache
$script:StudioUvCacheMode = "studio"
step "uv cache" "uv caching is off (UV_NO_CACHE); nothing to select or record"
return
}
$defaultCache = $null
$scanBlocked = $false
$blockedCache = ""
$warnCache = ""
$chosenCache = ""
try {
# Ask uv so uv.toml / UV_CONFIG_FILE / the default count; remove a
# blank inherited value so it cannot override them.
Remove-Item -LiteralPath Env:UV_CACHE_DIR -ErrorAction SilentlyContinue
if ($UvExecutable) {
$resolvedCache = @(& $UvExecutable cache dir 2>$null)
$uvCacheExit = $LASTEXITCODE
# Last nonblank line, not [0]: a notice would become the path.
$resolvedLine = $resolvedCache |
Where-Object { -not [string]::IsNullOrWhiteSpace([string]$_) } |
Select-Object -Last 1
if ($uvCacheExit -eq 0 -and $null -ne $resolvedLine) {
$defaultCache = ([string]$resolvedLine).Trim()
}
}
if (-not $defaultCache -and -not [string]::IsNullOrWhiteSpace($env:LOCALAPPDATA)) {
$defaultCache = Join-Path (Join-Path $env:LOCALAPPDATA "uv") "cache"
}
# A relative cache-dir comes back verbatim and uv resolves it against its working
# directory, so scanning it as written inspects a same-named directory beside us.
if ($defaultCache) { $defaultCache = Resolve-StudioUvCachePath -Cache $defaultCache }
# The recorded cache outranks uv's default while still warm. An unmarked (pre-marker)
# Studio cache goes LAST. Same order as install.sh and unsloth_cli/commands/studio.py.
$recorded = Read-StudioUvCacheMarker -StudioRoot $StudioRoot
# A marker naming a directory that is gone is a stale pointer, not a decision, so
# it lands in the same place.
$unmarkedStudio = if ($recorded -and (Test-Path -LiteralPath $recorded -PathType Container -ErrorAction SilentlyContinue)) {
$null
} else { $studioCache }
foreach ($candidate in @($recorded, $defaultCache, $unmarkedStudio)) {
if ([string]::IsNullOrWhiteSpace([string]$candidate)) { continue }
if ($chosenCache) { continue }
# SilentlyContinue: under "Stop" Test-Path in an ACL-denied dir throws and kills the loop.
if (-not (Test-Path -LiteralPath $candidate -PathType Container -ErrorAction SilentlyContinue)) { continue }
# Per candidate, so the message names the cache actually refused.
$candidateBlocked = $false
$populated = Test-StudioUvCachePopulated -Cache $candidate -Blocked ([ref]$candidateBlocked)
if ($candidateBlocked) {
$scanBlocked = $true
if (-not $blockedCache) { $blockedCache = $candidate }
}
if (-not $populated) { continue }
if (Test-StudioUvCacheWritable -Cache $candidate) {
$chosenCache = $candidate
} elseif (-not $warnCache) {
$warnCache = $candidate
}
}
} catch {
# An uninspectable cache is not an install error; isolation is safe.
$chosenCache = ""
$scanBlocked = $true
}
if ($chosenCache) {
$selectedCache = $chosenCache
# studio, not shared, when the choice IS the Studio cache: the launch repoint below
# only has to move a cache that is not already ours.
$script:StudioUvCacheMode = if ($chosenCache -eq $studioCache) { "studio" } else { "shared" }
} else {
$selectedCache = $studioCache
$script:StudioUvCacheMode = "studio"
# The fallback must be usable (root AND buckets); otherwise prefer the populated cache
# that only failed the probe, then uv's default.
if (-not (Test-StudioUvCacheUsable -Cache $studioCache)) {
# A warm cache that merely failed the probe beats a cold one.
if ($warnCache) {
$selectedCache = $warnCache
if ($warnCache -ne $studioCache) {
$script:StudioUvCacheMode = "shared"
}
} elseif ($defaultCache -and $defaultCache -ne $studioCache -and
(Test-StudioUvCacheUsable -Cache $defaultCache)) {
$selectedCache = $defaultCache
$script:StudioUvCacheMode = "shared"
}
}
}
Set-Item -LiteralPath Env:UV_CACHE_DIR -Value $selectedCache
Write-StudioUvCacheMarker -StudioRoot $StudioRoot -Cache $selectedCache
switch ($script:StudioUvCacheMode) {
"shared" {
step "uv cache" "reusing existing shared cache ($selectedCache) to avoid duplicate Torch/CUDA downloads; use --isolated-uv-cache to isolate"
}
"studio" {
if ($chosenCache) {
step "uv cache" "reusing this install's Unsloth Studio cache ($selectedCache)"
# Never about the directory we are falling back TO: the Studio cache is itself
# a candidate now, so it can be the one refused, and naming it claims a fallback
# that did not happen.
} elseif ($scanBlocked -and -not [string]::IsNullOrWhiteSpace([string]$blockedCache) -and $blockedCache -ne $selectedCache) {
step "uv cache" "using new Unsloth Studio-owned cache ($selectedCache); part of $blockedCache could not be read, so cached packages may download again" "Yellow"
} elseif ($scanBlocked -and [string]::IsNullOrWhiteSpace([string]$blockedCache)) {
step "uv cache" "using new Unsloth Studio-owned cache ($selectedCache); the existing uv cache could not be inspected, so cached packages may download again" "Yellow"
# Warm and still here means the write probe refused it.
} elseif ($warnCache -and $warnCache -ne $selectedCache) {
step "uv cache" "using new Unsloth Studio-owned cache ($selectedCache); $warnCache is populated but not writable, so cached packages may download again" "Yellow"
} else {
step "uv cache" "using new Unsloth Studio-owned cache ($selectedCache)"
}
}
}
}
function Set-StudioUvCacheForLaunch {
param([Parameter(Mandatory = $true)][string]$StudioRoot)
if ($script:StudioUvCacheMode -ne "shared") { return }
# Only repoint the launch to a cache the backend can fill (root AND buckets). Mirrors
# _prepare_studio_uv_cache_for_launch.
$launchCache = Join-Path (Join-Path $StudioRoot "cache") "uv"
if (-not (Test-StudioUvCacheUsable -Cache $launchCache)) { return }
Set-Item -LiteralPath Env:UV_CACHE_DIR -Value $launchCache
}
function Restore-StudioUvCacheEnvironment {
param(
[bool]$WasPresent,
[AllowNull()][string]$PreviousValue
)
# The provider, not the .NET API: that maps empty to deletion on Windows.
if ($WasPresent) {
Set-Item -LiteralPath Env:UV_CACHE_DIR -Value $PreviousValue
} else {
Remove-Item -LiteralPath Env:UV_CACHE_DIR -ErrorAction SilentlyContinue
}
}
$Rule = [string]::new([char]0x2500, 52)
$Sloth = [char]::ConvertFromUtf32(0x1F9A5)
function Enable-StudioVirtualTerminal {
if ($env:NO_COLOR) { return $false }
# A redirected stdout is not a console: the desktop app's pipe has no virtual terminal.
if ($script:StudioStdoutRedirected) { return $false }
# The console host already enables VT at startup and verifies it, so this property cannot
# read True while VT is off (measured in PR #10984; see
# tests/studio/test_installer_av_shapes.py (AV_SHAPES_RECORD)). [bool] maps a no-UI host's
# $null to $false; try/catch covers a caller's Set-StrictMode.
try { return [bool]$Host.UI.SupportsVirtualTerminal } 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-StudioLine ""
if ($script:StudioVtOk -and -not $env:NO_COLOR) {
Write-StudioLine (" " + (Get-StudioAnsi Title) + $Sloth + " Unsloth Studio Installer (Windows)" + (Get-StudioAnsi Reset))
Write-StudioLine (" {0}{1}{2}" -f (Get-StudioAnsi Dim), $Rule, (Get-StudioAnsi Reset))
} else {
Write-StudioLine (" {0} Unsloth Studio Installer (Windows)" -f $Sloth) -ForegroundColor DarkGreen
Write-StudioLine " $Rule" -ForegroundColor DarkGray
}
Write-StudioLine ""
# Here so its warning lands under the banner, before the temp dir's first user.
Initialize-StudioTempEnvironment
# ── Helper: refresh PATH from registry (deduplicating entries) ──
# Merge order: venv Scripts (if active) > Machine > User > current $env:Path.
# Active conda prefixes, deepest first: CONDA_PREFIX, CONDA_PREFIX_1.., and CONDA_EXE's root.
function Get-ActiveCondaPrefixes {
$prefixes = New-Object System.Collections.Generic.List[string]
if (-not [string]::IsNullOrWhiteSpace($env:CONDA_PREFIX)) { $prefixes.Add($env:CONDA_PREFIX) }
# Enumerated from CONDA_SHLVL with a ceiling, not a fixed list.
$levels = 0
if (-not [string]::IsNullOrWhiteSpace($env:CONDA_SHLVL)) {
[void][int]::TryParse($env:CONDA_SHLVL, [ref]$levels)
}
if ($levels -lt 1) { $levels = 1 }
if ($levels -gt 64) { $levels = 64 }
for ($level = 1; $level -le $levels; $level++) {
$value = [Environment]::GetEnvironmentVariable("CONDA_PREFIX_$level")
if (-not [string]::IsNullOrWhiteSpace($value)) { $prefixes.Add($value) }
}
if (-not [string]::IsNullOrWhiteSpace($env:_CONDA_ROOT)) { $prefixes.Add($env:_CONDA_ROOT) }
if (-not [string]::IsNullOrWhiteSpace($env:CONDA_EXE)) {
# Regex, not Split-Path, which is provider-aware on non-Windows test hosts.
$scripts = $env:CONDA_EXE -replace '[\\/][^\\/]*$', ''
$root = $scripts -replace '[\\/][^\\/]*$', ''
if ($root -and $root -ne $env:CONDA_EXE) { $prefixes.Add($root) }
}
return $prefixes
}
# Is $Path inside one of $Prefixes? On a directory boundary, so "C:\conda-backup" is not
# dragged to the front along with "C:\conda".
function Test-PathUnderCondaPrefix {
param([string]$Path, $Prefixes)
if ([string]::IsNullOrWhiteSpace($Path) -or -not $Prefixes) { return $false }
$candidate = [Environment]::ExpandEnvironmentVariables($Path).Trim().Trim('"').TrimEnd('\')
if (-not $candidate) { return $false }
foreach ($prefix in $Prefixes) {
$normalized = [Environment]::ExpandEnvironmentVariables($prefix).Trim().Trim('"').TrimEnd('\')
if (-not $normalized) { continue }
if ($candidate -ieq $normalized) { return $true }
if ($candidate.StartsWith($normalized + '\', [System.StringComparison]::OrdinalIgnoreCase)) {
return $true
}
}
return $false
}
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 }
# An activated conda env lives only in the process PATH, so keep it in front.
$condaFront = @()
if (Test-ActiveCondaEnvironment) {
$prefixes = Get-ActiveCondaPrefixes
if ($prefixes) {
foreach ($entry in ($env:Path -split ";")) {
if (Test-PathUnderCondaPrefix -Path $entry -Prefixes $prefixes) {
$condaFront += $entry
}
}
} else {
# Only CONDA_DEFAULT_ENV, no directory: keep the caller's PATH whole and in front.
$condaFront = @($env:Path)
}
}
$sources = @()
# Still first: this is the environment the installer is driving, and it is ours.
if ($venvScripts) { $sources += $venvScripts }
$sources += $condaFront
$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: is a conda environment ACTIVE in this session? ──
# CONDA_PREFIX or CONDA_DEFAULT_ENV, not merely conda being installed.
function Test-ActiveCondaEnvironment {
foreach ($condaVar in @($env:CONDA_PREFIX, $env:CONDA_DEFAULT_ENV)) {
if (-not [string]::IsNullOrWhiteSpace($condaVar)) { return $true }
}
return $false
}
# Persistent writes must append under an active conda env: a prepend outlives the
# activation and corrupts Anaconda base (#5871).
function Resolve-UserPathPosition {
param(
[ValidateSet('Append','Prepend')]
[string]$Position = 'Append'
)
if ($Position -eq 'Prepend' -and (Test-ActiveCondaEnvironment)) { return 'Append' }
return $Position
}
# ── Helper: safely add a directory to the persistent User PATH ──
# Direct registry access preserves REG_EXPAND_SZ (dotnet/runtime#1442).
function Add-ToUserPath {
param(
[Parameter(Mandatory = $true)][string]$Directory,
[ValidateSet('Append','Prepend')]
[string]$Position = 'Append'
)
# Every persistent PATH write goes through Resolve-UserPathPosition, so an active
# conda environment cannot be demoted by any call site. Announced once per run: the
# installer makes several of these writes and one note is information, five is noise.
$resolvedPosition = Resolve-UserPathPosition -Position $Position
# A downgraded request must MOVE an existing front entry, or the #5871 demotion stays armed.
$positionDowngraded = ($resolvedPosition -ne $Position)
if ($positionDowngraded) {
if (-not $script:CondaPathPositionNoted) {
$script:CondaPathPositionNoted = $true
substep "conda environment active ($env:CONDA_PREFIX) -- appending to PATH instead of prepending, so conda keeps priority." "Yellow"
}
$Position = $resolvedPosition
}
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
# Not $positionDowngraded: that request has to reposition an existing front entry.
# Already at the back is still a no-op, caught by the $newPath -ceq $rawPath check.
if ($alreadyPresent -and $Position -eq 'Append' -and -not $positionDowngraded) {
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-StudioLine "[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-StudioLine "[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-StudioLine (" {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) }
$fc = switch ($Color) {
'Green' { 'DarkGreen' }
'Yellow' { 'Yellow' }
'Red' { 'Red' }
'DarkGray' { 'DarkGray' }
default { 'DarkGreen' }
}
# One composed record so a redirected consumer cannot split label from value.
Write-StudioLine (" {0}{1}" -f $padded, $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-StudioLine (" {0}{1}{2}{3}" -f $msgCol, $pad, $Message, (Get-StudioAnsi Reset))
} else {
$fc = switch ($Color) {
'Yellow' { 'Yellow' }
'Red' { 'Red' }
default { 'DarkGray' }
}
Write-StudioLine (" {0,-15}{1}" -f "", $Message) -ForegroundColor $fc
}
}
# Managed llama.cpp access check. This script cannot dot-source setup.ps1, so
# it holds byte-identical copies; test_denied_llama_cpp_preflight.py enforces that.
# ── BEGIN SHARED WITH studio/setup.ps1 ──
# Recognize ERROR_ACCESS_DENIED through PowerShell's wrapper exceptions.
function Test-AccessDeniedError {
param($ErrorRecord)
$ex = if ($ErrorRecord -is [System.Management.Automation.ErrorRecord]) { $ErrorRecord.Exception } else { $ErrorRecord }
while ($ex) {
if ($ex -is [System.UnauthorizedAccessException]) { return $true }
# IOException uses HRESULT; Win32Exception uses NativeErrorCode.
if ($ex.HResult -eq -2147024891) { return $true }
if ($ex -is [System.ComponentModel.Win32Exception] -and $ex.NativeErrorCode -eq 5) { return $true }
$ex = $ex.InnerException
}
if ($ErrorRecord -is [System.Management.Automation.ErrorRecord]) {
return ($ErrorRecord.CategoryInfo.Category -eq [System.Management.Automation.ErrorCategory]::PermissionDenied)
}
return $false
}
# Keep denied ACLs distinct from absent paths instead of letting Test-Path throw.
function Get-PathState {
param(
[Parameter(Mandatory = $true)][AllowEmptyString()][string]$Path,
[ValidateSet("Any", "Leaf", "Container")][string]$PathType = "Any"
)
if ([string]::IsNullOrWhiteSpace($Path)) { return "Absent" }
try {
if (Test-Path -LiteralPath $Path -PathType $PathType -ErrorAction Stop) { return "Present" }
return "Absent"
} catch {
if (Test-AccessDeniedError $_) { return "Denied" }
# Malformed path, offline drive, dangling link: nothing usable there.
return "Absent"
}
}
# Dir, marker and listing: no single probe separates readable/absent/denied.
function Get-LlamaCppInstallReadState {
param([Parameter(Mandatory = $true)][AllowEmptyString()][string]$Path)
$dirState = Get-PathState -Path $Path -PathType Container
if ($dirState -eq "Denied") { return "Denied" }
if ($dirState -ne "Present") { return "Absent" }
switch (Get-PathState -Path (Join-Path $Path "UNSLOTH_PREBUILT_INFO.json") -PathType Leaf) {
"Denied" { return "Denied" }
}
try { $null = @(Get-ChildItem -LiteralPath $Path -Force -ErrorAction Stop | Select-Object -First 1) }
catch {
if (Test-AccessDeniedError $_) { return "Denied" }
# Nonfatal here: nothing has been installed yet.
}
return "Readable"
}
# Describe a denied link target without risking another reporting failure.
function Get-PathDenialDetail {
param([Parameter(Mandatory = $true)][AllowNull()][AllowEmptyString()][string]$Path)
if ([string]::IsNullOrWhiteSpace($Path)) { return "" }
# Read attributes through the filesystem API rather than Get-Item. Getting
# attributes needs only FILE_READ_ATTRIBUTES, which survives the ACLs that
# deny reading the directory itself, so Get-Item returns nothing in exactly
# the case this detail is meant to describe and the caller silently loses
# every hint below.
$attrs = $null
try { $attrs = [System.IO.File]::GetAttributes($Path) } catch { $attrs = $null }
if ($null -eq $attrs) { return "" }
# Ordered by how much each one changes the fix. takeown/icacls cannot help
# with any of the first three, so name them before falling back to ACLs.
# On a directory this attribute only means new descendants are encrypted
# by default, so listing it never needs the key and a denial there is an
# ACL. Only a file's own streams are unreadable without the certificate.
$isDirectory = ([int]$attrs -band [int][System.IO.FileAttributes]::Directory) -ne 0
if (-not $isDirectory -and ($attrs -band [System.IO.FileAttributes]::Encrypted)) {
return " (it is EFS-encrypted, so it stays unreadable even elevated unless the encrypting account or its recovery certificate is available)"
}
# Offline plus either recall attribute is a cloud placeholder, typically
# OneDrive Files On-Demand that cannot hydrate.
# RECALL_ON_OPEN (0x40000) and RECALL_ON_DATA_ACCESS (0x400000) are absent
# from the FileAttributes enum on Windows PowerShell 5.1, so test the bits.
$offline = ([int]$attrs -band [int][System.IO.FileAttributes]::Offline) -ne 0
$recall = ([int]$attrs -band (0x00040000 -bor 0x00400000)) -ne 0
if ($offline -or $recall) {
return " (it is a cloud placeholder, e.g. OneDrive Files On-Demand, that cannot be hydrated right now)"
}
if ($attrs -band [System.IO.FileAttributes]::ReparsePoint) {
$target = $null
try {
$item = Get-Item -LiteralPath $Path -Force -ErrorAction SilentlyContinue
if ($item -is [System.IO.FileSystemInfo]) { $target = $item.Target }
} catch { $target = $null }
# PS 5.1 exposes .Target as a collection; PS 7 as a string.
if ($target) { return " (it is a link to $(@($target) -join ', '))" }
return " (it is a link)"
}
return ""
}
# Which security software could be denying this path, as a possibility.
#
# Worth naming because takeown and icacls cannot clear a filter-driver block
# and elevation does not either, so a user whose antivirus is holding the
# folder is otherwise sent round the takeown loop for as long as they are
# willing. Nothing readable from here attributes the specific denial though,
# so this names the candidate and the log that settles it and never
# contradicts the ACL advice it follows.
#
# Defender's Controlled folder access modes are 0 Disabled, 1 Enabled,
# 2 AuditMode, 3 BlockDiskModificationOnly, 4 AuditDiskModificationOnly. Only
# 1 gates file access; 3 and 4 are direct disk-sector writes rather than
# files, so neither explains a denied folder.
#
# When Defender is not it, name whichever antivirus is registered and running
# instead: third-party suites ship the same protected-folders feature under
# their own product names, and the user cannot act on advice that does not say
# which product to open. Which suite ships what is recorded in
# tests/studio/test_installer_av_shapes.py and deliberately not repeated here,
# because this file is scanned in full before a line of it runs and a comment
# listing security products raises the score of the very file explaining it.
# Nothing below hard-codes a product: it reads what SecurityCenter2 registered.
#
# Answers "" whenever it cannot tell, so a machine with no Defender module
# and no SecurityCenter registration reads the same as one that says no.
function Get-SecuritySoftwareNote {
$mode = $null
try {
if (Get-Command Get-MpPreference -ErrorAction SilentlyContinue) {
$mode = [int](Get-MpPreference -ErrorAction Stop).EnableControlledFolderAccess
}
} catch { $mode = $null }
if ($mode -eq 1) {
return "Controlled folder access is ON here, and it gates writes to protected folders whatever your privileges are, so where it is the cause takeown and icacls will not clear it: Windows Defender Operational events 1123 and 1124 say whether it stopped this path, and Virus & threat protection > Ransomware protection > Allow an app is where to allow Unsloth"
}
# SecurityCenter2 is the registration every consumer antivirus makes, and
# it is absent on Server SKUs, so this stays best-effort. productState
# packs the running state in 0xF000: 0x1000 on, 0x2000 snoozed, 0 off.
# A product that is not running cannot be holding the folder and naming it
# sends the user to the wrong console; a state we cannot read proves
# nothing either way, so it is kept.
$others = @()
try {
$others = @(Get-CimInstance -Namespace "root/SecurityCenter2" -ClassName AntiVirusProduct -ErrorAction Stop |
Where-Object { $state = $_.productState -as [uint32]; ($null -eq $state) -or (($state -band 0xF000) -eq 0x1000) } |
ForEach-Object { [string]$_.displayName } |
Where-Object { $_ -and $_ -notmatch "Windows Defender" -and $_ -notmatch "Microsoft Defender" })
} catch { $others = @() }
if ($others.Count -gt 0) {
$names = ($others | Select-Object -Unique) -join ", "
return "$names is running here, and its ransomware or protected-folder feature can deny a path whatever your privileges are. If takeown and icacls do not clear this, look in $names for a block on this folder, and add an exclusion for it and for Unsloth"
}
if ($mode -eq 2) {
return "Controlled folder access is in audit mode, so it is logging rather than blocking and is not the cause here; Windows Defender Operational events 1123 and 1124 name whatever it did stop"
}
return ""
}
# Print guidance; returns the failure reason as its only pipeline output.
function Write-PathAccessDenied {
param(
[Parameter(Mandatory = $true)][AllowNull()][AllowEmptyString()][string]$Path,
[Parameter(Mandatory = $true)][AllowEmptyString()][string]$Label,
# Never tell users to delete a build they supplied.
[switch]$UserSupplied,
# Unreadable custom homes cannot be identified as managed caches.
[switch]$OwnershipUnverified
)
step "permissions" "$Label at $Path cannot be read: access is denied$(Get-PathDenialDetail -Path $Path)" "Red"
if ($UserSupplied) {
substep "Unsloth will not touch a directory you pointed it at, so this has to be fixed at the source" "Yellow"
substep "Restore access with these two in an elevated PowerShell, or point UNSLOTH_LOCAL_LLAMA_CPP_DIR at a readable build:" "Yellow"
} elseif ($OwnershipUnverified) {
substep "Unsloth cannot confirm this folder is its own install while it is unreadable, so it will not tell you to remove it" "Yellow"
substep "Restore access with these two in an elevated PowerShell, or move the folder aside and re-run setup:" "Yellow"
} else {
substep "This folder lives outside the app, so reinstalling Unsloth Studio, to any drive, reuses it and fails the same way" "Yellow"
substep "Simplest fix: close Unsloth, delete or rename $Path, then re-run setup (it is a managed cache and gets reinstalled)" "Yellow"
substep "If deleting is also denied, run these two in an elevated PowerShell, then re-run setup:" "Yellow"
}
substep "takeown /F `"$Path`" /R /D Y" "Yellow"
substep "icacls `"$Path`" /reset /T" "Yellow"
substep "Antivirus or Controlled folder access can deny this path too; allow or exclude it, then retry" "Yellow"
# After the generic line, since this one either confirms it or rules it
# out, and an empty answer must leave the generic advice standing.
$securitySoftware = Get-SecuritySoftwareNote
if ($securitySoftware) { substep $securitySoftware "Yellow" }
if ($UserSupplied) {
return "Access denied reading $Label at $Path. Restore access with takeown/icacls, or point UNSLOTH_LOCAL_LLAMA_CPP_DIR at a readable build, then re-run setup."
}
if ($OwnershipUnverified) {
return "Access denied reading $Label at $Path. Unsloth cannot confirm that folder is its own install while it is unreadable: restore access with takeown/icacls, or move it aside, then re-run setup."
}
return "Access denied reading the existing $Label at $Path. Delete or rename that folder (Unsloth reinstalls it) or restore access with takeown/icacls, then re-run setup. Reinstalling the app does not reset it."
}
# Canonicalize a directory for comparison; unresolvable ones are only normalized.
function Get-CanonicalDir {
param([Parameter(Mandatory = $true)][AllowEmptyString()][string]$Path)
$trimmedPath = $Path.Trim()
if ([string]::IsNullOrWhiteSpace($trimmedPath)) { return $trimmedPath }
$resolvedPath = $null
if ((Get-PathState -Path $trimmedPath -PathType Container) -eq "Present") {
try { $resolvedPath = (Resolve-Path -LiteralPath $trimmedPath).Path } catch {}
}
if (-not $resolvedPath) {
try {
$resolvedPath =
$ExecutionContext.SessionState.Path.GetUnresolvedProviderPathFromPSPath($trimmedPath)
} catch {
$resolvedPath = [System.Environment]::ExpandEnvironmentVariables($trimmedPath)
}
try { $resolvedPath = [System.IO.Path]::GetFullPath($resolvedPath) } catch {}
}
# Resolve-Path keeps a trailing separator, so trim after both branches (never a root).
try {
$root = [System.IO.Path]::GetPathRoot($resolvedPath)
if ($root -and $resolvedPath.Length -gt $root.Length) { return $resolvedPath.TrimEnd('\', '/') }
} catch {}
return $resolvedPath
}
# Compare canonical homes so path spelling does not change ownership policy.
function Test-StudioHomeIsCustom {
return ((Get-CanonicalDir -Path $StudioHome) -ne
(Get-CanonicalDir -Path (Join-Path $env:USERPROFILE ".unsloth\studio")))
}
# The master root storage_roots.unsloth_home() reads. llama.cpp, node and whisper.cpp sit
# BESIDE studio\ under it, so deriving them from $StudioHome would put them one level below
# where every runtime resolver looks.
function Get-MasterRootOverride {
if ([string]::IsNullOrWhiteSpace($env:UNSLOTH_HOME)) { return $null }
$value = $env:UNSLOTH_HOME.Trim()
if ($value -eq "~") {
$value = $env:USERPROFILE
} elseif ($value -like "~/*" -or $value -like "~\*") {
$value = (Join-Path $env:USERPROFILE $value.Substring(1).TrimStart('/', '\'))
}
return (Get-CanonicalDir -Path $value)
}
# Anchor the master root before the System32 escape changes the working directory.
$_installMasterRoot = Get-MasterRootOverride
if ($_installMasterRoot) { $env:UNSLOTH_HOME = $_installMasterRoot }
# Explicit staging root, the master root, the shared default cache, or the custom Unsloth
# home's tree.
function Get-ManagedLlamaCppDir {
param([AllowNull()][string]$StagingRoot = $null)
if ($StagingRoot) {
return (Join-Path $StagingRoot "llama.cpp")
}
$masterRoot = Get-MasterRootOverride
if ($masterRoot) {
return (Join-Path $masterRoot "llama.cpp")
}
if (-not (Test-StudioHomeIsCustom)) {
return (Join-Path $env:USERPROFILE ".unsloth\llama.cpp")
}
return (Join-Path (Get-CanonicalDir -Path $StudioHome) "llama.cpp")
}
# Failure reason when the managed tree is denied; never touches its ACLs.
function Invoke-ManagedLlamaCppPreflight {
param([AllowNull()][string]$StagingRoot = $null)
# Let the existing profile validation handle a missing USERPROFILE later.
if ([string]::IsNullOrWhiteSpace($env:USERPROFILE)) { return $null }
$dir = Get-ManagedLlamaCppDir -StagingRoot $StagingRoot
if ((Get-LlamaCppInstallReadState -Path $dir) -ne "Denied") { return $null }
Write-StudioLine ""
# A denied custom home cannot be claimed as an Unsloth-managed cache. Computed rather
# than read off $RuntimeRootIsCustom: this runs beside the line that defines it.
$homeIsCustom = (Test-StudioHomeIsCustom) -or [bool](Get-MasterRootOverride)
# Preserve user-supplied wording when either override names this tree, or
# names a build inside it: moving or deleting this folder takes that build
# with it, and the later --with-llama-cpp-dir check then aborts on a path
# we made disappear.
$suppliedDir = if ($WithLlamaCppDir) { $WithLlamaCppDir } else { $env:UNSLOTH_LOCAL_LLAMA_CPP_DIR }
# Walking the ancestors beats comparing the two canonical strings: an
# override that does not exist yet cannot be resolved, so a prefix test
# would compare a resolved path against an unresolved one and miss.
$userSupplied = $false
if (-not [string]::IsNullOrWhiteSpace($suppliedDir)) {
$canonicalDir = [string](Get-CanonicalDir -Path $dir)
$probe = [string](Get-CanonicalDir -Path $suppliedDir)
while (-not [string]::IsNullOrWhiteSpace($probe)) {
if ([string](Get-CanonicalDir -Path $probe) -eq $canonicalDir) {
$userSupplied = $true
break
}
$parent = ""
try { $parent = [string](Split-Path -Parent $probe) } catch { $parent = "" }
if ($parent -eq $probe) { break }
$probe = $parent
}
}
# Only the default branch below tells the user to delete this folder, so
# only that case may move it. A user-supplied build is not ours to touch,
# and an unreadable custom home cannot be confirmed as a managed cache.
# Renaming needs DELETE on the folder plus write on its parent, neither of
# which is read access, so this recovers denials that takeown and icacls
# do not: the folder is a managed cache that setup reinstalls anyway.
# Setup never makes this a link, so a link here is something the user
# arranged, pointing at a build we were not told about. Moving it would
# silently change which tree they run without touching the one they were
# protecting, so it is left alone and named in the guidance instead.
$isLink = $false
try {
$linkAttrs = [System.IO.File]::GetAttributes($dir)
$isLink = ([int]$linkAttrs -band [int][System.IO.FileAttributes]::ReparsePoint) -ne 0
} catch {
# Unreadable attributes prove nothing, and "proves nothing" must not
# mean "movable": fall through to the guidance rather than guess.
$isLink = $true
}
if (-not $userSupplied -and -not $homeIsCustom -and -not $isLink) {
$asideDir = "$dir.denied-$(Get-Date -Format 'yyyyMMddHHmmss')"
$moved = $false
try {
# [System.IO.Directory]::Move, not Move-Item. Move-Item falls back to
# copy-then-delete when the rename fails, which creates $asideDir and then
# dies on the unreadable contents, leaving a stray llama.cpp.denied-* folder
# beside the original on every run. Directory.Move is a bare rename: it
# either moves the tree or throws having created nothing.
# Measured on windows-latest, denying each shape on the folder itself:
# (OI)(CI)(RX) rename refused, Move-Item left a stray folder
# (OI)(CI)(R) rename refused, Move-Item left a stray folder
# (RX) rename refused, Move-Item left a stray folder
# (DE) rename SUCCEEDED, both ways
# So on Windows a read denial always refuses the rename (the open asks for
# SYNCHRONIZE, which every read deny removes) and this recovery cannot fire;
# denying DELETE, which sounds like the blocker, does not stop it. On POSIX
# the rename needs only write+execute on the parent, so the recovery is real
# there and is why this stays rather than being deleted.
[System.IO.Directory]::Move($dir, $asideDir)
$moved = $true
} catch {
# Expected when the denial covers the rename; fall through to guidance.
# Nothing to clean up: Directory.Move creates nothing when it throws.
}
if ($moved) {
step "permissions" "llama.cpp install at $dir could not be read, so it was moved aside" "Yellow"
substep "Moved to $asideDir; it is a managed cache and setup reinstalls it" "Yellow"
substep "Delete the moved folder once access is restored, it is no longer used" "Yellow"
Write-StudioLine ""
return $null
}
# This runs before the install lock, so a second run can have moved
# the folder in between. Re-probe rather than report a denial for a
# path that is no longer there and stop an install that can proceed.
if ((Get-LlamaCppInstallReadState -Path $dir) -ne "Denied") { return $null }
}
$reason = Write-PathAccessDenied -Path $dir -Label "llama.cpp install" `
-UserSupplied:$userSupplied -OwnershipUnverified:$homeIsCustom
substep "Stopping here, before phase 1: nothing has been downloaded or installed" "Yellow"
substep "Fix access, then run the same install, setup, or update command again" "Yellow"
Write-StudioLine ""
return "$reason Nothing was installed."
}
function Test-MirrorConfigured {
param([ValidateSet('uv', 'python', 'pip')][string]$Tool)
if ($Tool -ne 'pip') {
if ($Tool -eq 'python') {
# UV_PYTHON_INSTALL_MIRROR would override UV_ASTRAL_MIRROR_URL; uv rejects a mirror beside a custom manifest.
if ("$env:UV_PYTHON_INSTALL_MIRROR$env:UV_ASTRAL_MIRROR_URL$env:UV_PYTHON_DOWNLOADS_JSON_URL") { return $true }
$pattern = '^\s*(python-install-mirror|python-downloads-json-url)\s*='
} else {
if ("$env:UV_DEFAULT_INDEX$env:UV_INDEX_URL$env:UV_INDEX$env:UV_EXTRA_INDEX_URL") { return $true }
$pattern = '^\s*(\[\[(tool\.uv\.)?index\]\]|(pip\.)?(index|index-url|default-index|extra-index-url|no-index)\s*=)'
}
# uv's discovery: UV_CONFIG_FILE alone (even with UV_NO_CONFIG); relative paths from --directory.
$files = @("$env:APPDATA\uv\uv.toml")
if (-not (Test-UvEnvFlag 'UV_NO_SYSTEM_CONFIG')) { $files += "$env:ProgramData\uv\uv.toml" }
$dir = (Get-Location -PSProvider FileSystem).ProviderPath
$workDir = if ($env:UV_WORKING_DIR) { $env:UV_WORKING_DIR } else { $env:UV_WORKING_DIRECTORY }
if ($workDir) { $dir = [System.IO.Path]::GetFullPath([System.IO.Path]::Combine($dir, $workDir)) }
$configFile = if ($env:UV_CONFIG_FILE) { [System.IO.Path]::GetFullPath([System.IO.Path]::Combine($dir, $env:UV_CONFIG_FILE)) }
if ($env:UV_PROJECT) { $dir = [System.IO.Path]::GetFullPath([System.IO.Path]::Combine($dir, $env:UV_PROJECT)) }
if ($configFile) { $files = @($configFile); $dir = $null }
elseif (Test-UvEnvFlag 'UV_NO_CONFIG') { $files = @(); $dir = $null }
while ($dir) {
$pyproject = Join-Path $dir 'pyproject.toml'
if (Test-Path -LiteralPath (Join-Path $dir 'uv.toml') -PathType Leaf) { $files += Join-Path $dir 'uv.toml'; break }
if ((Test-Path -LiteralPath $pyproject -PathType Leaf) -and
(Select-String -LiteralPath $pyproject -Pattern '^\s*\[+tool\.uv(\.|\])' -Quiet -ErrorAction SilentlyContinue)) { $files += $pyproject; break }
$dir = Split-Path -Parent $dir
}
} else {
if ("$env:PIP_INDEX_URL$env:PIP_EXTRA_INDEX_URL$env:PIP_NO_INDEX") { return $true }
$pattern = '^\s*(index[-_]url|extra[-_]index[-_]url|no[-_]index)\s*[=:]'
$files = @($env:PIP_CONFIG_FILE, $(if ($venv = Get-Variable VenvDir -ValueOnly -ErrorAction SilentlyContinue) { Join-Path $venv 'pip.ini' }), "$env:APPDATA\pip\pip.ini", "$env:USERPROFILE\pip\pip.ini", "$env:ProgramData\pip\pip.ini")
}
foreach ($file in $files) {
if ($file -and (Test-Path -LiteralPath $file -PathType Leaf) -and
(Select-String -LiteralPath $file -Pattern $pattern -Quiet -ErrorAction SilentlyContinue)) {
return $true
}
}
return $false
}
function Start-MirrorProbe {
param([string[]]$Urls, [double]$Seconds, [long]$LastByte)
$state = @{ Urls = $Urls; Seconds = $Seconds; LastByte = $LastByte; Clock = [System.Diagnostics.Stopwatch]::StartNew(); Requests = @{}; Heads = @{}; InTime = @{}; Bodies = @{}; Buffers = @{} }
$deadline = [System.Threading.Tasks.Task]::Delay([int]($Seconds * 1000))
foreach ($url in $Urls) {
$state.Requests[$url] = [System.Net.WebRequest]::Create($url)
# The CERNET mirrors answer 403 to a request without a User-Agent; a fresh connection keeps every probe cold, as curl's are.
$state.Requests[$url].UserAgent = 'unsloth-installer'
$state.Requests[$url].KeepAlive = $false
$state.Requests[$url].AddRange(0, $LastByte)
$state.Heads[$url] = $state.Requests[$url].GetResponseAsync()
$state.InTime[$url] = [System.Threading.Tasks.Task]::WhenAny([System.Threading.Tasks.Task[]]@($state.Heads[$url], $deadline))
}
return $state
}
function Wait-MirrorProbe {
param($Probe)
$results = @{}
$measure = { param($url) @([int]$Probe.Heads[$url].Result.StatusCode, [long]($Probe.Buffers[$url].Position / $Probe.Clock.Elapsed.TotalSeconds)) }
try {
while ($true) {
foreach ($url in $Probe.Urls) {
if ($results.ContainsKey($url) -or -not $Probe.InTime[$url].IsCompleted) { continue }
if (-not [object]::ReferenceEquals($Probe.InTime[$url].Result, $Probe.Heads[$url])) {
$results[$url] = @(0, [long]0)
} elseif ($Probe.Heads[$url].Status -ne 'RanToCompletion') {
$failure = $Probe.Heads[$url].Exception.InnerException -as [System.Net.WebException]
$results[$url] = @($(if ($failure -and $failure.Response) { [int]$failure.Response.StatusCode } else { 0 }), [long]0)
} elseif (-not $Probe.Bodies.ContainsKey($url)) {
$Probe.Buffers[$url] = [System.IO.MemoryStream]::new([byte[]]::new($Probe.LastByte + 65537))
$Probe.Bodies[$url] = $Probe.Heads[$url].Result.GetResponseStream().CopyToAsync($Probe.Buffers[$url], 65536)
} elseif ($Probe.Bodies[$url].IsCompleted) {
$results[$url] = & $measure $url
}
}
$pending = @($Probe.Urls | Where-Object { -not $results.ContainsKey($_) } | ForEach-Object { if ($Probe.Bodies.ContainsKey($_)) { $Probe.Bodies[$_] } else { $Probe.InTime[$_] } })
$left = [int](($Probe.Seconds - $Probe.Clock.Elapsed.TotalSeconds) * 1000)
if ($pending.Count -eq 0 -or $left -le 0) { break }
[void][System.Threading.Tasks.Task]::WaitAny([System.Threading.Tasks.Task[]]$pending, $left)
}
foreach ($url in $Probe.Urls) {
if ($results.ContainsKey($url)) { continue }
$results[$url] = if ($Probe.Buffers.ContainsKey($url)) { & $measure $url } else { @(0, [long]0) }
}
} finally {
foreach ($url in @($Probe.Requests.Keys)) {
$Probe.Requests[$url].Abort()
if ($Probe.Heads[$url].Status -eq 'RanToCompletion') { $Probe.Heads[$url].Result.Dispose() }
}
}
return $results
}
function Get-MirrorDnsServers {
try {
[System.Net.NetworkInformation.NetworkInterface]::GetAllNetworkInterfaces() | Where-Object { $_.OperationalStatus -eq 'Up' } |
ForEach-Object { $_.GetIPProperties().DnsAddresses } | ForEach-Object { "$_" }
} catch {}
}
# No network call: a mainland China time zone, or a resolver from a mainland public DNS or cloud, as _mirror_in_china in install.sh.
function Test-MirrorInChina {
try { if ((Get-TimeZone).Id -in 'China Standard Time', 'Asia/Shanghai', 'Asia/Chongqing', 'Asia/Chungking', 'Asia/Harbin', 'Asia/Urumqi', 'Asia/Kashgar', 'PRC') { return $true } } catch {}
return [bool](@(Get-MirrorDnsServers) -match '^(223\.5\.5\.5|223\.6\.6\.6|119\.29\.29\.29|114\.114\.11[45]\.11[0459]|182\.254\.116\.116|119\.28\.28\.28|180\.76\.76\.76|1\.2\.4\.8|210\.2\.4\.8|100\.100\.2\.13[68]|183\.60\.8[23]\.(19|98))$')
}
function Invoke-MirrorFallback {
param([switch]$SpareOnly)
$optIn = "$env:UNSLOTH_MIRROR_FALLBACK".Trim()
# When off, a retry state inherited from a parent is dropped so nothing downstream acts on it.
if ($optIn -match '^(0|false|no|off)$' -or ($optIn -notmatch '^(1|true|yes|on)$' -and -not (Test-MirrorInChina))) {
Remove-Item Env:_UNSLOTH_MIRROR_SPARE -ErrorAction SilentlyContinue
return
}
if ($env:_UNSLOTH_MIRROR_PROBED) { return }
if (-not $SpareOnly) { $env:_UNSLOTH_MIRROR_PROBED = '1' }
$cernet = 'https://tuna.mirrors.cernet.edu.cn'
$npmMirror = 'https://registry.npmmirror.com'
$pypiMirror = "$cernet/pypi/web/simple"
# GitHub-release mirrors keep only the newest Python builds, while a pinned uv asks for the builds it shipped with; npmmirror keeps every release.
$pythonMirror = "$npmMirror/-/binary/python-build-standalone"
$minBps = 1MB
$useUv = -not (Test-MirrorConfigured -Tool uv)
$usePip = -not (Test-MirrorConfigured -Tool pip)
$uvWheel = 'packages/3a/ad/3c2a81d66d1839063662e5b6c0002ec31f8ee987dbc5295747627ca6160a/uv-0.13.5-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl'
$torchWheel = 'whl/cpu/torch-2.9.1%2Bcpu-cp312-cp312-manylinux_2_28_x86_64.whl'
$nodeTarball = 'v24.18.0/node-v24.18.0-linux-x64.tar.gz'
$artifact = @{
'pypi' = "https://files.pythonhosted.org/$uvWheel"; 'cernet-pypi' = "$cernet/pypi/web/$uvWheel"
'torch' = "https://download-r2.pytorch.org/$torchWheel"; 'cernet-torch' = "$cernet/pytorch/$torchWheel"
'node' = "https://nodejs.org/dist/$nodeTarball"; 'npmmirror-node' = "$npmMirror/-/binary/node/$nodeTarball"
'npm' = 'https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz'; 'npmmirror' = "$npmMirror/typescript/-/typescript-5.9.3.tgz"
'astral' = 'https://releases.astral.sh/github/uv/releases/download/0.13.5/uv-x86_64-unknown-linux-gnu.tar.gz'
}
$hosts = [ordered]@{}
if ($useUv -or $usePip) { $hosts['pypi'] = @('pypi', 'cernet-pypi', $pypiMirror, 'https://pypi.org/simple/uv/', "$pypiMirror/uv/") }
if (-not "$env:UNSLOTH_PYTORCH_MIRROR$env:UNSLOTH_TORCH_INDEX_URL") {
$hosts['torch'] = @('torch', 'cernet-torch', "$cernet/pytorch/whl", 'https://download.pytorch.org/whl/cpu/torch/', "$cernet/pytorch/whl/cpu/torch/")
}
if (-not $env:UNSLOTH_NODE_MIRROR) { $hosts['node'] = @('node', 'npmmirror-node', "$npmMirror/-/binary/node", $null, $null) }
if (-not "$env:UNSLOTH_NPM_REGISTRY$env:NPM_CONFIG_REGISTRY") { $hosts['npm'] = @('npm', 'npmmirror', $npmMirror, $null, $null) }
if (-not (Test-MirrorConfigured -Tool python)) { $hosts['python'] = @('astral', 'npmmirror', $pythonMirror, $null, $null) }
if (-not "$env:UNSLOTH_UV_WHEEL_MIRROR$env:UV_DOWNLOAD_URL$env:INSTALLER_DOWNLOAD_URL$env:UV_INSTALLER_GHE_BASE_URL$env:UV_INSTALLER_GITHUB_BASE_URL") {
$hosts['uvbin'] = @('astral', 'cernet-pypi', "$cernet/pypi/web", $null, $null)
}
if ($hosts.Count -eq 0) { return }
$varsOf = {
param($name)
$to = if ($hosts.Contains($name)) { $hosts[$name][2] }
switch ($name) {
'pypi' {
if ($useUv) { "UV_DEFAULT_INDEX=$to" }
if ($usePip) { "PIP_INDEX_URL=$to" }
}
'unsynced' {
# Only for one rerun: uv's unsafe-first-match fetches every package from every index, and fails outright when one is unreachable.
if ($useUv) {
'UV_DEFAULT_INDEX=https://pypi.org/simple'; "UV_INDEX=$pypiMirror"
"UV_INDEX_STRATEGY=$(if ($env:UV_INDEX_STRATEGY) { $env:UV_INDEX_STRATEGY } else { 'unsafe-first-match' })"
}
if ($usePip) { 'PIP_EXTRA_INDEX_URL=https://pypi.org/simple'; "PIP_INDEX_URL=$pypiMirror" }
}
'torch' { "UNSLOTH_PYTORCH_MIRROR=$to" }
'node' { "UNSLOTH_NODE_MIRROR=$to" }
'npm' { "UNSLOTH_NPM_REGISTRY=$to" }
'python' { "UV_PYTHON_INSTALL_MIRROR=$to" }
'uvbin' { "UNSLOTH_UV_WHEEL_MIRROR=$to" }
}
}
$env:_UNSLOTH_MIRROR_SPARE = @($hosts.Keys | ForEach-Object { (@($_) + @(& $varsOf $_)) -join '|' }) -join ' '
if ($SpareOnly -or (Test-UvEnvFlag 'UV_OFFLINE')) { return }
$answered = @{}
$codeOf = { param($index, $result) if ($index -and "$($answered[$index][0])" -notmatch '^2\d\d$') { 0 } else { $result[0] } }
# PS 5.1 may pin TLS 1.0/1.1 (every probed host refuses it; Tls|Tls12 still fails) and queues past 2 connections per host.
$savedProtocol = [System.Net.ServicePointManager]::SecurityProtocol
$savedLimit = [System.Net.ServicePointManager]::DefaultConnectionLimit
if ([int]$savedProtocol -ne 0) { [System.Net.ServicePointManager]::SecurityProtocol = [System.Net.SecurityProtocolType]::Tls12 }
[System.Net.ServicePointManager]::DefaultConnectionLimit = [Math]::Max($savedLimit, 16)
try {
$defaults = @($hosts.Values | ForEach-Object { $_[0] } | Select-Object -Unique)
$indexes = Start-MirrorProbe -Urls @($hosts.Values | ForEach-Object { $_[3] } | Where-Object { $_ }) -Seconds 4 -LastByte 1023
$timed = @{}
foreach ($name in $defaults) { $timed[$name] = (Wait-MirrorProbe (Start-MirrorProbe -Urls $artifact[$name] -Seconds 1.5 -LastByte 1048575))[$artifact[$name]] }
$answered = Wait-MirrorProbe $indexes
$slow = @($hosts.Keys | Where-Object {
$code = & $codeOf $hosts[$_][3] $timed[$hosts[$_][0]]
$code -eq 0 -or ($code -ge 300 -and $code -lt 400) -or ($code -ge 200 -and $code -lt 300 -and $timed[$hosts[$_][0]][1] -lt $minBps)
})
if ($slow.Count -eq 0) { return }
$sources = @($slow | ForEach-Object { $hosts[$_][1] } | Select-Object -Unique)
$indexes = Start-MirrorProbe -Urls @($slow | ForEach-Object { $hosts[$_][3]; $hosts[$_][4] } | Where-Object { $_ }) -Seconds 4 -LastByte 1023
$race = Wait-MirrorProbe (Start-MirrorProbe -Urls @(@($slow | ForEach-Object { $artifact[$hosts[$_][0]] }) + @($sources | ForEach-Object { $artifact[$_] }) | Select-Object -Unique) -Seconds 4 -LastByte 1048575)
$sourceIndexes = Wait-MirrorProbe $indexes
foreach ($url in $sourceIndexes.Keys) { $answered[$url] = $sourceIndexes[$url] }
} finally {
[System.Net.ServicePointManager]::SecurityProtocol = $savedProtocol
[System.Net.ServicePointManager]::DefaultConnectionLimit = $savedLimit
}
$used = $false
foreach ($name in $slow) {
$default = $race[$artifact[$hosts[$name][0]]]
$mirror = $race[$artifact[$hosts[$name][1]]]
$how = if ("$(& $codeOf $hosts[$name][3] $default)" -match '^2\d\d$') { 'slow' } else { 'blocked' }
$defaultBps = if ($how -eq 'slow') { $default[1] } else { [long]0 }
if ("$(& $codeOf $hosts[$name][4] $mirror)" -notmatch '^2\d\d$' -or $defaultBps -ge $minBps -or $mirror[1] -le $defaultBps) { continue }
Set-MirrorEnv @(& $varsOf $name)
$env:_UNSLOTH_MIRROR_SPARE = @(-split $env:_UNSLOTH_MIRROR_SPARE | Where-Object { $_ -notlike "$name|*" }) -join ' '
if ($name -eq 'pypi' -and $how -eq 'slow') { $env:_UNSLOTH_MIRROR_SPARE = (@(-split $env:_UNSLOTH_MIRROR_SPARE) + ((@('unsynced') + @(& $varsOf 'unsynced')) -join '|')) -join ' ' }
step "mirror" "$(Get-MirrorName $name) is $how ($($defaultBps -shr 10) KB/s, mirror $($mirror[1] -shr 10) KB/s); using $($hosts[$name][2])" "Yellow"
$used = $true
}
if ($used) { substep "Set UNSLOTH_MIRROR_FALLBACK=0 to always use the default hosts." }
}
function Get-MirrorName {
param([string]$Name)
@{ pypi = 'PyPI'; unsynced = 'The PyPI mirror'; torch = 'download.pytorch.org'; node = 'nodejs.org'; npm = 'registry.npmjs.org'; python = 'releases.astral.sh (Python builds)'; uvbin = 'releases.astral.sh (uv)' }[$Name]
}
function Set-MirrorEnv {
param([string[]]$Pairs)
foreach ($pair in $Pairs) { Set-Item "Env:$($pair.Split('=', 2)[0])" $pair.Split('=', 2)[1] }
}
function Pop-MirrorSpare {
param([string]$Name)
$entry = @(-split $env:_UNSLOTH_MIRROR_SPARE | Where-Object { $_ -like "$Name|*" })
if (-not $entry) { return }
$env:_UNSLOTH_MIRROR_SPARE = @(-split $env:_UNSLOTH_MIRROR_SPARE | Where-Object { $_ -notlike "$Name|*" }) -join ' '
$pairs = @($entry[0].Split('|') | Select-Object -Skip 1)
step "mirror" "$(Get-MirrorName $Name) failed; retrying through $($pairs[0].Split('=', 2)[1])" "Yellow"
return $pairs
}
function Use-MirrorSpare {
param([string]$Name)
$pairs = @(Pop-MirrorSpare $Name)
Set-MirrorEnv $pairs
return $pairs.Count -gt 0
}
function Get-MirrorFailedHost {
param([string]$Output, [string]$Ran)
if ($Output -notmatch 'error sending request|timed out|network timeout|idle timeout|connection (reset|refused|closed|aborted)|network aborted|broken pipe|dns error|failed to lookup address|name resolution|nodename nor servname|network is unreachable|error decoding response body|end of file before message length|unexpected eof|tls handshake|sslerror|certificate verify failed|server error|service unavailable|bad gateway|gateway time-?out|too many requests|max retries exceeded|remotedisconnected|incompleteread|econnreset|etimedout|eidletimeout|eai_again|enotfound|econnrefused|socket hang up') {
if ($Output -match 'only \S+ (.* )?(is|are) available|no versions? of|not found in the package registry|could not find a version that satisfies|no matching distribution found') { 'unsynced' }
return
}
if ($Output -match 'download(-r2)?\.pytorch\.org') { 'torch' }
elseif ($Output -match 'python-build-standalone') { 'python' }
elseif ($Output -match 'registry\.npmjs\.org') { 'npm' }
elseif ($Output -match 'pypi\.org|pythonhosted\.org') { 'pypi' }
elseif ($Ran -and $Output -notmatch 'https?://') { $Ran }
}
# ── END SHARED WITH studio/setup.ps1 ──
# Redact index-URL credentials (userinfo, query, fragment) from captured output before
# printing on failure. Verbose mode streams uncaptured, so it isn't redacted.
function Redact-InstallOutput {
param([string]$Text)
if (-not $Text) { return $Text }
$Text = $Text -replace '(https?://)[^/@\s`]+@', '$1<redacted>@'
$Text = $Text -replace '([?&][^=\s&`]+)=[^&#\s`]+', '$1=<redacted>'
return $Text -replace '(https?://[^\s`#]+)#[^\s`]+', '$1#<redacted>'
}
function Invoke-InstallCommand {
param(
[Parameter(Mandatory = $true)][ScriptBlock]$Command,
[string]$Label = "install command",
[switch]$NoMirror
)
if ($script:InstallTorchMirror) {
foreach ($v in $Command.Ast.FindAll({ param($n) $n -is [System.Management.Automation.Language.VariableExpressionAst] -and $n.VariablePath.IsUnqualified }, $true)) {
$value = $ExecutionContext.SessionState.PSVariable.GetValue($v.VariablePath.UserPath)
if (@($value) -like 'https://download.pytorch.org/whl*') {
Set-Variable -Name $v.VariablePath.UserPath -Value ($value -replace '^https://download\.pytorch\.org/whl', $script:InstallTorchMirror.TrimEnd('/'))
}
}
}
# A pinned index must beat an inherited uv mirror (#6898); UV_NO_CONFIG=1 blocks uv.toml.
$savedUvIndex = $null
if ($Command.ToString() -match '--default-index') {
$savedUvIndex = @{}
foreach ($n in 'UV_DEFAULT_INDEX', 'UV_INDEX_URL', 'UV_INDEX', 'UV_EXTRA_INDEX_URL', 'UV_TORCH_BACKEND', 'UV_FIND_LINKS', 'UV_CONFIG_FILE', 'UV_NO_CONFIG') {
$savedUvIndex[$n] = [Environment]::GetEnvironmentVariable($n)
Remove-Item "Env:$n" -ErrorAction SilentlyContinue
}
$env:UV_NO_CONFIG = '1'
}
$prevEap = $ErrorActionPreference
$ErrorActionPreference = "Continue"
try {
# Reset to avoid stale values from prior native commands.
$global:LASTEXITCODE = 0
Write-TauriLog "OUTPUT_CLEAR" $Label
$collected = [System.Text.StringBuilder]::new()
if ($script:UnslothVerbose) {
# Merge stderr into stdout without flipping $? (5.1 treats stderr records as errors).
# Redact per record, verbose mode included.
& $Command 2>&1 | ForEach-Object {
[void]$collected.AppendLine("$_")
Write-UvDownloadMarker "$_"
Redact-InstallOutput "$_"
} | Out-Host
} else {
# Streamed, not collected, so a marker reaches the app mid-download.
& $Command 2>&1 | ForEach-Object {
$line = "$_"
[void]$collected.AppendLine($line)
Write-UvDownloadMarker $line
}
$output = $collected.ToString()
if ($LASTEXITCODE -ne 0) {
Write-StudioLine (Redact-InstallOutput $output) -ForegroundColor Red
}
}
$exitCode = [int]$LASTEXITCODE
$script:LastInstallOutput = if ($exitCode -eq 0) { "" } else { $collected.ToString() }
if ($exitCode -eq 0) {
Clear-TauriInstallError "$Label recovered"
} else {
Write-TauriLog "ERROR_OUTPUT" "$Label failed (exit code $exitCode)"
}
} finally {
$ErrorActionPreference = $prevEap
if ($savedUvIndex) {
Remove-Item "Env:UV_NO_CONFIG" -ErrorAction SilentlyContinue
foreach ($n in $savedUvIndex.Keys) { if ($null -ne $savedUvIndex[$n]) { Set-Item "Env:$n" $savedUvIndex[$n] } }
}
}
if ($exitCode -eq 0 -or $NoMirror -or -not $env:_UNSLOTH_MIRROR_SPARE) { return $exitCode }
return (Invoke-InstallMirrorRetry -Code $exitCode -Command $Command -Label $Label -Output $collected.ToString())
}
function Invoke-InstallMirrorRetry {
param([int]$Code, [ScriptBlock]$Command, [string]$Label, [string]$Output)
$words = @(foreach ($n in $Command.Ast.FindAll({ param($n) $n -is [System.Management.Automation.Language.StringConstantExpressionAst] -or $n -is [System.Management.Automation.Language.VariableExpressionAst] }, $true)) {
if ($n -is [System.Management.Automation.Language.StringConstantExpressionAst]) { $n.Value } else { $ExecutionContext.SessionState.PSVariable.GetValue($n.VariablePath.UserPath) }
}) | ForEach-Object { "$_" }
$torchArg = [bool](@($words) -like 'https://download.pytorch.org/whl*')
$pinned = [bool](@($words) -match '^--(index-url|default-index)$')
$ran = if ($torchArg) { 'torch' } elseif ($pinned -or @($words) -match '^(--find-links|--no-index|--torch-backend.*|venv)$|://') { '' } elseif ($Command.ToString() -match '(^|\s)python\s+install(\s|$)') { 'python' } else { 'pypi' }
$failed = Get-MirrorFailedHost -Output $Output -Ran $ran
$ownIndex = $pinned -or @($words) -contains '--no-index'
if (-not (($failed -eq 'torch' -and $torchArg) -or ($failed -eq 'pypi' -and -not $pinned) -or ($failed -eq 'unsynced' -and -not $ownIndex) -or $failed -eq 'python')) { return $Code }
$pairs = @(Pop-MirrorSpare $failed)
if (-not $pairs) { return $Code }
$saved = @{}
foreach ($pair in $pairs) { $saved[$pair.Split('=', 2)[0]] = [Environment]::GetEnvironmentVariable($pair.Split('=', 2)[0]) }
$savedTorch = $script:InstallTorchMirror
Set-MirrorEnv $pairs
if ($failed -eq 'torch') { $script:InstallTorchMirror = $env:UNSLOTH_PYTORCH_MIRROR }
$code = Invoke-InstallCommand -Command $Command -Label $Label -NoMirror
if ($code -ne 0) {
foreach ($n in $saved.Keys) { [Environment]::SetEnvironmentVariable($n, $saved[$n]) }
$script:InstallTorchMirror = $savedTorch
}
return $code
}
# uv reuses a damaged cached wheel on every run, --refresh included (#17375).
function Repair-UvDamagedWheels {
param([string]$Output, [hashtable]$Cleaned)
$names = @(foreach ($m in [regex]::Matches("$Output", '(?m)Failed to install: \S+ \(([A-Za-z0-9][A-Za-z0-9._-]*)(?:==| @ )[^\r\n]*\r?\n\s*(?:cause|Caused by): The wheel is invalid')) {
$n = $m.Groups[1].Value.ToLowerInvariant()
if (-not $Cleaned.ContainsKey($n) -and $Cleaned.Count -lt 50) { $Cleaned[$n] = $true; $n }
})
if (-not $names) { return $false }
substep "clearing damaged uv cache entries for $($names -join ', ')..." "Yellow"
# Another uv process may hold the cache lock.
$prevLock = $env:UV_LOCK_TIMEOUT
if (-not $prevLock) { $env:UV_LOCK_TIMEOUT = "60" }
$prevEap = $ErrorActionPreference
$ErrorActionPreference = "Continue"
try { & $script:UvExe cache clean @names 2>&1 | Out-Null } finally {
$ErrorActionPreference = $prevEap
$env:UV_LOCK_TIMEOUT = $prevLock
}
return ($LASTEXITCODE -eq 0)
}
function Invoke-InstallCommandRetry {
param(
[Parameter(Mandatory = $true, Position = 0)][ScriptBlock]$Command,
[string]$Label = "install step"
)
$maxAttempts = 3
$parsedAttempts = 0
if ([int]::TryParse($env:UNSLOTH_INSTALL_RETRIES, [ref]$parsedAttempts) -and $parsedAttempts -ge 1 -and $parsedAttempts -le 100) {
$maxAttempts = $parsedAttempts
}
$delay = 3
$parsedDelay = 0
if ([int]::TryParse($env:UNSLOTH_INSTALL_RETRY_DELAY, [ref]$parsedDelay) -and $parsedDelay -ge 0 -and $parsedDelay -le 3600) {
$delay = $parsedDelay
}
$attempt = 1
$cleaned = @{}
while ($true) {
$script:LastInstallOutput = ""
$code = Invoke-InstallCommand -Command $Command -Label $Label -NoMirror:($attempt -lt $maxAttempts)
if ($code -eq 0) { return 0 }
# Not an attempt: uv names one damaged package per run.
if (Repair-UvDamagedWheels -Output $script:LastInstallOutput -Cleaned $cleaned) { continue }
if ($attempt -ge $maxAttempts) { return $code }
substep ("retrying ""$Label"" after transient failure (attempt $($attempt + 1)/$maxAttempts, waiting ${delay}s)...") "Yellow"
Start-Sleep -Seconds $delay
$attempt++
$delay = $delay * 2
}
}
# ── Managed CLI invocation, Application Control safe ──
# The generated unsigned unsloth.exe is denied by AppLocker/WDAC/Smart App Control while
# the venv's signed python.exe runs (#8490), so go through the interpreter.
# Byte-identical to WINDOWS_CLI_ENTRYPOINT in studio/src-tauri/src/process.rs: argv[0] is
# set BEFORE the import (unsloth_cli reads it at import time), and sys.path[:1] drops the
# cwd entry `python -c` adds. Not -I: it implies -E.
# Marker written into every bin\unsloth.cmd; mirrored in scripts/uninstall.ps1 and
# studio/setup.ps1.
$script:UnslothCmdShimMarker = "unsloth-studio-managed-launcher"
$script:UnslothCliTrampoline = "import sys, os; sys.path[:1] = [x for x in sys.path[:1] if getattr(sys.flags, 'safe_path', False) or x not in ('', os.getcwd())]; sys.argv[0] = 'unsloth'; from unsloth_cli import app; sys.exit(app())"
# Policy refusal through PowerShell's wrapper exceptions: AppLocker 1260, App Control
# for Business and Smart App Control 4551 (ERROR_SYSTEM_INTEGRITY_POLICY_VIOLATION).
# $LASTEXITCODE cannot answer this: no process was created, so it still holds the
# exit code of whichever native command ran last.
function Test-ApplicationControlBlock {
param($ErrorRecord)
$ex = if ($ErrorRecord -is [System.Management.Automation.ErrorRecord]) { $ErrorRecord.Exception } else { $ErrorRecord }
while ($ex) {
# Win32Exception carries NativeErrorCode; outer wrappers keep only the HRESULT
# form of the same code (0x800704EC, 0x800711C7).
if ($ex -is [System.ComponentModel.Win32Exception] -and $ex.NativeErrorCode -in @(1260, 4551)) { return $true }
if ($ex.HResult -in @(-2147023636, -2147020345)) { return $true }
$ex = $ex.InnerException
}
return $false
}
# Never suggests turning a security policy off.
function Write-ApplicationControlBlocked {
param([Parameter(Mandatory = $true)][AllowEmptyString()][string]$Path)
Write-StudioLine "[ERROR] Windows Application Control blocked the managed Python runtime." -ForegroundColor Red
Write-StudioLine " AppLocker, App Control for Business or Smart App Control refused to start it." -ForegroundColor Yellow
Write-StudioLine " Blocked program: $Path" -ForegroundColor Yellow
Write-StudioLine " Review AppLocker `"EXE and DLL`" event 8004," -ForegroundColor Yellow
Write-StudioLine " or CodeIntegrity/Operational event 3077." -ForegroundColor Yellow
return "Windows Application Control blocked the managed Python runtime at $Path."
}
# One pre-quoted command line: Start-Process joins -ArgumentList unquoted.
function Get-ManagedUnslothCliCommandLine {
param([string[]]$Arguments = @())
$quoted = '"' + $script:UnslothCliTrampoline + '"'
# Callers pass bare subcommands today, but the signature invites a path:
# unquoted, `--model C:\my models\a.gguf` arrives as three arguments.
$safeArgs = @($Arguments | ForEach-Object {
if ($_ -match '\s') { '"' + $_ + '"' } else { $_ }
})
return (@("-X", "utf8", "-c", $quoted) + $safeArgs) -join " "
}
# Exit code goes to $script:ManagedUnslothCliExit, since stdout must keep streaming.
# $null means the interpreter never started under Application Control.
function Invoke-ManagedUnslothCli {
param(
[Parameter(Mandatory = $true)][string]$Python,
[string[]]$Arguments = @()
)
# -X utf8, not the env var. No -I (see the trampoline). Same as
# build_managed_cli_command in the desktop app.
$pythonArgs = @("-X", "utf8", "-c", $script:UnslothCliTrampoline) + $Arguments
$script:ManagedUnslothCliExit = $null
try {
# Otherwise a child that exits 0 inherits the last native command's code.
$global:LASTEXITCODE = 0
& $Python @pythonArgs
$script:ManagedUnslothCliExit = [int]$LASTEXITCODE
} catch {
# $PSNativeCommandUseErrorActionPreference is off, so a nonzero exit never
# lands here: anything caught is a failure to create the process at all.
if (-not (Test-ApplicationControlBlock $_)) { throw }
}
}
# Does Application Control deny this launcher here? Windows cannot be asked without
# trying: AppLocker's cmdlets need the policy and an admin token, WDAC and Smart App
# Control expose nothing. A denial is free (CreateProcess fails synchronously, no
# child, the policy code straight back); an unaffected machine pays one `--version`,
# measured at a quarter second. A process that started is not blocked whatever it does next,
# so the timeout path still answers "not blocked" and just stops waiting.
function Test-ShimLaunchBlocked {
param([Parameter(Mandatory = $true)][string]$Path)
if (-not (Test-Path -LiteralPath $Path -PathType Leaf)) { return $false }
# Both the PATH-shadow warning and the launch instructions ask, and the answer
# cannot change between them.
if ($null -ne $script:ShimLaunchBlockedCache -and
$script:ShimLaunchBlockedPath -eq $Path) {
return $script:ShimLaunchBlockedCache
}
$script:ShimLaunchBlockedPath = $Path
try {
$psi = New-Object System.Diagnostics.ProcessStartInfo
$psi.FileName = $Path
$psi.Arguments = "--version"
# UseShellExecute = false surfaces a denial as a Win32Exception rather than
# a shell dialog, and is required before output can be redirected.
$psi.UseShellExecute = $false
$psi.CreateNoWindow = $true
$psi.RedirectStandardOutput = $true
$psi.RedirectStandardError = $true
$proc = [System.Diagnostics.Process]::Start($psi)
try {
# Drain both pipes asynchronously BEFORE waiting, or a child filling one buffer deadlocks
# (PYTHONPROFILEIMPORTTIME can produce ~24 KB of stderr).
$stdout = $proc.StandardOutput.ReadToEndAsync()
$stderr = $proc.StandardError.ReadToEndAsync()
if ($proc.WaitForExit(20000)) {
# The timed overload does not wait for the readers to finish; the
# parameterless one does, and returns at once for an exited process.
$proc.WaitForExit()
} else {
# It started, so it is not blocked, but leaving it running would
# hold the launcher open against the next update.
try { $proc.Kill() } catch { }
}
# Observed so a faulted read cannot surface later as an unobserved task.
foreach ($reader in @($stdout, $stderr)) {
try { $null = $reader.Result } catch { }
}
} finally {
$proc.Dispose()
}
$script:ShimLaunchBlockedCache = $false
return $false
} catch {
$script:ShimLaunchBlockedCache = (Test-ApplicationControlBlock $_)
return $script:ShimLaunchBlockedCache
}
}
# Name the .cmd when the .exe is blocked OR missing (removed after install); a missing file is
# not "blocked".
function Test-UnslothCmdShimPreferred {
param(
[Parameter(Mandatory = $true)][string]$ShimExe,
[Parameter(Mandatory = $true)][string]$ShimCmd
)
if (-not (Test-Path -LiteralPath $ShimCmd -PathType Leaf)) { return $false }
if (-not (Test-Path -LiteralPath $ShimExe -PathType Leaf)) { return $true }
return (Test-ShimLaunchBlocked -Path $ShimExe)
}
# Relative path or $null across roots. Longhand: GetRelativePath is .NET Core only and
# MakeRelativeUri percent-encodes.
function Get-RelativeShimPath {
param(
[Parameter(Mandatory = $true)][AllowEmptyString()][string]$From,
[Parameter(Mandatory = $true)][AllowEmptyString()][string]$To
)
if ([string]::IsNullOrWhiteSpace($From) -or [string]::IsNullOrWhiteSpace($To)) { return $null }
$fromParts = @($From.TrimEnd('\', '/') -split '[\\/]+' | Where-Object { $_ -ne "" })
$toParts = @($To -split '[\\/]+' | Where-Object { $_ -ne "" })
if ($fromParts.Count -eq 0 -or $toParts.Count -eq 0) { return $null }
# Drive letter or share name: no common root means no relative path exists.
if ($fromParts[0] -ne $toParts[0]) { return $null }
$shared = 0
while ($shared -lt $fromParts.Count -and $shared -lt $toParts.Count -and
$fromParts[$shared] -eq $toParts[$shared]) {
$shared++
}
# Guarded: a PowerShell range whose start passes its end counts DOWN, so
# $toParts[2..1] returns the path reversed, not the empty tail it reads like.
if ($shared -ge $toParts.Count) { return $null }
$up = @("..") * ($fromParts.Count - $shared)
$down = @($toParts[$shared..($toParts.Count - 1)])
return (@($up + $down) -join '\')
}
# Pure function of two paths, so re-runs are byte-identical. %~dp0 keeps special path
# characters out of the file. CRLF, ASCII, no BOM (cmd reads a BOM as a command).
# Covers EXE/DLL enforcement (#8490); AppLocker Script rules block .cmd too.
function Get-UnslothCmdShimContent {
param(
[Parameter(Mandatory = $true)][string]$ShimDir,
[Parameter(Mandatory = $true)][string]$PythonPath
)
$relative = Get-RelativeShimPath -From $ShimDir -To $PythonPath
$target = if ($relative) { "%~dp0$relative" } else {
# Different volume: an absolute path is the only option left. '%' is the one
# character cmd still expands inside double quotes, so double it.
$PythonPath -replace '%', '%%'
}
$lines = @(
"@echo off",
"rem Runs the Unsloth CLI through the managed interpreter. The generated",
"rem unsloth.exe beside it is unsigned, and Windows Application Control",
"rem denies it on managed machines; this file is the way through.",
# The ownership marker; it gates a recursive delete, so it must be unmistakable.
"rem $script:UnslothCmdShimMarker",
# With delayed expansion on (cmd /V:ON, or the machine-wide default) a '!'
# is eaten out of every argument. exit /b ends the scope, so nothing leaks.
"setlocal DisableDelayedExpansion",
"`"$target`" -X utf8 -c `"$script:UnslothCliTrampoline`" %*",
"@exit /b %errorlevel%"
)
return (($lines -join "`r`n") + "`r`n")
}
# Write bin\unsloth.cmd, but only when the bytes would change, so a re-run is a
# no-op. Never fatal: the .exe shim is still the primary launcher and nothing
# requires this file to exist.
function Write-UnslothCmdShim {
param(
[Parameter(Mandatory = $true)][string]$ShimDir,
[Parameter(Mandatory = $true)][string]$PythonPath
)
# Probes inside the try: a throw under the rollback guard would undo the install.
$shimCmd = Join-Path $ShimDir "unsloth.cmd"
try {
if (Test-Path -LiteralPath $shimCmd -PathType Container) {
substep "cannot write $shimCmd, a directory is in the way" "Yellow"
return
}
# Sweep temps from killed runs (not live ones) before the unchanged-content return.
Get-ChildItem -LiteralPath $ShimDir -Filter "unsloth.cmd.*.tmp" -File `
-ErrorAction SilentlyContinue | ForEach-Object {
$ownerPid = 0
if ([int]::TryParse(($_.Name -replace '^unsloth\.cmd\.', '' -replace '\.tmp$', ''),
[ref]$ownerPid) -and $ownerPid -eq $PID) { return }
if ($ownerPid -gt 0 -and (Get-Process -Id $ownerPid -ErrorAction SilentlyContinue)) { return }
Remove-Item -LiteralPath $_.FullName -Force -ErrorAction SilentlyContinue
}
$content = Get-UnslothCmdShimContent -ShimDir $ShimDir -PythonPath $PythonPath
$desired = (New-Object System.Text.UTF8Encoding($false)).GetBytes($content)
# Bytes, not ReadAllText: that strips a BOM and -eq is case insensitive.
if (Test-Path -LiteralPath $shimCmd -PathType Leaf) {
$existing = [System.IO.File]::ReadAllBytes($shimCmd)
if (@(Compare-Object $existing $desired -SyncWindow 0).Count -eq 0) { return }
# Warn when replacing a file that is not ours.
if (-not (Test-UnslothCmdShimFile -Path $shimCmd)) {
substep "replacing an unrecognised $shimCmd" "Yellow"
}
}
# Publish by rename so a shell reading it mid-install never sees half a file.
$tmp = "$shimCmd.$PID.tmp"
[System.IO.File]::WriteAllBytes($tmp, $desired)
try {
Move-Item -LiteralPath $tmp -Destination $shimCmd -Force -ErrorAction Stop
} finally {
Remove-Item -LiteralPath $tmp -Force -ErrorAction SilentlyContinue
}
} catch {
substep "could not write $shimCmd`: $($_.Exception.Message)" "Yellow"
}
}
# Ownership check that gates deleting a user-chosen directory. Mirrored in
# scripts/uninstall.ps1 (_IsUnslothCmdShim) and studio/setup.ps1.
function Test-UnslothCmdShimFile {
param([Parameter(Mandatory = $true)][AllowEmptyString()][string]$Path)
if ([string]::IsNullOrWhiteSpace($Path)) { return $false }
if (-not (Test-Path -LiteralPath $Path -PathType Leaf)) { return $false }
try {
if ((Get-Item -LiteralPath $Path -ErrorAction Stop).Length -gt 8192) { return $false }
$text = [System.IO.File]::ReadAllText($Path)
} catch {
# Unreadable proves nothing, and "proves nothing" must not mean "deletable".
return $false
}
return ($text -like "*unsloth-studio-managed-launcher*" -and $text -like "*from unsloth_cli import app*")
}
function New-StudioShortcuts {
param(
[Parameter(Mandatory = $true)][string]$ManagedPythonPath,
[switch]$DestinationValidated
)
if (-not (Test-Path -LiteralPath $ManagedPythonPath)) {
substep "cannot create shortcuts, managed Python not found at $ManagedPythonPath" "Yellow"
return
}
try {
# Persist an absolute path in launcher scripts so shortcut working
# directory changes do not break process startup.
$ManagedPythonPath = (Resolve-Path -LiteralPath $ManagedPythonPath).Path
# Escape for single-quoted embedding in generated launcher script.
# This prevents runtime variable expansion for paths containing '$'.
$SingleQuotedPythonPath = $ManagedPythonPath -replace "'", "''"
# Same escaping for the trampoline: it contains apostrophes of its own.
$SingleQuotedTrampoline = $script:UnslothCliTrampoline -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"
$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"
}
# The packaged .ico (studio\frontend\dist) serves irm|iex installs with no $PSScriptRoot.
$packagedIcon = $null
try {
$venvRoot = Split-Path -Parent (Split-Path -Parent $ManagedPythonPath)
$packagedIconCandidate = Join-Path $venvRoot "Lib\site-packages\studio\frontend\dist\unsloth.ico"
if (Test-Path -LiteralPath $packagedIconCandidate) { $packagedIcon = $packagedIconCandidate }
} catch {}
$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
}
# Per-install opaque id for launcher and backend, so neither compares install paths.
$_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()
# 64 lowercase hex, as the backend and desktop app require. -cnotmatch (case sensitive);
# anything else would be code in the single-quoted launcher assignment.
if ($_studioRootId -cnotmatch '^[0-9a-f]{64}$') {
$_studioRootId = ""
}
}
if (-not $_studioRootId) {
$_idBytes = New-Object byte[] 32
[Security.Cryptography.RandomNumberGenerator]::Create().GetBytes($_idBytes)
$_studioRootId = -join ($_idBytes | ForEach-Object { $_.ToString('x2') })
# No-clobber: the desktop app mints the same id. Two-arg File.Move throws on an existing
# destination, so adopt it.
$_idTmp = $_studioIdFile + ".$PID.tmp"
[System.IO.File]::WriteAllText($_idTmp, $_studioRootId)
try {
[System.IO.File]::Move($_idTmp, $_studioIdFile)
} catch [System.IO.IOException] {
$_adoptedRootId = ""
try {
$_adoptedRootId = ([System.IO.File]::ReadAllText($_studioIdFile)).Trim()
} catch { }
if ($_adoptedRootId -cmatch '^[0-9a-f]{64}$') {
$_studioRootId = $_adoptedRootId
} else {
# Malformed is an interrupted write or a planted value: replace atomically.
Move-Item -LiteralPath $_idTmp -Destination $_studioIdFile -Force
}
} finally {
Remove-Item -LiteralPath $_idTmp -Force -ErrorAction SilentlyContinue
}
}
# Bake per-install $portFile / $mutexName so custom-root launchers do not share a mutex.
$studioHomeExport = if ($StudioRedirectMode -eq 'env') {
# Reuse the preflight's managed path for legacy-home overrides.
$_llamaPath = Get-ManagedLlamaCppDir
$_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'
`$_StudioInstallIdFile = '$($_studioIdFile -replace "'", "''")'
# A missing or malformed id makes /api/health report "", which the baked id never matches: restore ours no-clobber (the desktop app mints it too), leaving a different valid id or an unreadable file alone.
function Repair-StudioInstallId {
`$idTmp = `$null
try {
if (Test-Path -LiteralPath `$_StudioInstallIdFile -PathType Container) { return }
if (Test-Path -LiteralPath `$_StudioInstallIdFile -PathType Leaf) {
`$current = ([System.IO.File]::ReadAllText(`$_StudioInstallIdFile)).Trim()
if (`$current -cmatch '^[0-9a-f]{64}$') { return }
}
[System.IO.Directory]::CreateDirectory((Split-Path -Parent `$_StudioInstallIdFile)) | Out-Null
`$idTmp = "`$_StudioInstallIdFile.`$([System.IO.Path]::GetRandomFileName()).tmp"
[System.IO.File]::WriteAllText(`$idTmp, `$_ExpectedStudioRootId)
try {
[System.IO.File]::Move(`$idTmp, `$_StudioInstallIdFile)
} catch [System.IO.IOException] {
`$incumbent = ''
try { `$incumbent = ([System.IO.File]::ReadAllText(`$_StudioInstallIdFile)).Trim() } catch { return }
if (`$incumbent -cnotmatch '^[0-9a-f]{64}$') {
Move-Item -LiteralPath `$idTmp -Destination `$_StudioInstallIdFile -Force
}
}
} catch {
} finally {
if (`$idTmp) { Remove-Item -LiteralPath `$idTmp -Force -ErrorAction SilentlyContinue }
}
}
Repair-StudioInstallId
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 Unsloth 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 Unsloth 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'
# The managed interpreter, not the generated unsloth.exe console script: that one is
# unsigned and Application Control denies it on managed machines (#8490).
`$studioPython = '$SingleQuotedPythonPath'
`$studioEntry = '$SingleQuotedTrampoline'
`$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.
# The entry point is single-quoted for the same reason: it carries apostrophes
# around 'unsloth', and unquoted they would end the string mid-expression.
`$studioCommand = "& '" + (`$studioPython -replace "'", "''") + "' -X utf8 -c '" +
(`$studioEntry -replace "'", "''") + "' studio -p " + `$launchPort
# RemoteSigned, not Bypass: the child runs an inline -Command against an executable, so no
# script file is loaded and the two behave identically here. No reason to spend a scored
# token on a launch that needs no policy relief.
`$launchArgs = @(
'-NoExit',
'-NoProfile',
'-ExecutionPolicy',
'RemoteSigned',
'-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
"@
# UTF-8 with BOM for 5.1. Content-compared first so a re-run is a no-op on disk.
$utf8Bom = New-Object System.Text.UTF8Encoding($true)
# Compare bytes including the BOM, so an older BOM-less launcher gets rewritten.
$desiredLauncher = $utf8Bom.GetPreamble() + $utf8Bom.GetBytes($launcherContent)
$launcherUnchanged = $false
if (Test-Path -LiteralPath $launcherPs1 -PathType Leaf) {
try {
$existingLauncher = [System.IO.File]::ReadAllBytes($launcherPs1)
$launcherUnchanged =
(@(Compare-Object $existingLauncher $desiredLauncher -SyncWindow 0).Count -eq 0)
} catch {
# Unreadable is not "unchanged"; fall through and rewrite it.
$launcherUnchanged = $false
}
}
if (-not $launcherUnchanged) {
[System.IO.File]::WriteAllBytes($launcherPs1, $desiredLauncher)
}
# Clear any mark-of-the-web (RemoteSigned refuses marked unsigned scripts), outside the
# content check. -Confirm:$false: a profile's $ConfirmPreference would otherwise prompt.
Unblock-File -LiteralPath $launcherPs1 -Confirm:$false -ErrorAction SilentlyContinue
# No .vbs launcher: the .lnk shortcuts run powershell.exe on launch-studio.ps1 hidden.
# tests/studio/test_installer_av_shapes.py (AV_SHAPES_RECORD)
# Drop any launch-studio.vbs left by an install that predates that change.
$legacyLauncherVbs = Join-Path $appDir "launch-studio.vbs"
if (Test-Path -LiteralPath $legacyLauncherVbs) {
Remove-Item -LiteralPath $legacyLauncherVbs -Force -ErrorAction SilentlyContinue
}
# Attach only on a valid ICO header; snapshot the icon so the refresh runs on a change.
$preIconHash = $null
if (Test-Path -LiteralPath $iconPath) {
try { $preIconHash = (Get-FileHash -LiteralPath $iconPath -Algorithm SHA256).Hash } catch {}
}
$hasValidIcon = $false
if ($bundledIcon -and (Test-Path -LiteralPath $bundledIcon)) {
try {
Copy-Item -LiteralPath $bundledIcon -Destination $iconPath -Force
} catch {
Write-StudioLine "[DEBUG] Error copying bundled icon: $($_.Exception.Message)" -ForegroundColor DarkGray
}
} elseif ($packagedIcon) {
try {
Copy-Item -LiteralPath $packagedIcon -Destination $iconPath -Force
} catch {
Write-StudioLine "[DEBUG] Error copying packaged icon: $($_.Exception.Message)" -ForegroundColor DarkGray
}
} elseif (-not (Test-Path -LiteralPath $iconPath)) {
try {
Invoke-WebRequest -Uri $iconUrl -OutFile $iconPath -UseBasicParsing
} catch {
Write-StudioLine "[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-StudioLine "[DEBUG] Error validating or removing icon: $($_.Exception.Message)" -ForegroundColor DarkGray
Remove-Item -LiteralPath $iconPath -Force -ErrorAction SilentlyContinue
}
}
$iconChanged = $false
if ($hasValidIcon) {
if (-not $preIconHash) {
$iconChanged = $true
} else {
try {
$postIconHash = (Get-FileHash -LiteralPath $iconPath -Algorithm SHA256).Hash
$iconChanged = ($postIconHash -ne $preIconHash)
} catch { $iconChanged = $true }
}
} elseif ($preIconHash) {
# A previously present icon was removed or invalidated.
$iconChanged = $true
}
# Env-mode: skip .lnk shortcuts (may point at a deleted workspace); launcher stays.
if ($StudioRedirectMode -eq 'env') {
substep "wrote launcher at $launcherPs1 (persistent shortcuts skipped in env-override mode)"
return
}
# Gates the heavy refresh below.
# tests/studio/test_installer_av_shapes.py (AV_SHAPES_RECORD)
$firstInstall = -not (
($desktopLink -and (Test-Path -LiteralPath $desktopLink)) -or
($startMenuLink -and (Test-Path -LiteralPath $startMenuLink))
)
# Shortcuts run powershell.exe on launch-studio.ps1 hidden, without a .vbs wrapper.
# RemoteSigned, not Bypass, as install.rs does; a locally written launcher loads either way.
# tests/studio/test_installer_av_shapes.py (AV_SHAPES_RECORD)
$powershellForLnk = Join-Path $env:SystemRoot "System32\WindowsPowerShell\v1.0\powershell.exe"
$shortcutTarget = $powershellForLnk
$shortcutArgs = "-NoProfile -WindowStyle Hidden -ExecutionPolicy RemoteSigned -File `"$launcherPs1`""
# A launcher on a share (UNC or mapped drive) is remote to RemoteSigned, so use Bypass
# without the hidden window for that case only.
$launcherIsRemote = $launcherPs1 -like "\\*"
if (-not $launcherIsRemote) {
try {
$launcherIsRemote = ([System.IO.DriveInfo]::new(
[System.IO.Path]::GetPathRoot($launcherPs1))).DriveType -eq 'Network'
} catch {}
}
if ($launcherIsRemote) {
$shortcutArgs = "-NoProfile -ExecutionPolicy Bypass -File `"$launcherPs1`""
}
try {
$wshell = New-Object -ComObject WScript.Shell
$createdShortcutCount = 0
$createdShortcutPaths = @()
$desiredIconLocation = if ($hasValidIcon) { "$iconPath,0" } else { $null }
foreach ($linkPath in @($desktopLink, $startMenuLink)) {
if (-not $linkPath -or [string]::IsNullOrWhiteSpace($linkPath)) { continue }
try {
$shortcut = $wshell.CreateShortcut($linkPath)
# Save() rewrites even when unchanged, so compare first (IconLocation carries ",0").
$shortcutUnchanged = (Test-Path -LiteralPath $linkPath -PathType Leaf) -and
($shortcut.TargetPath -eq $shortcutTarget) -and
($shortcut.Arguments -eq $shortcutArgs) -and
($shortcut.WorkingDirectory -eq $appDir) -and
($shortcut.WindowStyle -eq 7) -and
($shortcut.Description -eq "Launch Unsloth Studio") -and
((-not $desiredIconLocation) -or ($shortcut.IconLocation -eq $desiredIconLocation))
if (-not $shortcutUnchanged) {
$shortcut.TargetPath = $shortcutTarget
$shortcut.Arguments = $shortcutArgs
$shortcut.WorkingDirectory = $appDir
# Start minimized so the brief PowerShell console flash is muted.
$shortcut.WindowStyle = 7
$shortcut.Description = "Launch Unsloth Studio"
if ($hasValidIcon) {
$shortcut.IconLocation = $desiredIconLocation
}
$shortcut.Save()
}
$createdShortcutCount++
$createdShortcutPaths += $linkPath
} catch {
substep "could not create shortcut at ${linkPath}: $($_.Exception.Message)" "Yellow"
}
}
if ($createdShortcutCount -gt 0) {
substep "Created Unsloth Studio shortcut"
# Per-item SHChangeNotify: the global broadcast misses a rewritten same-name .lnk.
try {
$null = Invoke-StudioPythonShellIconRefresh `
-Paths $createdShortcutPaths -Exe $ManagedPythonPath -DestinationValidated:$DestinationValidated
} catch {}
if ($firstInstall -or $iconChanged) {
try { & "$env:SystemRoot\System32\ie4uinit.exe" -ClearIconCache 2>$null } catch {}
try { & "$env:SystemRoot\System32\ie4uinit.exe" -show 2>$null } catch {}
# Win11 caches tile icons past ie4uinit; never drop start2.bin (the layout).
try {
$smehTemp = Join-Path $env:LOCALAPPDATA "Packages\Microsoft.Windows.StartMenuExperienceHost_cw5n1h2txyewy\TempState"
if (Test-Path -LiteralPath $smehTemp) {
Get-ChildItem -LiteralPath $smehTemp -Filter "TileCache_*" -ErrorAction SilentlyContinue |
Remove-Item -Force -ErrorAction SilentlyContinue
Remove-Item -LiteralPath (Join-Path $smehTemp "StartUnifiedTileModelCache.dat") -Force -ErrorAction SilentlyContinue
Stop-Process -Name StartMenuExperienceHost -Force -ErrorAction SilentlyContinue
}
} catch {}
}
} 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) {
# Returns before the lock try/finally, so undo the temp redirection on every way out.
try {
if ($TauriMode) { return }
# Check the interpreter, which the launcher runs, not the possibly denied unsloth.exe.
$ShortcutPython = Join-Path $VenvDir "Scripts\python.exe"
if (-not (Test-Path -LiteralPath $ShortcutPython)) {
Write-StudioLine "[ERROR] managed Python missing at $ShortcutPython; run install.ps1 first." -ForegroundColor Red
# throw (not Exit-InstallFailure) so non-Tauri callers see rc != 0.
throw "managed Python missing"
}
# Older installs only reach the installer through `unsloth studio update`, so create the
# shim directory and .cmd here too. Idempotent and never fatal.
$ShortcutShimDir = Join-Path $StudioHome "bin"
try {
[System.IO.Directory]::CreateDirectory($ShortcutShimDir) | Out-Null
} catch {
substep "could not create $ShortcutShimDir`: $($_.Exception.Message)" "Yellow"
}
if (Test-Path -LiteralPath $ShortcutShimDir -PathType Container) {
Write-UnslothCmdShim -ShimDir $ShortcutShimDir -PythonPath $ShortcutPython
# Older installers put Scripts on PATH instead; add the shim dir (idempotent). Env mode
# never writes the registry.
if ($StudioRedirectMode -ne 'env') {
if (Add-ToUserPath -Directory $ShortcutShimDir -Position 'Prepend') {
substep "added $ShortcutShimDir to PATH"
}
}
}
New-StudioShortcuts -ManagedPythonPath $ShortcutPython
return
} finally {
Restore-StudioTempEnvironment
}
}
# ── Leave Windows system directories before installing ──
# "Run as administrator" starts in System32, where setup refuses after the PyTorch
# download. Relocating is safe (nothing reads the caller's directory except
# --with-llama-cpp-dir, rebased first). Not restored, like the PSModulePath fix.
$SystemRootDir = if ($env:SystemRoot) { $env:SystemRoot } else { "C:\Windows" }
$SystemRootDir = [System.IO.Path]::GetFullPath($SystemRootDir).TrimEnd('\')
# Separator included, or siblings like C:\Windows.old and C:\WindowsStudio match too.
$SystemRootPrefix = $SystemRootDir + [System.IO.Path]::DirectorySeparatorChar
$CurrentDir = $null
try {
# FileSystem provider: a caller parked on HKLM:\ still has the location children inherit.
$CurrentDir = [System.IO.Path]::GetFullPath(
(Get-Location -PSProvider FileSystem -ErrorAction Stop).ProviderPath
).TrimEnd('\')
} catch {
$CurrentDir = $null
}
function Test-UnderSystemRoot {
param([string]$Path)
return $Path -and (
$Path.Equals($SystemRootDir, [System.StringComparison]::OrdinalIgnoreCase) -or
$Path.StartsWith($SystemRootPrefix, [System.StringComparison]::OrdinalIgnoreCase)
)
}
$InSystemDir = Test-UnderSystemRoot $CurrentDir
if ($InSystemDir) {
if ($WithLlamaCppDir) {
# Anchor to the directory the user typed against; GetFullPath uses the .NET current
# directory, which Set-Location never updates.
try {
$WithLlamaCppDir =
$ExecutionContext.SessionState.Path.GetUnresolvedProviderPathFromPSPath($WithLlamaCppDir)
} catch {
$WithLlamaCppDir = Join-Path $CurrentDir $WithLlamaCppDir
}
}
# SYSTEM's profile is C:\Windows\System32\config\systemprofile, so a candidate
# inside the Windows directory is no better than where we already are.
$SafeDirCandidates = @($env:USERPROFILE, $HOME, $env:PUBLIC, $env:TEMP) |
Where-Object {
$_ -and (Test-Path -LiteralPath $_ -PathType Container) -and
-not (Test-UnderSystemRoot ([System.IO.Path]::GetFullPath($_).TrimEnd('\')))
}
$SafeDir = $null
foreach ($candidate in $SafeDirCandidates) {
try {
Set-Location -LiteralPath $candidate -ErrorAction Stop
$SafeDir = (Get-Location -PSProvider FileSystem).ProviderPath
break
} catch {
continue
}
}
# A SYSTEM account's $StudioHome is under System32; rebasing would orphan the install, so
# stop.
$StudioHomeFull = ""
try { $StudioHomeFull = [System.IO.Path]::GetFullPath($StudioHome).TrimEnd('\') } catch {}
if ($SafeDir -and (Test-UnderSystemRoot $StudioHomeFull)) {
Write-StudioLine ""
Write-StudioLine "[ERROR] Unsloth would install into $StudioHomeFull," -ForegroundColor Red
Write-StudioLine " which is inside $SystemRootDir." -ForegroundColor Yellow
Write-StudioLine " That is where a service or the SYSTEM account keeps its profile." -ForegroundColor Yellow
Write-StudioLine " Sign in as a normal user, open PowerShell there, and run the" -ForegroundColor Yellow
Write-StudioLine " installer again:" -ForegroundColor Yellow
Write-StudioLine " irm https://unsloth.ai/install.ps1 | iex" -ForegroundColor Cyan
Write-StudioLine ""
return (Exit-InstallFailure "Refusing to install into $StudioHomeFull, which is inside $SystemRootDir. Run the installer from a normal user account.")
}
if ($SafeDir) {
Write-TauriLog "STEP" "Left system directory $CurrentDir for $SafeDir"
step "directory" "$CurrentDir is a Windows system folder" "Yellow"
substep "Unsloth cannot install or run from there, so this install continues in:" "Yellow"
substep " $SafeDir" "Yellow"
substep "This is normal: 'Run as administrator' opens PowerShell in System32." "Yellow"
} else {