fix: use direct registry API for PATH writes instead of SetEnvironmentVariable (#4961)
* fix: replacing SetEnvironmentVariable with direct registry API
* apply reviews
* Use CreateSubKey for HKCU\Environment
* Store PATH backup under HKCU\Software\Unsloth
* Fix $backupKey registry handle leak in PATH backup block
Wrap $backupKey operations in try/finally so the handle is closed even
if GetValue or SetValue throws. The Add-ToUserPath helper already uses
this pattern for its registry key -- the backup block was the only
place missing it.
* Isolate WM_SETTINGCHANGE broadcast from PATH write error handling
Wrap the broadcast dummy-variable calls in their own try/catch so a
broadcast failure does not mask a successful registry PATH write.
Previously, if SetEnvironmentVariable threw after SetValue already
committed the new PATH, Add-ToUserPath would return $false and the
caller would skip Refresh-SessionPath.
* PATH helper polish: venv precedence, quoted entries, raw/expanded dedup
Three small follow-ups surfaced by a 10-reviewer pass against the rebased
PR head. None fix a regression vs main; each strictly improves the new
helpers.
Refresh-SessionPath / Refresh-Environment:
- Move $env:Path to the front of the merge so an activated venv keeps
precedence over machine/user PATH after a refresh. Pre-PR dropped
process-only entries entirely; post-PR kept them but at the back.
- Dedup on both raw and expanded forms so %USERPROFILE%\foo and the
already-expanded C:\Users\me\foo do not both survive.
Add-ToUserPath:
- Trim whitespace and surrounding double-quotes from each compared entry
so quoted PATH entries like "C:\Program Files\CMake\bin" deduplicate
against an unquoted directory of the same path.
* Back up User PATH inside Add-ToUserPath, before first mutation
Previously only studio/setup.ps1 took a one-time PATH backup, at script
top (line ~547). install.ps1 (the irm | iex entry point) had no backup,
so users who installed via that path had no recovery surface if anything
clobbered their PATH. The PR description's "one-time backup before any
modifications" promise only held for the studio installer flow.
Move the backup into Add-ToUserPath itself: just before the first actual
SetValue mutation, write the pristine raw PATH to
HKCU\Software\Unsloth\PathBackup if no backup already exists. This:
- Covers both entry points (install.ps1 and studio/setup.ps1).
- Captures the TRUE pristine PATH even when install.ps1 runs first and
studio/setup.ps1 runs afterwards (the script-top backup in setup.ps1
would otherwise see an already-modified PATH).
- Is idempotent: once a backup exists, subsequent calls preserve it.
- Skips when nothing would mutate (dedup match) or PATH is empty.
The script-top backup in studio/setup.ps1 is kept for defense in depth.
* Refresh PATH: venv-aware merge order
Reconcile two competing concerns about Refresh-SessionPath /
Refresh-Environment surfaced by separate review rounds:
- venv at the back -> activated venv loses precedence to system Python
- process at the front -> stale shims (old node, old python, etc.)
still on $env:Path can beat a freshly installed tool
New merge order:
1. Activated venv Scripts dir, only if $env:VIRTUAL_ENV is set
2. Machine PATH freshly read from registry
3. User PATH freshly read from registry
4. Current $env:Path as fallback
This way an explicitly-activated venv keeps priority while a tool the
script just installed wins over any stale entry that was already on
the inherited shell PATH. When no venv is active, fresh registry
entries take precedence as expected.
* Append to User PATH by default, close $envKey in finally
Add-ToUserPath gains a -Position Append|Prepend parameter defaulting to
Append so installing unsloth no longer prepends the bundled venv Scripts
directory ahead of the user's existing python / pip on new shells. The
four current call sites (install.ps1 launcher, studio/setup.ps1 CMake,
nvcc, Python user Scripts) all take the Append default because each one
that needs in-session precedence already does an inline $env:Path prepend
independently. This matches rustup / cargo / nvm / pyenv / uv behavior.
Also wrap the script-top $envKey.GetValue in a try/finally so the
registry handle is released even if the read throws. Matches the pattern
already used for $backupKey five lines below.
* Prepend cmake, nvcc, Python Scripts; keep venv Scripts appended
The previous commit switched Add-ToUserPath to append by default so that
installing unsloth would not silently hijack the user's system python /
pip. That was correct for the venv Scripts dir (which contains python.exe
and pip.exe alongside unsloth.exe), but wrong for the three studio/setup
call sites. Those persist cmake, the driver-compatible nvcc, and the
Python user Scripts dir for future shells, and in all three cases an
older tool already earlier in the user PATH would keep winning after the
install finished. The nvcc case is especially load-bearing: setup selects
a driver-compatible CUDA toolkit, then llama.cpp builds against whatever
wins PATH resolution, so a stale older nvcc produces broken builds.
Pass -Position 'Prepend' explicitly at the three setup.ps1 call sites
(cmake at line 754, nvcc bin at line 1025, Python user Scripts at line
1191). None of those directories holds python.exe, so prepending them
does not re-introduce the original hijack problem. Leave the install.ps1
venv Scripts call on the default Append with a comment explaining why.
* Symmetric dedup, Prepend reorders duplicates, unsloth shim dir
Address three separate findings surfaced by review:
1. Dedup asymmetry (Gemini high-priority): the existing dedup expanded
registry entries via ExpandEnvironmentVariables but did NOT expand the
new directory. Passing "%USERPROFILE%\foo" when "C:\Users\me\foo" was
already in PATH produced a duplicate. Expand both sides so the check
is symmetric.
2. -Position Prepend no-op on existing duplicates: the dedup loop
returned $false as soon as it saw a match, regardless of position.
That left a late-position duplicate in place instead of moving it to
the front, so "prepend the newly selected cmake/nvcc" did not always
beat an older copy earlier in PATH. Partition entries into kept and
dropped lists, then reinsert a single copy at the requested position.
Append still returns $false on any match so user-curated orderings
are not reshuffled. Prepend also returns $false when the only copy
is already at position 0 so we preserve the user's casing.
3. Stop adding the venv Scripts dir to User PATH entirely. That dir
holds python.exe and pip.exe alongside unsloth.exe, so neither
Prepend nor Append worked: prepend hijacked the user's system python
and pip, append made the freshly-installed unsloth.exe lose to any
older unsloth.exe earlier on PATH. Replace the Scripts-dir PATH add
with a dedicated shim directory that contains only unsloth.cmd, and
prepend that dir. The shim calls the venv's unsloth.exe by absolute
path so future pip upgrades inside the venv propagate automatically.
* Shim via hardlink, Append user Scripts, drop venv sysconfig fallback
Three follow-ups to the c0ab1ab shim commit, targeting concerns raised in
the second 20-reviewer pass:
1. Shim uses unsloth.exe (hardlink, copy fallback) instead of unsloth.cmd.
The batch-file approach had three distinct regressions:
- cmd.exe expanded %...% sequences inside user arguments, so prompts
like "What does 50% mean?" got mangled before reaching the CLI
- Git Bash / MSYS2 / POSIX-style shells on Windows do not resolve
bare-name lookups to .cmd files, so `unsloth` stopped working there
- Set-Content -Encoding ASCII replaced non-ASCII profile characters
with '?', so installs under C:\Users\Jörg\... wrote a broken shim
A hardlink (fallback: copy) of unsloth.exe is a native Windows
executable with no shell indirection. PATHEXT picks .exe before .cmd
in cmd.exe and PowerShell, Git Bash honors .exe natively, subprocess
callers hit it directly, and a hardlink stays in sync with the venv
on pip upgrades because both names point at the same inode.
2. studio/setup.ps1 Python user Scripts dir is added with default Append
instead of -Position Prepend. That directory holds every pip-installed
user console script (pip, pytest, huggingface-cli, and so on), not
just unsloth, so reordering it silently changed resolution order for
unrelated tools. The new install.ps1 shim at PATH position 0 already
guarantees `unsloth` resolves to the freshly installed copy, so the
Python user Scripts entry only needs to be present, not at the front.
3. The sysconfig lookup in studio/setup.ps1 no longer falls back to
sysconfig.get_path('scripts') when the nt_user scheme dir does not
exist. When setup.ps1 is invoked from an activated venv (a flow the
linked issue actually hits) that fallback returns the venv's Scripts
directory, which would then be added to the persisted User PATH and
re-introduce the python / pip hijack the shim dir is meant to avoid.
Stick strictly to the nt_user scheme; skip the block if it does not
exist on disk.
* Do not crash installer when unsloth.exe shim is locked
The shim update sequence at install.ps1:1095 did a bare Remove-Item /
New-Item HardLink / Copy-Item. Under the script's $ErrorActionPreference
a locked target (most commonly 'unsloth studio' still running while the
user re-invokes the installer) turns the Remove-Item failure into a
terminating error that aborts the install with no actionable message.
The existing shim is perfectly usable in that state, so there is no
reason to abort. Wrap the whole remove/link/copy sequence in a try/catch
that logs the probable cause (Studio still running), points at the fix
(close Studio and re-run), and lets the installer finish with the old
launcher still serving the command.
Also only emit the "added unsloth launcher to PATH" step line when the
launcher was actually (re)created AND the PATH entry was newly added --
previously the message fired even when the shim refresh silently failed,
which was confusing.
* Guard shim PATH entry on existence, use NullString for broadcast delete
Two follow-ups surfaced by the latest review pass:
1. Do not add the shim directory to User PATH when the launcher was not
actually created. Antivirus blocking unsloth.exe, a disk-full volume,
or restrictive filesystem permissions can make both the hardlink and
the copy fallback fail on a fresh install. In that case the existing
sequence would report "added unsloth launcher to PATH" warnings but
still prepend the empty $ShimDir to User PATH -- the user sees an
install that claims success but then cannot resolve `unsloth` in a
new shell. Gate Add-ToUserPath on Test-Path $ShimExe so the PATH
entry is only persisted when the launcher is really there.
2. Pass [NullString]::Value instead of $null to the broadcast-delete
call in Add-ToUserPath. On PowerShell 7.5 and later (running on .NET
9), a bare $null going into [Environment]::SetEnvironmentVariable
can be coerced to an empty string rather than a true .NET null,
which sets the dummy UnslothPathRefresh_XXXXXXXX variable to "" in
HKCU\Environment instead of deleting it. The leaked variable is
visible in System Properties and accumulates one entry per install
run. [NullString]::Value is a PowerShell-specific sentinel that
crosses the interop boundary as a real null and works on both PS 5.1
and PS 7.x. See PowerShell/PowerShell#24637 for the underlying issue.
---------
Co-authored-by: Daniel Han <danielhanchen@gmail.com>
Co-authored-by: Lee Jackson <130007945+Imagineer99@users.noreply.github.com>
This commit is contained in:
parent
14ab6fbfae
commit
ec32ce2e82
2 changed files with 409 additions and 37 deletions
227
install.ps1
227
install.ps1
|
|
@ -100,22 +100,155 @@ function Install-UnslothStudio {
|
|||
Write-Host ""
|
||||
|
||||
# ── Helper: refresh PATH from registry (deduplicating entries) ──
|
||||
# Merge order:
|
||||
# 1. Activated venv Scripts dir (only if $env:VIRTUAL_ENV is set) so an
|
||||
# explicitly-activated venv keeps precedence.
|
||||
# 2. Machine, then User PATH freshly read from registry so a tool we
|
||||
# just installed wins over any stale shim still in $env:Path.
|
||||
# 3. Current $env:Path as fallback so process-only entries that nothing
|
||||
# else covers are not lost.
|
||||
# Dedup compares both raw and expanded forms so %VAR% references don't
|
||||
# survive twice (once as %VAR%\foo and once as the expanded literal).
|
||||
function Refresh-SessionPath {
|
||||
$machine = [System.Environment]::GetEnvironmentVariable("Path", "Machine")
|
||||
$user = [System.Environment]::GetEnvironmentVariable("Path", "User")
|
||||
$merged = "$machine;$user;$env:Path"
|
||||
$venvScripts = if ($env:VIRTUAL_ENV) { Join-Path $env:VIRTUAL_ENV "Scripts" } else { $null }
|
||||
$sources = @()
|
||||
if ($venvScripts) { $sources += $venvScripts }
|
||||
$sources += @($machine, $user, $env:Path)
|
||||
$merged = ($sources | Where-Object { $_ }) -join ";"
|
||||
$seen = @{}
|
||||
$unique = @()
|
||||
$unique = New-Object System.Collections.Generic.List[string]
|
||||
foreach ($p in $merged -split ";") {
|
||||
$key = $p.TrimEnd("\").ToLowerInvariant()
|
||||
if ($key -and -not $seen.ContainsKey($key)) {
|
||||
$seen[$key] = $true
|
||||
$unique += $p
|
||||
$rawKey = $p.Trim().Trim('"').TrimEnd("\").ToLowerInvariant()
|
||||
$expKey = [Environment]::ExpandEnvironmentVariables($p).Trim().Trim('"').TrimEnd("\").ToLowerInvariant()
|
||||
if ($rawKey -and -not $seen.ContainsKey($rawKey) -and -not $seen.ContainsKey($expKey)) {
|
||||
$seen[$rawKey] = $true
|
||||
if ($expKey -and $expKey -ne $rawKey) { $seen[$expKey] = $true }
|
||||
$unique.Add($p)
|
||||
}
|
||||
}
|
||||
$env:Path = $unique -join ";"
|
||||
}
|
||||
|
||||
# ── Helper: safely add a directory to the persistent User PATH ──
|
||||
# Uses direct registry access to preserve REG_EXPAND_SZ type
|
||||
# (avoids .NET SetEnvironmentVariable bug that converts to REG_SZ).
|
||||
#
|
||||
# Position: 'Append' (default) adds $Directory to the END of the persisted
|
||||
# User PATH so existing user tools (e.g. system python, pip) keep taking
|
||||
# precedence in new shells. This matches rustup/cargo/nvm/pyenv/uv behavior
|
||||
# and avoids silently hijacking resolution of common executables. Pass
|
||||
# 'Prepend' only when a caller truly needs the new entry to win over
|
||||
# existing ones at registry scope. In-session precedence should be handled
|
||||
# by an inline $env:Path = "$Dir;$env:Path" prepend instead.
|
||||
function Add-ToUserPath {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)][string]$Directory,
|
||||
[ValidateSet('Append','Prepend')]
|
||||
[string]$Position = 'Append'
|
||||
)
|
||||
try {
|
||||
$regKey = [Microsoft.Win32.Registry]::CurrentUser.CreateSubKey('Environment')
|
||||
try {
|
||||
$rawPath = $regKey.GetValue('Path', '', [Microsoft.Win32.RegistryValueOptions]::DoNotExpandEnvironmentNames)
|
||||
# Explicit string[] cast: a single-entry split otherwise collapses
|
||||
# to a scalar string, which then gets char-indexed and breaks the
|
||||
# partition loop below.
|
||||
[string[]]$entries = if ($rawPath) { $rawPath -split ';' } else { @() }
|
||||
# Normalize both the raw and expanded forms of the new directory
|
||||
# so dedup catches mirror-image cases: PATH holding %USERPROFILE%\foo
|
||||
# vs Directory passed as C:\Users\me\foo, and vice versa.
|
||||
$normalDir = $Directory.Trim().Trim('"').TrimEnd('\').ToLowerInvariant()
|
||||
$expNormalDir = [Environment]::ExpandEnvironmentVariables($Directory).Trim().Trim('"').TrimEnd('\').ToLowerInvariant()
|
||||
# Partition existing entries into "kept" (not our dir) and "dropped"
|
||||
# (matches our dir). Track match indices so we can distinguish
|
||||
# "already at position 0" from "present but at a late position".
|
||||
$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
|
||||
# Append semantics: if the entry is already anywhere in PATH we
|
||||
# leave it untouched (idempotent, never reorder user-curated order).
|
||||
if ($alreadyPresent -and $Position -eq 'Append') {
|
||||
return $false
|
||||
}
|
||||
# Prepend semantics: if the entry is already at position 0 with
|
||||
# exactly one copy, preserve the user's existing casing/form and
|
||||
# no-op. Only rebuild when a reorder or dedup is actually needed.
|
||||
if ($alreadyPresent -and $Position -eq 'Prepend' -and
|
||||
$matchIndices.Count -eq 1 -and $matchIndices[0] -eq 0) {
|
||||
return $false
|
||||
}
|
||||
# One-time backup of the pristine User PATH before our first
|
||||
# mutation. Stored under HKCU\Software\Unsloth so a wiped/clobbered
|
||||
# PATH can be recovered. Idempotent: existing backup is preserved.
|
||||
if ($rawPath) {
|
||||
try {
|
||||
$backupKey = [Microsoft.Win32.Registry]::CurrentUser.CreateSubKey('Software\Unsloth')
|
||||
try {
|
||||
$existingBackup = $backupKey.GetValue('PathBackup', $null)
|
||||
if (-not $existingBackup) {
|
||||
$backupKey.SetValue('PathBackup', $rawPath, [Microsoft.Win32.RegistryValueKind]::ExpandString)
|
||||
}
|
||||
} finally {
|
||||
$backupKey.Close()
|
||||
}
|
||||
} catch { }
|
||||
}
|
||||
if (-not $rawPath) {
|
||||
Write-Host "[WARN] User PATH is empty - initializing with $Directory" -ForegroundColor Yellow
|
||||
}
|
||||
$newPath = if ($rawPath) {
|
||||
if ($Position -eq 'Prepend') {
|
||||
(@($Directory) + $kept) -join ';'
|
||||
} else {
|
||||
($kept + @($Directory)) -join ';'
|
||||
}
|
||||
} else {
|
||||
$Directory
|
||||
}
|
||||
# Prepend idempotency: if the new directory was already at
|
||||
# position 0 (and no duplicates existed elsewhere) the composed
|
||||
# string matches rawPath byte-for-byte. Skip the registry write
|
||||
# so we do not broadcast an unnecessary WM_SETTINGCHANGE.
|
||||
if ($newPath -ceq $rawPath) {
|
||||
return $false
|
||||
}
|
||||
$regKey.SetValue('Path', $newPath, [Microsoft.Win32.RegistryValueKind]::ExpandString)
|
||||
# Broadcast WM_SETTINGCHANGE so other processes pick up the change.
|
||||
# Use [NullString]::Value (not $null) for the delete call so the
|
||||
# sentinel crosses into .NET as a real null reference -- on
|
||||
# PowerShell 7.5+ / .NET 9, a bare $null here can be coerced to
|
||||
# an empty string, which sets the dummy variable to "" instead
|
||||
# of deleting it and leaves UnslothPathRefresh_XXXXXXXX in
|
||||
# HKCU\Environment permanently.
|
||||
try {
|
||||
$d = "UnslothPathRefresh_$([guid]::NewGuid().ToString('N').Substring(0,8))"
|
||||
[Environment]::SetEnvironmentVariable($d, '1', 'User')
|
||||
[Environment]::SetEnvironmentVariable($d, [NullString]::Value, 'User')
|
||||
} catch { }
|
||||
return $true
|
||||
} finally {
|
||||
$regKey.Close()
|
||||
}
|
||||
} catch {
|
||||
Write-Host "[WARN] Could not update User PATH: $($_.Exception.Message)" -ForegroundColor Yellow
|
||||
return $false
|
||||
}
|
||||
}
|
||||
|
||||
function step {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)][string]$Label,
|
||||
|
|
@ -945,18 +1078,80 @@ shell.Run cmd, 0, False
|
|||
|
||||
New-StudioShortcuts -UnslothExePath $UnslothExe
|
||||
|
||||
# ── Add venv Scripts dir to User PATH so `unsloth studio` works from any terminal ──
|
||||
$ScriptsDir = Join-Path $VenvDir "Scripts"
|
||||
$UserPath = [System.Environment]::GetEnvironmentVariable("Path", "User")
|
||||
if (-not $UserPath -or $UserPath -notlike "*$ScriptsDir*") {
|
||||
if ($UserPath) {
|
||||
[System.Environment]::SetEnvironmentVariable("Path", "$ScriptsDir;$UserPath", "User")
|
||||
} else {
|
||||
[System.Environment]::SetEnvironmentVariable("Path", "$ScriptsDir", "User")
|
||||
# ── Expose the `unsloth` command via a single-purpose shim directory ──
|
||||
# The venv's Scripts dir holds python.exe and pip.exe alongside unsloth.exe,
|
||||
# so adding that dir to PATH (at either position) has unwanted side effects:
|
||||
# Prepend hijacks the user's system python / pip in every future shell;
|
||||
# Append makes the installer's newly-built unsloth.exe lose to any older
|
||||
# unsloth.exe the user already had earlier on PATH. Both are bad.
|
||||
#
|
||||
# Instead we create a small directory that contains only the unsloth
|
||||
# launcher (hardlinked or copied from the venv's Scripts\unsloth.exe),
|
||||
# and Prepend just that directory. Benefits over a .cmd wrapper:
|
||||
# - no batch %...% expansion of user arguments (e.g. prompts with `%`)
|
||||
# - works in Git Bash / MSYS2 / POSIX-style shells on Windows that do
|
||||
# not resolve .cmd by bare name
|
||||
# - no source encoding concerns on non-ASCII profile paths
|
||||
# - programmatic callers (subprocess.run, child_process.execFile) hit
|
||||
# the native executable directly instead of shelling into cmd.exe
|
||||
# We try a hardlink first so pip upgrades inside the venv propagate
|
||||
# automatically (same inode). If the filesystem or volume rejects the
|
||||
# hardlink we fall back to a plain copy, which the next install run
|
||||
# will refresh.
|
||||
$ShimDir = Join-Path $StudioHome "bin"
|
||||
New-Item -ItemType Directory -Force -Path $ShimDir | Out-Null
|
||||
$ShimExe = Join-Path $ShimDir "unsloth.exe"
|
||||
# Wrap the whole remove/link/copy sequence in a try/catch so a locked
|
||||
# launcher does not crash the installer. The common case is a re-run
|
||||
# while the user still has `unsloth studio` open: the existing shim is
|
||||
# held open by the running process, Remove-Item refuses (and under the
|
||||
# script's $ErrorActionPreference this would otherwise be fatal). When
|
||||
# that happens the existing shim is perfectly usable, so we log and
|
||||
# keep going instead of aborting the install.
|
||||
$shimUpdated = $false
|
||||
try {
|
||||
if (Test-Path $ShimExe) { Remove-Item $ShimExe -Force -ErrorAction Stop }
|
||||
try {
|
||||
New-Item -ItemType HardLink -Path $ShimExe -Target $UnslothExe -ErrorAction Stop | Out-Null
|
||||
} catch {
|
||||
# Hardlink unavailable (cross-volume, non-NTFS, permissions). Copy
|
||||
# is self-contained; future pip upgrades inside the venv will not
|
||||
# update the copy until the user re-runs the installer.
|
||||
Copy-Item -Path $UnslothExe -Destination $ShimExe -Force -ErrorAction Stop
|
||||
}
|
||||
$shimUpdated = $true
|
||||
} catch {
|
||||
if (Test-Path $ShimExe) {
|
||||
Write-Host "[WARN] Could not refresh unsloth launcher at $ShimExe." -ForegroundColor Yellow
|
||||
Write-Host " This usually means a running 'unsloth studio' process still holds the file open." -ForegroundColor Yellow
|
||||
Write-Host " Close Studio and re-run the installer to pick up the latest launcher." -ForegroundColor Yellow
|
||||
Write-Host " Continuing with the existing launcher." -ForegroundColor Yellow
|
||||
} else {
|
||||
Write-Host "[WARN] Could not create unsloth launcher at $ShimExe" -ForegroundColor Yellow
|
||||
Write-Host " $($_.Exception.Message)" -ForegroundColor Yellow
|
||||
Write-Host " Launch unsloth studio directly via '$UnslothExe' until the next successful install." -ForegroundColor Yellow
|
||||
}
|
||||
Refresh-SessionPath
|
||||
step "path" "added unsloth to PATH"
|
||||
}
|
||||
# Only add the shim directory to PATH when the launcher actually exists
|
||||
# in it. Otherwise a total shim-creation failure on a fresh install (e.g.
|
||||
# antivirus blocks unsloth.exe, disk full, restrictive FS permissions)
|
||||
# would prepend an empty directory to User PATH and leave the user with
|
||||
# an install that reports success but cannot resolve `unsloth` in a new
|
||||
# shell. Also gate the "added to PATH" step message on both a successful
|
||||
# shim (re)create AND a fresh PATH insertion, so idempotent re-runs stay
|
||||
# quiet.
|
||||
$pathAdded = $false
|
||||
if (Test-Path $ShimExe) {
|
||||
$pathAdded = Add-ToUserPath -Directory $ShimDir -Position 'Prepend'
|
||||
}
|
||||
if ($shimUpdated -and $pathAdded) {
|
||||
step "path" "added unsloth launcher to PATH"
|
||||
}
|
||||
# Sync the current session unconditionally so re-runs in stale terminals
|
||||
# see the shim, and so PATH entries that the studio/setup.ps1 subprocess
|
||||
# persisted (cmake, nvcc, Python Scripts) are visible in this parent
|
||||
# process before it returns control to the user's shell.
|
||||
Refresh-SessionPath
|
||||
|
||||
# Launch studio automatically in interactive terminals;
|
||||
# in non-interactive environments (CI, Docker) just print instructions.
|
||||
|
|
|
|||
221
studio/setup.ps1
221
studio/setup.ps1
|
|
@ -73,7 +73,152 @@ function Refresh-Environment {
|
|||
}
|
||||
$machinePath = [System.Environment]::GetEnvironmentVariable('Path', 'Machine')
|
||||
$userPath = [System.Environment]::GetEnvironmentVariable('Path', 'User')
|
||||
$env:Path = "$machinePath;$userPath"
|
||||
# Merge order:
|
||||
# 1. Activated venv Scripts dir (only if $env:VIRTUAL_ENV is set) so an
|
||||
# explicitly-activated venv keeps precedence.
|
||||
# 2. Machine, then User PATH freshly read from registry so a tool we
|
||||
# just installed wins over any stale shim still in $env:Path.
|
||||
# 3. Current $env:Path as fallback so process-only entries that nothing
|
||||
# else covers are not lost.
|
||||
# Dedup compares both raw and expanded forms so %VAR% references don't
|
||||
# survive twice (once as %VAR%\foo and once as the expanded literal).
|
||||
$venvScripts = if ($env:VIRTUAL_ENV) { Join-Path $env:VIRTUAL_ENV 'Scripts' } else { $null }
|
||||
$sources = @()
|
||||
if ($venvScripts) { $sources += $venvScripts }
|
||||
$sources += @($machinePath, $userPath, $env:Path)
|
||||
$merged = ($sources | Where-Object { $_ }) -join ';'
|
||||
$seen = @{}
|
||||
$unique = New-Object System.Collections.Generic.List[string]
|
||||
foreach ($p in $merged -split ";") {
|
||||
$rawKey = $p.Trim().Trim('"').TrimEnd("\").ToLowerInvariant()
|
||||
$expKey = [Environment]::ExpandEnvironmentVariables($p).Trim().Trim('"').TrimEnd("\").ToLowerInvariant()
|
||||
if ($rawKey -and -not $seen.ContainsKey($rawKey) -and -not $seen.ContainsKey($expKey)) {
|
||||
$seen[$rawKey] = $true
|
||||
if ($expKey -and $expKey -ne $rawKey) { $seen[$expKey] = $true }
|
||||
$unique.Add($p)
|
||||
}
|
||||
}
|
||||
$env:Path = $unique -join ";"
|
||||
}
|
||||
|
||||
# ── Helper: safely add a directory to the persistent User PATH ──
|
||||
# Uses direct registry access to preserve REG_EXPAND_SZ type
|
||||
# (avoids .NET SetEnvironmentVariable bug that converts to REG_SZ).
|
||||
#
|
||||
# Position: 'Append' (default) adds $Directory to the END of the persisted
|
||||
# User PATH so existing user tools (e.g. system python, pip) keep taking
|
||||
# precedence in new shells. This matches rustup/cargo/nvm/pyenv/uv behavior
|
||||
# and avoids silently hijacking resolution of common executables. Pass
|
||||
# 'Prepend' only when a caller truly needs the new entry to win over
|
||||
# existing ones at registry scope. In-session precedence should be handled
|
||||
# by an inline $env:Path = "$Dir;$env:Path" prepend instead.
|
||||
function Add-ToUserPath {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)][string]$Directory,
|
||||
[ValidateSet('Append','Prepend')]
|
||||
[string]$Position = 'Append'
|
||||
)
|
||||
try {
|
||||
$regKey = [Microsoft.Win32.Registry]::CurrentUser.CreateSubKey('Environment')
|
||||
try {
|
||||
$rawPath = $regKey.GetValue('Path', '', [Microsoft.Win32.RegistryValueOptions]::DoNotExpandEnvironmentNames)
|
||||
# Explicit string[] cast: a single-entry split otherwise collapses
|
||||
# to a scalar string, which then gets char-indexed and breaks the
|
||||
# partition loop below.
|
||||
[string[]]$entries = if ($rawPath) { $rawPath -split ';' } else { @() }
|
||||
# Normalize both the raw and expanded forms of the new directory
|
||||
# so dedup catches mirror-image cases: PATH holding %USERPROFILE%\foo
|
||||
# vs Directory passed as C:\Users\me\foo, and vice versa.
|
||||
$normalDir = $Directory.Trim().Trim('"').TrimEnd('\').ToLowerInvariant()
|
||||
$expNormalDir = [Environment]::ExpandEnvironmentVariables($Directory).Trim().Trim('"').TrimEnd('\').ToLowerInvariant()
|
||||
# Partition existing entries into "kept" (not our dir) and "dropped"
|
||||
# (matches our dir). Track match indices so we can distinguish
|
||||
# "already at position 0" from "present but at a late position".
|
||||
$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
|
||||
# Append semantics: if the entry is already anywhere in PATH we
|
||||
# leave it untouched (idempotent, never reorder user-curated order).
|
||||
if ($alreadyPresent -and $Position -eq 'Append') {
|
||||
return $false
|
||||
}
|
||||
# Prepend semantics: if the entry is already at position 0 with
|
||||
# exactly one copy, preserve the user's existing casing/form and
|
||||
# no-op. Only rebuild when a reorder or dedup is actually needed.
|
||||
if ($alreadyPresent -and $Position -eq 'Prepend' -and
|
||||
$matchIndices.Count -eq 1 -and $matchIndices[0] -eq 0) {
|
||||
return $false
|
||||
}
|
||||
# One-time backup of the pristine User PATH before our first
|
||||
# mutation. Stored under HKCU\Software\Unsloth so a wiped/clobbered
|
||||
# PATH can be recovered. Idempotent: existing backup is preserved.
|
||||
# The script-top backup at line ~547 covers the studio entry point;
|
||||
# this in-helper backup also covers callers that bypass that block.
|
||||
if ($rawPath) {
|
||||
try {
|
||||
$backupKey = [Microsoft.Win32.Registry]::CurrentUser.CreateSubKey('Software\Unsloth')
|
||||
try {
|
||||
$existingBackup = $backupKey.GetValue('PathBackup', $null)
|
||||
if (-not $existingBackup) {
|
||||
$backupKey.SetValue('PathBackup', $rawPath, [Microsoft.Win32.RegistryValueKind]::ExpandString)
|
||||
}
|
||||
} finally {
|
||||
$backupKey.Close()
|
||||
}
|
||||
} catch { }
|
||||
}
|
||||
if (-not $rawPath) {
|
||||
Write-Host "[WARN] User PATH is empty - initializing with $Directory" -ForegroundColor Yellow
|
||||
}
|
||||
$newPath = if ($rawPath) {
|
||||
if ($Position -eq 'Prepend') {
|
||||
(@($Directory) + $kept) -join ';'
|
||||
} else {
|
||||
($kept + @($Directory)) -join ';'
|
||||
}
|
||||
} else {
|
||||
$Directory
|
||||
}
|
||||
# Prepend idempotency: if the new directory was already at
|
||||
# position 0 (and no duplicates existed elsewhere) the composed
|
||||
# string matches rawPath byte-for-byte. Skip the registry write
|
||||
# so we do not broadcast an unnecessary WM_SETTINGCHANGE.
|
||||
if ($newPath -ceq $rawPath) {
|
||||
return $false
|
||||
}
|
||||
$regKey.SetValue('Path', $newPath, [Microsoft.Win32.RegistryValueKind]::ExpandString)
|
||||
# Broadcast WM_SETTINGCHANGE so other processes pick up the change.
|
||||
# Use [NullString]::Value (not $null) for the delete call so the
|
||||
# sentinel crosses into .NET as a real null reference -- on
|
||||
# PowerShell 7.5+ / .NET 9, a bare $null here can be coerced to
|
||||
# an empty string, which sets the dummy variable to "" instead
|
||||
# of deleting it and leaves UnslothPathRefresh_XXXXXXXX in
|
||||
# HKCU\Environment permanently.
|
||||
try {
|
||||
$d = "UnslothPathRefresh_$([guid]::NewGuid().ToString('N').Substring(0,8))"
|
||||
[Environment]::SetEnvironmentVariable($d, '1', 'User')
|
||||
[Environment]::SetEnvironmentVariable($d, [NullString]::Value, 'User')
|
||||
} catch { }
|
||||
return $true
|
||||
} finally {
|
||||
$regKey.Close()
|
||||
}
|
||||
} catch {
|
||||
Write-Host "[WARN] Could not update User PATH: $($_.Exception.Message)" -ForegroundColor Yellow
|
||||
return $false
|
||||
}
|
||||
}
|
||||
|
||||
# PowerShell 5.1 compatibility helper: avoid relying on New-TemporaryFile.
|
||||
|
|
@ -493,6 +638,33 @@ if ($script:StudioVtOk -and -not $env:NO_COLOR) {
|
|||
Write-Host " $Rule" -ForegroundColor DarkGray
|
||||
}
|
||||
|
||||
# Back up User PATH before any modifications for recovery.
|
||||
# Stored under HKCU\Software\Unsloth (not HKCU\Environment) to avoid
|
||||
# polluting the process environment block with a multi-KB variable.
|
||||
try {
|
||||
$envKey = [Microsoft.Win32.Registry]::CurrentUser.OpenSubKey('Environment', $false)
|
||||
if ($envKey) {
|
||||
try {
|
||||
$rawPath = $envKey.GetValue('Path', '', [Microsoft.Win32.RegistryValueOptions]::DoNotExpandEnvironmentNames)
|
||||
} finally {
|
||||
$envKey.Close()
|
||||
}
|
||||
if ($rawPath) {
|
||||
$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 {
|
||||
Write-Host "[DEBUG] Could not back up User PATH: $($_.Exception.Message)" -ForegroundColor DarkGray
|
||||
}
|
||||
|
||||
# ==========================================================================
|
||||
# PHASE 1: System-level prerequisites (winget installs, env vars)
|
||||
# All heavy system tool installs happen here BEFORE touching Python.
|
||||
|
|
@ -626,11 +798,11 @@ if (-not $HasCmake) {
|
|||
foreach ($d in $cmakeDefaults) {
|
||||
if (Test-Path (Join-Path $d "cmake.exe")) {
|
||||
$env:Path = "$d;$env:Path"
|
||||
# Persist to user PATH so Refresh-Environment does not drop it later
|
||||
$userPath = [Environment]::GetEnvironmentVariable('Path', 'User')
|
||||
if (-not $userPath -or $userPath -notlike "*$d*") {
|
||||
[Environment]::SetEnvironmentVariable('Path', "$d;$userPath", 'User')
|
||||
}
|
||||
# Persist to user PATH so Refresh-Environment does not drop it later.
|
||||
# Prepend so the newly-selected cmake wins over any older cmake
|
||||
# entry already in the user PATH (this dir has only cmake.exe, no
|
||||
# python.exe, so prepending does not hijack the user's interpreter).
|
||||
Add-ToUserPath -Directory $d -Position 'Prepend' | Out-Null
|
||||
$HasCmake = $null -ne (Get-Command cmake -ErrorAction SilentlyContinue)
|
||||
if ($HasCmake) {
|
||||
Write-Host " Found cmake at $d (added to PATH)" -ForegroundColor Gray
|
||||
|
|
@ -896,14 +1068,12 @@ $nvccBinDir = Split-Path $NvccPath -Parent
|
|||
if ($env:PATH -notlike "*$nvccBinDir*") {
|
||||
[Environment]::SetEnvironmentVariable('PATH', "$nvccBinDir;$env:PATH", 'Process')
|
||||
}
|
||||
# Persist nvcc bin dir to User PATH so it works in new terminals
|
||||
$userPath = [Environment]::GetEnvironmentVariable('Path', 'User')
|
||||
if (-not $userPath -or $userPath -notlike "*$nvccBinDir*") {
|
||||
if ($userPath) {
|
||||
[Environment]::SetEnvironmentVariable('Path', "$nvccBinDir;$userPath", 'User')
|
||||
} else {
|
||||
[Environment]::SetEnvironmentVariable('Path', "$nvccBinDir", 'User')
|
||||
}
|
||||
# Persist nvcc bin dir to User PATH so it works in new terminals.
|
||||
# Prepend so the toolkit we just selected (driver-compatible) wins over any
|
||||
# older CUDA bin dir already on the user PATH. Critical for llama.cpp builds:
|
||||
# a later Refresh-Environment could otherwise reorder the selected nvcc behind
|
||||
# a stale one. No hijack risk since this dir has only CUDA tools, no python.
|
||||
if (Add-ToUserPath -Directory $nvccBinDir -Position 'Prepend') {
|
||||
substep "Persisted CUDA bin dir to user PATH"
|
||||
}
|
||||
|
||||
|
|
@ -1061,15 +1231,22 @@ if ($HasPython) {
|
|||
$PythonOk = $true
|
||||
}
|
||||
|
||||
# Ensure Python Scripts dir is on PATH (so 'unsloth' command works in new terminals)
|
||||
$ScriptsDir = python -c "import sysconfig; print(sysconfig.get_path('scripts', 'nt_user') if __import__('os').path.exists(sysconfig.get_path('scripts', 'nt_user')) else sysconfig.get_path('scripts'))"
|
||||
# Ensure the user-scheme Python Scripts dir is on PATH so any pip-installed
|
||||
# console scripts (including `unsloth` if installed via `pip install --user`)
|
||||
# are discoverable in new terminals. Stick strictly to the 'nt_user' scheme:
|
||||
# we do NOT fall back to sysconfig.get_path('scripts') because that returns
|
||||
# the venv's Scripts dir when this setup.ps1 is invoked inside an activated
|
||||
# venv, which would re-introduce the python / pip hijack that the dedicated
|
||||
# shim directory (install.ps1) was designed to avoid.
|
||||
$ScriptsDir = python -c "import os, sysconfig; p = sysconfig.get_path('scripts', 'nt_user'); print(p if os.path.exists(p) else '')"
|
||||
if ($LASTEXITCODE -eq 0 -and $ScriptsDir -and (Test-Path $ScriptsDir)) {
|
||||
$UserPath = [Environment]::GetEnvironmentVariable('Path', 'User')
|
||||
$UserPathEntries = if ($UserPath) { $UserPath.Split(';') } else { @() }
|
||||
if (-not ($UserPathEntries | Where-Object { $_.TrimEnd('\') -eq $ScriptsDir })) {
|
||||
$newUserPath = if ($UserPath) { "$ScriptsDir;$UserPath" } else { $ScriptsDir }
|
||||
[Environment]::SetEnvironmentVariable('Path', $newUserPath, 'User')
|
||||
|
||||
# Use Append semantics here: this dir holds ALL user-installed pip
|
||||
# console scripts (pip, pytest, huggingface-cli, etc.), and reordering
|
||||
# it to the front of PATH would silently change resolution precedence
|
||||
# for every one of those tools. Install.ps1 already guarantees the new
|
||||
# `unsloth` wins via a dedicated shim dir at PATH position 0, so we
|
||||
# only need to make sure this directory is present, not at the front.
|
||||
if (Add-ToUserPath -Directory $ScriptsDir) {
|
||||
# Also add to current process so it's available immediately
|
||||
$ProcessPathEntries = $env:PATH.Split(';')
|
||||
if (-not ($ProcessPathEntries | Where-Object { $_.TrimEnd('\') -eq $ScriptsDir })) {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue