From c41ce170ec118f23e4ebdb0c1dceb44f0f230d81 Mon Sep 17 00:00:00 2001 From: Michael Han <107991372+shimmyshimmer@users.noreply.github.com> Date: Mon, 18 May 2026 02:11:05 -0700 Subject: [PATCH] studio: add uninstall.sh and document it in README (#5497) * studio: add uninstall.sh and document it in README The current uninstall guidance in README.md is `rm -rf ~/.unsloth/studio`, which leaves behind everything that lives outside that path: - ~/.local/share/unsloth/ (launcher script, studio.conf, studio.log, icon assets) - ~/Applications/Unsloth Studio.app (macOS bundle, orphaned and pointing nowhere on next reinstall) - ~/Desktop/Unsloth Studio (broken symlink after the bundle is gone) - ~/Desktop/unsloth-studio.desktop (Linux) - ~/.local/share/applications/unsloth-studio.desktop (Linux) - /tmp/unsloth-studio-launcher-*.lock (lock dir, possibly stale) - Launch Services cache entry for ai.unsloth.studio on macOS - Any running `unsloth studio -p N` processes Users who follow the documented uninstall and reinstall end up with the new launcher layered on top of stale state from the previous install, which has produced concrete bugs (e.g. self-referential symlink inside the .app bundle after a reinstall over leftover state). Add uninstall.sh at the repo root that handles all of the above, and update README.md to point at it as the recommended path. The plain `rm -rf ~/.unsloth/studio` line is kept as a "partial uninstall, keep launcher for a later reinstall" alternative. The model cache at ~/.cache/huggingface is intentionally left untouched, with a note in the script suggesting how to remove it if desired. Script is POSIX sh, idempotent (every removal is gated on existence and uses `2>/dev/null || true`), and handles macOS, Linux, and WSL. Windows is intentionally not covered here; the existing PowerShell Remove-Item line in README is kept for that. * studio: trim uninstall.sh header * studio: address PR review feedback on uninstall.sh Four findings from automated review, all verified real: 1. pkill pattern only matched `-p N`, not `--port N`. Studio instances launched with the long option form survived the uninstall. Fix: run two pkill passes, one for each form, with `[ =]` covering both space and `=` separators. 2. CLI shim at ~/.local/bin/unsloth (symlink into the venv created by install.sh:2167) was left behind, becoming a broken symlink after the venv directory is removed. Fix: add it to the removals. 3. Custom install roots via UNSLOTH_STUDIO_HOME / STUDIO_HOME were not removed. install.sh records the install location in ~/.local/share/unsloth/studio.conf as UNSLOTH_EXE; parse it, derive the root as three dirnames up, and remove the root if it is non-default. 4. On WSL the installer creates 'Unsloth Studio.lnk' on the Windows Desktop and Start Menu Programs folder via powershell.exe. Mirror that path on uninstall by invoking powershell.exe to Remove-Item the same two locations. Best-effort, gated on powershell.exe being available. Tests (T2.8b, T2.15, T2.16, T2.17, T2.18, T2.5b) added behind the scenes; all pass on macOS Darwin 25.3 with `dash -n`, `sh -n`, shellcheck-clean (SC2016 suppressed on the PowerShell single-quoted heredoc since the $env: expansions must remain literal to the shell so PowerShell receives them verbatim). * studio: harden uninstall.sh against env-mode and shim collisions - Honor UNSLOTH_STUDIO_HOME / STUDIO_HOME at uninstall time and read env-mode studio.conf at $/share/studio.conf, not just the default-mode conf under $HOME/.local/share/unsloth/. Without this, installs done with a custom STUDIO_HOME leak the install tree even when the env var is re-exported. - Guard the custom-root resolver against "/" and empty so a corrupted studio.conf (UNSLOTH_EXE='/etc/passwd' or similar) or an UNSLOTH_STUDIO_HOME=/ cannot trick the script into rm -rf'ing root. - Only remove $HOME/.local/bin/unsloth when it is a symlink resolving to a Studio venv. pyproject.toml declares unsloth as a console script, so pip install --user unsloth places a regular file at the same path; the previous unconditional rm wiped that unrelated CLI. - When neither env var is set, print a tail hint so users with custom install roots know to re-run with the variable. Verified with a sandboxed harness covering 24 scenarios (default and env-mode installs across macOS / Linux / WSL, idempotency, hostile lockfile names, path-traversal attempts, malformed conf, pkill long and short forms, pip-conflict shim, broken-symlink bundle path). Script remains POSIX (shellcheck -s sh clean, runs under /bin/dash). Co-Authored-By: Claude Opus 4.7 (1M context) * Refuse non-Studio uninstall roots and tighten process matching for PR #5497 Three issues found while testing custom-root paths and process cleanup: 1. UNSLOTH_STUDIO_HOME=$HOME sh uninstall.sh rm -rf'd $HOME (same for STUDIO_HOME and parent-of-$HOME). install.sh accepts any writable directory for STUDIO_HOME, so the uninstaller must validate ownership before deletion. _is_studio_root accepts a candidate root only if it contains share/studio.conf, an unsloth_studio/ directory, or a bin/unsloth shim pointing into unsloth_studio/bin. _is_unsafe_root is a defense-in-depth deny list (/, $HOME, $HOME's parent, system paths). 2. pkill -f patterns "unsloth studio.*-p[ =][0-9]" over-matched on argv substrings. A user running `less notes.md` whose filename contained "unsloth studio ... -p N" had their less killed. New patterns anchor on /unsloth_studio/bin/ so only processes whose actual exe lives in a Studio venv match. 3. pkill missed processes that exec into studio/backend/run.py --port N (the post-exec form when the unsloth CLI replaces itself). Added a third pattern for that shape, and prefer PID files written by install.sh's _spawn_terminal (studio-$port.pid in DATA_DIR) over argv matching for installs that have them. * Tighten ownership guards from review round for PR #5497 Three findings from the second reviewer round: 1. _is_studio_root accepted any directory containing an unsloth_studio/ subdir as Studio-owned. A user workspace that happens to contain a folder named unsloth_studio/ would be deleted. install.sh's env-mode guard at install.sh:1358-1361 already requires .unsloth-studio-owned before treating the venv as replaceable. Mirror that: require the owner marker, share/studio.conf, or the bin/unsloth shim target. 2. The pkill -f fallback patterns were global, so uninstalling install A would also kill install B's running server. Scope each pattern to the actual install root being removed by interpolating the root path into the regex. Also adds a third pattern shape for `unsloth studio` with no -p / --port flag (the CLI default-port form). 3. Desktop/Unsloth Studio is created by install.sh as a symlink to the .app bundle. If a user has a regular directory by that name (photos, notes, etc.), the previous _remove_path call rm -rf'd it. Now we only remove it when it is a symlink or does not exist. * Canonicalize env roots and honor UNSLOTH_STUDIO_HOME precedence for PR #5497 Two findings from the latest review round: 1. Canonicalize env-derived roots before the safety check. The deny list only string-compares against $HOME, so a syntactic variant like UNSLOTH_STUDIO_HOME=$HOME/../$USER (or trailing slash, or relative path) bypassed _is_unsafe_root even though it resolves to $HOME. Now _emit runs CDPATH= cd -P -- + pwd -P first, so all variants normalize to the same canonical path before the deny check. Also added the same tilde expansion install.sh's _resolve_studio_destinations does. 2. Mirror install.sh's env-var precedence (install.sh:282-290). When both UNSLOTH_STUDIO_HOME and STUDIO_HOME are set, install.sh resolves only UNSLOTH_STUDIO_HOME and ignores STUDIO_HOME. Uninstall was emitting both, so running uninstall.sh for install A would also delete install B if the user had a stale STUDIO_HOME pointing at B. --------- Co-authored-by: Claude Opus 4.7 (1M context) Co-authored-by: Daniel Han --- README.md | 6 +- uninstall.sh | 283 +++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 287 insertions(+), 2 deletions(-) create mode 100755 uninstall.sh diff --git a/README.md b/README.md index a654518d14..949167283d 100644 --- a/README.md +++ b/README.md @@ -218,11 +218,13 @@ unsloth studio -p 8888 ``` #### Uninstall -You can uninstall Unsloth Studio by deleting its install folder usually located under `$HOME/.unsloth/studio` on Mac/Linux/WSL and `%USERPROFILE%\.unsloth\studio` on Windows. Using the `rm -rf` commands will **delete everything**, including your history, cache: +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: -* ​ **MacOS, WSL, Linux:** `rm -rf ~/.unsloth/studio` +* ​ **MacOS, WSL, Linux:** `curl -fsSL https://unsloth.ai/uninstall.sh | sh` * ​ **Windows (PowerShell):** `Remove-Item -Recurse -Force "$HOME\.unsloth\studio"` +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. + For more info, [see our docs](https://unsloth.ai/docs/new/studio/install#uninstall). #### Deleting model files diff --git a/uninstall.sh b/uninstall.sh new file mode 100755 index 0000000000..7fbdc8dfac --- /dev/null +++ b/uninstall.sh @@ -0,0 +1,283 @@ +#!/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://unsloth.ai/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 $/share/studio.conf (discovered via 1) +# install.sh writes UNSLOTH_EXE='/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 uninstall.sh" +fi