unsloth/scripts/uninstall.sh
Daniel Han a74a1080e0
Move uninstall scripts into scripts/ and fix references (#5644)
* Move uninstall scripts into scripts/ and fix all references

Relocates `uninstall.sh` and `uninstall.ps1` from the repo root into
the existing `scripts/` directory, alongside the other helper scripts.

Reference fixes:
* `README.md`: Studio uninstall instructions now point at the raw
  GitHub URLs under `scripts/`. The previous `unsloth.ai/uninstall.*`
  short URLs currently 404 (unlike `unsloth.ai/install.sh`, which
  301s to the raw github URL), so the raw URL is the working entry
  point until that redirect is configured.
* `scripts/uninstall.sh` header `Usage:` example updated to the new
  raw GitHub path.
* `scripts/uninstall.ps1` header `Usage:` example updated to the new
  raw GitHub path.
* `.github/workflows/studio-update-smoke.yml`: `paths:` trigger and
  round-trip exec/exists checks now use `scripts/uninstall.sh`.
* `.github/workflows/studio-mac-update-smoke.yml`: same.
* `.github/workflows/studio-windows-update-smoke.yml`: `paths:`
  trigger and round-trip exec/exists checks now use
  `scripts/uninstall.ps1`.

The in-script help hints (e.g. `sh uninstall.sh`, `.\uninstall.ps1`)
are left unchanged because they are user-facing examples shown after
the user already has the file locally, and the basename form works
regardless of which directory the user downloaded the script into.

Follow-up note for unsloth.ai: once this lands, please add the
`unsloth.ai/uninstall.sh` and `unsloth.ai/uninstall.ps1` short-URL
redirects to `raw.githubusercontent.com/unslothai/unsloth/main/scripts/...`
(matching the existing `unsloth.ai/install.sh` redirect pattern).

* Update remaining uninstall script help hints for new scripts/ path

Three user-facing strings inside the uninstall scripts still showed
the old basename form, which became misleading after the move:

* `scripts/uninstall.ps1` header `# Local:` example: now references
  `.\scripts\uninstall.ps1` (the actual path from the cloned repo
  root).
* `scripts/uninstall.sh` env-var re-run hint: now shows the canonical
  curl-pipe form documented in README, since callers who came via
  `curl -fsSL ... | sh` never had a local `uninstall.sh` to invoke.
* `scripts/uninstall.ps1` env-var re-run hint: same, switched to the
  `irm ... | iex` form documented in README.

Pure string changes, no behavior change.

---------

Co-authored-by: Lee Jackson <130007945+Imagineer99@users.noreply.github.com>
2026-05-20 04:42:03 -07:00

283 lines
12 KiB
Bash
Executable file

#!/usr/bin/env sh
# Unsloth Studio uninstaller (macOS / Linux / WSL).
# Stops running servers and removes install dir, launcher data,
# CLI shim, desktop shortcut, .app bundle, and Launch Services entry.
# Honors custom roots set via UNSLOTH_STUDIO_HOME / STUDIO_HOME at
# install time (read back from studio.conf).
#
# Usage: curl -fsSL https://raw.githubusercontent.com/unslothai/unsloth/main/scripts/uninstall.sh | sh
set -e
# Stop a Studio server via its PID file (written by install.sh's _spawn_terminal).
_kill_pid_file() {
_pid_file="$1"
[ -f "$_pid_file" ] || return 0
_pid=$(sed -n '1s/[^0-9].*//p' "$_pid_file" 2>/dev/null || true)
if [ -n "$_pid" ] && kill -0 "$_pid" 2>/dev/null; then
kill -TERM "$_pid" 2>/dev/null || true
# Wait up to 10s for graceful shutdown.
_i=0
while kill -0 "$_pid" 2>/dev/null && [ "$_i" -lt 20 ]; do
sleep 0.5
_i=$((_i + 1))
done
kill -0 "$_pid" 2>/dev/null && kill -KILL "$_pid" 2>/dev/null || true
fi
rm -f "$_pid_file" 2>/dev/null || true
}
# BRE-escape a path so it can be embedded in a pkill -f regex.
_pkill_escape() {
printf '%s' "$1" | sed -e 's:[][\\.^$*+?{|}()/]:\\&:g'
}
_pkill_studio() {
# Prefer PID files written by _spawn_terminal so we only touch our own installs.
for _data_dir in "$HOME/.local/share/unsloth" $(_custom_studio_data_dirs); do
[ -d "$_data_dir" ] || continue
for _pf in "$_data_dir"/studio-*.pid; do
[ -f "$_pf" ] && _kill_pid_file "$_pf"
done
done
command -v pkill >/dev/null 2>&1 || return 0
# Scope fallback patterns to the install roots we are removing so a
# different Studio install (different UNSLOTH_STUDIO_HOME) is not touched.
_kill_roots="$HOME/.unsloth/studio"
_roots_from_conf=$(_custom_studio_roots 2>/dev/null || true)
[ -n "$_roots_from_conf" ] && _kill_roots="$_kill_roots
$_roots_from_conf"
printf '%s\n' "$_kill_roots" | while IFS= read -r _root; do
[ -n "$_root" ] || continue
[ -d "$_root" ] || continue
_re=$(_pkill_escape "$_root")
# `unsloth studio` (default port) + `-p N` + `--port N` forms, all
# anchored on the install root's venv path.
for _pat in \
"${_re}/unsloth_studio/bin/[^ ]* studio( |\$|.*-p[ =][0-9])" \
"${_re}/unsloth_studio/bin/[^ ]* studio.*--port[ =][0-9]" \
"${_re}/.*studio/backend/run\.py"
do
pkill -TERM -f "$_pat" 2>/dev/null || true
done
done
sleep 0.5
printf '%s\n' "$_kill_roots" | while IFS= read -r _root; do
[ -n "$_root" ] || continue
[ -d "$_root" ] || continue
_re=$(_pkill_escape "$_root")
for _pat in \
"${_re}/unsloth_studio/bin/[^ ]* studio( |\$|.*-p[ =][0-9])" \
"${_re}/unsloth_studio/bin/[^ ]* studio.*--port[ =][0-9]" \
"${_re}/.*studio/backend/run\.py"
do
pkill -KILL -f "$_pat" 2>/dev/null || true
done
done
}
_remove_path() {
_p="$1"
if [ -e "$_p" ] || [ -L "$_p" ]; then
rm -rf "$_p" 2>/dev/null && echo " removed: $_p" || echo " could not remove: $_p" >&2
fi
}
# Accept as Studio root only if Studio sentinels exist (matches install.sh's
# env-mode ownership guard at install.sh:1358-1361). A bare unsloth_studio/
# directory is NOT enough -- require the install-time owner marker so a user
# directory that happens to contain a folder named "unsloth_studio" is safe.
_is_studio_root() {
_r="$1"
[ -n "$_r" ] || return 1
[ -f "$_r/share/studio.conf" ] && return 0
[ -f "$_r/unsloth_studio/.unsloth-studio-owned" ] && return 0
if [ -L "$_r/bin/unsloth" ]; then
_t=$(readlink "$_r/bin/unsloth" 2>/dev/null || true)
case "$_t" in *unsloth_studio/bin/unsloth) return 0 ;; esac
fi
return 1
}
# Hard deny list: never delete /, $HOME, $HOME's parent, or system paths.
_is_unsafe_root() {
_r="$1"
[ -z "$_r" ] && return 0
case "$_r" in /|""|"$HOME"|"$HOME/") return 0 ;; esac
case "$_r" in /bin|/sbin|/etc|/usr|/usr/*|/var|/var/*|/opt|/opt/*|/Library|/Library/*|/System|/System/*|/Applications|/Applications/*) return 0 ;; esac
_parent=$(dirname "$HOME" 2>/dev/null || echo "")
[ -n "$_parent" ] && [ "$_r" = "$_parent" ] && return 0
return 1
}
# Print share/ dirs of known custom roots (where PID files live).
_custom_studio_data_dirs() {
_custom_studio_roots 2>/dev/null | while IFS= read -r _r; do
[ -d "$_r/share" ] && printf '%s\n' "$_r/share"
done
}
# Resolve a custom install root from any of:
# 1. UNSLOTH_STUDIO_HOME / STUDIO_HOME env vars at uninstall time
# 2. Default-mode studio.conf at $HOME/.local/share/unsloth/studio.conf
# 3. Env-mode studio.conf at $<root>/share/studio.conf (discovered via 1)
# install.sh writes UNSLOTH_EXE='<root>/unsloth_studio/bin/unsloth', so
# the install root is three dirnames up. Prints each discovered non-default
# root on its own line; the caller iterates and de-duplicates.
_custom_studio_roots() {
_seen=""
_emit() {
_r="$1"
[ -z "$_r" ] && return 0
# Tilde expansion (env vars are not subject to it on quoted assignment),
# matches install.sh's _resolve_studio_destinations. The literal "~/"
# pattern is intentional; SC2088 is a false positive here.
# shellcheck disable=SC2088
case "$_r" in
"~") _r="$HOME" ;;
"~/"*) _r="$HOME/${_r#'~/'}" ;;
esac
# Canonicalize so syntactic variants ($HOME/../$USER, trailing slash)
# resolve to the same path and hit the _is_unsafe_root deny list.
# shellcheck disable=SC1007
_canon=$(CDPATH= cd -P -- "$_r" 2>/dev/null && pwd -P)
[ -n "$_canon" ] && _r="$_canon"
case "$_r" in "$HOME/.unsloth/studio"|/|"") return 0 ;; esac
case ":$_seen:" in *":$_r:"*) return 0 ;; esac
_seen="$_seen:$_r"
printf '%s\n' "$_r"
}
_from_conf() {
[ -f "$1" ] || return 0
# Tolerate paths containing apostrophes (install.sh emits '\'' for them).
_exe=$(sed -n "s/^UNSLOTH_EXE='\(.*\)'\$/\1/p" "$1" | head -n1)
_exe=$(printf '%s' "$_exe" | sed "s/'\\\\''/'/g")
[ -n "$_exe" ] || return 0
_emit "$(dirname "$(dirname "$(dirname "$_exe")")")"
}
# Mirror install.sh's precedence: UNSLOTH_STUDIO_HOME wins, STUDIO_HOME is
# ignored when both are set. Otherwise uninstalling install A could also
# delete install B if the user has STUDIO_HOME left over from B.
if [ -n "${UNSLOTH_STUDIO_HOME:-}" ]; then
_emit "$UNSLOTH_STUDIO_HOME"
_from_conf "$UNSLOTH_STUDIO_HOME/share/studio.conf"
elif [ -n "${STUDIO_HOME:-}" ]; then
_emit "$STUDIO_HOME"
_from_conf "$STUDIO_HOME/share/studio.conf"
fi
# Default-mode conf.
_from_conf "$HOME/.local/share/unsloth/studio.conf"
}
# Remove $HOME/.local/bin/unsloth only if it's a Studio-managed symlink.
# Studio's install.sh writes this as a symlink into the studio venv
# (install.sh: `ln -sfn "$VENV_DIR/bin/unsloth" "$_shim_path"`). A
# pip-installed `unsloth` CLI is a regular file — leave it alone to avoid
# wiping an unrelated install.
_remove_cli_shim() {
_shim="$HOME/.local/bin/unsloth"
[ -L "$_shim" ] || return 0
_target=$(readlink "$_shim" 2>/dev/null || true)
case "$_target" in
*/unsloth_studio/bin/unsloth) _remove_path "$_shim" ;;
*) ;;
esac
}
_uid=$(id -u 2>/dev/null || echo 0)
_os=$(uname 2>/dev/null || echo unknown)
_is_wsl=0
[ "$_os" = "Linux" ] && grep -qi microsoft /proc/version 2>/dev/null && _is_wsl=1
echo "Stopping any running Unsloth Studio servers..."
_pkill_studio
echo "Removing data and install directories..."
_custom_studio_roots | while IFS= read -r _custom_root; do
[ -n "$_custom_root" ] || continue
if _is_unsafe_root "$_custom_root"; then
echo " refusing to remove unsafe path: $_custom_root" >&2
continue
fi
if ! _is_studio_root "$_custom_root"; then
echo " refusing to remove non-Studio path: $_custom_root" >&2
continue
fi
_remove_path "$_custom_root"
done
_remove_path "$HOME/.unsloth/studio"
_remove_path "$HOME/.local/share/unsloth"
# CLI shim: only the symlink Studio created, never a pip-installed file.
_remove_cli_shim
echo "Removing desktop shortcut and launcher lock..."
# install.sh creates Desktop/Unsloth Studio as a symlink. If the user has an
# unrelated regular directory by that name, leave it alone.
_desktop_link="$HOME/Desktop/Unsloth Studio"
if [ -L "$_desktop_link" ] || [ ! -e "$_desktop_link" ]; then
_remove_path "$_desktop_link"
else
echo " refusing to remove non-symlink Desktop path: $_desktop_link" >&2
fi
_remove_path "$HOME/Desktop/unsloth-studio.desktop"
# Locks are namespaced per-uid; env-mode adds an extra suffix.
_lock_glob="${XDG_RUNTIME_DIR:-/tmp}/unsloth-studio-launcher-${_uid}"
for _lock in "$_lock_glob".lock "$_lock_glob"-*.lock; do
[ -e "$_lock" ] && _remove_path "$_lock"
done
case "$_os" in
Darwin)
echo "Removing macOS .app bundle and Launch Services entry..."
_remove_path "$HOME/Applications/Unsloth Studio.app"
_lsr="/System/Library/Frameworks/CoreServices.framework/Versions/A/Frameworks/LaunchServices.framework/Versions/A/Support/lsregister"
if [ -x "$_lsr" ]; then
"$_lsr" -u "$HOME/Applications/Unsloth Studio.app" 2>/dev/null || true
fi
;;
Linux)
if [ "$_is_wsl" = "1" ]; then
echo "Removing WSL Windows-side shortcuts..."
# install.sh creates 'Unsloth Studio.lnk' on the Windows Desktop and
# Start Menu Programs folder via powershell.exe; mirror that path.
if command -v powershell.exe >/dev/null 2>&1; then
# shellcheck disable=SC2016
# $env:APPDATA is a PowerShell expansion; intentionally literal at shell level.
powershell.exe -NoProfile -Command '
$names = @("Desktop","StartMenu");
$dirs = @(
[Environment]::GetFolderPath("Desktop"),
(Join-Path $env:APPDATA "Microsoft\Windows\Start Menu\Programs")
);
foreach ($d in $dirs) {
if (-not $d) { continue }
$p = Join-Path $d "Unsloth Studio.lnk";
if (Test-Path -LiteralPath $p) { Remove-Item -LiteralPath $p -Force }
}' >/dev/null 2>&1 || true
fi
fi
echo "Removing Linux .desktop entry..."
_remove_path "$HOME/.local/share/applications/unsloth-studio.desktop"
if command -v update-desktop-database >/dev/null 2>&1; then
update-desktop-database "$HOME/.local/share/applications" 2>/dev/null || true
fi
;;
esac
echo ""
echo "Unsloth Studio uninstalled."
echo "Note: Hugging Face model cache at ~/.cache/huggingface was left in place."
echo "Remove it manually with 'rm -rf ~/.cache/huggingface/hub' if desired."
# Env-mode installs leave no breadcrumb in $HOME, so a custom root can
# only be located if the user re-exports the variable. Print a hint when
# neither var is set so the bare `curl | sh` flow doesn't silently miss.
if [ -z "${UNSLOTH_STUDIO_HOME:-}" ] && [ -z "${STUDIO_HOME:-}" ]; then
echo ""
echo "If you installed Unsloth Studio with UNSLOTH_STUDIO_HOME or STUDIO_HOME"
echo "pointing at a custom directory, re-run this script with the same variable"
echo "set to also remove that install tree, e.g.:"
echo " UNSLOTH_STUDIO_HOME=/your/path sh -c \"\$(curl -fsSL https://raw.githubusercontent.com/unslothai/unsloth/main/scripts/uninstall.sh)\""
fi