studio: add uninstall.ps1 for Windows (#5513)
* studio: add uninstall.ps1 and document it in README for Windows The previous Windows uninstall guidance was Remove-Item -Recurse -Force on $HOME\.unsloth\studio, which only deletes the install dir and leaves behind: * %LOCALAPPDATA%\Unsloth Studio (data dir) * Desktop\Unsloth Studio.lnk (Desktop shortcut) * %APPDATA%\Microsoft\Windows\Start Menu\Programs\Unsloth Studio.lnk * Custom UNSLOTH_STUDIO_HOME / STUDIO_HOME roots * Running unsloth_studio venv processes * User PATH entry under .unsloth\studio * HKCU\Software\Unsloth\PathBackup This script mirrors uninstall.sh for Windows. It stops listening backends by reading the port from share\studio.port (with a Win32_Process sweep anchored on \unsloth_studio\ as a fallback), removes the install dir, data dir, both shortcuts, the Studio PATH entry, and the PathBackup registry key. Custom roots discovered from env vars or share\studio.conf are accepted only if they contain a Studio sentinel (share\studio.conf, unsloth_studio\.unsloth-studio-owned, or bin\unsloth.exe) and are not on a hard deny list (drive root, %USERPROFILE%, parent of %USERPROFILE%, or top-level system paths). README now points Windows users at the script. * Scope port-file kill and PATH cleanup to known Studio roots for PR #5513 Three findings from the reviewer round: 1. _StopByPortFile killed whatever owned the recorded port without proving the PID belonged to this Studio install. A stale studio.port pointing at a port a different local service later bound would force-kill that service. New _PidUnderKnownRoot checks the listening PID's exe path against the same $KnownRoots that _StopStudioProcesses already uses. 2. The netstat.exe fallback matched ":$port " anywhere in the line, so a stale port file with 443 (or any common port) could match an ESTABLISHED row whose remote endpoint was that port, killing an unrelated process (browser, IDE). Now requires the row contain LISTENING, and applies the same _PidUnderKnownRoot ownership check. 3. PATH cleanup removed any entry whose expanded path contained \unsloth_studio\, which would also clobber an unrelated user virtualenv that shared the name. Now only removes entries that resolve inside a known Studio root (default %USERPROFILE%\.unsloth\studio plus any custom roots discovered from UNSLOTH_STUDIO_HOME / STUDIO_HOME / share\studio.conf). * Expand tilde and honor UNSLOTH_STUDIO_HOME precedence for PR #5513 Two findings from the latest review round: 1. install.ps1 (lines 152-154) expands ~ and ~\path to $env:USERPROFILE before resolving the install root, but uninstall.ps1 was passing the raw env value to [System.IO.Path]::GetFullPath. That resolved ~\foo relative to the current directory rather than the user profile, so a user who installed with UNSLOTH_STUDIO_HOME='~\custom' could not uninstall through the same variable. New _ExpandTilde helper matches install.ps1's behavior. 2. Mirror install.ps1's env-var precedence: UNSLOTH_STUDIO_HOME wins, STUDIO_HOME is ignored when both are set. Otherwise uninstalling install A could also touch install B if the user has a stale STUDIO_HOME pointing at B. --------- Co-authored-by: Daniel Han <info@unsloth.ai>
This commit is contained in:
parent
dff0163e80
commit
47167885b0
2 changed files with 355 additions and 3 deletions
|
|
@ -218,12 +218,12 @@ unsloth studio -p 8888
|
|||
```
|
||||
|
||||
#### Uninstall
|
||||
On Mac/Linux/WSL the recommended way to fully remove Unsloth Studio is the `uninstall.sh` script. It stops any running servers, removes the install dir, the launcher data dir, the desktop shortcut, the macOS `.app` bundle, and the Launch Services entry:
|
||||
The recommended way to fully remove Unsloth Studio is the matching uninstall script for your OS. It stops any running servers, removes the install dir, the launcher data dir, the desktop shortcut, and any platform-specific entries (macOS `.app` bundle + Launch Services on Mac; Start Menu, `HKCU\Software\Unsloth` registry key and user `PATH` entries on Windows):
|
||||
|
||||
* **MacOS, WSL, Linux:** `curl -fsSL https://unsloth.ai/uninstall.sh | sh`
|
||||
* **Windows (PowerShell):** `Remove-Item -Recurse -Force "$HOME\.unsloth\studio"`
|
||||
* **Windows (PowerShell):** `irm https://unsloth.ai/uninstall.ps1 | iex`
|
||||
|
||||
If you only want to drop the install dir and keep the launcher/shortcut for a later reinstall, you can instead run `rm -rf ~/.unsloth/studio`. The model cache at `~/.cache/huggingface` is not touched by either command.
|
||||
If you only want to drop the install dir and keep the launcher/shortcut for a later reinstall, you can instead run `rm -rf ~/.unsloth/studio` (Mac/Linux/WSL) or `Remove-Item -Recurse -Force "$HOME\.unsloth\studio"` (Windows). The model cache at `~/.cache/huggingface` is not touched by any of these.
|
||||
|
||||
For more info, [see our docs](https://unsloth.ai/docs/new/studio/install#uninstall).
|
||||
|
||||
|
|
|
|||
352
uninstall.ps1
Normal file
352
uninstall.ps1
Normal file
|
|
@ -0,0 +1,352 @@
|
|||
# Unsloth Studio uninstaller for Windows PowerShell.
|
||||
# Stops running servers and removes install dir, launcher data, CLI shim,
|
||||
# desktop and Start Menu shortcuts, the user PATH entry, and the PathBackup
|
||||
# registry key. Honors custom roots set via UNSLOTH_STUDIO_HOME / STUDIO_HOME
|
||||
# at install time (read back from share\studio.conf).
|
||||
#
|
||||
# Usage: irm https://raw.githubusercontent.com/unslothai/unsloth/main/uninstall.ps1 | iex
|
||||
# Local: Set-ExecutionPolicy -Scope Process -ExecutionPolicy Bypass; .\uninstall.ps1
|
||||
|
||||
function Uninstall-UnslothStudio {
|
||||
$ErrorActionPreference = "Continue"
|
||||
|
||||
function _Step { param([string]$Msg) Write-Host $Msg }
|
||||
function _Substep { param([string]$Msg, [string]$Color = "Gray") Write-Host " $Msg" -ForegroundColor $Color }
|
||||
|
||||
# Remove a file/dir/symlink only if it exists. Idempotent.
|
||||
function _RemovePath {
|
||||
param([string]$Path)
|
||||
if ([string]::IsNullOrWhiteSpace($Path)) { return }
|
||||
if (Test-Path -LiteralPath $Path) {
|
||||
try {
|
||||
Remove-Item -LiteralPath $Path -Recurse -Force -ErrorAction Stop
|
||||
_Substep "removed: $Path" "Green"
|
||||
} catch {
|
||||
_Substep "could not remove: $Path ($($_.Exception.Message))" "Yellow"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
# A path is a Studio-owned root iff one of install.ps1's sentinels exists:
|
||||
# <root>\share\studio.conf, <root>\unsloth_studio\.unsloth-studio-owned,
|
||||
# or <root>\bin\unsloth.exe.
|
||||
function _IsStudioRoot {
|
||||
param([string]$Path)
|
||||
if ([string]::IsNullOrWhiteSpace($Path)) { return $false }
|
||||
if (Test-Path -LiteralPath (Join-Path $Path "share\studio.conf") -PathType Leaf) { return $true }
|
||||
if (Test-Path -LiteralPath (Join-Path $Path "unsloth_studio\.unsloth-studio-owned") -PathType Leaf) { return $true }
|
||||
if (Test-Path -LiteralPath (Join-Path $Path "bin\unsloth.exe") -PathType Leaf) { return $true }
|
||||
return $false
|
||||
}
|
||||
|
||||
# Hard deny list. Refuse to recursively delete drive roots, USERPROFILE
|
||||
# itself, parent of USERPROFILE, or system directories.
|
||||
function _IsUnsafeRoot {
|
||||
param([string]$Path)
|
||||
if ([string]::IsNullOrWhiteSpace($Path)) { return $true }
|
||||
$norm = $null
|
||||
try { $norm = [System.IO.Path]::GetFullPath($Path).TrimEnd('\','/') } catch { return $true }
|
||||
if ([string]::IsNullOrWhiteSpace($norm)) { return $true }
|
||||
# Drive root, e.g. C:\
|
||||
if ($norm -match '^[A-Za-z]:[\\/]?$') { return $true }
|
||||
$userProfile = $env:USERPROFILE
|
||||
if ($userProfile) {
|
||||
$userProfile = $userProfile.TrimEnd('\','/')
|
||||
if ($norm -ieq $userProfile) { return $true }
|
||||
try {
|
||||
$parent = Split-Path -LiteralPath $userProfile -Parent
|
||||
if ($parent -and ($norm -ieq $parent.TrimEnd('\','/'))) { return $true }
|
||||
} catch { }
|
||||
}
|
||||
$systemRoots = @(
|
||||
$env:SystemRoot, $env:windir, $env:ProgramFiles, ${env:ProgramFiles(x86)},
|
||||
$env:ProgramData, $env:APPDATA, $env:LOCALAPPDATA
|
||||
)
|
||||
foreach ($s in $systemRoots) {
|
||||
if (-not [string]::IsNullOrWhiteSpace($s)) {
|
||||
$s2 = $s.TrimEnd('\','/')
|
||||
if ($norm -ieq $s2) { return $true }
|
||||
}
|
||||
}
|
||||
return $false
|
||||
}
|
||||
|
||||
# Parse UNSLOTH_EXE='<path>' out of a share\studio.conf and return the
|
||||
# implied install root (three dirnames up from the venv exe).
|
||||
function _RootFromConf {
|
||||
param([string]$ConfFile)
|
||||
if (-not (Test-Path -LiteralPath $ConfFile -PathType Leaf)) { return $null }
|
||||
$line = Get-Content -LiteralPath $ConfFile -ErrorAction SilentlyContinue |
|
||||
Where-Object { $_ -match "^UNSLOTH_EXE\s*=" } | Select-Object -First 1
|
||||
if (-not $line) { return $null }
|
||||
# Tolerate ' value ' single-quoted with '' -> ' apostrophe escape.
|
||||
if ($line -match "^UNSLOTH_EXE\s*=\s*'(.*)'\s*$") {
|
||||
$exe = $Matches[1] -replace "''", "'"
|
||||
try {
|
||||
$bin = Split-Path -LiteralPath $exe -Parent
|
||||
$studio = Split-Path -LiteralPath $bin -Parent
|
||||
$root = Split-Path -LiteralPath $studio -Parent
|
||||
if ($root) { return $root }
|
||||
} catch { }
|
||||
}
|
||||
return $null
|
||||
}
|
||||
|
||||
# Expand a leading ~ or ~/ ~\ to $env:USERPROFILE so env-mode roots
|
||||
# written with the tilde shape install.ps1 supports (lines 152-154) are
|
||||
# found here too.
|
||||
function _ExpandTilde {
|
||||
param([string]$Path)
|
||||
if ([string]::IsNullOrWhiteSpace($Path)) { return $Path }
|
||||
$p = $Path.Trim()
|
||||
if ($p -eq '~') { return $env:USERPROFILE }
|
||||
if ($p.StartsWith('~/') -or $p.StartsWith('~\')) {
|
||||
if ($env:USERPROFILE) {
|
||||
return (Join-Path $env:USERPROFILE $p.Substring(2).TrimStart('/','\'))
|
||||
}
|
||||
}
|
||||
return $p
|
||||
}
|
||||
|
||||
# Discover non-default Studio roots from env vars + studio.conf files.
|
||||
# Mirrors install.ps1's precedence: UNSLOTH_STUDIO_HOME wins, STUDIO_HOME
|
||||
# is ignored when both are set, so uninstalling install A doesn't also
|
||||
# delete install B if the user has a stale STUDIO_HOME pointing at B.
|
||||
function _CustomStudioRoots {
|
||||
$seen = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase)
|
||||
$defaultRoot = $null
|
||||
if ($env:USERPROFILE) {
|
||||
$defaultRoot = (Join-Path $env:USERPROFILE ".unsloth\studio")
|
||||
}
|
||||
|
||||
$emit = {
|
||||
param($Path)
|
||||
if ([string]::IsNullOrWhiteSpace($Path)) { return }
|
||||
$expanded = _ExpandTilde $Path
|
||||
$norm = $null
|
||||
try { $norm = [System.IO.Path]::GetFullPath($expanded).TrimEnd('\','/') } catch { return }
|
||||
if (-not $norm) { return }
|
||||
if ($defaultRoot -and ($norm -ieq $defaultRoot.TrimEnd('\','/'))) { return }
|
||||
if ($seen.Add($norm)) { Write-Output $norm }
|
||||
}
|
||||
|
||||
$envRoot = $null
|
||||
if ($env:UNSLOTH_STUDIO_HOME) {
|
||||
$envRoot = $env:UNSLOTH_STUDIO_HOME
|
||||
} elseif ($env:STUDIO_HOME) {
|
||||
$envRoot = $env:STUDIO_HOME
|
||||
}
|
||||
if ($envRoot) {
|
||||
$expandedEnv = _ExpandTilde $envRoot
|
||||
& $emit $expandedEnv
|
||||
$confRoot = _RootFromConf (Join-Path $expandedEnv "share\studio.conf")
|
||||
if ($confRoot) { & $emit $confRoot }
|
||||
}
|
||||
# Default-mode conf at LOCALAPPDATA\Unsloth Studio.
|
||||
if ($env:LOCALAPPDATA) {
|
||||
$confRoot = _RootFromConf (Join-Path $env:LOCALAPPDATA "Unsloth Studio\studio.conf")
|
||||
if ($confRoot) { & $emit $confRoot }
|
||||
}
|
||||
}
|
||||
|
||||
# Return $true iff the PID's image path lives under one of $KnownRoots.
|
||||
# Prevents killing an unrelated process that happens to listen on a stale
|
||||
# Studio port.
|
||||
function _PidUnderKnownRoot {
|
||||
param([int]$Pid_, [string[]]$KnownRoots)
|
||||
if (-not $KnownRoots -or $KnownRoots.Count -eq 0) { return $false }
|
||||
try {
|
||||
$proc = Get-CimInstance Win32_Process -Filter "ProcessId=$Pid_" -ErrorAction SilentlyContinue
|
||||
if (-not $proc) { return $false }
|
||||
$exe = $proc.ExecutablePath
|
||||
if (-not $exe) { return $false }
|
||||
foreach ($r in $KnownRoots) {
|
||||
if ($r -and ($exe -ilike "$r\*")) { return $true }
|
||||
}
|
||||
} catch { }
|
||||
return $false
|
||||
}
|
||||
|
||||
# Stop a Studio backend whose port is recorded in <DataDir>\studio.port.
|
||||
# Only kills if the listening PID's exe path is under a known Studio root.
|
||||
function _StopByPortFile {
|
||||
param([string]$PortFile, [string[]]$KnownRoots)
|
||||
if (-not (Test-Path -LiteralPath $PortFile -PathType Leaf)) { return }
|
||||
$port = Get-Content -LiteralPath $PortFile -ErrorAction SilentlyContinue | Select-Object -First 1
|
||||
if ($port) { $port = $port.Trim() }
|
||||
if (-not ($port -match '^[0-9]+$')) {
|
||||
Remove-Item -LiteralPath $PortFile -Force -ErrorAction SilentlyContinue
|
||||
return
|
||||
}
|
||||
try {
|
||||
$conns = Get-NetTCPConnection -State Listen -LocalPort ([int]$port) -ErrorAction SilentlyContinue
|
||||
foreach ($c in $conns) {
|
||||
if (-not (_PidUnderKnownRoot -Pid_ ([int]$c.OwningProcess) -KnownRoots $KnownRoots)) { continue }
|
||||
try {
|
||||
Stop-Process -Id $c.OwningProcess -Force -ErrorAction SilentlyContinue
|
||||
} catch { }
|
||||
}
|
||||
} catch {
|
||||
# netstat fallback for older PowerShell. Require LISTENING state so
|
||||
# we never kill a process whose remote endpoint just happens to be
|
||||
# the cached port (browser -> :443 etc.).
|
||||
try {
|
||||
$lines = & netstat.exe -ano 2>$null |
|
||||
Select-String -Pattern "LISTENING" |
|
||||
Select-String -Pattern ":$port\s"
|
||||
foreach ($l in $lines) {
|
||||
$parts = ($l.ToString() -split '\s+') | Where-Object { $_ }
|
||||
$pid_ = $parts[-1]
|
||||
if ($pid_ -match '^\d+$') {
|
||||
if (-not (_PidUnderKnownRoot -Pid_ ([int]$pid_) -KnownRoots $KnownRoots)) { continue }
|
||||
try { Stop-Process -Id ([int]$pid_) -Force -ErrorAction SilentlyContinue } catch { }
|
||||
}
|
||||
}
|
||||
} catch { }
|
||||
}
|
||||
Remove-Item -LiteralPath $PortFile -Force -ErrorAction SilentlyContinue
|
||||
}
|
||||
|
||||
# Stop processes whose ExecutablePath lives under an unsloth_studio venv.
|
||||
# Anchoring on the venv path avoids matching unrelated python.exe / studio.exe.
|
||||
function _StopStudioProcesses {
|
||||
param([string[]]$KnownRoots)
|
||||
try {
|
||||
$procs = Get-CimInstance Win32_Process -ErrorAction SilentlyContinue |
|
||||
Where-Object {
|
||||
$_.ExecutablePath -and ($_.ExecutablePath -match '\\unsloth_studio\\.*\\(unsloth|python|studio)\.exe$') -and
|
||||
$_.CommandLine -and ($_.CommandLine -match 'studio')
|
||||
}
|
||||
foreach ($p in $procs) {
|
||||
# Optional scope: only kill if the exe is under a known root.
|
||||
if ($KnownRoots) {
|
||||
$match = $false
|
||||
foreach ($r in $KnownRoots) {
|
||||
if ($p.ExecutablePath -and ($p.ExecutablePath -ilike "$r\*")) { $match = $true; break }
|
||||
}
|
||||
if (-not $match) { continue }
|
||||
}
|
||||
try {
|
||||
Stop-Process -Id $p.ProcessId -Force -ErrorAction SilentlyContinue
|
||||
} catch { }
|
||||
}
|
||||
} catch { }
|
||||
}
|
||||
|
||||
# Default install root + default data dir.
|
||||
$defaultStudioHome = if ($env:USERPROFILE) { Join-Path $env:USERPROFILE ".unsloth\studio" } else { $null }
|
||||
$defaultDataDir = if ($env:LOCALAPPDATA) { Join-Path $env:LOCALAPPDATA "Unsloth Studio" } else { $null }
|
||||
|
||||
# Build known-root list FIRST so the port-file kill can verify ownership.
|
||||
$customRoots = @(_CustomStudioRoots)
|
||||
$knownRoots = @()
|
||||
if ($defaultStudioHome) { $knownRoots += $defaultStudioHome }
|
||||
$knownRoots += $customRoots
|
||||
|
||||
# ── Stop running servers ──
|
||||
_Step "Stopping any running Unsloth Studio servers..."
|
||||
if ($defaultDataDir) {
|
||||
_StopByPortFile -PortFile (Join-Path $defaultDataDir "studio.port") -KnownRoots $knownRoots
|
||||
}
|
||||
foreach ($r in $customRoots) {
|
||||
_StopByPortFile -PortFile (Join-Path $r "share\studio.port") -KnownRoots $knownRoots
|
||||
}
|
||||
_StopStudioProcesses -KnownRoots $knownRoots
|
||||
|
||||
# ── Remove custom-root install trees ──
|
||||
_Step "Removing data and install directories..."
|
||||
foreach ($r in $customRoots) {
|
||||
if (_IsUnsafeRoot $r) {
|
||||
_Substep "refusing to remove unsafe path: $r" "Yellow"
|
||||
continue
|
||||
}
|
||||
if (-not (_IsStudioRoot $r)) {
|
||||
_Substep "refusing to remove non-Studio path: $r" "Yellow"
|
||||
continue
|
||||
}
|
||||
_RemovePath $r
|
||||
}
|
||||
# Default install dir (always at %USERPROFILE%\.unsloth\studio when present).
|
||||
if ($defaultStudioHome) { _RemovePath $defaultStudioHome }
|
||||
# Default data dir.
|
||||
if ($defaultDataDir) { _RemovePath $defaultDataDir }
|
||||
|
||||
# ── Remove desktop and Start Menu shortcuts ──
|
||||
_Step "Removing desktop and Start Menu shortcuts..."
|
||||
try {
|
||||
$desktop = [Environment]::GetFolderPath("Desktop")
|
||||
if ($desktop) { _RemovePath (Join-Path $desktop "Unsloth Studio.lnk") }
|
||||
} catch { }
|
||||
if ($env:APPDATA) {
|
||||
_RemovePath (Join-Path $env:APPDATA "Microsoft\Windows\Start Menu\Programs\Unsloth Studio.lnk")
|
||||
}
|
||||
|
||||
# ── Clean user PATH and registry backup ──
|
||||
_Step "Cleaning user PATH and registry..."
|
||||
try {
|
||||
$regKey = [Microsoft.Win32.Registry]::CurrentUser.OpenSubKey('Environment', $true)
|
||||
if ($regKey) {
|
||||
try {
|
||||
$rawPath = $regKey.GetValue('Path', '', [Microsoft.Win32.RegistryValueOptions]::DoNotExpandEnvironmentNames)
|
||||
if ($rawPath) {
|
||||
$entries = $rawPath -split ';'
|
||||
$kept = New-Object System.Collections.ArrayList
|
||||
$removedAny = $false
|
||||
# Only remove PATH entries that live inside a Studio root we
|
||||
# actually own (default or env-mode). A literal substring
|
||||
# match on `unsloth_studio` would clobber unrelated user
|
||||
# virtualenvs that happen to share the name.
|
||||
foreach ($e in $entries) {
|
||||
if ([string]::IsNullOrWhiteSpace($e)) { continue }
|
||||
$expanded = [Environment]::ExpandEnvironmentVariables($e).TrimEnd('\','/')
|
||||
$isStudio = $false
|
||||
foreach ($r in $knownRoots) {
|
||||
if (-not $r) { continue }
|
||||
$rNorm = $r.TrimEnd('\','/')
|
||||
if ($expanded -ieq $rNorm -or $expanded -ilike "$rNorm\*") {
|
||||
$isStudio = $true; break
|
||||
}
|
||||
}
|
||||
if ($isStudio) {
|
||||
_Substep "removed PATH entry: $e" "Green"
|
||||
$removedAny = $true
|
||||
continue
|
||||
}
|
||||
[void]$kept.Add($e)
|
||||
}
|
||||
if ($removedAny) {
|
||||
$newPath = ($kept -join ';')
|
||||
$regKey.SetValue('Path', $newPath, [Microsoft.Win32.RegistryValueKind]::ExpandString)
|
||||
try {
|
||||
$d = "UnslothPathRefresh_" + ([guid]::NewGuid().ToString('N').Substring(0, 8))
|
||||
[Environment]::SetEnvironmentVariable($d, '1', 'User')
|
||||
[Environment]::SetEnvironmentVariable($d, [NullString]::Value, 'User')
|
||||
} catch { }
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
$regKey.Close()
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
_Substep "could not update user PATH: $($_.Exception.Message)" "Yellow"
|
||||
}
|
||||
# Remove HKCU\Software\Unsloth (PathBackup lives here; install.ps1 owns it).
|
||||
try {
|
||||
Remove-Item -LiteralPath 'HKCU:\Software\Unsloth' -Recurse -Force -ErrorAction SilentlyContinue
|
||||
} catch { }
|
||||
|
||||
Write-Host ""
|
||||
Write-Host "Unsloth Studio uninstalled."
|
||||
Write-Host "Note: Hugging Face model cache at %USERPROFILE%\.cache\huggingface was left in place."
|
||||
Write-Host "Remove it manually with 'Remove-Item -Recurse -Force `"$env:USERPROFILE\.cache\huggingface\hub`"' if desired."
|
||||
if (-not $env:UNSLOTH_STUDIO_HOME -and -not $env:STUDIO_HOME) {
|
||||
Write-Host ""
|
||||
Write-Host "If you installed Unsloth Studio with UNSLOTH_STUDIO_HOME or STUDIO_HOME"
|
||||
Write-Host "pointing at a custom directory, re-run this script with the same variable"
|
||||
Write-Host "set to also remove that install tree, e.g.:"
|
||||
Write-Host " `$env:UNSLOTH_STUDIO_HOME = 'C:\your\path'; .\uninstall.ps1"
|
||||
}
|
||||
}
|
||||
|
||||
Uninstall-UnslothStudio @args
|
||||
Loading…
Add table
Add a link
Reference in a new issue