* 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>
2565 lines
113 KiB
PowerShell
2565 lines
113 KiB
PowerShell
#Requires -Version 5.1
|
|
# SPDX-License-Identifier: AGPL-3.0-only
|
|
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
|
|
<#
|
|
.SYNOPSIS
|
|
Full environment setup for Unsloth Studio on Windows (bundled version).
|
|
.DESCRIPTION
|
|
Always installs Node.js if needed. When running from pip install:
|
|
skips frontend build (already bundled). When running from git repo:
|
|
full setup including frontend build.
|
|
Supports NVIDIA GPU (full training + inference) and CPU-only (GGUF chat mode).
|
|
.NOTES
|
|
Default output is minimal (step/substep), aligned with studio/setup.sh.
|
|
|
|
FULL / LEGACY LOGGING (defensible audit trail, detailed multi-line output):
|
|
unsloth studio setup --verbose
|
|
Or: $env:UNSLOTH_VERBOSE='1'; powershell -File .\studio\setup.ps1
|
|
Or: .\setup.ps1 --verbose
|
|
#>
|
|
|
|
$ErrorActionPreference = "Stop"
|
|
$ScriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path
|
|
$PackageDir = Split-Path -Parent $ScriptDir
|
|
|
|
# --------------------------------------------------------------------------
|
|
# Maintainer-editable defaults
|
|
# Change these in the GitHub-hosted script so users get updated defaults.
|
|
# User env vars always override these baked-in values.
|
|
# --------------------------------------------------------------------------
|
|
# Prefer "latest" over "master" -- "master" bypasses the prebuilt resolver
|
|
# (no matching GitHub release), forces a source build, and causes HTTP 422
|
|
# errors. Only use "master" temporarily when the latest release is missing
|
|
# support for a new model architecture.
|
|
$DefaultLlamaPrForce = ""
|
|
$DefaultLlamaSource = "https://github.com/ggml-org/llama.cpp"
|
|
$DefaultLlamaTag = "latest"
|
|
$DefaultLlamaForceCompileRef = "master"
|
|
|
|
# Verbose can be enabled either by CLI flag or by UNSLOTH_VERBOSE=1.
|
|
$script:UnslothVerbose = ($env:UNSLOTH_VERBOSE -eq '1')
|
|
foreach ($a in $args) {
|
|
if ($a -eq '--verbose' -or $a -eq '-v') {
|
|
$script:UnslothVerbose = $true
|
|
break
|
|
}
|
|
}
|
|
# Propagate to child processes (e.g. install_python_stack.py) so they
|
|
# also respect verbose mode. Process-scoped -- does not persist.
|
|
if ($script:UnslothVerbose) {
|
|
$env:UNSLOTH_VERBOSE = '1'
|
|
}
|
|
$script:LlamaCppDegraded = $false
|
|
|
|
# Detect if running from pip install (no frontend/ dir in studio)
|
|
$FrontendDir = Join-Path $ScriptDir "frontend"
|
|
$OxcValidatorDir = Join-Path $ScriptDir "backend\core\data_recipe\oxc-validator"
|
|
$IsPipInstall = -not (Test-Path $FrontendDir)
|
|
|
|
# ─────────────────────────────────────────────
|
|
# Helper functions
|
|
# ─────────────────────────────────────────────
|
|
|
|
# Reload ALL environment variables from registry.
|
|
# Picks up changes made by installers (winget, msi, etc.) including
|
|
# Path, CUDA_PATH, CUDA_PATH_V*, and any other vars they set.
|
|
function Refresh-Environment {
|
|
foreach ($level in @('Machine', 'User')) {
|
|
$vars = [System.Environment]::GetEnvironmentVariables($level)
|
|
foreach ($key in $vars.Keys) {
|
|
if ($key -eq 'Path') { continue }
|
|
Set-Item -Path "Env:$key" -Value $vars[$key] -ErrorAction SilentlyContinue
|
|
}
|
|
}
|
|
$machinePath = [System.Environment]::GetEnvironmentVariable('Path', 'Machine')
|
|
$userPath = [System.Environment]::GetEnvironmentVariable('Path', 'User')
|
|
# 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.
|
|
function New-UnslothTemporaryFile {
|
|
$tempPath = [System.IO.Path]::GetTempFileName()
|
|
return Get-Item -LiteralPath $tempPath
|
|
}
|
|
|
|
function Get-InstalledLlamaPrebuiltRelease {
|
|
param([string]$InstallDir)
|
|
|
|
$metadataPath = Join-Path $InstallDir "UNSLOTH_PREBUILT_INFO.json"
|
|
if (-not (Test-Path $metadataPath)) {
|
|
return $null
|
|
}
|
|
|
|
try {
|
|
$payload = Get-Content $metadataPath -Raw | ConvertFrom-Json
|
|
} catch {
|
|
return $null
|
|
}
|
|
|
|
if (-not $payload.published_repo -or -not $payload.release_tag) {
|
|
return $null
|
|
}
|
|
|
|
$message = "installed release: $($payload.published_repo)@$($payload.release_tag)"
|
|
if ($payload.tag -and $payload.tag -ne $payload.release_tag) {
|
|
$message += " (tag $($payload.tag))"
|
|
}
|
|
return $message
|
|
}
|
|
|
|
# Find nvcc on PATH, CUDA_PATH, or standard toolkit dirs.
|
|
# Returns the path to nvcc.exe, or $null if not found.
|
|
function Find-Nvcc {
|
|
param([string]$MaxVersion = "")
|
|
|
|
# If MaxVersion is set, we need to find a toolkit <= that version.
|
|
# CUDA toolkits install side-by-side under C:\Program Files\NVIDIA GPU Computing Toolkit\CUDA\vX.Y\
|
|
|
|
$toolkitBase = 'C:\Program Files\NVIDIA GPU Computing Toolkit\CUDA'
|
|
|
|
if ($MaxVersion -and (Test-Path $toolkitBase)) {
|
|
$drMajor = [int]$MaxVersion.Split('.')[0]
|
|
$drMinor = [int]$MaxVersion.Split('.')[1]
|
|
|
|
# Get all installed CUDA dirs, sorted descending (highest first)
|
|
$cudaDirs = Get-ChildItem -Directory $toolkitBase | Where-Object {
|
|
$_.Name -match '^v(\d+)\.(\d+)'
|
|
} | Sort-Object { [version]($_.Name -replace '^v','') } -Descending
|
|
|
|
foreach ($dir in $cudaDirs) {
|
|
if ($dir.Name -match '^v(\d+)\.(\d+)') {
|
|
$tkMajor = [int]$Matches[1]; $tkMinor = [int]$Matches[2]
|
|
$compatible = ($tkMajor -lt $drMajor) -or ($tkMajor -eq $drMajor -and $tkMinor -le $drMinor)
|
|
if ($compatible) {
|
|
$nvcc = Join-Path $dir.FullName 'bin\nvcc.exe'
|
|
if (Test-Path $nvcc) {
|
|
return $nvcc
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
# No compatible side-by-side version found
|
|
return $null
|
|
}
|
|
|
|
# Fallback: no version constraint — pick latest or whatever is available
|
|
|
|
# 1. Check nvcc on PATH
|
|
$cmd = Get-Command nvcc -ErrorAction SilentlyContinue
|
|
if ($cmd) { return $cmd.Source }
|
|
|
|
# 2. Check CUDA_PATH env var
|
|
$cudaRoot = [Environment]::GetEnvironmentVariable('CUDA_PATH', 'Process')
|
|
if (-not $cudaRoot) { $cudaRoot = [Environment]::GetEnvironmentVariable('CUDA_PATH', 'Machine') }
|
|
if (-not $cudaRoot) { $cudaRoot = [Environment]::GetEnvironmentVariable('CUDA_PATH', 'User') }
|
|
if ($cudaRoot -and (Test-Path (Join-Path $cudaRoot 'bin\nvcc.exe'))) {
|
|
return (Join-Path $cudaRoot 'bin\nvcc.exe')
|
|
}
|
|
|
|
# 3. Scan standard toolkit directory
|
|
if (Test-Path $toolkitBase) {
|
|
$latest = Get-ChildItem -Directory $toolkitBase | Where-Object {
|
|
$_.Name -match '^v(\d+)\.(\d+)'
|
|
} | Sort-Object { [version]($_.Name -replace '^v','') } -Descending | Select-Object -First 1
|
|
if ($latest -and (Test-Path (Join-Path $latest.FullName 'bin\nvcc.exe'))) {
|
|
return (Join-Path $latest.FullName 'bin\nvcc.exe')
|
|
}
|
|
}
|
|
|
|
return $null
|
|
}
|
|
|
|
# Detect CUDA Compute Capability via nvidia-smi.
|
|
# Returns e.g. "80" for A100 (8.0), "89" for RTX 4090 (8.9), etc.
|
|
# Returns $null if detection fails.
|
|
function Get-CudaComputeCapability {
|
|
# Use the resolved absolute path ($NvidiaSmiExe) to survive Refresh-Environment
|
|
$smiExe = if ($script:NvidiaSmiExe) { $script:NvidiaSmiExe } else {
|
|
$cmd = Get-Command nvidia-smi -ErrorAction SilentlyContinue
|
|
if ($cmd) { $cmd.Source } else { $null }
|
|
}
|
|
if (-not $smiExe) { return $null }
|
|
|
|
try {
|
|
$raw = & $smiExe --query-gpu=compute_cap --format=csv,noheader 2>$null
|
|
if ($LASTEXITCODE -ne 0 -or -not $raw) { return $null }
|
|
|
|
# nvidia-smi may return multiple GPUs; take the first one
|
|
$cap = ($raw -split "`n")[0].Trim()
|
|
if ($cap -match '^(\d+)\.(\d+)$') {
|
|
$major = $Matches[1]
|
|
$minor = $Matches[2]
|
|
return "$major$minor"
|
|
}
|
|
} catch { }
|
|
|
|
return $null
|
|
}
|
|
|
|
# Check if an nvcc binary supports a given sm_ architecture.
|
|
# Uses `nvcc --list-gpu-code` which outputs sm_* tokens (--list-gpu-arch
|
|
# outputs compute_* tokens instead). Available since CUDA 11.6.
|
|
# Returns $false if the flag isn't supported (old toolkit) — safer to reject
|
|
# and fall back to scanning/PTX than to assume support and fail later.
|
|
function Test-NvccArchSupport {
|
|
param([string]$NvccExe, [string]$Arch)
|
|
try {
|
|
$listCode = & $NvccExe --list-gpu-code 2>&1 | Out-String
|
|
if ($LASTEXITCODE -ne 0) { return $false }
|
|
return ($listCode -match "sm_$Arch")
|
|
} catch {
|
|
return $false
|
|
}
|
|
}
|
|
|
|
# Given an nvcc binary, return the highest sm_ architecture it supports.
|
|
# Returns e.g. "90" for CUDA 12.4. Returns $null if detection fails.
|
|
function Get-NvccMaxArch {
|
|
param([string]$NvccExe)
|
|
try {
|
|
$listCode = & $NvccExe --list-gpu-code 2>&1 | Out-String
|
|
if ($LASTEXITCODE -ne 0) { return $null }
|
|
$arches = @()
|
|
foreach ($line in $listCode -split "`n") {
|
|
if ($line.Trim() -match '^sm_(\d+)') {
|
|
$arches += [int]$Matches[1]
|
|
}
|
|
}
|
|
if ($arches.Count -gt 0) {
|
|
return ($arches | Sort-Object | Select-Object -Last 1).ToString()
|
|
}
|
|
} catch { }
|
|
return $null
|
|
}
|
|
|
|
# Detect driver's max CUDA version from nvidia-smi and return the highest
|
|
# compatible PyTorch CUDA index tag (e.g. "cu128").
|
|
# PyTorch on Windows ships CPU-only by default from PyPI; CUDA wheels live at
|
|
# https://download.pytorch.org/whl/<tag>. The tag must not exceed the driver's
|
|
# capability: e.g. driver "CUDA Version: 12.9" → cu128 (not cu130).
|
|
function Get-PytorchCudaTag {
|
|
$smiExe = if ($script:NvidiaSmiExe) { $script:NvidiaSmiExe } else {
|
|
$cmd = Get-Command nvidia-smi -ErrorAction SilentlyContinue
|
|
if ($cmd) { $cmd.Source } else { $null }
|
|
}
|
|
if (-not $smiExe) { return "cu126" }
|
|
|
|
try {
|
|
# 2>&1 | Out-String merges stderr into stdout then converts to a single
|
|
# string. Plain 2>$null doesn't fully suppress stderr in PS 5.1 --
|
|
# ErrorRecord objects leak into $output and break the -match.
|
|
$output = & $smiExe 2>&1 | Out-String
|
|
if ($output -match 'CUDA Version:\s+(\d+)\.(\d+)') {
|
|
$major = [int]$Matches[1]
|
|
$minor = [int]$Matches[2]
|
|
# PyTorch 2.10 offers: cu124, cu126, cu128, cu130
|
|
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 "cpu"
|
|
}
|
|
} catch { }
|
|
|
|
return "cu126"
|
|
}
|
|
|
|
# Find Visual Studio Build Tools for cmake -G flag.
|
|
# Strategy: (1) vswhere, (2) scan filesystem (handles broken vswhere registration).
|
|
# Returns @{ Generator = "Visual Studio 17 2022"; InstallPath = "C:\..."; Source = "..." } or $null.
|
|
function Find-VsBuildTools {
|
|
$map = @{ '2022' = '17'; '2019' = '16'; '2017' = '15' }
|
|
|
|
# --- Try vswhere first (works when VS is properly registered) ---
|
|
$vsw = "${env:ProgramFiles(x86)}\Microsoft Visual Studio\Installer\vswhere.exe"
|
|
if (Test-Path $vsw) {
|
|
$info = & $vsw -latest -requires Microsoft.VisualStudio.Component.VC.Tools.x86.x64 -property catalog_productLineVersion 2>$null
|
|
$path = & $vsw -latest -requires Microsoft.VisualStudio.Component.VC.Tools.x86.x64 -property installationPath 2>$null
|
|
if ($info -and $path) {
|
|
$y = $info.Trim()
|
|
$n = $map[$y]
|
|
if ($n) {
|
|
return @{ Generator = "Visual Studio $n $y"; InstallPath = $path.Trim(); Source = 'vswhere' }
|
|
}
|
|
}
|
|
}
|
|
|
|
# --- Scan filesystem (handles broken vswhere registration after winget cycles) ---
|
|
$roots = @($env:ProgramFiles, ${env:ProgramFiles(x86)})
|
|
$editions = @('BuildTools', 'Community', 'Professional', 'Enterprise')
|
|
$years = @('2022', '2019', '2017')
|
|
|
|
foreach ($y in $years) {
|
|
foreach ($r in $roots) {
|
|
foreach ($ed in $editions) {
|
|
$candidate = Join-Path $r "Microsoft Visual Studio\$y\$ed"
|
|
if (Test-Path $candidate) {
|
|
$vcDir = Join-Path $candidate "VC\Tools\MSVC"
|
|
if (Test-Path $vcDir) {
|
|
$cl = Get-ChildItem -Path $vcDir -Filter "cl.exe" -Recurse -ErrorAction SilentlyContinue | Select-Object -First 1
|
|
if ($cl) {
|
|
$n = $map[$y]
|
|
if ($n) {
|
|
return @{ Generator = "Visual Studio $n $y"; InstallPath = $candidate; Source = "filesystem ($ed)"; ClExe = $cl.FullName }
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
return $null
|
|
}
|
|
|
|
# ─────────────────────────────────────────────
|
|
# Output style (aligned with studio/setup.sh: step / substep)
|
|
# ─────────────────────────────────────────────
|
|
$Rule = [string]::new([char]0x2500, 52)
|
|
|
|
function Enable-StudioVirtualTerminal {
|
|
if ($env:NO_COLOR) { return $false }
|
|
try {
|
|
Add-Type -Namespace StudioVT -Name Native -MemberDefinition @'
|
|
[DllImport("kernel32.dll")] public static extern IntPtr GetStdHandle(int nStdHandle);
|
|
[DllImport("kernel32.dll")] public static extern bool GetConsoleMode(IntPtr h, out uint m);
|
|
[DllImport("kernel32.dll")] public static extern bool SetConsoleMode(IntPtr h, uint m);
|
|
'@ -ErrorAction Stop
|
|
$h = [StudioVT.Native]::GetStdHandle(-11)
|
|
[uint32]$mode = 0
|
|
if (-not [StudioVT.Native]::GetConsoleMode($h, [ref]$mode)) { return $false }
|
|
$mode = $mode -bor 0x0004
|
|
return [StudioVT.Native]::SetConsoleMode($h, $mode)
|
|
} catch {
|
|
return $false
|
|
}
|
|
}
|
|
$script:StudioVtOk = Enable-StudioVirtualTerminal
|
|
|
|
function Get-StudioAnsi {
|
|
param(
|
|
[Parameter(Mandatory = $true)]
|
|
[ValidateSet('Title', 'Dim', 'Ok', 'Warn', 'Err', 'Reset')]
|
|
[string]$Kind
|
|
)
|
|
$e = [char]27
|
|
switch ($Kind) {
|
|
'Title' { return "${e}[38;5;150m" }
|
|
'Dim' { return "${e}[38;5;245m" }
|
|
'Ok' { return "${e}[38;5;108m" }
|
|
'Warn' { return "${e}[38;5;136m" }
|
|
'Err' { return "${e}[91m" }
|
|
'Reset' { return "${e}[0m" }
|
|
}
|
|
}
|
|
|
|
function Write-SetupVerboseDetail {
|
|
param(
|
|
[Parameter(Mandatory = $true)][string]$Message,
|
|
[string]$Color = "Gray"
|
|
)
|
|
if (-not $script:UnslothVerbose) { return }
|
|
if ($script:StudioVtOk -and -not $env:NO_COLOR) {
|
|
$ansi = switch ($Color) {
|
|
'Green' { (Get-StudioAnsi Ok) }
|
|
'Gray' { (Get-StudioAnsi Dim) }
|
|
'DarkGray' { (Get-StudioAnsi Dim) }
|
|
'Yellow' { (Get-StudioAnsi Warn) }
|
|
'Cyan' { (Get-StudioAnsi Title) }
|
|
'Red' { (Get-StudioAnsi Err) }
|
|
default { (Get-StudioAnsi Dim) }
|
|
}
|
|
Write-Host ($ansi + $Message + (Get-StudioAnsi Reset))
|
|
} else {
|
|
$fc = switch ($Color) {
|
|
'Green' { 'DarkGreen' }
|
|
'Gray' { 'DarkGray' }
|
|
'Cyan' { 'Green' }
|
|
default { $Color }
|
|
}
|
|
Write-Host $Message -ForegroundColor $fc
|
|
}
|
|
}
|
|
|
|
function Invoke-SetupCommand {
|
|
param(
|
|
[Parameter(Mandatory = $true)][scriptblock]$Command,
|
|
[switch]$AlwaysQuiet
|
|
)
|
|
$prevEap = $ErrorActionPreference
|
|
$ErrorActionPreference = "Continue"
|
|
try {
|
|
# Reset to avoid stale values from prior native commands.
|
|
$global:LASTEXITCODE = 0
|
|
if ($script:UnslothVerbose -and -not $AlwaysQuiet) {
|
|
# Merge stderr into stdout so progress/warning output stays visible
|
|
# without flipping $? on successful native commands (PS 5.1 treats
|
|
# stderr records as errors that set $? = $false even on exit code 0).
|
|
& $Command 2>&1 | Out-Host
|
|
} else {
|
|
$output = & $Command 2>&1 | Out-String
|
|
if ($LASTEXITCODE -ne 0) {
|
|
Write-Host $output -ForegroundColor Red
|
|
}
|
|
}
|
|
return [int]$LASTEXITCODE
|
|
} finally {
|
|
$ErrorActionPreference = $prevEap
|
|
}
|
|
}
|
|
|
|
function Write-LlamaFailureLog {
|
|
param(
|
|
[string]$Output,
|
|
[int]$MaxLines = 120
|
|
)
|
|
if (-not $Output) { return }
|
|
$lines = @(
|
|
($Output -split "`r?`n") | Where-Object { -not [string]::IsNullOrWhiteSpace($_) }
|
|
)
|
|
if ($lines.Count -eq 0) { return }
|
|
if ($lines.Count -gt $MaxLines) {
|
|
Write-Host " Showing last $MaxLines lines:" -ForegroundColor DarkGray
|
|
$lines = $lines | Select-Object -Last $MaxLines
|
|
}
|
|
foreach ($line in $lines) {
|
|
Write-Host " | $line" -ForegroundColor DarkGray
|
|
}
|
|
}
|
|
function step {
|
|
param(
|
|
[Parameter(Mandatory = $true)][string]$Label,
|
|
[Parameter(Mandatory = $true)][string]$Value,
|
|
[string]$Color = "Green"
|
|
)
|
|
if ($script:StudioVtOk -and -not $env:NO_COLOR) {
|
|
$dim = Get-StudioAnsi Dim
|
|
$rst = Get-StudioAnsi Reset
|
|
$val = switch ($Color) {
|
|
'Green' { Get-StudioAnsi Ok }
|
|
'Yellow' { Get-StudioAnsi Warn }
|
|
'Red' { Get-StudioAnsi Err }
|
|
'DarkGray' { Get-StudioAnsi Dim }
|
|
default { Get-StudioAnsi Ok }
|
|
}
|
|
$padded = if ($Label.Length -ge 15) { $Label.Substring(0, 15) } else { $Label.PadRight(15) }
|
|
Write-Host (" {0}{1}{2}{3}{4}{2}" -f $dim, $padded, $rst, $val, $Value)
|
|
} else {
|
|
$padded = if ($Label.Length -ge 15) { $Label.Substring(0, 15) } else { $Label.PadRight(15) }
|
|
Write-Host (" {0}" -f $padded) -NoNewline -ForegroundColor DarkGray
|
|
$fc = switch ($Color) {
|
|
'Green' { 'DarkGreen' }
|
|
'Yellow' { 'Yellow' }
|
|
'Red' { 'Red' }
|
|
'DarkGray' { 'DarkGray' }
|
|
default { 'DarkGreen' }
|
|
}
|
|
Write-Host $Value -ForegroundColor $fc
|
|
}
|
|
}
|
|
|
|
function substep {
|
|
param(
|
|
[Parameter(Mandatory = $true)][string]$Message,
|
|
[string]$Color = "DarkGray"
|
|
)
|
|
if ($script:StudioVtOk -and -not $env:NO_COLOR) {
|
|
$msgCol = switch ($Color) {
|
|
'Yellow' { (Get-StudioAnsi Warn) }
|
|
default { (Get-StudioAnsi Dim) }
|
|
}
|
|
$pad = "".PadRight(15)
|
|
Write-Host (" {0}{1}{2}{3}" -f $msgCol, $pad, $Message, (Get-StudioAnsi Reset))
|
|
} else {
|
|
$fc = switch ($Color) {
|
|
'Yellow' { 'Yellow' }
|
|
default { 'DarkGray' }
|
|
}
|
|
Write-Host (" {0,-15}{1}" -f "", $Message) -ForegroundColor $fc
|
|
}
|
|
}
|
|
|
|
# ─────────────────────────────────────────────
|
|
# Banner
|
|
# ─────────────────────────────────────────────
|
|
Write-Host ""
|
|
if ($script:StudioVtOk -and -not $env:NO_COLOR) {
|
|
Write-Host (" " + (Get-StudioAnsi Title) + [char]::ConvertFromUtf32(0x1F9A5) + " Unsloth Studio Setup" + (Get-StudioAnsi Reset))
|
|
Write-Host (" {0}{1}{2}" -f (Get-StudioAnsi Dim), $Rule, (Get-StudioAnsi Reset))
|
|
} else {
|
|
Write-Host (" " + [char]::ConvertFromUtf32(0x1F9A5) + " Unsloth Studio Setup") -ForegroundColor Green
|
|
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.
|
|
# ==========================================================================
|
|
|
|
# ============================================
|
|
# 1a. GPU detection
|
|
# ============================================
|
|
$HasNvidiaSmi = $false
|
|
$NvidiaSmiExe = $null # Absolute path -- survives Refresh-Environment
|
|
try {
|
|
$nvSmiCmd = Get-Command nvidia-smi -ErrorAction SilentlyContinue
|
|
if ($nvSmiCmd) {
|
|
& $nvSmiCmd.Source *> $null
|
|
if ($LASTEXITCODE -eq 0) {
|
|
$HasNvidiaSmi = $true
|
|
$NvidiaSmiExe = $nvSmiCmd.Source
|
|
}
|
|
}
|
|
} catch {}
|
|
# Fallback: nvidia-smi may not be on PATH even though a GPU + driver exist.
|
|
# Check the default install location and the Windows driver store.
|
|
if (-not $HasNvidiaSmi) {
|
|
$nvSmiDefaults = @(
|
|
"$env:ProgramFiles\NVIDIA Corporation\NVSMI\nvidia-smi.exe",
|
|
"$env:SystemRoot\System32\nvidia-smi.exe"
|
|
)
|
|
foreach ($p in $nvSmiDefaults) {
|
|
if (Test-Path $p) {
|
|
try {
|
|
& $p *> $null
|
|
if ($LASTEXITCODE -eq 0) {
|
|
$HasNvidiaSmi = $true
|
|
$NvidiaSmiExe = $p
|
|
Write-Host " Found nvidia-smi at $(Split-Path $p -Parent)" -ForegroundColor Gray
|
|
break
|
|
}
|
|
} catch {}
|
|
}
|
|
}
|
|
}
|
|
if (-not $HasNvidiaSmi) {
|
|
Write-Host ""
|
|
step "gpu" "none (chat-only / GGUF)" "Yellow"
|
|
substep "Training and GPU inference require an NVIDIA GPU with drivers installed." "Yellow"
|
|
Write-Host ""
|
|
} else {
|
|
step "gpu" "NVIDIA GPU detected"
|
|
}
|
|
|
|
# ============================================
|
|
# 1a.5. Windows Long Paths (required for deep node_modules / Python paths)
|
|
# ============================================
|
|
$LongPathsEnabled = $false
|
|
try {
|
|
$regVal = Get-ItemProperty -Path "HKLM:\SYSTEM\CurrentControlSet\Control\FileSystem" -Name "LongPathsEnabled" -ErrorAction SilentlyContinue
|
|
if ($regVal -and $regVal.LongPathsEnabled -eq 1) {
|
|
$LongPathsEnabled = $true
|
|
}
|
|
} catch {}
|
|
|
|
if ($LongPathsEnabled) {
|
|
step "long paths" "enabled"
|
|
} else {
|
|
Write-Host "Windows Long Paths not enabled (required for Triton compilation and deep dependency paths)." -ForegroundColor Yellow
|
|
Write-Host " Requesting admin access to fix..." -ForegroundColor Yellow
|
|
try {
|
|
# Spawn an elevated process to set the registry key (triggers UAC prompt)
|
|
$proc = Start-Process -FilePath "reg.exe" `
|
|
-ArgumentList 'add "HKLM\SYSTEM\CurrentControlSet\Control\FileSystem" /v LongPathsEnabled /t REG_DWORD /d 1 /f' `
|
|
-Verb RunAs -Wait -PassThru -ErrorAction Stop
|
|
if ($proc.ExitCode -eq 0) {
|
|
$LongPathsEnabled = $true
|
|
step "long paths" "enabled (via UAC)"
|
|
} else {
|
|
step "long paths" "failed to enable (exit code: $($proc.ExitCode))" "Yellow"
|
|
}
|
|
} catch {
|
|
step "long paths" "could not enable (UAC declined/unavailable)" "Yellow"
|
|
Write-Host " Run this manually in an Admin terminal:" -ForegroundColor Yellow
|
|
Write-Host ' reg add "HKLM\SYSTEM\CurrentControlSet\Control\FileSystem" /v LongPathsEnabled /t REG_DWORD /d 1 /f' -ForegroundColor Cyan
|
|
}
|
|
}
|
|
|
|
# ============================================
|
|
# 1b. Git (required by pip for git+https:// deps and by npm)
|
|
# ============================================
|
|
$HasGit = $null -ne (Get-Command git -ErrorAction SilentlyContinue)
|
|
if (-not $HasGit) {
|
|
Write-Host "Git not found -- installing via winget..." -ForegroundColor Yellow
|
|
$HasWinget = $null -ne (Get-Command winget -ErrorAction SilentlyContinue)
|
|
if ($HasWinget) {
|
|
try {
|
|
Invoke-SetupCommand { winget install Git.Git --source winget --accept-package-agreements --accept-source-agreements } | Out-Null
|
|
Refresh-Environment
|
|
$HasGit = $null -ne (Get-Command git -ErrorAction SilentlyContinue)
|
|
} catch { }
|
|
}
|
|
if (-not $HasGit) {
|
|
Write-Host "[ERROR] Git is required but could not be installed automatically." -ForegroundColor Red
|
|
Write-Host " Install Git from https://git-scm.com/download/win and re-run." -ForegroundColor Red
|
|
exit 1
|
|
}
|
|
step "git" "$(git --version)"
|
|
} else {
|
|
step "git" "$(git --version)"
|
|
}
|
|
|
|
# ============================================
|
|
# 1c. CMake (required for llama.cpp build)
|
|
# ============================================
|
|
$HasCmake = $null -ne (Get-Command cmake -ErrorAction SilentlyContinue)
|
|
if (-not $HasCmake) {
|
|
Write-Host "CMake not found -- installing via winget..." -ForegroundColor Yellow
|
|
$HasWinget = $null -ne (Get-Command winget -ErrorAction SilentlyContinue)
|
|
if ($HasWinget) {
|
|
try {
|
|
Invoke-SetupCommand { winget install Kitware.CMake --source winget --accept-package-agreements --accept-source-agreements } | Out-Null
|
|
Refresh-Environment
|
|
$HasCmake = $null -ne (Get-Command cmake -ErrorAction SilentlyContinue)
|
|
} catch { }
|
|
}
|
|
# winget may succeed but cmake isn't on PATH yet (MSI PATH changes need a
|
|
# new shell). Try the default install location as a fallback.
|
|
if (-not $HasCmake) {
|
|
$cmakeDefaults = @(
|
|
"$env:ProgramFiles\CMake\bin",
|
|
"${env:ProgramFiles(x86)}\CMake\bin",
|
|
"$env:LOCALAPPDATA\CMake\bin"
|
|
)
|
|
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.
|
|
# 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
|
|
break
|
|
}
|
|
}
|
|
}
|
|
}
|
|
if ($HasCmake) {
|
|
step "cmake" "installed"
|
|
} else {
|
|
Write-Host "[ERROR] CMake is required but could not be installed." -ForegroundColor Red
|
|
Write-Host " Install CMake from https://cmake.org/download/ and re-run." -ForegroundColor Red
|
|
exit 1
|
|
}
|
|
} else {
|
|
step "cmake" "$(cmake --version | Select-Object -First 1)"
|
|
}
|
|
|
|
# ============================================
|
|
# 1d. Visual Studio Build Tools (C++ compiler for llama.cpp)
|
|
# ============================================
|
|
$CmakeGenerator = $null
|
|
$VsInstallPath = $null
|
|
$vsResult = Find-VsBuildTools
|
|
|
|
if (-not $vsResult) {
|
|
Write-Host "Visual Studio Build Tools not found -- installing via winget..." -ForegroundColor Yellow
|
|
Write-Host " (This is a one-time install, may take several minutes)" -ForegroundColor Gray
|
|
$HasWinget = $null -ne (Get-Command winget -ErrorAction SilentlyContinue)
|
|
if ($HasWinget) {
|
|
$prevEAPTemp = $ErrorActionPreference
|
|
$ErrorActionPreference = "Continue"
|
|
winget install Microsoft.VisualStudio.2022.BuildTools --source winget --accept-package-agreements --accept-source-agreements --override "--add Microsoft.VisualStudio.Workload.VCTools --includeRecommended --passive --wait"
|
|
$ErrorActionPreference = $prevEAPTemp
|
|
# Re-scan after install (don't trust vswhere catalog)
|
|
$vsResult = Find-VsBuildTools
|
|
}
|
|
}
|
|
|
|
if ($vsResult) {
|
|
$CmakeGenerator = $vsResult.Generator
|
|
$VsInstallPath = $vsResult.InstallPath
|
|
step "vs" "$CmakeGenerator ($($vsResult.Source))"
|
|
if ($vsResult.ClExe) { substep "cl.exe: $($vsResult.ClExe)" }
|
|
} else {
|
|
Write-Host "[ERROR] Visual Studio Build Tools could not be found or installed." -ForegroundColor Red
|
|
Write-Host " Manual install:" -ForegroundColor Red
|
|
Write-Host ' 1. winget install Microsoft.VisualStudio.2022.BuildTools --source winget' -ForegroundColor Yellow
|
|
Write-Host ' 2. Open Visual Studio Installer -> Modify -> check "Desktop development with C++"' -ForegroundColor Yellow
|
|
exit 1
|
|
}
|
|
|
|
# ============================================
|
|
# 1e. CUDA Toolkit (nvcc for llama.cpp build + env vars)
|
|
# ============================================
|
|
if ($HasNvidiaSmi) {
|
|
# IMPORTANT: The CUDA Toolkit version must be <= the max CUDA version the
|
|
# NVIDIA driver supports. nvidia-smi reports this as "CUDA Version: X.Y".
|
|
# If we install a toolkit newer than the driver supports, llama-server will
|
|
# fail at runtime with "ggml_cuda_init: failed to initialize CUDA: (null)".
|
|
|
|
# -- Detect max CUDA version the driver supports --
|
|
$DriverMaxCuda = $null
|
|
try {
|
|
$smiOut = & $NvidiaSmiExe 2>&1 | Out-String
|
|
if ($smiOut -match "CUDA Version:\s+([\d]+)\.([\d]+)") {
|
|
$DriverMaxCuda = "$($Matches[1]).$($Matches[2])"
|
|
substep "driver supports up to CUDA $DriverMaxCuda"
|
|
}
|
|
} catch {}
|
|
|
|
# Detect compute capability early so we can validate toolkit support
|
|
$CudaArch = Get-CudaComputeCapability
|
|
if ($CudaArch) {
|
|
substep "GPU Compute Capability = $($CudaArch.Insert($CudaArch.Length-1, '.')) (sm_$CudaArch)"
|
|
}
|
|
|
|
# -- Find a toolkit that's compatible with the driver AND the GPU --
|
|
# Strategy: prefer the toolkit at CUDA_PATH (user's existing setup) if it's
|
|
# compatible with the driver AND supports the GPU architecture. Only fall back
|
|
# to scanning side-by-side installs if CUDA_PATH is missing, points to an
|
|
# incompatible version, or can't compile for the GPU. This avoids
|
|
# header/binary mismatches when multiple toolkits are installed.
|
|
$IncompatibleToolkit = $null
|
|
$NvccPath = $null
|
|
|
|
if ($DriverMaxCuda) {
|
|
$drMajorCuda = [int]$DriverMaxCuda.Split('.')[0]
|
|
$drMinorCuda = [int]$DriverMaxCuda.Split('.')[1]
|
|
|
|
# --- Step 1: Check existing CUDA_PATH first ---
|
|
$existingCudaPath = [Environment]::GetEnvironmentVariable('CUDA_PATH', 'Machine')
|
|
if (-not $existingCudaPath) {
|
|
$existingCudaPath = [Environment]::GetEnvironmentVariable('CUDA_PATH', 'User')
|
|
}
|
|
if ($existingCudaPath -and (Test-Path (Join-Path $existingCudaPath 'bin\nvcc.exe'))) {
|
|
$candidateNvcc = Join-Path $existingCudaPath 'bin\nvcc.exe'
|
|
$verOut = & $candidateNvcc --version 2>&1 | Out-String
|
|
if ($verOut -match 'release\s+(\d+)\.(\d+)') {
|
|
$tkMaj = [int]$Matches[1]; $tkMin = [int]$Matches[2]
|
|
$isCompat = ($tkMaj -lt $drMajorCuda) -or ($tkMaj -eq $drMajorCuda -and $tkMin -le $drMinorCuda)
|
|
if ($isCompat) {
|
|
# Also verify the toolkit supports our GPU architecture
|
|
$archOk = $true
|
|
if ($CudaArch) {
|
|
$archOk = Test-NvccArchSupport -NvccExe $candidateNvcc -Arch $CudaArch
|
|
if (-not $archOk) {
|
|
substep "CUDA_PATH toolkit (CUDA $tkMaj.$tkMin) does not support GPU arch sm_$CudaArch" "Yellow"
|
|
substep "Looking for a newer toolkit..." "Yellow"
|
|
}
|
|
}
|
|
if ($archOk) {
|
|
$NvccPath = $candidateNvcc
|
|
substep "using existing CUDA Toolkit at CUDA_PATH (nvcc: $NvccPath)"
|
|
}
|
|
} else {
|
|
substep "CUDA_PATH ($existingCudaPath) has CUDA $tkMaj.$tkMin which exceeds driver max $DriverMaxCuda" "Yellow"
|
|
}
|
|
}
|
|
}
|
|
|
|
# --- Step 2: Fall back to scanning side-by-side installs ---
|
|
if (-not $NvccPath) {
|
|
$NvccPath = Find-Nvcc -MaxVersion $DriverMaxCuda
|
|
if ($NvccPath) {
|
|
substep "found compatible CUDA Toolkit (nvcc: $NvccPath)"
|
|
if ($existingCudaPath) {
|
|
$selectedRoot = Split-Path (Split-Path $NvccPath -Parent) -Parent
|
|
if ($existingCudaPath.TrimEnd('\') -ne $selectedRoot.TrimEnd('\')) {
|
|
substep "overriding CUDA_PATH from $existingCudaPath to $selectedRoot" "Yellow"
|
|
}
|
|
}
|
|
} else {
|
|
# Check if there's an incompatible (too new) toolkit installed
|
|
$AnyNvcc = Find-Nvcc
|
|
if ($AnyNvcc) {
|
|
$NvccOut = & $AnyNvcc --version 2>&1 | Out-String
|
|
if ($NvccOut -match "release\s+([\d]+\.[\d]+)") {
|
|
$IncompatibleToolkit = $Matches[1]
|
|
}
|
|
}
|
|
}
|
|
}
|
|
} else {
|
|
$NvccPath = Find-Nvcc
|
|
}
|
|
|
|
# -- If incompatible toolkit is blocking, tell user to uninstall it --
|
|
if (-not $NvccPath -and $IncompatibleToolkit) {
|
|
Write-Host "" -ForegroundColor Red
|
|
Write-Host "========================================================================" -ForegroundColor Red
|
|
Write-Host "[ERROR] CUDA Toolkit $IncompatibleToolkit is installed but INCOMPATIBLE" -ForegroundColor Red
|
|
Write-Host " with your NVIDIA driver (which supports up to CUDA $DriverMaxCuda)." -ForegroundColor Red
|
|
Write-Host "" -ForegroundColor Red
|
|
Write-Host " This will cause 'failed to initialize CUDA' errors at runtime." -ForegroundColor Red
|
|
Write-Host "" -ForegroundColor Red
|
|
Write-Host " To fix:" -ForegroundColor Yellow
|
|
Write-Host " 1. Open Control Panel -> Programs -> Uninstall a program" -ForegroundColor Yellow
|
|
Write-Host " 2. Uninstall 'NVIDIA CUDA Toolkit $IncompatibleToolkit'" -ForegroundColor Yellow
|
|
Write-Host " 3. Re-run setup.bat (it will install CUDA $DriverMaxCuda automatically)" -ForegroundColor Yellow
|
|
Write-Host "" -ForegroundColor Yellow
|
|
Write-Host " Alternatively, update your NVIDIA driver to one that supports CUDA $IncompatibleToolkit." -ForegroundColor Gray
|
|
Write-Host "========================================================================" -ForegroundColor Red
|
|
exit 1
|
|
}
|
|
|
|
# -- No toolkit at all: install via winget --
|
|
if (-not $NvccPath) {
|
|
Write-Host "CUDA toolkit (nvcc) not found -- installing via winget..." -ForegroundColor Yellow
|
|
$HasWinget = $null -ne (Get-Command winget -ErrorAction SilentlyContinue)
|
|
if ($HasWinget) {
|
|
if ($DriverMaxCuda) {
|
|
# Query winget for available CUDA Toolkit versions
|
|
$drMajor = [int]$DriverMaxCuda.Split('.')[0]
|
|
$drMinor = [int]$DriverMaxCuda.Split('.')[1]
|
|
$AvailableVersions = @()
|
|
try {
|
|
$rawOutput = winget show Nvidia.CUDA --versions --accept-source-agreements 2>&1 | Out-String
|
|
# Parse version lines (e.g. "12.6", "12.5", "11.8")
|
|
foreach ($line in $rawOutput -split "`n") {
|
|
$line = $line.Trim()
|
|
if ($line -match '^\d+\.\d+') {
|
|
$AvailableVersions += $line
|
|
}
|
|
}
|
|
} catch {}
|
|
|
|
# Filter to compatible versions (<= driver max) and pick the highest
|
|
$BestVersion = $null
|
|
foreach ($ver in $AvailableVersions) {
|
|
$parts = $ver.Split('.')
|
|
$vMajor = [int]$parts[0]
|
|
$vMinor = [int]$parts[1]
|
|
if ($vMajor -lt $drMajor -or ($vMajor -eq $drMajor -and $vMinor -le $drMinor)) {
|
|
$BestVersion = $ver
|
|
break # list is descending, first match is highest compatible
|
|
}
|
|
}
|
|
|
|
if ($BestVersion) {
|
|
substep "Installing CUDA Toolkit $BestVersion via winget..."
|
|
$prevEAPCuda = $ErrorActionPreference
|
|
$ErrorActionPreference = "Continue"
|
|
Invoke-SetupCommand { winget install --id=Nvidia.CUDA --version=$BestVersion -e --source winget --accept-package-agreements --accept-source-agreements } | Out-Null
|
|
$ErrorActionPreference = $prevEAPCuda
|
|
Refresh-Environment
|
|
$NvccPath = Find-Nvcc -MaxVersion $DriverMaxCuda
|
|
if ($NvccPath) {
|
|
substep "CUDA Toolkit $BestVersion installed (nvcc: $NvccPath)"
|
|
}
|
|
} else {
|
|
substep "no compatible CUDA Toolkit version found in winget (need <= $DriverMaxCuda)" "Yellow"
|
|
}
|
|
} else {
|
|
substep "Installing CUDA Toolkit (latest) via winget..."
|
|
winget install --id=Nvidia.CUDA -e --source winget --accept-package-agreements --accept-source-agreements
|
|
Refresh-Environment
|
|
$NvccPath = Find-Nvcc
|
|
if ($NvccPath) {
|
|
substep "CUDA Toolkit installed (nvcc: $NvccPath)"
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
if (-not $NvccPath) {
|
|
Write-Host "[ERROR] CUDA Toolkit (nvcc) is required but could not be found or installed." -ForegroundColor Red
|
|
if ($DriverMaxCuda) {
|
|
Write-Host " Install CUDA Toolkit $DriverMaxCuda from https://developer.nvidia.com/cuda-toolkit-archive" -ForegroundColor Yellow
|
|
} else {
|
|
Write-Host " Install CUDA Toolkit from https://developer.nvidia.com/cuda-downloads" -ForegroundColor Yellow
|
|
}
|
|
exit 1
|
|
}
|
|
|
|
# -- Set CUDA env vars so cmake AND MSBuild can find the toolkit --
|
|
$CudaToolkitRoot = Split-Path (Split-Path $NvccPath -Parent) -Parent
|
|
# CUDA_PATH: used by cmake's find_package(CUDAToolkit)
|
|
[Environment]::SetEnvironmentVariable('CUDA_PATH', $CudaToolkitRoot, 'Process')
|
|
# CudaToolkitDir: the MSBuild property that CUDA .targets checks directly
|
|
# Trailing backslash required -- the .targets file appends subpaths to it
|
|
[Environment]::SetEnvironmentVariable('CudaToolkitDir', "$CudaToolkitRoot\", 'Process')
|
|
# Always persist CUDA_PATH to User registry so the compatible toolkit is used
|
|
# in future sessions (overwrites any existing value pointing to a newer, incompatible version)
|
|
[Environment]::SetEnvironmentVariable('CUDA_PATH', $CudaToolkitRoot, 'User')
|
|
substep "Persisted CUDA_PATH=$CudaToolkitRoot to user environment"
|
|
# Clear all versioned CUDA_PATH_V* env vars in this process to prevent
|
|
# cmake/MSBuild from discovering a conflicting CUDA installation.
|
|
$cudaPathVars = @([Environment]::GetEnvironmentVariables('Process').Keys | Where-Object { $_ -match '^CUDA_PATH_V' })
|
|
foreach ($v in $cudaPathVars) {
|
|
[Environment]::SetEnvironmentVariable($v, $null, 'Process')
|
|
}
|
|
# Set only the versioned var matching the selected toolkit (e.g. CUDA_PATH_V13_0)
|
|
$tkDirName = Split-Path $CudaToolkitRoot -Leaf
|
|
if ($tkDirName -match '^v(\d+)\.(\d+)') {
|
|
$cudaPathVerVar = "CUDA_PATH_V$($Matches[1])_$($Matches[2])"
|
|
[Environment]::SetEnvironmentVariable($cudaPathVerVar, $CudaToolkitRoot, 'Process')
|
|
substep "Set $cudaPathVerVar (cleared other CUDA_PATH_V* vars)"
|
|
}
|
|
# Ensure nvcc's bin dir is on PATH for this process
|
|
$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.
|
|
# 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"
|
|
}
|
|
|
|
# -- Ensure CUDA ↔ Visual Studio integration files exist --
|
|
# When CUDA is installed before VS Build Tools (or VS is reinstalled after CUDA),
|
|
# the MSBuild .targets/.props files that let VS compile .cu files are missing.
|
|
# cmake fails with "No CUDA toolset found". Fix: copy from CUDA extras dir.
|
|
if ($VsInstallPath -and $CudaToolkitRoot) {
|
|
$vsCustomizations = Join-Path $VsInstallPath "MSBuild\Microsoft\VC\v170\BuildCustomizations"
|
|
$cudaExtras = Join-Path $CudaToolkitRoot "extras\visual_studio_integration\MSBuildExtensions"
|
|
if ((Test-Path $cudaExtras) -and (Test-Path $vsCustomizations)) {
|
|
$hasTargets = Get-ChildItem $vsCustomizations -Filter "CUDA *.targets" -ErrorAction SilentlyContinue
|
|
if (-not $hasTargets) {
|
|
substep "CUDA VS integration missing -- copying .targets files..." "Yellow"
|
|
try {
|
|
Copy-Item "$cudaExtras\*" $vsCustomizations -Force -ErrorAction Stop
|
|
substep "CUDA VS integration files installed"
|
|
} catch {
|
|
# Direct copy failed (needs admin). Try elevated copy via Start-Process.
|
|
try {
|
|
$copyCmd = "Copy-Item '$cudaExtras\*' '$vsCustomizations' -Force"
|
|
Start-Process powershell -ArgumentList "-NoProfile -Command $copyCmd" -Verb RunAs -Wait -ErrorAction Stop
|
|
$hasTargetsRetry = Get-ChildItem $vsCustomizations -Filter "CUDA *.targets" -ErrorAction SilentlyContinue
|
|
if ($hasTargetsRetry) {
|
|
substep "CUDA VS integration files installed (elevated)"
|
|
} else {
|
|
throw "Copy did not produce .targets files"
|
|
}
|
|
} catch {
|
|
substep "could not copy CUDA VS integration files" "Yellow"
|
|
substep "The llama.cpp build may fail with 'No CUDA toolset found'." "Yellow"
|
|
substep "Manual fix: copy contents of" "Yellow"
|
|
substep "$cudaExtras"
|
|
substep "into:" "Yellow"
|
|
substep "$vsCustomizations"
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
step "cuda" $NvccPath
|
|
substep "CUDA_PATH = $CudaToolkitRoot"
|
|
substep "CudaToolkitDir = $CudaToolkitRoot\"
|
|
|
|
# $CudaArch was detected earlier (before toolkit selection) so it could
|
|
# influence which toolkit we picked. Just log the final state here.
|
|
if (-not $CudaArch) {
|
|
substep "could not detect compute capability -- cmake will use defaults" "Yellow"
|
|
}
|
|
} else {
|
|
step "cuda" "skipped (no NVIDIA GPU detected)" "Yellow"
|
|
}
|
|
|
|
# ============================================
|
|
# 1f. Node.js / npm (skip if pip-installed -- only needed for frontend build)
|
|
# ============================================
|
|
if ($IsPipInstall) {
|
|
step "frontend" "bundled (pip install)"
|
|
} else {
|
|
# setup.sh installs Node LTS (v22) via nvm. We enforce the same range here:
|
|
# Vite 8 requires Node ^20.19.0 || >=22.12.0, npm >= 11.
|
|
$NeedNode = $true
|
|
try {
|
|
$NodeVersion = (node -v 2>$null)
|
|
$NpmVersion = (npm -v 2>$null)
|
|
if ($NodeVersion -and $NpmVersion) {
|
|
$NodeParts = ($NodeVersion -replace 'v','').Split('.')
|
|
$NodeMajor = [int]$NodeParts[0]
|
|
$NodeMinor = [int]$NodeParts[1]
|
|
$NpmMajor = [int]$NpmVersion.Split('.')[0]
|
|
|
|
# Vite 8: ^20.19.0 || >=22.12.0
|
|
$NodeOk = ($NodeMajor -eq 20 -and $NodeMinor -ge 19) -or
|
|
($NodeMajor -eq 22 -and $NodeMinor -ge 12) -or
|
|
($NodeMajor -ge 23)
|
|
if ($NodeOk -and $NpmMajor -ge 11) {
|
|
substep "Node $NodeVersion and npm $NpmVersion already meet requirements."
|
|
$NeedNode = $false
|
|
} else {
|
|
substep "Node $NodeVersion / npm $NpmVersion too old." "Yellow"
|
|
}
|
|
}
|
|
} catch {
|
|
substep "Node/npm not found." "Yellow"
|
|
}
|
|
|
|
if ($NeedNode) {
|
|
substep "installing Node.js LTS via winget..."
|
|
try {
|
|
winget install OpenJS.NodeJS.LTS --source winget --accept-package-agreements --accept-source-agreements
|
|
Refresh-Environment
|
|
} catch {
|
|
Write-Host "[ERROR] Could not install Node.js automatically." -ForegroundColor Red
|
|
Write-Host "Please install Node.js >= 20 from https://nodejs.org/" -ForegroundColor Red
|
|
exit 1
|
|
}
|
|
}
|
|
|
|
step "node" "$(node -v) | npm $(npm -v)"
|
|
|
|
# ── bun (optional, faster package installs) ──
|
|
# Installed via npm — Node is already guaranteed above. Works on all platforms.
|
|
if (-not (Get-Command bun -ErrorAction SilentlyContinue)) {
|
|
substep "installing bun (faster frontend package installs)..."
|
|
$prevEAP_bun = $ErrorActionPreference
|
|
$ErrorActionPreference = "Continue"
|
|
Invoke-SetupCommand { npm install -g bun } | Out-Null
|
|
$ErrorActionPreference = $prevEAP_bun
|
|
Refresh-Environment
|
|
if (Get-Command bun -ErrorAction SilentlyContinue) {
|
|
substep "bun installed ($(bun --version))"
|
|
} else {
|
|
substep "bun install skipped (npm will be used instead)"
|
|
}
|
|
} else {
|
|
substep "bun already installed ($(bun --version))"
|
|
}
|
|
}
|
|
|
|
# ============================================
|
|
# 1g. Python (>= 3.11 and < 3.14, matching setup.sh)
|
|
# ============================================
|
|
$HasPython = $null -ne (Get-Command python -ErrorAction SilentlyContinue)
|
|
$PythonOk = $false
|
|
|
|
if ($HasPython) {
|
|
$PyVer = python --version 2>&1
|
|
if ($PyVer -match "(\d+)\.(\d+)") {
|
|
$PyMajor = [int]$Matches[1]; $PyMinor = [int]$Matches[2]
|
|
if ($PyMajor -eq 3 -and $PyMinor -ge 11 -and $PyMinor -lt 14) {
|
|
substep "Python $PyVer"
|
|
$PythonOk = $true
|
|
} else {
|
|
Write-Host "[ERROR] Python $PyVer is outside supported range (need >= 3.11 and < 3.14)." -ForegroundColor Red
|
|
Write-Host " Install Python 3.12 from https://python.org/downloads/" -ForegroundColor Yellow
|
|
exit 1
|
|
}
|
|
}
|
|
} else {
|
|
# No Python at all -- install 3.12
|
|
Write-Host "Python not found -- installing Python 3.12 via winget..." -ForegroundColor Yellow
|
|
$HasWinget = $null -ne (Get-Command winget -ErrorAction SilentlyContinue)
|
|
if ($HasWinget) {
|
|
winget install -e --id Python.Python.3.12 --source winget --accept-package-agreements --accept-source-agreements
|
|
Refresh-Environment
|
|
}
|
|
$HasPython = $null -ne (Get-Command python -ErrorAction SilentlyContinue)
|
|
if (-not $HasPython) {
|
|
Write-Host "[ERROR] Python could not be installed automatically." -ForegroundColor Red
|
|
Write-Host " Install Python 3.12 from https://python.org/downloads/" -ForegroundColor Yellow
|
|
exit 1
|
|
}
|
|
step "python" "$(python --version 2>&1)"
|
|
$PythonOk = $true
|
|
}
|
|
|
|
# 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)) {
|
|
# 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 })) {
|
|
$env:PATH = "$ScriptsDir;$env:PATH"
|
|
}
|
|
substep "Persisted Python Scripts dir to user PATH: $ScriptsDir"
|
|
}
|
|
}
|
|
|
|
Write-Host ""
|
|
step "system" "prerequisites ready"
|
|
Write-Host ""
|
|
|
|
# ==========================================================================
|
|
# PHASE 2: Frontend build (skip if pip-installed -- already bundled)
|
|
# ==========================================================================
|
|
$DistDir = Join-Path $FrontendDir "dist"
|
|
# Skip build if dist/ exists and no tracked input is newer than dist/.
|
|
# Checks src/, public/, package.json, config files -- not just src/.
|
|
$NeedFrontendBuild = $true
|
|
if ($IsPipInstall) {
|
|
$NeedFrontendBuild = $false
|
|
step "frontend" "bundled (pip install)"
|
|
} elseif (Test-Path $DistDir) {
|
|
$DistTime = (Get-Item $DistDir).LastWriteTime
|
|
$NewerFile = $null
|
|
# Check src/ and public/ recursively (probe paths directly, not via -Include)
|
|
foreach ($subDir in @("src", "public")) {
|
|
$subPath = Join-Path $FrontendDir $subDir
|
|
if (Test-Path $subPath) {
|
|
$NewerFile = Get-ChildItem -Path $subPath -Recurse -File -ErrorAction SilentlyContinue |
|
|
Where-Object { $_.LastWriteTime -gt $DistTime } | Select-Object -First 1
|
|
if ($NewerFile) { break }
|
|
}
|
|
}
|
|
# Also check all top-level files (package.json, vite.config.ts, index.html, etc.)
|
|
if (-not $NewerFile) {
|
|
$NewerFile = Get-ChildItem -Path $FrontendDir -File -ErrorAction SilentlyContinue |
|
|
Where-Object { $_.Name -ne "bun.lock" -and $_.LastWriteTime -gt $DistTime } |
|
|
Select-Object -First 1
|
|
}
|
|
if (-not $NewerFile) {
|
|
$NeedFrontendBuild = $false
|
|
step "frontend" "up to date"
|
|
} else {
|
|
substep "Frontend source changed since last build -- rebuilding..." "Yellow"
|
|
}
|
|
}
|
|
if ($NeedFrontendBuild -and -not $IsPipInstall) {
|
|
Write-Host ""
|
|
substep "building frontend..."
|
|
|
|
# ── Tailwind v4 .gitignore workaround ──
|
|
# Tailwind v4's oxide scanner respects .gitignore in parent directories.
|
|
# Python venvs create a .gitignore with "*" (ignore everything), which
|
|
# prevents Tailwind from scanning .tsx source files for class names.
|
|
# Temporarily hide any such .gitignore during the build, then restore it.
|
|
$HiddenGitignores = @()
|
|
$WalkDir = (Get-Item $FrontendDir).Parent.FullName
|
|
while ($WalkDir -and $WalkDir -ne [System.IO.Path]::GetPathRoot($WalkDir)) {
|
|
$gi = Join-Path $WalkDir ".gitignore"
|
|
if (Test-Path $gi) {
|
|
$content = Get-Content $gi -Raw -ErrorAction SilentlyContinue
|
|
if ($content -and ($content.Trim() -match '^\*$')) {
|
|
$hidden = "$gi._twbuild"
|
|
Rename-Item -Path $gi -NewName (Split-Path $hidden -Leaf) -Force
|
|
$HiddenGitignores += $gi
|
|
substep "Temporarily hiding $gi (venv .gitignore blocks Tailwind scanner)"
|
|
}
|
|
}
|
|
$WalkDir = Split-Path $WalkDir -Parent
|
|
}
|
|
|
|
# Use bun if available (faster install), fall back to npm.
|
|
# Bun is used only as package manager; Node runs the actual build (Vite 8).
|
|
$prevEAP_npm = $ErrorActionPreference
|
|
$ErrorActionPreference = "Continue"
|
|
Push-Location $FrontendDir
|
|
|
|
$UseBun = $null -ne (Get-Command bun -ErrorAction SilentlyContinue)
|
|
|
|
# bun's package cache can become corrupt -- packages get stored with only
|
|
# metadata but no actual content (bin/, lib/). When this happens bun install
|
|
# exits 0 but leaves binaries missing. We validate after install and clear
|
|
# the cache + retry once before falling back to npm.
|
|
if ($UseBun) {
|
|
Write-Host " Using bun for package install (faster)" -ForegroundColor DarkGray
|
|
$bunExit = Invoke-SetupCommand { bun install }
|
|
# On Windows, .bin/ entries vary by package manager:
|
|
# npm → tsc, tsc.cmd, tsc.ps1
|
|
# bun → tsc.exe, tsc.bunx
|
|
$hasTsc = (Test-Path "node_modules\.bin\tsc") -or (Test-Path "node_modules\.bin\tsc.cmd") -or (Test-Path "node_modules\.bin\tsc.exe") -or (Test-Path "node_modules\.bin\tsc.bunx")
|
|
$hasVite = (Test-Path "node_modules\.bin\vite") -or (Test-Path "node_modules\.bin\vite.cmd") -or (Test-Path "node_modules\.bin\vite.exe") -or (Test-Path "node_modules\.bin\vite.bunx")
|
|
if ($bunExit -eq 0 -and $hasTsc -and $hasVite) {
|
|
# bun install succeeded and critical binaries are present
|
|
} elseif ($bunExit -eq 0) {
|
|
Write-Host " bun install exited 0 but critical binaries are missing, clearing cache and retrying..." -ForegroundColor Yellow
|
|
if (Test-Path "node_modules") {
|
|
Remove-Item "node_modules" -Recurse -Force -ErrorAction SilentlyContinue
|
|
}
|
|
Invoke-SetupCommand { bun pm cache rm } | Out-Null
|
|
$bunExit = Invoke-SetupCommand { bun install }
|
|
$hasTsc = (Test-Path "node_modules\.bin\tsc") -or (Test-Path "node_modules\.bin\tsc.cmd") -or (Test-Path "node_modules\.bin\tsc.exe") -or (Test-Path "node_modules\.bin\tsc.bunx")
|
|
$hasVite = (Test-Path "node_modules\.bin\vite") -or (Test-Path "node_modules\.bin\vite.cmd") -or (Test-Path "node_modules\.bin\vite.exe") -or (Test-Path "node_modules\.bin\vite.bunx")
|
|
if ($bunExit -ne 0 -or -not $hasTsc -or -not $hasVite) {
|
|
Write-Host " bun retry failed, falling back to npm" -ForegroundColor Yellow
|
|
if (Test-Path "node_modules") {
|
|
Remove-Item "node_modules" -Recurse -Force -ErrorAction SilentlyContinue
|
|
}
|
|
$UseBun = $false
|
|
}
|
|
} else {
|
|
substep "bun install failed (exit $bunExit), falling back to npm" "Yellow"
|
|
if (Test-Path "node_modules") {
|
|
Remove-Item "node_modules" -Recurse -Force -ErrorAction SilentlyContinue
|
|
}
|
|
$UseBun = $false
|
|
}
|
|
}
|
|
if (-not $UseBun) {
|
|
$npmExit = Invoke-SetupCommand { npm install }
|
|
if ($npmExit -ne 0) {
|
|
Pop-Location
|
|
$ErrorActionPreference = $prevEAP_npm
|
|
foreach ($gi in $HiddenGitignores) { Rename-Item -Path "$gi._twbuild" -NewName (Split-Path $gi -Leaf) -Force -ErrorAction SilentlyContinue }
|
|
Write-Host "[ERROR] npm install failed (exit code $npmExit)" -ForegroundColor Red
|
|
Write-Host " Try running 'npm install' manually in frontend/ to see errors" -ForegroundColor Yellow
|
|
exit 1
|
|
}
|
|
}
|
|
|
|
# Always use npm to run the build (Node runtime — avoids bun Windows runtime issues)
|
|
$buildExit = Invoke-SetupCommand { npm run build }
|
|
if ($buildExit -ne 0) {
|
|
Pop-Location
|
|
$ErrorActionPreference = $prevEAP_npm
|
|
foreach ($gi in $HiddenGitignores) { Rename-Item -Path "$gi._twbuild" -NewName (Split-Path $gi -Leaf) -Force -ErrorAction SilentlyContinue }
|
|
Write-Host "[ERROR] npm run build failed (exit code $buildExit)" -ForegroundColor Red
|
|
exit 1
|
|
}
|
|
Pop-Location
|
|
$ErrorActionPreference = $prevEAP_npm
|
|
|
|
# ── Restore hidden .gitignore files ──
|
|
foreach ($gi in $HiddenGitignores) {
|
|
Rename-Item -Path "$gi._twbuild" -NewName (Split-Path $gi -Leaf) -Force -ErrorAction SilentlyContinue
|
|
}
|
|
|
|
# ── Validate CSS output ──
|
|
$CssFiles = Get-ChildItem (Join-Path $DistDir "assets") -Filter "*.css" -ErrorAction SilentlyContinue
|
|
$MaxCssSize = ($CssFiles | Measure-Object -Property Length -Maximum).Maximum
|
|
if ($MaxCssSize -lt 100000) {
|
|
step "frontend" "built (warning: CSS may be truncated)" "Yellow"
|
|
} else {
|
|
step "frontend" "built"
|
|
}
|
|
}
|
|
|
|
if (Test-Path $OxcValidatorDir) {
|
|
substep "installing OXC validator runtime..."
|
|
$prevEAP_oxc = $ErrorActionPreference
|
|
$ErrorActionPreference = "Continue"
|
|
Push-Location $OxcValidatorDir
|
|
$oxcInstallExit = Invoke-SetupCommand { npm install }
|
|
if ($oxcInstallExit -ne 0) {
|
|
Pop-Location
|
|
$ErrorActionPreference = $prevEAP_oxc
|
|
Write-Host "[ERROR] OXC validator npm install failed (exit code $oxcInstallExit)" -ForegroundColor Red
|
|
exit 1
|
|
}
|
|
Pop-Location
|
|
$ErrorActionPreference = $prevEAP_oxc
|
|
step "oxc runtime" "installed"
|
|
}
|
|
|
|
# ==========================================================================
|
|
# PHASE 3: Python environment + dependencies
|
|
# ==========================================================================
|
|
Write-Host ""
|
|
substep "setting up Python environment..."
|
|
|
|
# Find Python -- skip Anaconda/Miniconda distributions.
|
|
# Conda-bundled CPython ships modified DLL search paths that break
|
|
# torch's c10.dll loading on Windows. Standalone CPython (python.org,
|
|
# winget, uv) does not have this issue.
|
|
# Uses Get-Command -All to look past conda entries that shadow a valid
|
|
# standalone Python further down PATH, and probes py.exe (the Python
|
|
# Launcher) which reliably finds python.org installs.
|
|
#
|
|
# NOTE: A venv created from conda Python inherits conda's base_prefix
|
|
# even though the venv path itself does not contain "conda". We check
|
|
# both the executable path AND sys.base_prefix to catch this case.
|
|
$CondaSkipPattern = '(?i)(conda|miniconda|anaconda|miniforge|mambaforge)'
|
|
$PythonCmd = $null
|
|
|
|
# Helper: check if a Python executable is conda-based by inspecting
|
|
# both the path and sys.base_prefix (catches venvs created from conda).
|
|
function Test-IsConda {
|
|
param([string]$Exe)
|
|
if ($Exe -match $CondaSkipPattern) { return $true }
|
|
try {
|
|
$basePrefix = (& $Exe -c "import sys; print(sys.base_prefix)" 2>$null | Out-String).Trim()
|
|
if ($basePrefix -match $CondaSkipPattern) { return $true }
|
|
} catch { }
|
|
return $false
|
|
}
|
|
|
|
# 1. Try the Python Launcher (py.exe) first -- most reliable on Windows.
|
|
# py.exe is installed by python.org and resolves to standalone CPython.
|
|
$pyLauncher = Get-Command py -CommandType Application -ErrorAction SilentlyContinue
|
|
if ($pyLauncher -and $pyLauncher.Source -notmatch $CondaSkipPattern) {
|
|
foreach ($minor in @("3.13", "3.12", "3.11")) {
|
|
try {
|
|
$out = & $pyLauncher.Source "-$minor" --version 2>&1 | Out-String
|
|
if ($out -match 'Python 3\.(\d+)') {
|
|
$pyMinor = [int]$Matches[1]
|
|
if ($pyMinor -ge 11 -and $pyMinor -le 13) {
|
|
# Resolve the actual executable path so venv creation
|
|
# does not re-resolve back to a conda interpreter.
|
|
$resolvedExe = (& $pyLauncher.Source "-$minor" -c "import sys; print(sys.executable)" 2>$null | Out-String).Trim()
|
|
if ($resolvedExe -and (Test-Path $resolvedExe) -and -not (Test-IsConda $resolvedExe)) {
|
|
$PythonCmd = $resolvedExe
|
|
break
|
|
}
|
|
}
|
|
}
|
|
} catch { }
|
|
}
|
|
}
|
|
|
|
# 2. Fall back to scanning python3.x / python3 / python on PATH.
|
|
# Use Get-Command -All to look past conda entries.
|
|
if (-not $PythonCmd) {
|
|
foreach ($candidate in @("python3.13", "python3.12", "python3.11", "python3", "python")) {
|
|
foreach ($cmdInfo in @(Get-Command $candidate -All -ErrorAction SilentlyContinue)) {
|
|
try {
|
|
if (-not $cmdInfo.Source) { continue }
|
|
if ($cmdInfo.Source -like "*\WindowsApps\*") { continue }
|
|
if (Test-IsConda $cmdInfo.Source) {
|
|
substep "skipping $($cmdInfo.Source) (conda Python breaks torch DLL loading)" "Yellow"
|
|
continue
|
|
}
|
|
$ver = & $cmdInfo.Source --version 2>&1
|
|
if ($ver -match 'Python 3\.(\d+)') {
|
|
$minor = [int]$Matches[1]
|
|
if ($minor -ge 11 -and $minor -le 13) {
|
|
$PythonCmd = $cmdInfo.Source
|
|
break
|
|
}
|
|
}
|
|
} catch { }
|
|
}
|
|
if ($PythonCmd) { break }
|
|
}
|
|
}
|
|
|
|
if (-not $PythonCmd) {
|
|
Write-Host "[ERROR] No standalone Python 3.11-3.13 found (conda Python is not supported)." -ForegroundColor Red
|
|
Write-Host " Install Python from https://python.org/downloads/ or via:" -ForegroundColor Yellow
|
|
Write-Host " winget install -e --id Python.Python.3.12" -ForegroundColor Yellow
|
|
exit 1
|
|
}
|
|
|
|
substep "Using $PythonCmd ($(& $PythonCmd --version 2>&1))"
|
|
|
|
# The venv must already exist (created by install.ps1).
|
|
# This script (setup.ps1 / "unsloth studio update") only updates packages.
|
|
$VenvDir = Join-Path $env:USERPROFILE ".unsloth\studio\unsloth_studio"
|
|
|
|
# Stale-venv detection: if the venv exists but its torch flavor no longer
|
|
# matches the current machine, wipe it so we get a clean install.
|
|
if (Test-Path $VenvDir -PathType Container) {
|
|
$VenvPyExe = Join-Path $VenvDir "Scripts\python.exe"
|
|
$installedTorchTag = $null
|
|
$shouldRebuild = $false
|
|
|
|
if (Test-Path $VenvPyExe) {
|
|
try {
|
|
$psi = New-Object System.Diagnostics.ProcessStartInfo
|
|
$psi.FileName = $VenvPyExe
|
|
$psi.Arguments = '-c "import torch; print(torch.__version__)"'
|
|
$psi.RedirectStandardOutput = $true
|
|
$psi.RedirectStandardError = $true
|
|
$psi.UseShellExecute = $false
|
|
$psi.CreateNoWindow = $true
|
|
$proc = [System.Diagnostics.Process]::Start($psi)
|
|
$torchVer = $proc.StandardOutput.ReadToEnd().Trim()
|
|
$finished = $proc.WaitForExit(30000)
|
|
if ($finished -and $proc.ExitCode -eq 0 -and $torchVer) {
|
|
if ($torchVer -match '\+(cu\d+)') {
|
|
$installedTorchTag = $Matches[1]
|
|
} elseif ($torchVer -match '\+cpu') {
|
|
$installedTorchTag = "cpu"
|
|
} else {
|
|
# Untagged wheel (plain "2.x.y" from PyPI) -- treat as cpu
|
|
$installedTorchTag = "cpu"
|
|
}
|
|
} else {
|
|
if (-not $finished) { try { $proc.Kill() } catch {} }
|
|
$shouldRebuild = $true
|
|
}
|
|
} catch {
|
|
$shouldRebuild = $true
|
|
}
|
|
} else {
|
|
# Missing python.exe means the venv is incomplete -- rebuild it.
|
|
$shouldRebuild = $true
|
|
}
|
|
|
|
if (-not $shouldRebuild) {
|
|
$expectedTorchTag = if ($HasNvidiaSmi) { Get-PytorchCudaTag } else { "cpu" }
|
|
if ($installedTorchTag -and $installedTorchTag -ne $expectedTorchTag) {
|
|
$shouldRebuild = $true
|
|
}
|
|
}
|
|
|
|
if ($shouldRebuild) {
|
|
$reason = if ($installedTorchTag) { "torch $installedTorchTag != required $expectedTorchTag" } else { "torch could not be imported" }
|
|
substep "Stale venv detected ($reason) -- rebuilding..." "Yellow"
|
|
try {
|
|
Remove-Item $VenvDir -Recurse -Force -ErrorAction Stop
|
|
} catch {
|
|
Write-Host " [ERROR] Could not remove stale venv: $($_.Exception.Message)" -ForegroundColor Red
|
|
Write-Host " Close any running Studio/Python processes and re-run setup." -ForegroundColor Red
|
|
exit 1
|
|
}
|
|
}
|
|
}
|
|
|
|
if (-not (Test-Path $VenvDir)) {
|
|
Write-Host "[ERROR] Virtual environment not found at $VenvDir" -ForegroundColor Red
|
|
Write-Host " Run install.ps1 first to create the environment:" -ForegroundColor Yellow
|
|
Write-Host " irm https://unsloth.ai/install.ps1 | iex" -ForegroundColor Yellow
|
|
exit 1
|
|
} else {
|
|
substep "reusing existing virtual environment at $VenvDir"
|
|
}
|
|
|
|
# pip and python write to stderr even on success (progress bars, warnings).
|
|
# With $ErrorActionPreference = "Stop" (set at top of script), PS 5.1
|
|
# converts stderr lines into terminating ErrorRecords, breaking output.
|
|
# Lower to "Continue" for the pip/python section.
|
|
$prevEAP = $ErrorActionPreference
|
|
$ErrorActionPreference = "Continue"
|
|
|
|
$ActivateScript = Join-Path $VenvDir "Scripts\Activate.ps1"
|
|
. $ActivateScript
|
|
|
|
# Try to use uv (much faster than pip), fall back to pip if unavailable
|
|
$UseUv = $false
|
|
if (Get-Command uv -ErrorAction SilentlyContinue) {
|
|
$UseUv = $true
|
|
} else {
|
|
substep "installing uv package manager..."
|
|
try {
|
|
Invoke-SetupCommand { powershell -ExecutionPolicy ByPass -c "irm https://astral.sh/uv/install.ps1 | iex" } | Out-Null
|
|
Refresh-Environment
|
|
# Re-activate venv since Refresh-Environment rebuilds PATH from
|
|
# registry and drops the venv's Scripts directory
|
|
. $ActivateScript
|
|
if (Get-Command uv -ErrorAction SilentlyContinue) { $UseUv = $true }
|
|
} catch { }
|
|
}
|
|
|
|
# Helper: install a package, preferring uv with pip fallback
|
|
function Fast-Install {
|
|
param([Parameter(ValueFromRemainingArguments=$true)]$Args_)
|
|
if ($UseUv) {
|
|
$VenvPy = (Get-Command python).Source
|
|
$result = & uv pip install --python $VenvPy @Args_ 2>&1
|
|
if ($LASTEXITCODE -eq 0) { return }
|
|
}
|
|
& python -m pip install @Args_ 2>&1
|
|
}
|
|
|
|
# ── Check if Python deps need updating ──
|
|
# Compare installed package version against PyPI latest.
|
|
# Skip all Python dependency work if versions match (fast update path).
|
|
$_PkgName = if ($env:STUDIO_PACKAGE_NAME) { $env:STUDIO_PACKAGE_NAME } else { "unsloth" }
|
|
$SkipPythonDeps = $false
|
|
|
|
if ($env:SKIP_STUDIO_BASE -ne "1" -and $env:STUDIO_LOCAL_INSTALL -ne "1") {
|
|
# Only check when NOT called from install.ps1 (which just installed the package)
|
|
$InstalledVer = try { (& python -c "from importlib.metadata import version; print(version('$_PkgName'))" 2>$null | Out-String).Trim() } catch { "" }
|
|
$LatestVer = ""
|
|
try {
|
|
$pypiJson = Invoke-RestMethod -Uri "https://pypi.org/pypi/$_PkgName/json" -TimeoutSec 5 -ErrorAction Stop
|
|
$LatestVer = "$($pypiJson.info.version)".Trim()
|
|
} catch { }
|
|
|
|
if ($InstalledVer -and $LatestVer -and ($InstalledVer -eq $LatestVer)) {
|
|
step "python" "$_PkgName $InstalledVer is up to date"
|
|
$SkipPythonDeps = $true
|
|
} elseif ($InstalledVer -and $LatestVer) {
|
|
substep "$_PkgName $InstalledVer -> $LatestVer available, updating..."
|
|
} elseif (-not $LatestVer) {
|
|
substep "could not reach PyPI, updating to be safe..."
|
|
}
|
|
}
|
|
|
|
# if (-not $IsPipInstall) {
|
|
# # Running from repo: copy requirements and do editable install
|
|
# $RepoRoot = (Resolve-Path (Join-Path $ScriptDir "..\..")).Path
|
|
# $ReqsSrc = Join-Path $RepoRoot "backend\requirements"
|
|
# $ReqsDst = Join-Path $PackageDir "requirements"
|
|
# if (-not (Test-Path $ReqsDst)) { New-Item -ItemType Directory -Path $ReqsDst | Out-Null }
|
|
# Copy-Item (Join-Path $ReqsSrc "*.txt") $ReqsDst -Force
|
|
|
|
# Write-Host " Installing CLI entry point..." -ForegroundColor Cyan
|
|
# pip install -e $RepoRoot 2>&1 | Out-Null
|
|
# } else {
|
|
# # Running from pip install: the package is in system Python but not in
|
|
# # the fresh .venv. Install it so run_install() can find its modules
|
|
# # and bundled requirements files.
|
|
# Write-Host " Installing package into venv..." -ForegroundColor Cyan
|
|
# pip install unsloth-roland-test 2>&1 | Out-Null
|
|
# }
|
|
|
|
if (-not $SkipPythonDeps) {
|
|
|
|
if ($script:UnslothVerbose) {
|
|
Fast-Install --upgrade pip
|
|
} else {
|
|
Fast-Install --upgrade pip | Out-Null
|
|
}
|
|
|
|
# Pre-install PyTorch with CUDA support.
|
|
# On Windows, the default PyPI torch wheel is CPU-only.
|
|
# We need PyTorch's CUDA index to get GPU-enabled wheels.
|
|
# PyTorch bundles its own CUDA runtime, so this works regardless
|
|
# of whether the CUDA Toolkit is installed yet.
|
|
# The CUDA tag is chosen based on the driver's max supported CUDA version.
|
|
|
|
# Windows MAX_PATH (260 chars) causes Triton kernel compilation to fail because
|
|
# the auto-generated filenames are extremely long. Use a short cache directory.
|
|
$TorchCacheDir = "C:\tc"
|
|
if (-not (Test-Path $TorchCacheDir)) { New-Item -ItemType Directory -Path $TorchCacheDir -Force | Out-Null }
|
|
$env:TORCHINDUCTOR_CACHE_DIR = $TorchCacheDir
|
|
[Environment]::SetEnvironmentVariable('TORCHINDUCTOR_CACHE_DIR', $TorchCacheDir, 'User')
|
|
substep "TORCHINDUCTOR_CACHE_DIR set to $TorchCacheDir (avoids MAX_PATH issues)"
|
|
|
|
if ($HasNvidiaSmi) {
|
|
$CuTag = Get-PytorchCudaTag
|
|
} else {
|
|
$CuTag = "cpu"
|
|
}
|
|
|
|
$PyTorchWhlBase = if ($env:UNSLOTH_PYTORCH_MIRROR) { $env:UNSLOTH_PYTORCH_MIRROR.TrimEnd('/') } else { "https://download.pytorch.org/whl" }
|
|
|
|
if ($CuTag -eq "cpu") {
|
|
substep "installing PyTorch (CPU-only)..."
|
|
if ($script:UnslothVerbose) {
|
|
Fast-Install torch torchvision torchaudio --index-url "$PyTorchWhlBase/cpu"
|
|
$torchInstallExit = $LASTEXITCODE
|
|
$output = ""
|
|
} else {
|
|
$output = Fast-Install torch torchvision torchaudio --index-url "$PyTorchWhlBase/cpu" | Out-String
|
|
$torchInstallExit = $LASTEXITCODE
|
|
}
|
|
if ($torchInstallExit -ne 0) {
|
|
Write-Host "[FAILED] PyTorch install failed (exit code $torchInstallExit)" -ForegroundColor Red
|
|
Write-Host $output -ForegroundColor Red
|
|
exit 1
|
|
}
|
|
} else {
|
|
substep "installing PyTorch with CUDA support ($CuTag)..."
|
|
substep "(This download is ~2.8 GB -- may take a few minutes)"
|
|
if ($script:UnslothVerbose) {
|
|
Fast-Install torch torchvision torchaudio --index-url "$PyTorchWhlBase/$CuTag"
|
|
$torchInstallExit = $LASTEXITCODE
|
|
$output = ""
|
|
} else {
|
|
$output = Fast-Install torch torchvision torchaudio --index-url "$PyTorchWhlBase/$CuTag" | Out-String
|
|
$torchInstallExit = $LASTEXITCODE
|
|
}
|
|
if ($torchInstallExit -ne 0) {
|
|
Write-Host "[FAILED] PyTorch CUDA install failed (exit code $torchInstallExit)" -ForegroundColor Red
|
|
Write-Host $output -ForegroundColor Red
|
|
exit 1
|
|
}
|
|
|
|
# Install Triton for Windows (enables torch.compile -- without it training can hang)
|
|
substep "installing Triton for Windows..."
|
|
if ($script:UnslothVerbose) {
|
|
Fast-Install "triton-windows<3.7"
|
|
$tritonInstallExit = $LASTEXITCODE
|
|
$output = ""
|
|
} else {
|
|
$output = Fast-Install "triton-windows<3.7" | Out-String
|
|
$tritonInstallExit = $LASTEXITCODE
|
|
}
|
|
if ($tritonInstallExit -ne 0) {
|
|
substep "Triton install failed -- torch.compile may not work" "Yellow"
|
|
Write-Host $output -ForegroundColor Yellow
|
|
} else {
|
|
substep "Triton for Windows installed (enables torch.compile)"
|
|
}
|
|
}
|
|
|
|
# Ordered heavy dependency installation -- shared cross-platform script
|
|
substep "running ordered dependency installation..."
|
|
python "$PSScriptRoot\install_python_stack.py"
|
|
$stackExit = $LASTEXITCODE
|
|
# Restore ErrorActionPreference after pip/python work
|
|
$ErrorActionPreference = $prevEAP
|
|
if ($stackExit -ne 0) {
|
|
Write-Host "[FAILED] Python dependency installation failed (exit code $stackExit)" -ForegroundColor Red
|
|
Write-Host " Re-run the installer or check the error above for details." -ForegroundColor Red
|
|
exit 1
|
|
}
|
|
|
|
} else {
|
|
step "python" "dependencies up to date"
|
|
# Restore ErrorActionPreference (was lowered for pip/python section)
|
|
$ErrorActionPreference = $prevEAP
|
|
}
|
|
|
|
# ── Pre-install transformers 5.x into .venv_t5_530/ and .venv_t5_550/ ──
|
|
# Runs outside the deps fast-path gate so that upgrades from the legacy
|
|
# single .venv_t5 are always migrated to the tiered layout.
|
|
$VenvT5_530Dir = Join-Path $env:USERPROFILE ".unsloth\studio\.venv_t5_530"
|
|
$VenvT5_550Dir = Join-Path $env:USERPROFILE ".unsloth\studio\.venv_t5_550"
|
|
$VenvT5Legacy = Join-Path $env:USERPROFILE ".unsloth\studio\.venv_t5"
|
|
|
|
$_NeedT5Install = $false
|
|
if (Test-Path $VenvT5Legacy) {
|
|
Remove-Item -Recurse -Force $VenvT5Legacy
|
|
$_NeedT5Install = $true
|
|
}
|
|
if (-not (Test-Path $VenvT5_530Dir)) { $_NeedT5Install = $true }
|
|
if (-not (Test-Path $VenvT5_550Dir)) { $_NeedT5Install = $true }
|
|
# Also reinstall when python deps were updated
|
|
if (-not $SkipPythonDeps) { $_NeedT5Install = $true }
|
|
|
|
if ($_NeedT5Install) {
|
|
Write-Host ""
|
|
|
|
$prevEAP_t5 = $ErrorActionPreference
|
|
$ErrorActionPreference = "Continue"
|
|
|
|
# --- .venv_t5_530 (transformers 5.3.0) ---
|
|
substep "pre-installing transformers 5.3.0 for newer model support..."
|
|
if (Test-Path $VenvT5_530Dir) { Remove-Item -Recurse -Force $VenvT5_530Dir }
|
|
New-Item -ItemType Directory -Path $VenvT5_530Dir -Force | Out-Null
|
|
foreach ($pkg in @("transformers==5.3.0", "huggingface_hub==1.8.0", "hf_xet==1.4.2")) {
|
|
if ($script:UnslothVerbose) {
|
|
Fast-Install --target $VenvT5_530Dir --no-deps $pkg
|
|
$t5PkgExit = $LASTEXITCODE
|
|
$output = ""
|
|
} else {
|
|
$output = Fast-Install --target $VenvT5_530Dir --no-deps $pkg | Out-String
|
|
$t5PkgExit = $LASTEXITCODE
|
|
}
|
|
if ($t5PkgExit -ne 0) {
|
|
Write-Host "[FAIL] Could not install $pkg into .venv_t5_530/" -ForegroundColor Red
|
|
Write-Host $output -ForegroundColor Red
|
|
$ErrorActionPreference = $prevEAP_t5
|
|
exit 1
|
|
}
|
|
}
|
|
if ($script:UnslothVerbose) {
|
|
Fast-Install --target $VenvT5_530Dir tiktoken
|
|
$tiktokenInstallExit = $LASTEXITCODE
|
|
$output = ""
|
|
} else {
|
|
$output = Fast-Install --target $VenvT5_530Dir tiktoken | Out-String
|
|
$tiktokenInstallExit = $LASTEXITCODE
|
|
}
|
|
if ($tiktokenInstallExit -ne 0) {
|
|
substep "Could not install tiktoken into .venv_t5_530/ -- Qwen tokenizers may fail" "Yellow"
|
|
}
|
|
step "transformers" "5.3.0 pre-installed"
|
|
|
|
# --- .venv_t5_550 (transformers 5.5.0) ---
|
|
substep "pre-installing transformers 5.5.0 for Gemma 4 support..."
|
|
if (Test-Path $VenvT5_550Dir) { Remove-Item -Recurse -Force $VenvT5_550Dir }
|
|
New-Item -ItemType Directory -Path $VenvT5_550Dir -Force | Out-Null
|
|
foreach ($pkg in @("transformers==5.5.0", "huggingface_hub==1.8.0", "hf_xet==1.4.2")) {
|
|
if ($script:UnslothVerbose) {
|
|
Fast-Install --target $VenvT5_550Dir --no-deps $pkg
|
|
$t5PkgExit = $LASTEXITCODE
|
|
$output = ""
|
|
} else {
|
|
$output = Fast-Install --target $VenvT5_550Dir --no-deps $pkg | Out-String
|
|
$t5PkgExit = $LASTEXITCODE
|
|
}
|
|
if ($t5PkgExit -ne 0) {
|
|
Write-Host "[FAIL] Could not install $pkg into .venv_t5_550/" -ForegroundColor Red
|
|
Write-Host $output -ForegroundColor Red
|
|
$ErrorActionPreference = $prevEAP_t5
|
|
exit 1
|
|
}
|
|
}
|
|
if ($script:UnslothVerbose) {
|
|
Fast-Install --target $VenvT5_550Dir tiktoken
|
|
$tiktokenInstallExit = $LASTEXITCODE
|
|
$output = ""
|
|
} else {
|
|
$output = Fast-Install --target $VenvT5_550Dir tiktoken | Out-String
|
|
$tiktokenInstallExit = $LASTEXITCODE
|
|
}
|
|
if ($tiktokenInstallExit -ne 0) {
|
|
substep "Could not install tiktoken into .venv_t5_550/ -- Qwen tokenizers may fail" "Yellow"
|
|
}
|
|
$ErrorActionPreference = $prevEAP_t5
|
|
step "transformers" "5.5.0 pre-installed"
|
|
|
|
} # end $_NeedT5Install
|
|
|
|
# ==========================================================================
|
|
# PHASE 3.4: Prefer prebuilt llama.cpp bundles before source build
|
|
# ==========================================================================
|
|
$UnslothHome = Join-Path $env:USERPROFILE ".unsloth"
|
|
if (-not (Test-Path $UnslothHome)) { New-Item -ItemType Directory -Force $UnslothHome | Out-Null }
|
|
$LlamaCppDir = Join-Path $UnslothHome "llama.cpp"
|
|
$NeedLlamaSourceBuild = $false
|
|
$SkipPrebuiltInstall = $false
|
|
$RequestedLlamaTag = if ($env:UNSLOTH_LLAMA_TAG) { $env:UNSLOTH_LLAMA_TAG } else { $DefaultLlamaTag }
|
|
$HelperReleaseRepo = "ggml-org/llama.cpp"
|
|
$LlamaPr = if ($env:UNSLOTH_LLAMA_PR) { $env:UNSLOTH_LLAMA_PR.Trim() } else { "" }
|
|
|
|
$LlamaPrForce = if ($env:UNSLOTH_LLAMA_PR_FORCE) { $env:UNSLOTH_LLAMA_PR_FORCE.Trim() } else { $DefaultLlamaPrForce }
|
|
$LlamaSource = $DefaultLlamaSource
|
|
if ($LlamaSource.EndsWith('.git')) { $LlamaSource = $LlamaSource.Substring(0, $LlamaSource.Length - 4) }
|
|
$ResolvedSourceUrl = $LlamaSource
|
|
$ResolvedSourceRef = $RequestedLlamaTag
|
|
$ResolvedSourceRefKind = "tag"
|
|
$ResolvedLlamaTag = $RequestedLlamaTag
|
|
|
|
if ($env:UNSLOTH_LLAMA_FORCE_COMPILE -eq "1") {
|
|
$NeedLlamaSourceBuild = $true
|
|
$SkipPrebuiltInstall = $true
|
|
}
|
|
|
|
function Invoke-LlamaHelper {
|
|
param(
|
|
[string[]]$Arguments,
|
|
[string]$StderrPath = $null
|
|
)
|
|
|
|
$previousErrorActionPreference = $ErrorActionPreference
|
|
$previousNativeErrorPreference = $null
|
|
$restoreNativeErrorPreference = $false
|
|
$ErrorActionPreference = "Continue"
|
|
if ($PSVersionTable.PSVersion.Major -ge 7) {
|
|
$previousNativeErrorPreference = $PSNativeCommandUseErrorActionPreference
|
|
$PSNativeCommandUseErrorActionPreference = $false
|
|
$restoreNativeErrorPreference = $true
|
|
}
|
|
|
|
try {
|
|
# Capture all output (stdout + stderr) so that PowerShell does not
|
|
# convert stderr lines into visible ErrorRecord objects. Separate
|
|
# stdout from stderr afterwards.
|
|
$allOutput = & python "$PSScriptRoot\install_llama_prebuilt.py" @Arguments 2>&1
|
|
$exitCode = $LASTEXITCODE
|
|
$stdoutLines = @()
|
|
$stderrLines = @()
|
|
foreach ($line in $allOutput) {
|
|
if ($line -is [System.Management.Automation.ErrorRecord]) {
|
|
$stderrLines += $line.ToString()
|
|
} else {
|
|
$stdoutLines += $line
|
|
}
|
|
}
|
|
if ($StderrPath -and $stderrLines.Count -gt 0) {
|
|
$stderrLines | Out-File -FilePath $StderrPath -Encoding utf8
|
|
}
|
|
return [pscustomobject]@{
|
|
Output = $stdoutLines
|
|
ExitCode = $exitCode
|
|
}
|
|
} finally {
|
|
$ErrorActionPreference = $previousErrorActionPreference
|
|
if ($restoreNativeErrorPreference) {
|
|
$PSNativeCommandUseErrorActionPreference = $previousNativeErrorPreference
|
|
}
|
|
}
|
|
}
|
|
|
|
if ($LlamaSource -ne "https://github.com/ggml-org/llama.cpp") {
|
|
step "llama.cpp" "custom source: $LlamaSource -- forcing source build" "Yellow"
|
|
$NeedLlamaSourceBuild = $true
|
|
$SkipPrebuiltInstall = $true
|
|
}
|
|
|
|
if (-not $LlamaPr -and $LlamaPrForce -and $LlamaPrForce -match '^\d+$' -and [int]$LlamaPrForce -gt 0) {
|
|
$LlamaPr = $LlamaPrForce
|
|
step "llama.cpp" "baked-in PR_FORCE=$LlamaPrForce" "Yellow"
|
|
}
|
|
|
|
if ($LlamaPr) {
|
|
if ($LlamaPr -notmatch '^\d+$' -or [int]$LlamaPr -le 0) {
|
|
Write-Host "[ERROR] UNSLOTH_LLAMA_PR=$LlamaPr is not a valid PR number" -ForegroundColor Red
|
|
exit 1
|
|
}
|
|
step "llama.cpp" "UNSLOTH_LLAMA_PR=$LlamaPr -- will build from PR head" "Yellow"
|
|
$ResolvedLlamaTag = "pr-$LlamaPr"
|
|
$ResolvedSourceUrl = $LlamaSource
|
|
$ResolvedSourceRef = "pr-$LlamaPr"
|
|
$ResolvedSourceRefKind = "pull"
|
|
$NeedLlamaSourceBuild = $true
|
|
$SkipPrebuiltInstall = $true
|
|
}
|
|
|
|
if ($env:UNSLOTH_LLAMA_FORCE_COMPILE -eq "1") {
|
|
Write-Host ""
|
|
substep "UNSLOTH_LLAMA_FORCE_COMPILE=1 -- skipping prebuilt llama.cpp install" "Yellow"
|
|
$NeedLlamaSourceBuild = $true
|
|
} elseif ($SkipPrebuiltInstall) {
|
|
Write-Host ""
|
|
substep "Skipping prebuilt install -- falling back to source build" "Yellow"
|
|
} else {
|
|
Write-Host ""
|
|
substep "installing prebuilt llama.cpp bundle (preferred path)..."
|
|
if (Test-Path $LlamaCppDir) {
|
|
substep "Existing llama.cpp install detected -- validating staged prebuilt update before replacement"
|
|
}
|
|
$prebuiltArgs = @(
|
|
"$PSScriptRoot\install_llama_prebuilt.py",
|
|
"--install-dir", $LlamaCppDir,
|
|
"--llama-tag", $RequestedLlamaTag,
|
|
"--published-repo", $HelperReleaseRepo,
|
|
"--simple-policy"
|
|
)
|
|
if ($env:UNSLOTH_LLAMA_RELEASE_TAG) {
|
|
$prebuiltArgs += @("--published-release-tag", $env:UNSLOTH_LLAMA_RELEASE_TAG)
|
|
}
|
|
$prevEAPPrebuilt = $ErrorActionPreference
|
|
$ErrorActionPreference = "Continue"
|
|
$previousNativeErrorPreference = $null
|
|
$restoreNativeErrorPreference = $false
|
|
if ($PSVersionTable.PSVersion.Major -ge 7) {
|
|
$previousNativeErrorPreference = $PSNativeCommandUseErrorActionPreference
|
|
$PSNativeCommandUseErrorActionPreference = $false
|
|
$restoreNativeErrorPreference = $true
|
|
}
|
|
try {
|
|
if ($script:UnslothVerbose) {
|
|
# Show live output in verbose mode while still capturing for error log
|
|
$prebuiltLog = Join-Path $env:TEMP "unsloth-prebuilt-$PID.log"
|
|
& python @prebuiltArgs 2>&1 | Tee-Object -FilePath $prebuiltLog | Out-Host
|
|
$prebuiltExit = $LASTEXITCODE
|
|
$prebuiltOutput = if (Test-Path $prebuiltLog) { Get-Content $prebuiltLog -Raw } else { "" }
|
|
Remove-Item $prebuiltLog -ErrorAction SilentlyContinue
|
|
} else {
|
|
$prebuiltOutput = & python @prebuiltArgs 2>&1 | Out-String
|
|
$prebuiltExit = $LASTEXITCODE
|
|
}
|
|
} finally {
|
|
if ($restoreNativeErrorPreference) {
|
|
$PSNativeCommandUseErrorActionPreference = $previousNativeErrorPreference
|
|
}
|
|
}
|
|
$ErrorActionPreference = $prevEAPPrebuilt
|
|
|
|
if ($prebuiltExit -eq 0) {
|
|
if ($prebuiltOutput -match "already matches") {
|
|
step "llama.cpp" "prebuilt up to date and validated"
|
|
} else {
|
|
step "llama.cpp" "prebuilt installed and validated"
|
|
}
|
|
$installedRelease = Get-InstalledLlamaPrebuiltRelease -InstallDir $LlamaCppDir
|
|
if ($installedRelease) {
|
|
substep $installedRelease
|
|
}
|
|
} elseif ($prebuiltExit -eq 3) {
|
|
step "llama.cpp" "install blocked by active llama.cpp process" "Yellow"
|
|
Write-LlamaFailureLog -Output $prebuiltOutput
|
|
if (Test-Path $LlamaCppDir) {
|
|
substep "Existing install was restored" "Yellow"
|
|
}
|
|
substep "Close Studio or other llama.cpp users and retry" "Yellow"
|
|
exit 3
|
|
} else {
|
|
step "llama.cpp" "prebuilt install failed (continuing)" "Yellow"
|
|
Write-LlamaFailureLog -Output $prebuiltOutput
|
|
if (Test-Path $LlamaCppDir) {
|
|
substep "Prebuilt update failed; existing install was restored or cleaned before source build fallback" "Yellow"
|
|
}
|
|
substep "Prebuilt llama.cpp path unavailable or failed validation -- falling back to source build" "Yellow"
|
|
$NeedLlamaSourceBuild = $true
|
|
}
|
|
}
|
|
|
|
# ==========================================================================
|
|
# PHASE 3.5: Install OpenSSL dev (for HTTPS support in llama-server)
|
|
# ==========================================================================
|
|
# llama-server needs OpenSSL to download models from HuggingFace via -hf.
|
|
# ShiningLight.OpenSSL.Dev includes headers + libs that cmake can find.
|
|
$OpenSslAvailable = $false
|
|
|
|
if ($NeedLlamaSourceBuild) {
|
|
# Check if OpenSSL dev is already installed (look for include dir)
|
|
$OpenSslRoots = @(
|
|
'C:\Program Files\OpenSSL-Win64',
|
|
'C:\Program Files\OpenSSL',
|
|
'C:\OpenSSL-Win64'
|
|
)
|
|
$OpenSslRoot = $null
|
|
foreach ($root in $OpenSslRoots) {
|
|
if (Test-Path (Join-Path $root 'include\openssl\ssl.h')) {
|
|
$OpenSslRoot = $root
|
|
break
|
|
}
|
|
}
|
|
|
|
if ($OpenSslRoot) {
|
|
$OpenSslAvailable = $true
|
|
substep "OpenSSL dev found at $OpenSslRoot"
|
|
} else {
|
|
Write-Host ""
|
|
substep "installing OpenSSL dev (for HTTPS in llama-server)..."
|
|
$HasWinget = $null -ne (Get-Command winget -ErrorAction SilentlyContinue)
|
|
if ($HasWinget) {
|
|
winget install -e --id ShiningLight.OpenSSL.Dev --accept-package-agreements --accept-source-agreements
|
|
# Re-check after install
|
|
foreach ($root in $OpenSslRoots) {
|
|
if (Test-Path (Join-Path $root 'include\openssl\ssl.h')) {
|
|
$OpenSslRoot = $root
|
|
$OpenSslAvailable = $true
|
|
substep "OpenSSL dev installed at $OpenSslRoot"
|
|
break
|
|
}
|
|
}
|
|
}
|
|
if (-not $OpenSslAvailable) {
|
|
substep "OpenSSL dev not available -- llama-server will be built without HTTPS" "Yellow"
|
|
}
|
|
}
|
|
} else {
|
|
substep "OpenSSL dev install skipped -- prebuilt llama.cpp already validated" "Yellow"
|
|
}
|
|
|
|
# ==========================================================================
|
|
# PHASE 4: Build llama.cpp with CUDA for GGUF inference + export
|
|
# ==========================================================================
|
|
# Builds at ~/.unsloth/llama.cpp — a single shared location under the user's
|
|
# home directory. This is used by both the inference server and the GGUF
|
|
# export pipeline (unsloth-zoo).
|
|
# We build:
|
|
# - llama-server: for GGUF model inference (with HTTPS if OpenSSL available)
|
|
# - llama-quantize: for GGUF export quantization
|
|
# Prerequisites (git, cmake, VS Build Tools, CUDA Toolkit) already installed in Phase 1.
|
|
$OriginalLlamaCppDir = $LlamaCppDir
|
|
$BuildDir = Join-Path $LlamaCppDir "build"
|
|
$LlamaServerBin = Join-Path $BuildDir "bin\Release\llama-server.exe"
|
|
|
|
$HasCmakeForBuild = $null -ne (Get-Command cmake -ErrorAction SilentlyContinue)
|
|
|
|
# Check if existing llama-server matches current GPU mode. A CUDA-built binary
|
|
# on a now-CPU-only machine (or vice versa) needs to be rebuilt.
|
|
$NeedRebuild = $false
|
|
if (Test-Path $LlamaServerBin) {
|
|
$CmakeCacheFile = Join-Path $BuildDir "CMakeCache.txt"
|
|
if (Test-Path $CmakeCacheFile) {
|
|
$cachedCuda = Select-String -Path $CmakeCacheFile -Pattern 'GGML_CUDA:BOOL=ON' -Quiet
|
|
if ($HasNvidiaSmi -and -not $cachedCuda) {
|
|
Write-Host " Existing llama-server is CPU-only but GPU is available -- rebuilding" -ForegroundColor Yellow
|
|
$NeedRebuild = $true
|
|
} elseif (-not $HasNvidiaSmi -and $cachedCuda) {
|
|
Write-Host " Existing llama-server was built with CUDA but no GPU detected -- rebuilding" -ForegroundColor Yellow
|
|
$NeedRebuild = $true
|
|
}
|
|
}
|
|
}
|
|
|
|
if (-not $NeedLlamaSourceBuild) {
|
|
Write-Host ""
|
|
step "llama.cpp" "prebuilt (validated)"
|
|
} elseif ((Test-Path $LlamaServerBin) -and -not $NeedRebuild -and $RequestedLlamaTag -ne "master") {
|
|
# Skip rebuild only for pinned tags (e.g. b8635). When the requested
|
|
# tag is "master" (a moving target), always rebuild so the binary picks
|
|
# up new model architecture support (e.g. Gemma 4).
|
|
Write-Host ""
|
|
step "llama.cpp" "already built"
|
|
} elseif (-not $HasCmakeForBuild) {
|
|
Write-Host ""
|
|
if (-not $HasNvidiaSmi) {
|
|
# CPU-only machines depend entirely on llama-server for GGUF chat -- cmake is required
|
|
substep "CMake is required to build llama-server for GGUF chat mode." "Yellow"
|
|
substep "Continuing setup without llama.cpp build." "Yellow"
|
|
substep "Install CMake from https://cmake.org/download/ and re-run setup." "Yellow"
|
|
}
|
|
step "llama.cpp" "build skipped (cmake not available)" "Yellow"
|
|
substep "GGUF inference and export will not be available." "Yellow"
|
|
substep "Install CMake from https://cmake.org/download/ and re-run setup." "Yellow"
|
|
$script:LlamaCppDegraded = $true
|
|
} else {
|
|
Write-Host ""
|
|
if ($HasNvidiaSmi) {
|
|
substep "building llama.cpp with CUDA support..."
|
|
} else {
|
|
substep "building llama.cpp (CPU-only, no NVIDIA GPU detected)..."
|
|
}
|
|
substep "This typically takes 5-10 minutes on first build."
|
|
Write-Host ""
|
|
|
|
# Start total build timer
|
|
$totalSw = [System.Diagnostics.Stopwatch]::StartNew()
|
|
|
|
# Native commands (git, cmake) write to stderr even on success.
|
|
# With $ErrorActionPreference = "Stop" (set at top of script), PS 5.1
|
|
# converts stderr lines into terminating ErrorRecords, breaking output.
|
|
# Lower to "Continue" for the build section.
|
|
$prevEAP = $ErrorActionPreference
|
|
$ErrorActionPreference = "Continue"
|
|
|
|
$BuildOk = $true
|
|
$FailedStep = ""
|
|
|
|
# Re-sanitize CUDA_PATH_V* vars — Refresh-Environment (called during
|
|
# Node/Python installs above) may have repopulated conflicting versioned
|
|
# vars from the Machine registry.
|
|
if ($HasNvidiaSmi -and $CudaToolkitRoot) {
|
|
$cudaPathVars2 = @([Environment]::GetEnvironmentVariables('Process').Keys | Where-Object { $_ -match '^CUDA_PATH_V' })
|
|
foreach ($v2 in $cudaPathVars2) {
|
|
[Environment]::SetEnvironmentVariable($v2, $null, 'Process')
|
|
}
|
|
$tkDirName2 = Split-Path $CudaToolkitRoot -Leaf
|
|
if ($tkDirName2 -match '^v(\d+)\.(\d+)') {
|
|
[Environment]::SetEnvironmentVariable("CUDA_PATH_V$($Matches[1])_$($Matches[2])", $CudaToolkitRoot, 'Process')
|
|
}
|
|
# Also re-assert CUDA_PATH and CudaToolkitDir in case they were overwritten
|
|
[Environment]::SetEnvironmentVariable('CUDA_PATH', $CudaToolkitRoot, 'Process')
|
|
[Environment]::SetEnvironmentVariable('CudaToolkitDir', "$CudaToolkitRoot\", 'Process')
|
|
}
|
|
|
|
if (-not $LlamaPr) {
|
|
$ResolvedSourceUrl = $LlamaSource
|
|
if ($env:UNSLOTH_LLAMA_FORCE_COMPILE -eq "1") {
|
|
if ($RequestedLlamaTag -eq "latest") {
|
|
$ResolvedSourceRef = if ($env:UNSLOTH_LLAMA_FORCE_COMPILE_REF) {
|
|
$env:UNSLOTH_LLAMA_FORCE_COMPILE_REF
|
|
} else {
|
|
$DefaultLlamaForceCompileRef
|
|
}
|
|
$ResolvedSourceRefKind = "branch"
|
|
} else {
|
|
$ResolvedSourceRef = $RequestedLlamaTag
|
|
$ResolvedSourceRefKind = "tag"
|
|
}
|
|
} elseif ($RequestedLlamaTag -eq "latest") {
|
|
$resolveTagArgs = @("--resolve-llama-tag", "latest", "--published-repo", "ggml-org/llama.cpp", "--output-format", "json")
|
|
$resolveTagResult = Invoke-LlamaHelper -Arguments $resolveTagArgs
|
|
$resolveTagOutput = $resolveTagResult.Output
|
|
$resolveTagExit = $resolveTagResult.ExitCode
|
|
if ($resolveTagExit -eq 0 -and $resolveTagOutput) {
|
|
try {
|
|
$ResolvedSourceRef = (($resolveTagOutput | Out-String) | ConvertFrom-Json).llama_tag
|
|
} catch {
|
|
$ResolvedSourceRef = ""
|
|
}
|
|
} else {
|
|
$ResolvedSourceRef = ""
|
|
}
|
|
if ([string]::IsNullOrWhiteSpace($ResolvedSourceRef)) {
|
|
$ResolvedSourceRef = "latest"
|
|
}
|
|
$ResolvedSourceRefKind = "tag"
|
|
} else {
|
|
$ResolvedSourceRef = $RequestedLlamaTag
|
|
$ResolvedSourceRefKind = "tag"
|
|
}
|
|
if ([string]::IsNullOrWhiteSpace($ResolvedSourceUrl)) { $ResolvedSourceUrl = $LlamaSource }
|
|
if ([string]::IsNullOrWhiteSpace($ResolvedSourceRef)) { $ResolvedSourceRef = $RequestedLlamaTag }
|
|
}
|
|
|
|
# -- Step A: Clone or pull llama.cpp --
|
|
|
|
$UseConcreteRef = ($ResolvedSourceRef -ne "latest" -and -not [string]::IsNullOrWhiteSpace($ResolvedSourceRef))
|
|
|
|
if (Test-Path (Join-Path $LlamaCppDir ".git")) {
|
|
Write-Host " Syncing llama.cpp to $ResolvedSourceRef..." -ForegroundColor Gray
|
|
# Always sync the remote URL so switching between default/fork sources works
|
|
Invoke-SetupCommand -AlwaysQuiet { git -C $LlamaCppDir remote set-url origin "$ResolvedSourceUrl.git" } | Out-Null
|
|
if ($LlamaPr) {
|
|
$gitFetchExit = Invoke-SetupCommand -AlwaysQuiet { git -C $LlamaCppDir fetch --depth 1 origin "pull/$LlamaPr/head" }
|
|
if ($gitFetchExit -ne 0) {
|
|
$BuildOk = $false
|
|
$FailedStep = "git fetch PR #$LlamaPr"
|
|
} else {
|
|
$gitCheckoutExit = Invoke-SetupCommand -AlwaysQuiet { git -C $LlamaCppDir checkout -B "pr-$LlamaPr" FETCH_HEAD }
|
|
if ($gitCheckoutExit -ne 0) {
|
|
$BuildOk = $false
|
|
$FailedStep = "git checkout PR #$LlamaPr"
|
|
} else {
|
|
Invoke-SetupCommand -AlwaysQuiet { git -C $LlamaCppDir clean -fdx } | Out-Null
|
|
}
|
|
}
|
|
} elseif ($ResolvedSourceRefKind -eq "pull") {
|
|
$gitFetchExit = Invoke-SetupCommand -AlwaysQuiet { git -C $LlamaCppDir fetch --depth 1 origin $ResolvedSourceRef }
|
|
if ($gitFetchExit -ne 0) {
|
|
substep "git fetch failed -- using existing source" "Yellow"
|
|
} else {
|
|
$gitCheckoutExit = Invoke-SetupCommand -AlwaysQuiet { git -C $LlamaCppDir checkout -B unsloth-llama-build FETCH_HEAD }
|
|
if ($gitCheckoutExit -ne 0) {
|
|
$BuildOk = $false
|
|
$FailedStep = "git checkout"
|
|
} else {
|
|
Invoke-SetupCommand -AlwaysQuiet { git -C $LlamaCppDir clean -fdx } | Out-Null
|
|
}
|
|
}
|
|
} elseif ($ResolvedSourceRefKind -eq "commit") {
|
|
$gitFetchExit = Invoke-SetupCommand -AlwaysQuiet { git -C $LlamaCppDir fetch --depth 1 origin $ResolvedSourceRef }
|
|
if ($gitFetchExit -ne 0) {
|
|
substep "git fetch failed -- using existing source" "Yellow"
|
|
} else {
|
|
$gitCheckoutExit = Invoke-SetupCommand -AlwaysQuiet { git -C $LlamaCppDir checkout -B unsloth-llama-build FETCH_HEAD }
|
|
if ($gitCheckoutExit -ne 0) {
|
|
$BuildOk = $false
|
|
$FailedStep = "git checkout"
|
|
} else {
|
|
Invoke-SetupCommand -AlwaysQuiet { git -C $LlamaCppDir clean -fdx } | Out-Null
|
|
}
|
|
}
|
|
} elseif ($UseConcreteRef) {
|
|
$gitFetchExit = Invoke-SetupCommand -AlwaysQuiet { git -C $LlamaCppDir fetch --depth 1 origin $ResolvedSourceRef }
|
|
if ($gitFetchExit -ne 0) {
|
|
substep "git fetch failed -- using existing source" "Yellow"
|
|
} else {
|
|
$gitCheckoutExit = Invoke-SetupCommand -AlwaysQuiet { git -C $LlamaCppDir checkout -B unsloth-llama-build FETCH_HEAD }
|
|
if ($gitCheckoutExit -ne 0) {
|
|
$BuildOk = $false
|
|
$FailedStep = "git checkout"
|
|
} else {
|
|
Invoke-SetupCommand -AlwaysQuiet { git -C $LlamaCppDir clean -fdx } | Out-Null
|
|
}
|
|
}
|
|
} else {
|
|
$gitFetchExit = Invoke-SetupCommand -AlwaysQuiet { git -C $LlamaCppDir fetch --depth 1 origin }
|
|
if ($gitFetchExit -ne 0) {
|
|
substep "git fetch failed -- using existing source" "Yellow"
|
|
} else {
|
|
$gitCheckoutExit = Invoke-SetupCommand -AlwaysQuiet { git -C $LlamaCppDir checkout -B unsloth-llama-build FETCH_HEAD }
|
|
if ($gitCheckoutExit -ne 0) {
|
|
$BuildOk = $false
|
|
$FailedStep = "git checkout"
|
|
} else {
|
|
Invoke-SetupCommand -AlwaysQuiet { git -C $LlamaCppDir clean -fdx } | Out-Null
|
|
}
|
|
}
|
|
}
|
|
} else {
|
|
Write-Host " Cloning llama.cpp @ $ResolvedSourceRef..." -ForegroundColor Gray
|
|
$buildTmp = "$LlamaCppDir.build.$PID"
|
|
$null = New-Item -ItemType Directory -Force -Path (Split-Path $LlamaCppDir -Parent)
|
|
if (Test-Path $buildTmp) { Remove-Item -Recurse -Force $buildTmp }
|
|
if ($LlamaPr) {
|
|
$cloneExit = Invoke-SetupCommand -AlwaysQuiet { git clone --depth 1 "$LlamaSource.git" $buildTmp }
|
|
if ($cloneExit -ne 0) {
|
|
$BuildOk = $false
|
|
$FailedStep = "git clone"
|
|
if (Test-Path $buildTmp) { Remove-Item -Recurse -Force $buildTmp }
|
|
}
|
|
if ($BuildOk) {
|
|
$fetchExit = Invoke-SetupCommand -AlwaysQuiet { git -C $buildTmp fetch --depth 1 origin "pull/$LlamaPr/head:pr-$LlamaPr" }
|
|
if ($fetchExit -ne 0) {
|
|
$BuildOk = $false
|
|
$FailedStep = "git fetch PR #$LlamaPr"
|
|
if (Test-Path $buildTmp) { Remove-Item -Recurse -Force $buildTmp }
|
|
}
|
|
}
|
|
if ($BuildOk) {
|
|
$checkoutExit = Invoke-SetupCommand -AlwaysQuiet { git -C $buildTmp checkout "pr-$LlamaPr" }
|
|
if ($checkoutExit -ne 0) {
|
|
$BuildOk = $false
|
|
$FailedStep = "git checkout PR #$LlamaPr"
|
|
if (Test-Path $buildTmp) { Remove-Item -Recurse -Force $buildTmp }
|
|
}
|
|
}
|
|
} elseif ($ResolvedSourceRefKind -eq "pull") {
|
|
$cloneExit = Invoke-SetupCommand -AlwaysQuiet { git clone --depth 1 "$ResolvedSourceUrl.git" $buildTmp }
|
|
if ($cloneExit -ne 0) {
|
|
$BuildOk = $false
|
|
$FailedStep = "git clone"
|
|
if (Test-Path $buildTmp) { Remove-Item -Recurse -Force $buildTmp }
|
|
}
|
|
if ($BuildOk) {
|
|
$fetchExit = Invoke-SetupCommand -AlwaysQuiet { git -C $buildTmp fetch --depth 1 origin $ResolvedSourceRef }
|
|
if ($fetchExit -ne 0) {
|
|
$BuildOk = $false
|
|
$FailedStep = "git fetch source PR ref"
|
|
if (Test-Path $buildTmp) { Remove-Item -Recurse -Force $buildTmp }
|
|
}
|
|
}
|
|
if ($BuildOk) {
|
|
$checkoutExit = Invoke-SetupCommand -AlwaysQuiet { git -C $buildTmp checkout -B unsloth-llama-build FETCH_HEAD }
|
|
if ($checkoutExit -ne 0) {
|
|
$BuildOk = $false
|
|
$FailedStep = "git checkout source PR ref"
|
|
if (Test-Path $buildTmp) { Remove-Item -Recurse -Force $buildTmp }
|
|
}
|
|
}
|
|
} elseif ($ResolvedSourceRefKind -eq "commit") {
|
|
$cloneExit = Invoke-SetupCommand -AlwaysQuiet { git clone --depth 1 "$ResolvedSourceUrl.git" $buildTmp }
|
|
if ($cloneExit -ne 0) {
|
|
$BuildOk = $false
|
|
$FailedStep = "git clone"
|
|
if (Test-Path $buildTmp) { Remove-Item -Recurse -Force $buildTmp }
|
|
}
|
|
if ($BuildOk) {
|
|
$fetchExit = Invoke-SetupCommand -AlwaysQuiet { git -C $buildTmp fetch --depth 1 origin $ResolvedSourceRef }
|
|
if ($fetchExit -ne 0) {
|
|
$BuildOk = $false
|
|
$FailedStep = "git fetch source commit"
|
|
if (Test-Path $buildTmp) { Remove-Item -Recurse -Force $buildTmp }
|
|
}
|
|
}
|
|
if ($BuildOk) {
|
|
$checkoutExit = Invoke-SetupCommand -AlwaysQuiet { git -C $buildTmp checkout -B unsloth-llama-build FETCH_HEAD }
|
|
if ($checkoutExit -ne 0) {
|
|
$BuildOk = $false
|
|
$FailedStep = "git checkout source commit"
|
|
if (Test-Path $buildTmp) { Remove-Item -Recurse -Force $buildTmp }
|
|
}
|
|
}
|
|
} else {
|
|
$cloneArgs = @("clone", "--depth", "1")
|
|
if ($UseConcreteRef) {
|
|
$cloneArgs += @("--branch", $ResolvedSourceRef)
|
|
}
|
|
$cloneArgs += @("$ResolvedSourceUrl.git", $buildTmp)
|
|
$cloneExit = Invoke-SetupCommand -AlwaysQuiet { git @cloneArgs }
|
|
if ($cloneExit -ne 0) {
|
|
$BuildOk = $false
|
|
$FailedStep = "git clone"
|
|
if (Test-Path $buildTmp) { Remove-Item -Recurse -Force $buildTmp }
|
|
}
|
|
}
|
|
# Use temp dir for build; swap into $LlamaCppDir only after build succeeds
|
|
if ($BuildOk) {
|
|
$LlamaCppDir = $buildTmp
|
|
$BuildDir = Join-Path $LlamaCppDir "build"
|
|
}
|
|
}
|
|
|
|
# -- Step B: cmake configure --
|
|
|
|
if ($BuildOk) {
|
|
Write-Host ""
|
|
Write-Host "--- cmake configure ---" -ForegroundColor Cyan
|
|
|
|
$CmakeArgs = @(
|
|
'-S', $LlamaCppDir,
|
|
'-B', $BuildDir,
|
|
'-G', $CmakeGenerator,
|
|
'-Wno-dev'
|
|
)
|
|
# Tell cmake exactly where VS is (bypasses registry lookup)
|
|
if ($VsInstallPath) {
|
|
$CmakeArgs += "-DCMAKE_GENERATOR_INSTANCE=$VsInstallPath"
|
|
}
|
|
# Common flags
|
|
$CmakeArgs += '-DBUILD_SHARED_LIBS=OFF'
|
|
$CmakeArgs += '-DLLAMA_BUILD_TESTS=OFF'
|
|
$CmakeArgs += '-DLLAMA_BUILD_EXAMPLES=OFF'
|
|
$CmakeArgs += '-DLLAMA_BUILD_SERVER=ON'
|
|
$CmakeArgs += '-DGGML_NATIVE=ON'
|
|
# HTTPS support via OpenSSL
|
|
if ($OpenSslAvailable -and $OpenSslRoot) {
|
|
$CmakeArgs += "-DOPENSSL_ROOT_DIR=$OpenSslRoot"
|
|
$CmakeArgs += '-DLLAMA_OPENSSL=ON'
|
|
} else {
|
|
$CmakeArgs += '-DLLAMA_CURL=OFF'
|
|
}
|
|
$CmakeArgs += '-DCMAKE_EXE_LINKER_FLAGS=/NODEFAULTLIB:LIBCMT'
|
|
# CUDA flags -- only if GPU available, otherwise explicitly disable
|
|
if ($HasNvidiaSmi -and $NvccPath) {
|
|
$CmakeArgs += '-DGGML_CUDA=ON'
|
|
$CmakeArgs += "-DCUDAToolkit_ROOT=$CudaToolkitRoot"
|
|
$CmakeArgs += "-DCUDA_TOOLKIT_ROOT_DIR=$CudaToolkitRoot"
|
|
$CmakeArgs += "-DCMAKE_CUDA_COMPILER=$NvccPath"
|
|
if ($CudaArch) {
|
|
# Validate nvcc actually supports this architecture
|
|
if (Test-NvccArchSupport -NvccExe $NvccPath -Arch $CudaArch) {
|
|
$CmakeArgs += "-DCMAKE_CUDA_ARCHITECTURES=$CudaArch"
|
|
} else {
|
|
# GPU arch too new for this toolkit -- fall back to highest supported.
|
|
# PTX forward-compatibility will JIT-compile for the actual GPU at runtime.
|
|
$maxArch = Get-NvccMaxArch -NvccExe $NvccPath
|
|
if ($maxArch) {
|
|
$CmakeArgs += "-DCMAKE_CUDA_ARCHITECTURES=$maxArch"
|
|
substep "GPU is sm_$CudaArch but nvcc only supports up to sm_$maxArch" "Yellow"
|
|
substep "Building with sm_$maxArch (PTX will JIT for your GPU at runtime)" "Yellow"
|
|
}
|
|
# else: omit flag entirely, let cmake pick defaults
|
|
}
|
|
}
|
|
} else {
|
|
$CmakeArgs += '-DGGML_CUDA=OFF'
|
|
}
|
|
|
|
$cmakeOutput = cmake @CmakeArgs 2>&1 | Out-String
|
|
$cmakeConfigureExit = $LASTEXITCODE
|
|
if ($cmakeConfigureExit -ne 0) {
|
|
$BuildOk = $false
|
|
$FailedStep = "cmake configure"
|
|
Write-LlamaFailureLog -Output $cmakeOutput
|
|
if ($cmakeOutput -match 'No CUDA toolset found|CUDA_TOOLKIT_ROOT_DIR|nvcc') {
|
|
Write-Host ""
|
|
Write-Host " Hint: CUDA VS integration may be missing. Try running as admin:" -ForegroundColor Yellow
|
|
Write-Host " Copy contents of:" -ForegroundColor Yellow
|
|
Write-Host " <CUDA_PATH>\extras\visual_studio_integration\MSBuildExtensions" -ForegroundColor Yellow
|
|
Write-Host " into:" -ForegroundColor Yellow
|
|
Write-Host " <VS_PATH>\MSBuild\Microsoft\VC\v170\BuildCustomizations" -ForegroundColor Yellow
|
|
}
|
|
}
|
|
}
|
|
|
|
# -- Step C: Build llama-server --
|
|
$NumCpu = [Environment]::ProcessorCount
|
|
if ($NumCpu -lt 1) { $NumCpu = 4 }
|
|
|
|
if ($BuildOk) {
|
|
Write-Host ""
|
|
Write-Host "--- cmake build (llama-server) ---" -ForegroundColor Cyan
|
|
Write-Host " Parallel jobs: $NumCpu" -ForegroundColor Gray
|
|
Write-Host ""
|
|
|
|
$output = cmake --build $BuildDir --config Release --target llama-server -j $NumCpu 2>&1 | Out-String
|
|
$cmakeBuildServerExit = $LASTEXITCODE
|
|
if ($cmakeBuildServerExit -ne 0) {
|
|
$BuildOk = $false
|
|
$FailedStep = "cmake build (llama-server)"
|
|
Write-LlamaFailureLog -Output $output
|
|
}
|
|
}
|
|
|
|
# -- Step D: Build llama-quantize (optional, best-effort) --
|
|
if ($BuildOk) {
|
|
Write-Host ""
|
|
Write-Host "--- cmake build (llama-quantize) ---" -ForegroundColor Cyan
|
|
$output = cmake --build $BuildDir --config Release --target llama-quantize -j $NumCpu 2>&1 | Out-String
|
|
$cmakeBuildQuantizeExit = $LASTEXITCODE
|
|
if ($cmakeBuildQuantizeExit -ne 0) {
|
|
substep "llama-quantize build failed (GGUF export may be unavailable)" "Yellow"
|
|
Write-LlamaFailureLog -Output $output
|
|
}
|
|
}
|
|
|
|
# Swap temp build dir into final location (only if we built in a temp dir)
|
|
if ($BuildOk -and $LlamaCppDir -ne $OriginalLlamaCppDir) {
|
|
if (Test-Path $OriginalLlamaCppDir) { Remove-Item -Recurse -Force $OriginalLlamaCppDir }
|
|
Move-Item $LlamaCppDir $OriginalLlamaCppDir
|
|
$LlamaCppDir = $OriginalLlamaCppDir
|
|
$BuildDir = Join-Path $LlamaCppDir "build"
|
|
$LlamaServerBin = Join-Path $BuildDir "bin\Release\llama-server.exe"
|
|
} elseif (-not $BuildOk -and $LlamaCppDir -ne $OriginalLlamaCppDir) {
|
|
# Build failed -- clean up temp dir, preserve existing install
|
|
if (Test-Path $LlamaCppDir) { Remove-Item -Recurse -Force $LlamaCppDir }
|
|
$LlamaCppDir = $OriginalLlamaCppDir
|
|
$BuildDir = Join-Path $LlamaCppDir "build"
|
|
$LlamaServerBin = Join-Path $BuildDir "bin\Release\llama-server.exe"
|
|
}
|
|
|
|
# Restore ErrorActionPreference
|
|
$ErrorActionPreference = $prevEAP
|
|
|
|
# Stop timer
|
|
$totalSw.Stop()
|
|
$totalMin = [math]::Floor($totalSw.Elapsed.TotalMinutes)
|
|
$totalSec = [math]::Round($totalSw.Elapsed.TotalSeconds % 60, 1)
|
|
|
|
# -- Summary --
|
|
if ($BuildOk -and (Test-Path $LlamaServerBin)) {
|
|
step "llama.cpp" "built"
|
|
$QuantizeBin = Join-Path $BuildDir "bin\Release\llama-quantize.exe"
|
|
if (Test-Path $QuantizeBin) {
|
|
step "llama-quantize" "built"
|
|
}
|
|
step "build time" "${totalMin}m ${totalSec}s" "DarkGray"
|
|
} else {
|
|
$altBin = Join-Path $BuildDir "bin\llama-server.exe"
|
|
if ($BuildOk -and (Test-Path $altBin)) {
|
|
step "llama.cpp" "built"
|
|
step "build time" "${totalMin}m ${totalSec}s" "DarkGray"
|
|
} else {
|
|
step "llama.cpp" "build failed at: $FailedStep (${totalMin}m ${totalSec}s); continuing" "Yellow"
|
|
substep "To retry: delete $LlamaCppDir and re-run setup." "Yellow"
|
|
$script:LlamaCppDegraded = $true
|
|
}
|
|
}
|
|
}
|
|
|
|
# ─────────────────────────────────────────────
|
|
# Footer
|
|
# ─────────────────────────────────────────────
|
|
$DoneLabel = if ($env:SKIP_STUDIO_BASE -eq "1") { "Unsloth Studio Setup Complete" } else { "Unsloth Studio Updated" }
|
|
if ($script:StudioVtOk -and -not $env:NO_COLOR) {
|
|
Write-Host (" {0}{1}{2}" -f (Get-StudioAnsi Dim), $Rule, (Get-StudioAnsi Reset))
|
|
if ($script:LlamaCppDegraded) {
|
|
Write-Host (" " + (Get-StudioAnsi Warn) + "$DoneLabel (limited: llama.cpp unavailable)" + (Get-StudioAnsi Reset))
|
|
} else {
|
|
Write-Host (" " + (Get-StudioAnsi Title) + $DoneLabel + (Get-StudioAnsi Reset))
|
|
}
|
|
Write-Host (" {0}{1}{2}" -f (Get-StudioAnsi Dim), $Rule, (Get-StudioAnsi Reset))
|
|
} else {
|
|
Write-Host " $Rule" -ForegroundColor DarkGray
|
|
if ($script:LlamaCppDegraded) {
|
|
Write-Host " $DoneLabel (limited: llama.cpp unavailable)" -ForegroundColor Yellow
|
|
} else {
|
|
Write-Host " $DoneLabel" -ForegroundColor Green
|
|
}
|
|
Write-Host " $Rule" -ForegroundColor DarkGray
|
|
}
|
|
step "launch" "unsloth studio -H 0.0.0.0 -p 8888"
|
|
Write-Host ""
|
|
|
|
# Match studio/setup.sh: exit non-zero for degraded llama.cpp when called
|
|
# from install.ps1 (SKIP_STUDIO_BASE=1) so the installer can detect the
|
|
# failure. Direct 'unsloth studio update' does not set SKIP_STUDIO_BASE,
|
|
# so it keeps degraded installs successful.
|
|
if ($script:LlamaCppDegraded -and $env:SKIP_STUDIO_BASE -eq "1") {
|
|
exit 1
|
|
}
|