* install: support STUDIO_HOME / UNSLOTH_STUDIO_HOME for custom install paths Currently install.sh and install.ps1 hardcode all install paths off $HOME / $env:USERPROFILE with no env-var fallback. This blocks workspace-isolated installs (CI sandboxes, per-PR test environments, multi-tenant boxes) unless the entire HOME / USERPROFILE is faked, which also relocates ~/.gitconfig, ~/.ssh, and other unrelated state. Add an opt-in env-var override that does only what is needed. Resolution priority (highest first): 1. HOME / USERPROFILE explicitly redirected vs the password-database default. Detected via getent (Linux), dscl (macOS), or [Environment]::GetFolderPath (Windows). Best-effort: when the detection mechanism is unavailable the check is skipped and we fall through to step 2. 2. UNSLOTH_STUDIO_HOME, if set. 3. STUDIO_HOME, if set (alias for convenience; the variable name already matches the internal var install.sh sets). 4. Default: legacy $HOME/.unsloth/studio (or $USERPROFILE\.unsloth\studio on Windows). Identical to today's behavior when no env var is set. When an env var override fires: * DATA_DIR is nested inside ($STUDIO_HOME/share, or $StudioHome\share on Windows) so the runtime launcher and shortcuts find studio.conf in the same place install-time wrote it. * The unsloth CLI shim lands at $STUDIO_HOME/bin/unsloth (Unix) or $StudioHome\bin\unsloth.exe (Windows). On Windows the shim already lives under $StudioHome; the change only redirects DATA_DIR and skips the persistent registry PATH update. * Persistent shell PATH modifications are skipped (no .bashrc / .zshrc / .profile append on Unix; no Add-ToUserPath on Windows). Caller is expected to invoke via absolute path or add the bin dir to PATH explicitly. Avoids polluting the user's profile with a workspace-scoped path that may be deleted. The Unix launcher script is the only piece that must read DATA_DIR at runtime (it sources studio.conf from there). The hardcoded DATA_DIR inside the LAUNCHER_EOF heredoc is replaced with an @@DATA_DIR@@ placeholder substituted via sed at install time, using the same approach the script already uses for other install-time substitutions. Default path behavior is unchanged: when no env var is set and HOME is not redirected, install.sh / install.ps1 produce exactly the same file layout as today. Test scenarios verified locally on install.sh: * Default (no env vars) -> $HOME/.unsloth/studio (legacy) * HOME=/tmp/x -> /tmp/x/.unsloth/studio * UNSLOTH_STUDIO_HOME=/tmp/y -> /tmp/y as STUDIO_HOME root * STUDIO_HOME=/tmp/z (alias) -> /tmp/z as STUDIO_HOME root * HOME redirect + env var (HOME wins) -> install follows HOME * Unwritable override -> exits with clear ERROR message * install: priority change -- env vars now win over HOME redirect Flip the resolution order so explicit env vars take precedence over HOME / USERPROFILE redirection. New priority (highest first): 1. UNSLOTH_STUDIO_HOME, if set. 2. STUDIO_HOME, if set. 3. HOME / USERPROFILE explicitly redirected. 4. Default. Rationale: the env vars are explicit single-purpose signals (the user typed UNSLOTH_STUDIO_HOME=... specifically to redirect Studio). HOME redirection is broader and incidental -- the user may have redirected HOME for unrelated reasons (workspace tools, container builds) without wanting Studio to follow it. When both are set, the more specific signal should win. When only HOME is redirected (no env var), behavior is unchanged from the previous commit: install follows $HOME. * install: address review feedback (sed escape, downstream propagation, edge cases) Fixes from gemini-code-assist + chatgpt-codex-connector + reviewer.py 20-parallel run on the open PR. install.sh: * Escape sed replacement metacharacters before substituting @@DATA_DIR@@. Two-stage escape: ' -> '\'' for safe single-quote shell embedding, then \, &, | for sed replacement string + chosen delimiter. Heredoc switched to single-quoted DATA_DIR='@@DATA_DIR@@' so we only need single-quote escaping at runtime. Verified end-to-end with paths containing & and | (the sed delimiter). * Pass UNSLOTH_STUDIO_HOME into both setup.sh invocations (--local and PyPI paths) so the downstream install resolves the same Studio root install.sh picked. * macOS .app stub: replace hardcoded exec "$HOME/.local/share/unsloth/launch-studio.sh" with exec "$_css_data_dir/launch-studio.sh" so the .app launches the resolved launcher even in env-override mode. * Use mkdir -p -- and cd -- when validating the env override so paths starting with - cannot be misread as flags. install.ps1: * Drop .Guid from [guid]::NewGuid().Guid: the property does not exist; the probe filename was always identical and not unique. Default ToString() on System.Guid produces the canonical UUID string we want. * Guard LOCALAPPDATA before Join-Path to avoid aborting the installer in service / CI contexts where LOCALAPPDATA is unset (Join-Path under $ErrorActionPreference='Stop' would otherwise throw). Computed once into $defaultDataDir; both 'profile' and 'default' branches reuse it. * Set $env:UNSLOTH_STUDIO_HOME for the duration of the 'unsloth studio setup' subprocess so studio/setup.ps1 and unsloth_cli see the same install root install.ps1 picked. Restored in a finally block. studio/setup.sh: * Honor UNSLOTH_STUDIO_HOME / STUDIO_HOME (alias) when resolving STUDIO_HOME, VENV_DIR, VENV_T5_*_DIR. Falls back to the legacy $HOME/.unsloth/studio when no override is set. studio/setup.ps1: * Same change in PowerShell: honor $env:UNSLOTH_STUDIO_HOME / $env:STUDIO_HOME for $StudioHome / $VenvDir resolution. unsloth_cli/commands/studio.py: * Replace the module-level constant STUDIO_HOME = Path.home() / ".unsloth" / "studio" with a resolver that honors UNSLOTH_STUDIO_HOME / STUDIO_HOME before falling through to the legacy default. Same precedence the installers use. Verified locally: 6 install.sh scenarios still produce correct paths (default, HOME redirect, env var, alias, both, bad override). New sed-escape unit tests pass for paths containing & and |. Python resolver matches priority: UNSLOTH_STUDIO_HOME > STUDIO_HOME > default. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * install.sh: portable sed (no -i.bak) per gemini review feedback GNU sed -i.bak vs BSD/macOS sed -i.bak vs BusyBox sed have subtly different semantics. Use the POSIX-portable redirect-then-mv pattern instead. Functionally identical, runs everywhere. * studio: persist UNSLOTH_STUDIO_HOME so fresh shells find custom installs Without this, a custom-root install (UNSLOTH_STUDIO_HOME=/work/studio bash install.sh --local) only worked in the same shell that ran the installer. Closing the terminal and reopening lost the env var, the PATH was deliberately not persisted, and the Python CLI fell back to ~/.unsloth/studio. Result: 'Studio not set up' or quietly operating on a stale legacy install. Three persistence layers, all backwards-compatible (default installs emit zero changes): 1. Unix studio.conf install.sh now writes 'export UNSLOTH_STUDIO_HOME=...' next to UNSLOTH_EXE in studio.conf when in env-override mode. The launcher sources studio.conf at startup so the exec'd binary gets the var. Default installs do not write this line; studio.conf stays byte-identical to before. 2. Windows launch-studio.ps1 install.ps1 prepends '$env:UNSLOTH_STUDIO_HOME = ...' to the generated launcher when in env-override mode. Default installs produce the same launcher content as before. 3. Python sys.prefix inference storage_roots.studio_root() and unsloth_cli/commands/studio.py now infer the install root from sys.prefix when no env var is set (Path(sys.prefix).parent for unsloth_studio venvs). Catches direct invocations of <STUDIO_HOME>/bin/unsloth that bypass the launcher entirely. unsloth_cli/commands/studio.py also re-exports the resolved UNSLOTH_STUDIO_HOME via os.environ.setdefault so child processes (setup script, backend run.py) inherit it. Backend storage roots (storage_roots.studio_root, cache_root) now respect the env var via the shared resolver. run.py PID file, transformers_version.py T5 venvs, and model_config.py vision-check venv all switch to studio_root() so custom installs are self-contained. studio/setup.ps1: T5 sidecar venvs now resolve under $StudioHome (was $env:USERPROFILE\.unsloth\studio\.venv_t5_*). studio/setup.sh + studio/setup.ps1: llama.cpp build dir nests under $STUDIO_HOME / $StudioHome when env-override is active, otherwise keeps the legacy ~/.unsloth/llama.cpp. Verified locally: * studio.conf write block: env-override mode emits the export line; default mode does not (byte-identical to today). * PowerShell heredoc interpolation: correct output for both modes. * studio_root() resolver: default, UNSLOTH_STUDIO_HOME, STUDIO_HOME alias, and sys.prefix-based inference all return correct paths. * cache_root() now derives from studio_root(). * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * install: tilde expansion + macOS .app stub safe-quoting Two fixes from running a 25-scenario simulation sweep against install.sh across path edge cases (spaces, apostrophes, ampersands, pipes, backslashes, dollar signs, Unicode, trailing slash, relative paths). 1. UNSLOTH_STUDIO_HOME=~/foo was landing as literal '~/foo' (env vars are not subject to tilde expansion). Added a POSIX-portable case block in install.sh, install.ps1, studio/setup.sh, studio/setup.ps1 that expands a leading ~ or ~/ to $HOME / $env:USERPROFILE. The prefix-removal pattern is single-quoted ('${var#'~/'}') so the shell does not tilde-expand the pattern back to $HOME/ before matching -- a subtle dash/bash gotcha. 2. macOS .app stub used an unquoted heredoc ('<< STUB_EOF'), so any $VAR / backtick / etc in the path would expand at .app launch time. Switched to single-quoted heredoc ('<< 'STUB_EOF'') with a placeholder + sed substitution + single-quoted shell embedding, matching the @@DATA_DIR@@ pattern already used for launch-studio.sh. Verified: 25/25 simulation scenarios pass on Linux dash + bash, including paths with $VAR, &, |, \\, ', spaces, and Unicode. End-to-end install in env-mode + fresh-shell launcher invocation confirmed: studio binds to /api/health from a clean env, and sys.prefix-based inference correctly returns the workspace root. * install: stop accidentally treating default installs as env-override Reviewer.py 20-runs cycle 1 found a unanimous P1 regression: a default 'unsloth studio update' relocates llama.cpp from ~/.unsloth/llama.cpp to ~/.unsloth/studio/llama.cpp, because the CLI was re-exporting UNSLOTH_STUDIO_HOME unconditionally and install.sh / install.ps1 were passing it into setup.{sh,ps1} unconditionally. The setup scripts treated the var's mere presence as "env-override mode" and relocated the llama.cpp build dir away from the legacy path, breaking the runtime backend's _find_llama_server_binary lookup on default installs. Fixes: * unsloth_cli/commands/studio.py: _resolve_studio_home now returns (path, is_custom). Re-export only when is_custom -- a real env override or a sys.prefix inference that resolves to a non-legacy path. Default installs leave UNSLOTH_STUDIO_HOME unset. * install.sh: gate UNSLOTH_STUDIO_HOME on $_STUDIO_HOME_REDIRECT == env before calling setup.sh. Use 'env $VARS bash setup.sh' so the var is set only for the subprocess, never leaked. * install.ps1: gate $env:UNSLOTH_STUDIO_HOME on $StudioRedirectMode -eq 'env' before invoking 'unsloth studio setup'. Restore prior value in finally block (unset if it wasn't set). * studio/setup.sh + setup.ps1: decide llama.cpp install root from the resolved $STUDIO_HOME (not from env-var presence). If the resolved path equals the legacy default ($HOME/.unsloth/studio), fall back to ~/.unsloth/llama.cpp. This makes setup robust against a stale UNSLOTH_STUDIO_HOME inherited from a parent process that happens to point at the legacy default. * studio/backend/core/inference/llama_cpp.py: - _find_llama_server_binary() now searches studio_root() / llama.cpp AND the legacy ~/.unsloth/llama.cpp (de-duped). Custom-root installs become discoverable; default installs unaffected. - kill_orphaned_servers ownership allowlist also includes studio_root() / llama.cpp so custom-root processes are cleanable. Verified locally: * 25/25 sim scenarios still pass (path edge cases unchanged). * setup.sh unit test: default-mode lands UNSLOTH_HOME at $HOME/.unsloth; env-mode lands at $STUDIO_HOME. * Python CLI unit test: default-mode returns is_custom=False and does NOT setdefault UNSLOTH_STUDIO_HOME; env-mode sets is_custom=True. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * install: || exit 1 on STUDIO_HOME subshell (dash set -e gap) Gemini review feedback: in dash, set -e does not trigger on subshell failures inside variable assignments. If 'cd -- "$_override" && pwd' fails, STUDIO_HOME stays empty and DATA_DIR collapses to /share. Add explicit '|| exit 1' on both install.sh:187 and setup.sh:413. * install.sh: argv-safe setup invocation for paths with spaces Cycle 2 reviewer.py 20-runs found a unanimous P1: passing the env-var through 'env $_STUDIO_ENV_FOR_SETUP' word-splits on whitespace, so a custom root like '/tmp/Unsloth Studio' becomes 'UNSLOTH_STUDIO_HOME= /tmp/Unsloth' followed by env trying to exec 'Studio'. Replaced with a tiny helper that prepends the env-var directly to the argv (no string-form intermediary), so spaces are preserved as a single argument. Default-mode invocation skips the env-var entirely. Verified: 'UNSLOTH_STUDIO_HOME=/tmp/test space/studio' now reaches setup.sh as a single value. * studio: tighten sys.prefix inference + Tauri env handling + llama.cpp env Cycle 3 reviewer.py findings (3 P1s converging): * sys.prefix inference too broad: a developer venv named 'unsloth_studio' was being treated as a custom Studio root. Narrow with an installer- sentinel check (presence of share/studio.conf or bin/unsloth shim inside the parent dir) in both unsloth_cli/commands/studio.py and studio/backend/utils/paths/storage_roots.py. * Tauri studio/src-tauri/src/process.rs::find_unsloth_binary() hardcoded ~/.unsloth/studio. Honor UNSLOTH_STUDIO_HOME / STUDIO_HOME (in that priority order) before falling back to legacy. * unsloth-zoo's GGUF export binds LLAMA_CPP_DEFAULT_DIR at import time from UNSLOTH_LLAMA_CPP_PATH. For env-override installs, persist UNSLOTH_LLAMA_CPP_PATH alongside UNSLOTH_STUDIO_HOME in studio.conf (Unix), in the generated PowerShell launcher (Windows), and via os.environ.setdefault in the Python CLI when running on a custom root, so GGUF export uses the custom-root llama.cpp build instead of the legacy ~/.unsloth/llama.cpp. Default behaviour unchanged: no env vars are written to studio.conf in default mode, no LLAMA_CPP_PATH is set, and the dev-venv inference falls through to legacy when no installer sentinels are present. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * studio: desktop_auth env-aware + legacy-root llama.cpp consistency - desktop_auth.rs: honor UNSLOTH_STUDIO_HOME / STUDIO_HOME for the .desktop_secret path so Tauri desktop login works against custom-root installs instead of always reading ~/.unsloth/studio/auth/. - install.sh / install.ps1 / unsloth_cli/commands/studio.py: when an env override resolves to the legacy default ($HOME/.unsloth/studio), set UNSLOTH_LLAMA_CPP_PATH to ~/.unsloth/llama.cpp (matching setup.sh / setup.ps1's legacy-equality branch). Previously the persisted value pointed at $STUDIO_HOME/llama.cpp, which was a non-existent location and broke unsloth-zoo's import-time GGUF binding for that edge case. * studio: tauri studio_root helper + marker-file persistence + ~ expansion Address cycle-5 reviewer findings: - Add studio/src-tauri/src/studio_root.rs: shared resolver with UNSLOTH_STUDIO_HOME / STUDIO_HOME (priority order), tilde expansion (~, ~/..., ~\...), installer-written marker fallback, then ~/.unsloth/studio. 5 unit tests cover the expansion paths. - Tauri lookups now go through the shared resolver: - process.rs::find_unsloth_binary - desktop_auth.rs::desktop_secret_path - main.rs::setup_logging (tauri.log under custom root) - commands.rs::open_logs_dir (opens custom root dir) - install.rs work_dir uses parent of resolved root (avoids creating a stray ~/.unsloth on a custom-root install) - install.sh / install.ps1 (env-mode only): write ~/.unsloth/studio-home marker so the desktop app launched from Finder/Start Menu (no shell env inheritance) still resolves the custom root. - install.sh / install.ps1 non-interactive completion: when StudioRedirectMode=env, print the absolute custom-root shim path since the persistent rc/registry PATH update is intentionally skipped in env-override mode. - unsloth_cli/commands/studio.py: replace setdefault() with truthy-check so a blank UNSLOTH_STUDIO_HOME / UNSLOTH_LLAMA_CPP_PATH in the parent env doesn't suppress the inferred custom root. 40/40 cargo test --bins pass. * studio: validate marker file + write in --tauri mode + propagate to subprocess Cycle-6 reviewer follow-ups: - studio_root.rs marker resolver now validates the persisted path before using it. A stale ~/.unsloth/studio-home pointing at a deleted/moved workspace is ignored (resolution falls back to the legacy default rather than hijacking it). Validation accepts share/studio.conf sentinel or bin/unsloth shim. Trailing newline strip uses trim_end_matches(['\n','\r']) so paths whose content legitimately has leading/trailing spaces survive. - install.sh / install.ps1: marker write moved out of the launcher generation path so it runs before the Tauri-mode early exit. Both shell-launcher and Tauri-installed env-mode roots now persist the marker. Removed the duplicate marker write that was previously inside install.ps1's $studioHomeExport block. - studio/src-tauri/src/install.rs: pass UNSLOTH_STUDIO_HOME to the installer subprocess (when not already in scope) so app-initiated repair / update flows reach the same root the running app uses. cargo test --bins -- --test-threads=1: 44/44 pass (4 new tests for marker validation: sentinel accepted, bin shim accepted, empty dir rejected, missing path rejected). * studio: fix Tauri legacy-fallback regression + stale marker cleanup Cycle-7 reviewer follow-ups (regression I introduced in cycle 6): - studio_root.rs: add StudioRootSource enum + resolve_studio_root_with_source(). Lets callers distinguish a real custom override (Env / Marker) from the legacy fallback (Default). - studio/src-tauri/src/install.rs: only forward UNSLOTH_STUDIO_HOME to the installer subprocess when the resolution source is Env or Marker. The Default fallback must NOT be passed -- install.sh / install.ps1 treat any non-empty UNSLOTH_STUDIO_HOME as env-override mode and would relocate DATA_DIR to $STUDIO_HOME/share and _LOCAL_BIN to $STUDIO_HOME/bin (regressing default Tauri repair / update flows from the legacy ~/.local/share/unsloth and ~/.local/bin). - install.sh / install.ps1: clear stale marker on default / HOME-redirect installs. A user who first installed with UNSLOTH_STUDIO_HOME=/work/studio then later reinstalls without env vars no longer has the desktop app hijacked by ~/.unsloth/studio-home pointing at the old custom root. - install.sh / install.ps1: when env mode wins over a redirected HOME / USERPROFILE, write the marker into the OS-reported real profile home (getent / dscl on Unix; [Environment]::GetFolderPath on Windows) so a later desktop launch from the user's normal session still finds it. Falls back to the current HOME / USERPROFILE. cargo test --bins -- --test-threads=1: 45/45 pass (1 new for the source enum invariants). * install: scrub stale marker from real-home on HOME-redirect cleanup Cycle-8 reviewer follow-up: the previous cleanup branch only removed \$HOME/.unsloth/studio-home, leaving a stale marker in the real password-database home after a prior env-mode install. A later default install with redirected HOME / USERPROFILE would still see the desktop app resolving the old custom root. - install.sh: compute the real password-database home (via getent / dscl) unconditionally, and scrub markers from BOTH \$HOME and the real-home in the default / HOME-redirect cleanup branch. - install.ps1: build a profile-candidate list (current USERPROFILE + OS-reported real profile) and remove markers from EVERY candidate in the default / profile-redirect cleanup branch. bash -n + cleanup smoke verified. * revert: drop Tauri env-var support + marker file mechanism Keep this PR scoped to shell installer + Python backend env-var support. Tauri desktop integration with custom Studio roots is deferred to a separate, focused PR. Reverts to pre-PR state: - studio/src-tauri/src/process.rs (find_unsloth_binary) - studio/src-tauri/src/desktop_auth.rs (auth_secret_path) - studio/src-tauri/src/main.rs (setup_logging tauri.log path) - studio/src-tauri/src/commands.rs (open_logs_dir) - studio/src-tauri/src/install.rs (work_dir + subprocess env) - studio/src-tauri/src/studio_root.rs DELETED Removes from install.sh / install.ps1: - ~/.unsloth/studio-home marker write/read/cleanup - HOME-redirect-aware marker location logic What this PR keeps (the original scope): - install.sh / install.ps1: UNSLOTH_STUDIO_HOME / STUDIO_HOME env-var resolver with HOME-redirect detection, tilde expansion, legacy fallback. Default installs are byte-identical to pre-PR. - studio/setup.sh / studio/setup.ps1: legacy-equality llama.cpp path. - studio.conf / launcher persists UNSLOTH_STUDIO_HOME + UNSLOTH_LLAMA_CPP_PATH for fresh shells (env-mode only). - unsloth_cli/commands/studio.py: env > sys.prefix sentinel > legacy resolver, conditional re-export. - studio/backend/utils/paths/storage_roots.py: same resolver. - Backend modules use storage_roots (run.py, model_config.py, transformers_version.py, llama_cpp.py). cargo test --bins -- --test-threads=1: 34/34 pass (pre-PR baseline). bash -n install.sh: clean. * install: cycle-10 fixes (default launcher, --tauri guard, env-mode shortcuts, win PATH) - install.sh launcher: default and HOME-redirect installs keep the legacy DATA_DIR=\"\$HOME/.local/share/unsloth\" runtime form so a later shell with a different \$HOME still resolves DATA_DIR. Only env-mode bakes the resolved absolute path. Restores byte-identical default behavior. - install.sh / install.ps1: fail fast when --tauri is combined with UNSLOTH_STUDIO_HOME / STUDIO_HOME. The desktop app still resolves the legacy ~/.unsloth/studio root, so a custom-root --tauri install would yield a desktop app that cannot find its binary or auth secret. Print the right alternative. - install.sh / install.ps1: skip persistent desktop / Start-Menu shortcuts in env-override mode. Workspace-scoped installs would otherwise leave launchers pointing at a path the user may delete. Default and HOME/profile-redirect installs keep the shortcut. - install.ps1: re-prepend env-override \$ShimDir AFTER Refresh-SessionPath. Refresh rebuilds PATH as Machine > User > current \$env:Path, so a previously-installed legacy User PATH entry would otherwise win precedence over the current-session env-override shim. bash -n install.sh, pwsh parser install.ps1 + setup.ps1: clean. cargo test --bins -- --test-threads=1: 34/34 (Tauri unchanged). * install: cycle-11 fixes (env-mode launcher writes, --tauri legacy passthrough, run.py llama path) - install.sh / install.ps1: env-mode no longer skips the entire create_studio_shortcuts / New-StudioShortcuts function. Move the early-return INSIDE those functions, just before the persistent desktop / Start-Menu shortcut creation. The runtime launcher (launch-studio.sh / launch-studio.ps1), studio.conf with UNSLOTH_STUDIO_HOME / UNSLOTH_LLAMA_CPP_PATH exports, and the icon ARE always written so env-mode shims can resolve via fresh shells. - install.sh / install.ps1: --tauri guard passes through when the override resolves to the legacy default ($HOME/.unsloth/studio / %USERPROFILE%\.unsloth\studio). The desktop app already uses that path, so explicit-equality is a supported edge case (matches the llama.cpp legacy-equality branch). - studio/backend/run.py: when launched directly (bypassing the unsloth CLI), set UNSLOTH_STUDIO_HOME and UNSLOTH_LLAMA_CPP_PATH before the rest of import chain runs so unsloth-zoo's import-time LLAMA_CPP_DEFAULT_DIR binding picks up the custom-root build. Only set when STUDIO_ROOT is a real custom override; legacy default installs leave them unset. bash -n install.sh, pwsh parser install.ps1: clean. python ast parse studio/backend/run.py: clean. cargo test --bins -- --test-threads=1: 34/34 pass (Tauri unchanged). * install: cycle-12 fixes (--tauri trailing slash + main.py uvicorn env) - install.sh / install.ps1 --tauri legacy passthrough: strip trailing separators before comparing the override to the legacy default. Previously UNSLOTH_STUDIO_HOME=\"\$HOME/.unsloth/studio/\" (with trailing slash) was rejected even though it resolves to the supported legacy root. - studio/backend/main.py: when launched directly via \`uvicorn main:app\` from a custom-root venv (bypassing both unsloth_cli and run.py), export UNSLOTH_STUDIO_HOME and UNSLOTH_LLAMA_CPP_PATH before any unsloth-zoo import so its import-time LLAMA_CPP_DEFAULT_DIR binding picks up the custom-root build. Only sets when STUDIO_ROOT is a real custom override. bash -n install.sh, pwsh parser install.ps1, python ast main.py: clean. Smoke probe: UNSLOTH_STUDIO_HOME=\$HOME/.unsloth/studio/ install.sh --tauri no longer exits with the unsupported-custom-root error. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * install.ps1: skip CWD-relative venv migration in env-override mode The legacy ~/unsloth_studio venv migration path on Windows reads %USERPROFILE%\unsloth_studio\Scripts\python.exe (a fixed home-relative path). Under env-override mode this would Move-Item the user's pre-existing default-install venv into $StudioHome\unsloth_studio, breaking the default install and contaminating the workspace root. Gate the migration on $StudioRedirectMode -ne 'env' so workspace-scoped installs leave the user's default-install venv untouched. No Linux equivalent: install.sh migrates from \$STUDIO_HOME/.venv which is already env-mode-aware (points at the workspace root, not \$HOME). * install: cycle-14 fixes (Tauri env scrub + setup.ps1 missing-root error) Tauri does not honor UNSLOTH_STUDIO_HOME / STUDIO_HOME / UNSLOTH_LLAMA_CPP_PATH yet -- the desktop app's Rust paths use the legacy ~/.unsloth/studio root. If the user's shell has these env vars set, spawned Python subprocesses would diverge from the Rust paths (custom-root Python <-> legacy-root Rust). Scrub the three env vars at all Tauri subprocess spawn sites: - process.rs: backend launch - desktop_auth.rs: provision-desktop-auth subprocess - install.rs: install.sh / install.ps1 invoked from the desktop app (also prevents the --tauri guard from rejecting an inherited override). setup.ps1: when UNSLOTH_STUDIO_HOME points at a non-existent directory, 'Resolve-Path -LiteralPath' threw a confusing PSObject error under $ErrorActionPreference = "Stop". Test-Path the override first and emit a friendly "run install.ps1 to create the install root" message instead. * install: cycle-15 fixes (preserve UNSLOTH_LLAMA_CPP_PATH + add update.rs scrub) UNSLOTH_LLAMA_CPP_PATH is a pre-existing custom-llama.cpp-directory override the Python backend (studio/backend/core/inference/llama_cpp.py) and unsloth-zoo intentionally support. It is unrelated to the Studio install root. Cycle 14 over-scrubbed it from the Tauri spawn sites, regressing desktop GGUF/llama.cpp workflows for users who set it in their shell. - process.rs / desktop_auth.rs / install.rs: stop scrubbing UNSLOTH_LLAMA_CPP_PATH; only scrub UNSLOTH_STUDIO_HOME and STUDIO_HOME. - update.rs: missed Tauri spawn site -- add the same UNSLOTH_STUDIO_HOME / STUDIO_HOME scrub so 'unsloth studio update' from the desktop app updates the legacy-root install Tauri actually manages. Verified: cargo test --bins -- --test-threads=1 -> 34/34 pass. * install.sh: document apostrophe-escape derivation inline The shell quoting at install.sh:642 / 659 / 679 / 680 / 823 has been flagged as broken across multiple review cycles, but every end-to-end verification (DATA_DIR=\"a b's&c|d\$e\" -> generated launcher -> source -> recovered exact input) passes. The proposed "8 backslash" fix would double the escape and actually break what currently works. Strengthen the inline comments to spell out the derivation: - shell pattern \"s/'/'\\\\''/g\" passes \"s/'/'\\''/g\" to sed (\\\\ -> \\) - sed replacement '\\'' yields close-quote / escaped-quote / open-quote - stage 2 (\\, &, |) only needed where the value is then sed-replaced into a launcher template via s|@@DATA_DIR@@|VALUE|g studio.conf is written via printf, not sed, so it only needs stage 1. No behavior change, only inline doc to head off future false positives. * install/setup .ps1: use -LiteralPath for $StudioHome-derived paths Pre-PR, $StudioHome was hardcoded to %USERPROFILE%\.unsloth\studio -- no wildcard characters possible. The PR introduces UNSLOTH_STUDIO_HOME / STUDIO_HOME, so $StudioHome (and every path derived from it: $VenvDir, $VenvPyExe, $UnslothExe, $UnslothHome, $LlamaCppDir, $VenvT5_*, etc.) can now contain bracket characters that PowerShell would interpret as wildcards. Reproducer (from cycle 17 review 20): pwsh> Test-Path 'studio[abc]/Scripts/python.exe' False pwsh> Test-Path -LiteralPath 'studio[abc]/Scripts/python.exe' True Switch the relevant Test-Path / Remove-Item / New-Item / Move-Item calls in install.ps1 and studio/setup.ps1 to -LiteralPath. Sites where the path is fixed (the shim under %LOCALAPPDATA%\Microsoft\WindowsApps, $RepoRoot from -PSCommandPath) keep the wildcard-aware form. * install/setup .ps1: fix New-Item -LiteralPath regression from cycle 17 Cycle 17 added -LiteralPath to all $StudioHome-derived path operations, but New-Item has no -LiteralPath parameter (verified pwsh 7.6 syntax: "New-Item [-Path] <string[]> [-ItemType <string>] ..."). Every directory- creation site would throw "A parameter cannot be found that matches parameter name 'LiteralPath'" at runtime, blocking T5 sidecar setup, llama.cpp parent creation, and StudioHome creation. Likewise, "Split-Path -LiteralPath $X -Parent" cannot mix LiteralPath with -Parent (separate parameter sets). The default LiteralPath mode already returns the parent. Switch to [System.IO.Directory]::CreateDirectory($X), which natively takes a literal path, and drop the trailing -Parent on Split-Path. Verified end-to-end on a bracketed path "/tmp/...[abc]": - CreateDirectory: created - Test-Path -LiteralPath: detects - nested CreateDirectory(Split-Path -LiteralPath ...): works * install/setup .ps1: extend -LiteralPath sweep to remaining \$StudioHome paths Cycle 17/18 missed several wildcard-aware operations on user-controlled \$StudioHome-derived paths. Reviewers identified remaining sites: install.ps1: - \$UnslothExePath (Test-Path / Resolve-Path) at the shortcut creator - \$VenvDir (Get-ChildItem) at the no-torch-runtime resolver - \$ShimDir (New-Item Directory -- replaced with .NET CreateDirectory) - \$ShimExe (Test-Path / Remove-Item / re-prepend guards) -- the shim lives at \$StudioHome\\bin\\unsloth.exe in env-override mode, so it inherits bracket sensitivity from \$StudioHome. - \$UnslothExe (Copy-Item fallback) when HardLink fails. studio/setup.ps1: - \$LlamaServerBin (Test-Path) at the prebuilt-bundle / source-build validation gates (3 sites). \$LlamaServerBin lives under \$BuildDir under \$LlamaCppDir under \$UnslothHome under \$StudioHome. New-Item HardLink keeps -Path because creating a non-existent target with brackets succeeds (verified via direct pwsh smoke test). * install: cycle-20 fixes (more setup.ps1 -LiteralPath + shell-quote launch hints) setup.ps1: extend -LiteralPath sweep to remaining \$BuildDir-derived paths that the cycle-19 commit missed: - \$CmakeCacheFile (Test-Path + Select-String -Path) - \$buildTmp (10 Test-Path / Remove-Item sites in source-build cleanup) - \$QuantizeBin (Test-Path) - \$altBin (Test-Path) These all live under \$BuildDir -> \$LlamaCppDir -> \$UnslothHome -> \$StudioHome, which is now user-controlled via UNSLOTH_STUDIO_HOME. Bracket characters in the override would silently skip rebuild detection or leave stale build artifacts. install.sh: shell-quote the launch-instruction substep lines for env- override mode. UNSLOTH_STUDIO_HOME values containing spaces or apostrophes (e.g. "/tmp/O'Brien Studio") would print copy-paste- unsafe commands -- the install succeeded but the printed launch instructions split at the space. Now wraps with the canonical '\\''-style escape so the printed lines parse with bash -n. Verified end-to-end: - printed shim line: '/tmp/O'\''Brien Studio/bin/unsloth' studio ... - bash -n on the printed line passes. * install.ps1: -LiteralPath for macOS-stub-launcher \$appDir-derived paths The shortcut/launcher generator at install.ps1:418-693 writes the stub launcher, .vbs, and icon under \$appDir = \$StudioDataDir, which in env-override mode is \$StudioHome\share. Cycle 17/19/20 missed the following wildcard-aware ops on these paths: - Test-Path \$appDir (with New-Item Directory swap to .NET CreateDirectory) - Set-Content -Path \$launcherVbs (for the WSH .vbs stub) - Test-Path / Copy-Item \$bundledIcon (bundled icon copy) - Test-Path / Remove-Item \$iconPath (icon header validation) In env-override mode \$StudioHome can contain bracket characters; without -LiteralPath the .vbs write fails outright and the icon validation can either skip a present icon or fail to delete a malformed one. (The COM shortcut creation downstream returns early in env-override mode, so its path values don't need this treatment.) * install: don't override pre-existing UNSLOTH_LLAMA_CPP_PATH in launchers Cycle 14/15 established UNSLOTH_LLAMA_CPP_PATH as a pre-existing custom-llama.cpp-directory override the Python backend and unsloth-zoo intentionally support, independent of the Studio install root. The launchers (studio.conf sourced by Unix launch-studio.sh, and the PowerShell launch-studio.ps1) were unconditionally re-exporting it, which silently overrides a user's pre-existing value when they invoke the launcher from a shell where UNSLOTH_LLAMA_CPP_PATH is already set. Make the assignment conditional in both launchers: install.sh studio.conf: if [ -z "\${UNSLOTH_LLAMA_CPP_PATH:-}" ]; then export UNSLOTH_LLAMA_CPP_PATH='...' fi install.ps1 launch-studio.ps1: if (-not \$env:UNSLOTH_LLAMA_CPP_PATH) { \$env:UNSLOTH_LLAMA_CPP_PATH = '...' } UNSLOTH_STUDIO_HOME stays unconditional: the launcher is bound to a specific install, so its STUDIO_HOME must always match that install. * install.sh: harden --tauri legacy resolver against CDPATH and symlinks Reviewer cycle 23 (inst 19) noted that the bare \`cd -- ... && pwd\` form in the --tauri legacy comparison can echo a CDPATH-prefixed path when the user has CDPATH set in their environment, contaminating the resolved absolute path used in the legacy-equality check. Switch to \`CDPATH= cd -P -- ... && pwd -P\` so: - CDPATH= clears the cd-prefix-echo behavior - -P / pwd -P resolves any symlinks to a canonical path No behavior change for users without CDPATH set; correctness fix for users who have it set in their shell. * install + llama_cpp backend: cycle-24 hardening Three real findings from cycle 24 reviewers: 1. install.sh:231 + studio/setup.sh:413 -- main \$STUDIO_HOME resolvers used the same bare \`cd -- ... && pwd\` form that cycle 23 only fixed for the --tauri guard. Switch both to: \$(CDPATH= cd -P -- "\$override" && pwd -P) so relative custom-root values don't get CDPATH-prefixed or have the cd-on-CDPATH stdout newline contaminate the captured value. 2. install.sh --tauri legacy root used logical \$HOME/.unsloth/studio while the override side was canonicalized via pwd -P. A symlinked \$HOME (e.g. /home/alice -> /u/alice) made the comparison fail even when both sides pointed at the same directory. Canonicalize the legacy side too when the dir exists. 3. studio/backend/core/inference/llama_cpp.py:_find_llama_server_binary searched \$STUDIO_HOME/llama.cpp first then ~/.unsloth/llama.cpp in default-mode installs. setup.sh / setup.ps1 only install llama.cpp under \$STUDIO_HOME/llama.cpp in env-override mode; in default mode it always lives at ~/.unsloth/llama.cpp. The post-PR search would pick up a stale partial install at ~/.unsloth/studio/llama.cpp over the real legacy binary. Mirror setup's legacy-equality check: when studio_root() resolves equal to ~/.unsloth/studio, search ONLY the legacy ~/.unsloth/llama.cpp. Otherwise (env-override custom root), search custom first, legacy fallback. * install + setup: canonicalize legacy-equality comparison sites Cycle 24 made \$STUDIO_HOME canonical via 'CDPATH= cd -P -- ... && pwd -P', but the legacy-equality comparison sites still used the bare logical "\$HOME/.unsloth/studio" string. With a symlinked \$HOME (e.g. /home/alice -> /u/alice), the comparison fails even when both sides point at the same dir, and llama.cpp ends up under a custom-root path the Python backend's legacy comparison cannot find. Reviewer cycle 25 inst 2 reproduced this with HOME=/tmp/link -> /tmp/real and UNSLOTH_STUDIO_HOME=\$HOME/.unsloth/studio: setup.sh resolves UNSLOTH_HOME to /tmp/real/.unsloth/studio while the backend search resolves both physically equal and looks at /tmp/link/.unsloth/llama.cpp. Canonicalize the legacy side at all four sites: - install.sh:695 (create_studio_shortcuts llama.cpp path) - studio/setup.sh:577 (UNSLOTH_HOME selection) - install.ps1:462 (launcher UNSLOTH_LLAMA_CPP_PATH path) - studio/setup.ps1:1829 (UnslothHome selection) Apply CDPATH= cd -P -- ... && pwd -P (Unix) or Resolve-Path -LiteralPath (Windows) when the legacy dir exists. unsloth_cli/commands/studio.py already does this via Path.resolve(). * llama_cpp: gate _kill_orphaned_servers studio-root allowlist on env-override Cycle 24 fixed _find_llama_server_binary to only search \$STUDIO_HOME/llama.cpp when STUDIO_HOME is a real env override (not the legacy default), but the symmetric _kill_orphaned_servers allowlist still appended _sr() / "llama.cpp" unconditionally. In default mode _sr() resolves to ~/.unsloth/studio, so ~/.unsloth/studio/llama.cpp would be treated as a Studio-owned install root for the orphan-kill scan even though the default installer does not own that path. A llama-server process running there from a different tool or a stale partial install would be killed. Apply the same legacy-equality check used in _find_llama_server_binary and the install/setup scripts: only add _sr()/"llama.cpp" to the allowlist when STUDIO_HOME != legacy default. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * setup.sh + setup.ps1: canonicalize both sides of legacy-equality check Proactive audit pass found one real asymmetry the cycle-by-cycle review process had not yet flagged: - install.sh:704 / install.ps1:469 are gated on env-mode and only run when STUDIO_HOME has already been canonicalized (cycle 24). Symmetric. - studio/setup.sh:577 / studio/setup.ps1:1829 run UNCONDITIONALLY, including in default mode. In default mode STUDIO_HOME is set to the bare logical \$HOME/.unsloth/studio (setup.sh:416) or Join-Path \$env:USERPROFILE ".unsloth\\studio" (setup.ps1:1480). Cycle 25 canonicalized only the legacy side, creating an asymmetry under symlinked \$HOME / junctioned %USERPROFILE%. Result of the asymmetry: a default-mode install on a host with \$HOME=/tmp/link -> /tmp/real treats the legacy default as a custom root, putting llama.cpp at \$STUDIO_HOME/llama.cpp instead of ~/.unsloth/llama.cpp -- and the Python backend's _find_llama_server_binary (which uses .resolve() on both sides) then can't find the install. Fix: canonicalize STUDIO_HOME on the fly at the comparison site, in both setup.sh and setup.ps1. Symmetric with the now-canonicalized legacy side from cycle 25, regardless of which mode set STUDIO_HOME. The other two comparison sites (install.sh:704, install.ps1:469) are already symmetric because they only run when STUDIO_HOME comes from the env-override resolution path that already does pwd -P / Resolve-Path. unsloth_cli/commands/studio.py + studio/backend/run.py + main.py + llama_cpp.py already use .resolve() on both sides -- symmetric. * install.ps1: env-override resolution uses .NET API for literal paths Gemini code-review (review 4177641398, commit2ea2c91) caught two remaining New-Item -Path sites in the env-override resolution block that the cycle 18 sweep missed: - Line 123: New-Item -ItemType Directory -Path \$envOverride - Line 132: New-Item -ItemType File -Path \$probe (writability test) Both use -Path which interprets square brackets as wildcards. For a user with UNSLOTH_STUDIO_HOME=C:\\workspaces\\studio[abc], both calls would fail before the install starts. New-Item also has no -LiteralPath in PowerShell 5.1. Replace both with the .NET API: - [System.IO.Directory]::CreateDirectory(\$envOverride) - [System.IO.File]::WriteAllText(\$probe, "") -- closes the file handle before the Remove-Item below. End-to-end verified with /tmp/test-envoverride-[abc]-* path: CreateDirectory + WriteAllText + Test-Path -LiteralPath all work. * comments: condense multiline blocks added by this PR Across the 27-cycle review process, comments accumulated as multiline blocks explaining each fix's history (cycle numbers, prior bugs, reviewer rationale). Compress every block to 1-2 lines that capture just the WHY, dropping cycle references and history that belongs in the PR description / commit log instead. Net: 268 deletions / 124 insertions (-144 lines) of comments only. Behavior unchanged. Verified: bash -n, pwsh parser, python ast.parse, cargo check all pass. * install.ps1: use 'return' over 'exit 1' for Install-UnslothStudio bail-outs Per Gemini review #4177659001: when users run install.ps1 via 'irm ... | iex', 'exit 1' inside the function terminates the entire PowerShell process and closes the user's terminal. 'return' bails out of the function while keeping the shell open, matching existing error sites at lines 34, 50, 57. Three sites fixed: --tauri+env-override guard, env-override mkdir/access failure, and write-probe failure. The 'exit' calls at lines 591/611 are inside a generated launcher here-string (a separate top-level .ps1 that runs as its own process), so they correctly stay as 'exit'. * install.{sh,ps1}: address Gemini review #4177680451 Three medium fixes: 1. install.sh redirection detection: canonicalize both sides of the $HOME vs passwd-DB comparison via 'CDPATH= cd -P -- ... && pwd -P' so a trailing slash on $HOME (or symlink-vs-realpath mismatch with getent/dscl output) doesn't misfire the redirection branch. 2. install.sh shim symlink: 'ln -sf' into an existing directory creates the link INSIDE it ($_LOCAL_BIN/unsloth/unsloth instead of the intended file). Pre-strip a real (non-symlink) directory at $_LOCAL_BIN/unsloth before linking. 3. install.ps1 ShimExe: add -Recurse to Remove-Item so the launcher refresh recovers if $ShimExe somehow exists as a directory rather than a file (would otherwise drop into the catch and skip the shim update). * install.ps1: use 'throw' over 'return' for fatal validation failures Cycle 28 reviewer.py (12/8 RC/APPROVE) caught a regression introduced by the previous Gemini-review fix (#4177659001 -> commit393e676b). 'return' inside Install-UnslothStudio kept iex'd terminals alive but made 'pwsh -File install.ps1' exit with code 0 on fatal validation failures (--tauri+custom-root rejected, STUDIO_HOME unwritable, etc.), so CI / wrapper scripts treated failed installs as successful. 'throw' satisfies both constraints: - pwsh -File install.ps1: exits with code 1 (CI sees failure) - irm | iex: shows error to user, does NOT close the host terminal Three sites: --tauri+env-override guard, mkdir/access failure, write-probe failure. Verified throw -> exit code 1 under pwsh -File. * install.ps1 launcher: single-quote child -Command path Cycle 28 P2 finding: the generated launch-studio.ps1 builds the child PowerShell -Command string with the executable path inside double quotes, so a custom Studio root containing PowerShell metacharacters (\$, backtick) re-expands in the child shell. Example: D:\work\\\$job\studio -> child reparses \$job and runs the wrong path. Fix: single-quote the path inside the child command and double any apostrophes (PowerShell's literal-quote-escape form) so paths like "O'Brien Studio & x|y" or "C:\work\\\$bad\studio" survive verbatim. * install: harden custom Studio root handling - install.sh shim refresh: refuse to recursively delete a real directory at $_LOCAL_BIN/unsloth before creating the symlink. The previous rm -rf could destroy unrelated user data living at that path. - install.ps1 shim refresh: drop -Recurse from Remove-Item on $ShimExe and refuse early when the shim path is a directory; mirrors the install.sh guard so a directory at $StudioHome\bin\unsloth.exe is not blown away. - install.ps1 PATH wiring: remove the redundant first $ShimDir prepend in env-override mode; the post-Refresh-SessionPath prepend is the one that takes effect, and the duplicate left $ShimDir in $env:Path twice. - install.ps1 manual launch instructions: single-quote the printed shim and Activate.ps1 paths so '$' / backtick metacharacters in custom roots do not reparse when the user copies and pastes the command. - studio/setup.sh: validate writability of UNSLOTH_STUDIO_HOME with the same [ -w ] check install.sh already has, so a read-only override fails with a clear message instead of an obscure uv pip permission error. - Drop the STUDIO_HOME alias everywhere (storage_roots.py, studio.py, install.sh, studio/setup.sh, install.ps1, studio/setup.ps1). The name is too generic and an ambient STUDIO_HOME from unrelated tooling could silently redirect the install. Only UNSLOTH_STUDIO_HOME is honored. - unsloth_cli/commands/studio.py: defer UNSLOTH_STUDIO_HOME / UNSLOTH_LLAMA_CPP_PATH re-export from import time into a helper invoked by the studio app callback. Importing the module no longer mutates os.environ as a side effect, so test runners and CLI introspection stop leaking those vars into unrelated subprocesses. - studio/backend/core/inference/llama_cpp.py: replace set-mutation inside list comprehension with an explicit dedup loop for readability. * install: harden custom Studio root edge cases - install.ps1 shim refresh: move the directory-collision preflight outside the lock-handling try/catch. The previous throw inside the try block was swallowed by the surrounding catch and downgraded to a "Continuing with the existing launcher" warning, leaving the install in a broken state with no usable shim on disk. - storage_roots.py / unsloth_cli/commands/studio.py: tighten the bin-shim sentinel from .exists() to .is_file(). A directory at the candidate bin/unsloth (or bin/unsloth.exe) path would otherwise false-positive the venv inference and pick the wrong Studio root. - storage_roots.py / unsloth_cli/commands/studio.py: wrap the env-var override Path(...).expanduser().resolve() in try/except (OSError, ValueError), matching the defensive pattern already used in studio/backend/main.py and studio/backend/run.py. An invalid override (unresolvable network drive, bad characters) now falls back to the un-resolved path instead of crashing at import time. * install: fail fast on missing custom root, allow brackets in shim path - install.ps1 shim hardlink: switch the New-Item -ItemType HardLink call from -Path to -LiteralPath so a custom Studio root containing bracket characters does not fail under PowerShell's wildcard-aware -Path parameter. Matches the -LiteralPath usage on every other Test-Path / Remove-Item / Copy-Item call against the same shim path. - studio/setup.sh override branch: replace the silent mkdir -p of the override directory with an existence check that exits 1 with a clear message. setup.sh runs against an existing install (via 'unsloth studio update'), so a typo in UNSLOTH_STUDIO_HOME must not materialize an empty workspace dir. Brings the Unix flow in line with setup.ps1, which already errors on a missing override root. * llama_cpp: scope orphan-server kill to the active install root _kill_orphaned_servers used to unconditionally include the legacy ~/.unsloth/llama.cpp tree in install_roots, even when the running Studio is in env-override mode and operates out of a custom root. On a single OS user running both a default-install Studio and a custom-root Studio concurrently, the custom Studio would kill the default Studio's llama-server during startup orphan cleanup. Hoist _is_custom_root out of the import try/catch so the legacy- append decision sees it (default to False on ImportError so default mode behaviour is unchanged), and gate the legacy ~/.unsloth/llama.cpp append on `not _is_custom_root`. * install: harden custom-root .venv migration and shim hardlink - install.sh / install.ps1 OLD-layout .venv migration: gate on default-mode only. Without the guard, pointing UNSLOTH_STUDIO_HOME at a workspace that already has .venv (e.g. an unrelated Python project) caused the torch validation to fail and the installer to recursively remove the user's project venv. Mirrors the existing env-mode skip on the CWD-relative venv migration immediately below. - install.ps1 shim hardlink: revert to New-Item -ItemType HardLink -Path. -LiteralPath is not accepted on the HardLink ItemType in any PowerShell version, so the previous form always threw and silently fell back to Copy-Item, breaking hardlink-update propagation. Bracket characters in $ShimExe are still defended by the directory-collision preflight added earlier. - storage_roots.py / unsloth_cli/commands/studio.py: strip whitespace from the UNSLOTH_STUDIO_HOME env var before the truthy check so a blank " " override does not become a real path with trailing spaces (which would silently break every downstream Studio path operation). * Studio paths: tolerate stat / resolve failures during root inference - storage_roots._infer_studio_home_from_venv: wrap the share/studio.conf and bin/shim is_file() sentinel checks in try/except OSError. A PermissionError on a restricted candidate dir would otherwise propagate out of studio_root() and crash module import in run.py / main.py / transformers_version.py / model_config.py at server startup. - llama_cpp._kill_orphaned_servers: broaden the studio_root() guard from ImportError-only to (ImportError, OSError, ValueError) so transient resolve / sentinel failures do not crash the orphan-killer at server startup. Matches _find_llama_server_binary's existing pattern. - llama_cpp._find_llama_server_binary: nest the inner resolve() in its own try/except and fall back to unresolved-path comparison instead of dropping the custom search root entirely. A transient resolve() error on the legacy path no longer loses the custom-root llama.cpp lookup. * Add Studio install-root resilience tests * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Studio: isolate custom-root installs from default-install state - llama.cpp discovery in env-override mode no longer falls back to the legacy ~/.unsloth/llama.cpp tree. The orphan-cleanup path already excludes that root in custom mode; aligning discovery prevents a custom-root Studio from launching a sibling install's binary it then refuses to manage. Users who want a shared build set UNSLOTH_LLAMA_CPP_PATH explicitly. - Generated POSIX launcher (install.sh heredoc) namespaces LOCK_DIR with a hash of DATA_DIR and persists the launched port to $DATA_DIR/studio.port; in env-override mode the fast-path attaches only to a port we ourselves wrote, never to a sibling Studio that happens to be healthy on 8888..8908. - Generated Windows launcher (install.ps1 heredoc) bakes a per-install $portFile and SHA-256-suffixed mutex name, mirroring the POSIX side; Find-HealthyStudioPort uses the port file in env-override mode. - studio/setup.sh and studio/setup.ps1 require an .unsloth-studio-owned marker before deleting $STUDIO_HOME/.venv_t5*, $STUDIO_HOME/llama.cpp, and the sidecar T5 venvs in env-override mode. The marker is dropped after fresh creation so subsequent runs of 'unsloth studio update' proceed cleanly. Mirrors the existing .venv guard in install.sh. - Wrap bare Path.resolve() calls on the legacy STUDIO_HOME constant in studio/backend/main.py, studio/backend/run.py, and unsloth_cli/commands/studio.py in the same try/except (OSError, ValueError) used adjacently, so a restricted parent or recursive symlink on $HOME does not crash module import / CLI startup. * Studio: guard env-mode workspace against destructive cleanup - install.sh and install.ps1 unconditionally rm -rf / Remove-Item the new-layout $STUDIO_HOME/unsloth_studio when it has a python; in env-override mode that path is a user-chosen workspace, mirroring the .venv migration concern the .venv branch already guards. Refuse to remove an existing $STUDIO_HOME/unsloth_studio that lacks Studio sentinels (share/studio.conf or bin/unsloth). - studio/setup.ps1 only checked Test-Path -PathType Container on the custom root; setup.sh and install.ps1 both also write-probe via WriteAllText / Remove-Item. Add the matching probe so 'unsloth studio update' against an ACL-restricted root fails fast with a clear message instead of erroring later while creating sidecar venvs. * Add Studio install/setup workspace-isolation tests * Studio: tighten installer rationale comments - install.sh: collapse a 5-line restatement into 3 lines, naming env-mode behavior up front and the byte-identical pre-override fallback after. - install.ps1: correct misleading hardlink comment that claimed the directory-collision preflight guards against wildcard expansion; bracket characters in $ShimExe still glob-expand here, with the Copy-Item -LiteralPath fallback handling them. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Split: keep only 2 file(s) * Studio: harden env-mode workspace guards across installers and update path Tightens the UNSLOTH_STUDIO_HOME custom-root protections so destructive installer paths cannot displace unrelated user data when the override points at a workspace. install.sh / install.ps1: env-mode sentinel that gates rm -rf $VENV_DIR / Remove-Item $VenvDir now requires share/studio.conf or the bin/unsloth(.exe) shim to be a real file or symlink. Previously a directory at bin/unsloth or bin\unsloth.exe satisfied the check (-e and bare Test-Path accept any path type), so a workspace with unrelated content under unsloth_studio plus a sibling directory at bin/unsloth could be wiped. studio/setup.ps1: stale-venv rebuild branch now mirrors install.ps1's env-mode guard before Remove-Item -LiteralPath $VenvDir -Recurse -Force. Without this, "unsloth studio update" pointed at a custom workspace whose unsloth_studio venv fails torch validation deletes the venv even when the root carries no Studio sentinels. studio/setup.sh / studio/setup.ps1: prebuilt llama.cpp install path now calls _assert_studio_owned_or_absent / Assert-StudioOwnedOrAbsent before invoking install_llama_prebuilt.py, and writes the .unsloth-studio-owned marker on success. install_llama_prebuilt.py uses os.replace() to move any existing install_dir aside before staging, so an unrelated $STUDIO_HOME/llama.cpp could otherwise be displaced before the existing source-build ownership guard ever ran. * Studio: gate ownership guards on canonical custom-root and add venv marker Tightens UNSLOTH_STUDIO_HOME ownership semantics so they fire only for a genuinely custom root, never for an explicit override that resolves to the legacy default. Adds an in-VENV marker that lets a partial install be repaired and provides a strong primary sentinel for the deletion guard. studio/setup.sh + studio/setup.ps1: hoist the canonical $STUDIO_HOME vs legacy-default comparison so it sits next to the marker definition, derive _STUDIO_HOME_IS_CUSTOM / $StudioHomeIsCustom once, and gate the _assert_studio_owned_or_absent / Assert-StudioOwnedOrAbsent helpers and the prebuilt llama.cpp marker writes on that flag instead of raw env-var presence. UNSLOTH_STUDIO_HOME=$HOME/.unsloth/studio (legacy override) no longer trips the guard for pre-PR T5 sidecar venvs or llama.cpp dirs that predate the .unsloth-studio-owned marker. The duplicate canonical block inside the llama.cpp section is removed; the new flag is reused. studio/setup.ps1: Assert-StudioOwnedOrAbsent's marker check now requires -PathType Leaf so a directory at .unsloth-studio-owned cannot satisfy it. The in-place git-sync branch in the source-build path now calls Mark-StudioOwned after a successful sync so a later prebuilt-update path does not fail Assert-StudioOwnedOrAbsent on the same root. install.sh + install.ps1: write $VENV_DIR/.unsloth-studio-owned right after uv venv succeeds and accept it as the primary sentinel in the env-mode deletion guard. This recovers from a partial install that was previously unrepairable, and is a stronger sentinel than sibling shim files (the marker is inside the venv that is about to be wiped, so an unrelated workspace cannot accidentally satisfy it). install.sh: drop the standalone -L test on $STUDIO_HOME/bin/unsloth in the deletion guard. -L returns true for any symlink including symlinks to directories and broken symlinks; -f already accepts the legitimate file-targeted symlink shape created by ln -s at install.sh:1864. * Studio: close residual workspace-isolation gaps for custom roots Four follow-on hardenings that close the remaining cross-root leaks the custom-root install plumbing still left open. studio/setup.ps1 in-place git-sync: when the source-build path finds an existing $LlamaCppDir/.git, it ran git remote set-url, checkout -B, and clean -fdx in place before any ownership check. The previous fix marked the tree as Studio-owned AFTER the sync but did not guard the BEFORE case, so an unrelated workspace .git could be silently rewritten on the first source-build under a custom UNSLOTH_STUDIO_HOME. Add the same Assert-StudioOwnedOrAbsent guard already used by the prebuilt path and the temp-dir swap path (gated on $StudioHomeIsCustom for parity). Launcher port-file workspace isolation: the env-mode launchers' fast path attached to any backend listening on the cached port that returned a healthy /api/health, even when that backend belonged to a different install root. studio/backend/main.py /api/health now returns the resolved studio_root; install.sh _check_health and install.ps1 Test-StudioHealth verify it against UNSLOTH_STUDIO_HOME when set, so a stale studio.port pointing at a sibling Studio is rejected instead of opening the wrong UI. studio/src-tauri preflight + commands: the Tauri desktop app stays on the legacy root by design. process.rs / install.rs / desktop_auth.rs / update.rs already strip UNSLOTH_STUDIO_HOME and STUDIO_HOME from their CLI subprocesses, but preflight.rs run_cli_probe / probe_cli_capability and commands.rs check_install_status did not, so a desktop launch from a shell carrying those env vars produced status reflecting a different root than the desktop manages. Mirror the existing scrub. install.sh shim install: the previous `rm -f -- $_shim_path; ln -s ...` pair leaves a window with no shim if interrupted. Use ln -sfn for an atomic replace; the -n flag prevents descent into a symlink-to-directory target (the existing directory guard above already rejects a real dir). * Studio: replace launcher root verify with hex digest baked at install time The previous launcher identity check returned the absolute resolved Studio install root from /api/health and matched it against $UNSLOTH_STUDIO_HOME in the launcher. Three problems that this commit closes: - POSIX launcher used a raw bash `case` against the JSON-encoded value, so paths containing characters that JSON escapes (e.g. /tmp/back\slash, /tmp/O"Brien) caused the launcher to reject its own healthy backend. - /api/health is unauthenticated and Studio supports `-H 0.0.0.0`, so any reachable client could read the absolute install path (username, home dir, workspace name, CI checkout path). - The verification was gated on $UNSLOTH_STUDIO_HOME being set at runtime, so a default-mode launcher would attach to a sibling env-mode Studio listening on the same port instead of starting its own. The fix replaces the raw path with a SHA-256 hex digest computed at install time and baked into the generated launcher (mirroring how @@DATA_DIR@@ is substituted today): studio/backend/main.py: /api/health now returns `studio_root_id = sha256(str(_studio_root()))` instead of the raw `studio_root` path. install.sh: computes `_css_studio_root_id` once from $STUDIO_HOME using python3, bakes `_EXPECTED_STUDIO_ROOT_ID='@@STUDIO_ROOT_ID@@'` into the launcher heredoc, and adds `s|@@STUDIO_ROOT_ID@@|...|g` to the existing sed pipeline for ALL modes (env / home / default). _check_health verifies the baked id substring-matches the JSON response. Hex-only so no shell or sed escape corner cases. install.ps1: same shape on Windows. SHA256 the $StudioHome bytes, lower hex, bake `$_ExpectedStudioRootId = '...'` into the launcher heredoc. Test-StudioHealth now compares `$resp.studio_root_id -eq $_ExpectedStudioRootId` unconditionally (no special-case for env-mode). Default-mode launchers also bake their expected id, so two coexisting Studio installs on the same machine can no longer cross-attach. * Studio: harden launcher root-id and split install-time mode from runtime env - install.sh launcher: compute studio_root_id with the venv Python (uv-managed systems may not have system python3) and canonicalize STUDIO_HOME with cd -P/pwd -P so default and home-redirect modes match the backend's Path(sys.prefix).resolve() canonicalization. Fail fast instead of silently baking an empty discriminator. - install.sh launcher heredoc: gate PORT_FILE / namespaced LOCK_DIR on a baked install-time mode flag (@@INSTALLED_IS_ENV_MODE@@) instead of the runtime UNSLOTH_STUDIO_HOME variable so a sourced custom-root studio.conf cannot flip a default-mode launcher into env-mode behavior with stale state. - studio/backend/main.py: cache the studio_root_id digest at module load so /api/health does not recompute hashlib + filesystem probes on every poll. - studio/backend/core/inference/llama_cpp.py: widen the studio_root() probe except clause from ImportError to (ImportError, OSError, ValueError) so it matches the sibling _kill_orphaned_servers handler and tolerates Path.resolve failures from broken symlinks or odd codecs. * Studio: align launcher root-id digest with backend canonicalization - studio/backend/main.py: hash the already-resolved _STUDIO_ROOT_RESOLVED instead of recomputing str(_studio_root()); the default fallback in storage_roots returns Path.home()/.unsloth/studio without .resolve(), so on systems where $HOME is a symlink (NFS / AFS / Docker) the cached digest now matches install.sh's cd -P/pwd -P canonicalization and the launcher no longer rejects its own healthy backend. - install.ps1: canonicalize $StudioHome via Resolve-Path before the SHA256 compute (env-mode already resolves at line 121, only default and profile branches were raw); a junctioned USERPROFILE now produces the same digest the backend computes via Path.resolve() for the same install. - install.sh launcher template: substitute the non-user-controlled @@STUDIO_ROOT_ID@@ and @@INSTALLED_IS_ENV_MODE@@ placeholders before the user-controlled @@DATA_DIR@@ pass so a $DATA_DIR that contains the literal placeholder text cannot be mutated by the second sed. * Studio: tighten installer rationale comments * Studio install: extend workspace-guard test coverage Add behavioral coverage for env-mode workspace guards across install.sh, install.ps1, studio/setup.sh, studio/setup.ps1, the launcher root-id discriminator, and the backend's /api/health response. Also refresh the custom-mode llama.cpp resilience assertion so it matches the implementation that intentionally excludes the legacy tree from search_roots. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Honor STUDIO_HOME alias, fix workspace-guard test harness, harden rollback The PR title and description promise STUDIO_HOME as a priority-2 alias to UNSLOTH_STUDIO_HOME, but the implementation only read the longer name in all six resolution sites. Wire the alias through install.sh, install.ps1, studio/setup.sh, studio/setup.ps1, the Python storage_roots resolver, and the unsloth_cli studio resolver. UNSLOTH_STUDIO_HOME wins when both are set (more specific signal beats the generic alias). Whitespace-only values are now treated as unset to match the Python resolvers' .strip() semantics, preventing install/runtime layout drift where the installer would create a literal " " directory while the backend fell through to the legacy default. Error messages and the substep status line report the env-var name the user actually set ("UNSLOTH_STUDIO_HOME=..." vs "STUDIO_HOME=...") so diagnostics stay accurate under either spelling. Test harness fix: tests/test_studio_install_workspace_guard.py extracted the install.sh venv-replacement block, but after the merge that block delegates to _start_studio_venv_replacement (defined further up in install.sh, not in the extracted snippet). Five sentinel-positive tests echoed RESULT=ok but never moved $VENV_DIR. Add a single _INSTALL_GUARD_STUBS constant that stands in a minimal mv-based stub plus a no-op substep, and route every inline test script through a new _build_install_guard_script() helper. All 50 tests now pass (was 45/50). Rollback hardening: Start-StudioVenvRollback / Restore-StudioVenvRollback / Complete-StudioVenvRollback in install.ps1 used plain Test-Path, Move-Item, Remove-Item against paths derived from $StudioHome. With a custom UNSLOTH_STUDIO_HOME containing brackets (the very motivation for the broader -LiteralPath sweep this PR set out to do), rollback would silently misbehave under wildcard interpretation, turning a recoverable install error into a destroyed env. Same fix for the --local Tauri overlay block (Test-Path / Copy-Item / Get-FileHash on $VenvDir-derived paths). * Replace studio_root_id path-hash with per-install opaque id The previous design computed studio_root_id as sha256 of the resolved $STUDIO_HOME path, both at install time (baked into the launcher) and at backend startup (returned via /api/health). This worked but had three weaknesses: 1. Information disclosure on -H 0.0.0.0: anyone reaching /api/health could confirm a guessed install path (username, workspace name, etc.) by replaying the same hash. 2. Canonicalization brittleness: launcher (cd -P/pwd -P) and backend (Path.resolve()) had to produce identical strings, which required careful symlink/junction handling on every site (cycles 17-27 of the PR review history were entirely about closing this drift). 3. Stale-launcher attach: an uninstall + reinstall at the same path produced the same hash, so a launcher from the previous install would silently attach to the new (incompatible) backend. Replace the path-hash with a per-install opaque id: - install.sh and install.ps1 generate 32 bytes from the platform CSPRNG (/dev/urandom on POSIX with a python3 secrets fallback; RandomNumberGenerator.Create().GetBytes on Windows) and persist it to $STUDIO_HOME/share/studio_install_id with mode 0600. Atomic temp-file-rename so a crash mid-install can't leave a half-written id. The check 'if [ ! -s "$_css_id_file" ]' / Test-Path makes generation idempotent across re-runs (so re-running install.sh doesn't invalidate previously-baked launchers in the same install root). - studio/backend/main.py replaces hashlib.sha256 with _read_studio_install_id(), which reads $STUDIO_HOME/share/studio_install_id once at module load. Validates the content against ^[0-9a-f]{64}$ so malformed/truncated/uppercase/wrong-length content returns "" and triggers the launcher's existing "no baked id, accept any healthy Unsloth backend" fallback path. - /api/health field name (studio_root_id) and wire format (64 hex chars) preserved for compatibility with launchers already shipped via earlier PR iterations. Tests: - Drop test_install_sh_root_id_matches_backend_resolved_under_symlinked_home and test_install_ps1_canonicalizes_studio_home_before_root_id_hash -- the entire reason these existed (cd -P/Resolve-Path/Path.resolve() digest agreement under symlinks/junctions) is moot when the id comes from a file rather than from the path. - Drop test_main_py_studio_root_id_hashes_resolved_root_not_unresolved (no more hashing). - Rewrite test_main_py_studio_root_id_caches_at_module_load to assert the file-read pattern; add test_main_py_read_studio_install_id_validates_hex_and_handles_missing to pin the exact rejection rules (empty / non-hex / wrong case / wrong length all -> ""). - Rewrite test_install_sh_create_shortcuts_uses_venv_python_first as test_install_sh_create_shortcuts_seeds_id_from_csprng_with_python_fallback with a behavioral subprocess check that re-invocation is idempotent. - Rename test_check_health_handles_path_with_backslash_via_hash to test_check_health_handles_arbitrary_id_token (the JSON-escape concern it pinned is preserved -- ids are hex-only by construction -- but the test no longer derives the id from a path). - Add test_install_sh_install_id_survives_symlinked_studio_home as a regression test pinning that the new design has zero canonicalization drift across symlinked parents. - Update test_install_sh_bakes_studio_root_id_into_launcher and test_install_ps1_bakes_studio_root_id_into_launcher to assert the CSPRNG seed and the file location. 49/49 tests pass. Behavioral verification: install.sh-style generation is idempotent across runs, three parallel installs at different roots get distinct ids, reinstall at the same path produces a new id (so stale launchers correctly fail to attach to the new backend), and symlinked-\$HOME no longer causes launcher/backend disagreement. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --------- Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> Co-authored-by: Daniel Han <unslothai@gmail.com>
2227 lines
90 KiB
Bash
Executable file
2227 lines
90 KiB
Bash
Executable file
#!/bin/sh
|
|
# Unsloth Studio Installer
|
|
# Usage (curl): curl -fsSL https://unsloth.ai/install.sh | sh
|
|
# Usage (wget): wget -qO- https://unsloth.ai/install.sh | sh
|
|
# Usage (local): ./install.sh --local (install from local repo instead of PyPI)
|
|
# Usage (no-torch): ./install.sh --no-torch (skip PyTorch, GGUF-only mode)
|
|
# Usage (test): ./install.sh --package roland-sloth (install a different package name)
|
|
# Usage (py): ./install.sh --python 3.12 (override auto-detected Python version)
|
|
#
|
|
# Env vars (priority: UNSLOTH_STUDIO_HOME > STUDIO_HOME > HOME-redirect > default):
|
|
# UNSLOTH_STUDIO_HOME=/abs/path -> install under that path
|
|
# STUDIO_HOME=/abs/path -> alias, same effect (UNSLOTH_STUDIO_HOME wins)
|
|
# (DATA_DIR + unsloth CLI shim nest inside; no shell rc-file append.)
|
|
# Default ($HOME/.unsloth/studio) is preserved when no env var is set.
|
|
set -e
|
|
|
|
# ── Output style (aligned with studio/setup.sh) ──
|
|
RULE=""
|
|
_rule_i=0
|
|
while [ "$_rule_i" -lt 52 ]; do
|
|
RULE="${RULE}─"
|
|
_rule_i=$((_rule_i + 1))
|
|
done
|
|
if [ -n "${NO_COLOR:-}" ]; then
|
|
C_TITLE= C_DIM= C_OK= C_WARN= C_ERR= C_RST=
|
|
elif [ -t 1 ] || [ -n "${FORCE_COLOR:-}" ]; then
|
|
_ESC="$(printf '\033')"
|
|
C_TITLE="${_ESC}[38;5;150m"
|
|
C_DIM="${_ESC}[38;5;245m"
|
|
C_OK="${_ESC}[38;5;108m"
|
|
C_WARN="${_ESC}[38;5;136m"
|
|
C_ERR="${_ESC}[91m"
|
|
C_RST="${_ESC}[0m"
|
|
else
|
|
C_TITLE= C_DIM= C_OK= C_WARN= C_ERR= C_RST=
|
|
fi
|
|
|
|
step() { printf " ${C_DIM}%-15.15s${C_RST}${3:-$C_OK}%s${C_RST}\n" "$1" "$2"; }
|
|
substep() { printf " ${C_DIM}%-15s${2:-$C_DIM}%s${C_RST}\n" "" "$1"; }
|
|
|
|
# ── Parse flags ──
|
|
STUDIO_LOCAL_INSTALL=false
|
|
PACKAGE_NAME="unsloth"
|
|
TAURI_MODE=false
|
|
_USER_PYTHON=""
|
|
_NO_TORCH_FLAG=false
|
|
_VERBOSE=false
|
|
_next_is_package=false
|
|
_next_is_python=false
|
|
for arg in "$@"; do
|
|
if [ "$_next_is_package" = true ]; then
|
|
PACKAGE_NAME="$arg"
|
|
_next_is_package=false
|
|
continue
|
|
fi
|
|
if [ "$_next_is_python" = true ]; then
|
|
_USER_PYTHON="$arg"
|
|
_next_is_python=false
|
|
continue
|
|
fi
|
|
case "$arg" in
|
|
--local) STUDIO_LOCAL_INSTALL=true ;;
|
|
--package) _next_is_package=true ;;
|
|
--tauri) TAURI_MODE=true ;;
|
|
--python) _next_is_python=true ;;
|
|
--no-torch) _NO_TORCH_FLAG=true ;;
|
|
--verbose|-v) _VERBOSE=true ;;
|
|
esac
|
|
done
|
|
|
|
if [ "$_VERBOSE" = true ]; then
|
|
export UNSLOTH_VERBOSE=1
|
|
fi
|
|
|
|
# Custom Studio roots are not supported with --tauri (desktop app still
|
|
# resolves ~/.unsloth/studio). Pass through if the override == legacy default.
|
|
if [ "$TAURI_MODE" = true ]; then
|
|
_tauri_override_var=""
|
|
_tauri_override="${UNSLOTH_STUDIO_HOME:-}"
|
|
if [ -n "$_tauri_override" ]; then
|
|
_tauri_override_var="UNSLOTH_STUDIO_HOME"
|
|
else
|
|
_tauri_override="${STUDIO_HOME:-}"
|
|
[ -n "$_tauri_override" ] && _tauri_override_var="STUDIO_HOME"
|
|
fi
|
|
# Strip whitespace so " " is treated as unset (matches Python .strip()).
|
|
_tauri_override=$(printf '%s' "$_tauri_override" | sed -e 's/^[[:space:]]*//' -e 's/[[:space:]]*$//')
|
|
if [ -n "$_tauri_override" ]; then
|
|
case "$_tauri_override" in
|
|
"~") _tauri_override="$HOME" ;;
|
|
"~/"*) _tauri_override="$HOME/${_tauri_override#'~/'}" ;;
|
|
esac
|
|
# Canonicalize both sides (CDPATH=, -P) so a CDPATH-set env or
|
|
# symlinked $HOME doesn't break the legacy-equality comparison.
|
|
if [ -d "$_tauri_override" ]; then
|
|
_tauri_override_abs=$(CDPATH= cd -P -- "$_tauri_override" 2>/dev/null && pwd -P) \
|
|
|| _tauri_override_abs="$_tauri_override"
|
|
else
|
|
_tauri_override_abs="$_tauri_override"
|
|
fi
|
|
# Strip trailing separators so ".../studio/" matches ".../studio".
|
|
while [ "$_tauri_override_abs" != "/" ] \
|
|
&& [ "${_tauri_override_abs%/}" != "$_tauri_override_abs" ]; do
|
|
_tauri_override_abs=${_tauri_override_abs%/}
|
|
done
|
|
_tauri_legacy_root="$HOME/.unsloth/studio"
|
|
if [ -d "$_tauri_legacy_root" ]; then
|
|
_tauri_legacy_root=$(CDPATH= cd -P -- "$_tauri_legacy_root" 2>/dev/null && pwd -P) \
|
|
|| _tauri_legacy_root="$HOME/.unsloth/studio"
|
|
fi
|
|
while [ "$_tauri_legacy_root" != "/" ] \
|
|
&& [ "${_tauri_legacy_root%/}" != "$_tauri_legacy_root" ]; do
|
|
_tauri_legacy_root=${_tauri_legacy_root%/}
|
|
done
|
|
if [ "$_tauri_override_abs" != "$_tauri_legacy_root" ]; then
|
|
echo "ERROR: $_tauri_override_var is not supported with --tauri." >&2
|
|
echo " The desktop app still uses the legacy ~/.unsloth/studio root." >&2
|
|
echo " Run install.sh without --tauri for custom-root shell installs," >&2
|
|
echo " or unset the env var for default desktop installs." >&2
|
|
exit 1
|
|
fi
|
|
fi
|
|
fi
|
|
|
|
_is_verbose() {
|
|
[ "${UNSLOTH_VERBOSE:-0}" = "1" ]
|
|
}
|
|
|
|
run_maybe_quiet() {
|
|
if _is_verbose; then
|
|
"$@"
|
|
else
|
|
"$@" > /dev/null 2>&1
|
|
fi
|
|
}
|
|
|
|
run_install_cmd() {
|
|
_label="$1"
|
|
shift
|
|
if _is_verbose; then
|
|
"$@" && return 0
|
|
_rc=$?
|
|
step "error" "$_label failed (exit code $_rc)" "$C_ERR" >&2
|
|
return "$_rc"
|
|
fi
|
|
_log=$(mktemp)
|
|
"$@" >"$_log" 2>&1 && { rm -f "$_log"; return 0; }
|
|
_rc=$?
|
|
step "error" "$_label failed (exit code $_rc)" "$C_ERR" >&2
|
|
cat "$_log" >&2
|
|
rm -f "$_log"
|
|
return $_rc
|
|
}
|
|
|
|
# Install bitsandbytes on AMD ROCm hosts. Uses the continuous-release_main
|
|
# wheel for the ROCm 4-bit GEMV fix (bnb PR #1887, post-0.49.2); bnb <= 0.49.2
|
|
# NaNs at decode shape on every AMD GPU. Falls back to PyPI >=0.49.1 if the
|
|
# pre-release URL is unreachable. Drop the pin once bnb 0.50+ ships on PyPI.
|
|
_install_bnb_rocm() {
|
|
_label="$1"
|
|
_venv_py="$2"
|
|
case "$_ARCH" in
|
|
x86_64|amd64)
|
|
_bnb_whl_url="https://github.com/bitsandbytes-foundation/bitsandbytes/releases/download/continuous-release_main/bitsandbytes-1.33.7.preview-py3-none-manylinux_2_24_x86_64.whl"
|
|
;;
|
|
aarch64|arm64)
|
|
_bnb_whl_url="https://github.com/bitsandbytes-foundation/bitsandbytes/releases/download/continuous-release_main/bitsandbytes-1.33.7.preview-py3-none-manylinux_2_24_aarch64.whl"
|
|
;;
|
|
*)
|
|
_bnb_whl_url=""
|
|
;;
|
|
esac
|
|
# uv rejects the continuous-release_main bitsandbytes wheel because the
|
|
# filename version (1.33.7rc0) does not match the embedded metadata version
|
|
# (0.50.0.dev0). pip accepts the mismatch, so bootstrap pip and use it.
|
|
if ! "$_venv_py" -m pip --version >/dev/null 2>&1; then
|
|
if ! run_maybe_quiet "$_venv_py" -m ensurepip --upgrade; then
|
|
run_maybe_quiet uv pip install --python "$_venv_py" pip || \
|
|
substep "[WARN] could not bootstrap pip; bitsandbytes install will likely fail" "$C_WARN"
|
|
fi
|
|
fi
|
|
if [ -n "$_bnb_whl_url" ]; then
|
|
substep "installing bitsandbytes for AMD ROCm (pre-release, PR #1887)..."
|
|
if run_install_cmd "$_label (pre-release)" "$_venv_py" -m pip install \
|
|
--force-reinstall --no-cache-dir --no-deps "$_bnb_whl_url"; then
|
|
return 0
|
|
fi
|
|
substep "[WARN] bnb pre-release install failed; falling back to PyPI (4-bit decode broken on ROCm)" "$C_WARN"
|
|
fi
|
|
run_install_cmd "$_label (pypi fallback)" "$_venv_py" -m pip install \
|
|
--force-reinstall --no-cache-dir --no-deps "bitsandbytes>=0.49.1"
|
|
}
|
|
|
|
if [ "$_next_is_package" = true ]; then
|
|
echo "❌ ERROR: --package requires an argument." >&2
|
|
exit 1
|
|
fi
|
|
if [ "$_next_is_python" = true ]; then
|
|
echo "❌ ERROR: --python requires a version argument (e.g. --python 3.12)." >&2
|
|
exit 1
|
|
fi
|
|
|
|
# Validate --package to prevent injection into shell/Python commands.
|
|
# Must start with a letter/digit (rejects leading dashes that uv would parse as flags).
|
|
case "$PACKAGE_NAME" in
|
|
[!a-zA-Z0-9]*)
|
|
echo "❌ ERROR: --package name must start with a letter or digit." >&2
|
|
exit 1 ;;
|
|
*[!a-zA-Z0-9._-]*)
|
|
echo "❌ ERROR: --package name contains invalid characters (allowed: a-z A-Z 0-9 . _ -)" >&2
|
|
exit 1 ;;
|
|
esac
|
|
|
|
# ── Tauri structured output ──
|
|
tauri_log() {
|
|
if [ "$TAURI_MODE" = true ]; then
|
|
echo "[TAURI:$1] $2"
|
|
fi
|
|
}
|
|
|
|
tauri_diag_marker() {
|
|
_diag_gpu_branch="${1:-unknown}"
|
|
_diag_torch_index_family="${2:-none}"
|
|
tauri_log "DIAG" "diag_schema=1 platform=${OS:-unknown} arch=${_ARCH:-unknown} python_version=${PYTHON_VERSION:-unknown} skip_torch=${SKIP_TORCH:-false} mac_intel=${MAC_INTEL:-false} gpu_branch=${_diag_gpu_branch} torch_index_family=${_diag_torch_index_family}"
|
|
}
|
|
|
|
_tauri_torch_index_family() {
|
|
if [ "${SKIP_TORCH:-false}" = true ]; then
|
|
echo "none"
|
|
return
|
|
fi
|
|
_diag_url="${1:-}"
|
|
case "$_diag_url" in
|
|
*/cu118) echo "cu118" ;;
|
|
*/cu124) echo "cu124" ;;
|
|
*/cu126) echo "cu126" ;;
|
|
*/cu128) echo "cu128" ;;
|
|
*/cu130) echo "cu130" ;;
|
|
*/cpu) echo "cpu" ;;
|
|
*/rocm[0-9]*.[0-9]*)
|
|
_diag_family=${_diag_url##*/}
|
|
case "$_diag_family" in
|
|
rocm[0-9]*.[0-9]*) echo "$_diag_family" ;;
|
|
*) echo "auto" ;;
|
|
esac ;;
|
|
"") echo "none" ;;
|
|
*) echo "auto" ;;
|
|
esac
|
|
}
|
|
|
|
_tauri_gpu_branch() {
|
|
_diag_family="${1:-unknown}"
|
|
_diag_radeon="${2:-false}"
|
|
if [ "${SKIP_TORCH:-false}" = true ]; then
|
|
echo "no_torch"
|
|
return
|
|
fi
|
|
if [ "${OS:-}" = "macos" ]; then
|
|
echo "mac"
|
|
return
|
|
fi
|
|
case "$_diag_family" in
|
|
cu*) echo "cuda" ;;
|
|
rocm*)
|
|
if [ "$_diag_radeon" = true ]; then
|
|
echo "rocm_radeon"
|
|
else
|
|
echo "rocm"
|
|
fi ;;
|
|
radeon) echo "rocm_radeon" ;;
|
|
cpu) echo "cpu" ;;
|
|
none) echo "no_torch" ;;
|
|
*) echo "unknown" ;;
|
|
esac
|
|
}
|
|
|
|
PYTHON_VERSION="" # resolved after platform detection
|
|
|
|
# Resolve install destinations: env override, HOME-redirect (best-effort
|
|
# via getent/dscl), or default. Env-var priority: UNSLOTH_STUDIO_HOME wins
|
|
# over STUDIO_HOME (the more specific signal beats the generic alias).
|
|
_resolve_studio_destinations() {
|
|
_override_var=""
|
|
_override="${UNSLOTH_STUDIO_HOME:-}"
|
|
if [ -n "$_override" ]; then
|
|
_override_var="UNSLOTH_STUDIO_HOME"
|
|
else
|
|
_override="${STUDIO_HOME:-}"
|
|
[ -n "$_override" ] && _override_var="STUDIO_HOME"
|
|
fi
|
|
# Strip surrounding whitespace so " " is treated as unset (matches the
|
|
# Python resolvers' .strip()), preventing install/runtime layout drift.
|
|
_override=$(printf '%s' "$_override" | sed -e 's/^[[:space:]]*//' -e 's/[[:space:]]*$//')
|
|
# Tilde expansion: env vars are not subject to it when quoted on assignment.
|
|
case "$_override" in
|
|
"~") _override="$HOME" ;;
|
|
"~/"*) _override="$HOME/${_override#'~/'}" ;;
|
|
esac
|
|
if [ -n "$_override" ]; then
|
|
mkdir -p -- "$_override" 2>/dev/null || { echo "ERROR: $_override_var=$_override cannot be created." >&2; exit 1; }
|
|
[ -w "$_override" ] || { echo "ERROR: $_override_var=$_override is not writable." >&2; exit 1; }
|
|
STUDIO_HOME="$(CDPATH= cd -P -- "$_override" && pwd -P)" || exit 1
|
|
DATA_DIR="$STUDIO_HOME/share"
|
|
_LOCAL_BIN="$STUDIO_HOME/bin"
|
|
_STUDIO_HOME_REDIRECT=env
|
|
substep "custom $_override_var=$STUDIO_HOME"
|
|
return 0
|
|
fi
|
|
_default_home=""
|
|
if command -v getent >/dev/null 2>&1; then
|
|
_default_home=$(getent passwd "${USER:-$(whoami)}" 2>/dev/null | cut -d: -f6)
|
|
elif [ "$(uname)" = "Darwin" ] && command -v dscl >/dev/null 2>&1; then
|
|
_default_home=$(dscl . -read "/Users/${USER:-$(whoami)}" NFSHomeDirectory 2>/dev/null | awk '{print $2}')
|
|
fi
|
|
# Canonicalize both sides so a trailing slash on $HOME (or symlink mismatch
|
|
# with passwd-DB output) doesn't misfire the redirection branch.
|
|
_home_canon="$HOME"
|
|
if [ -d "$_home_canon" ]; then
|
|
_home_canon=$(CDPATH= cd -P -- "$_home_canon" 2>/dev/null && pwd -P) || _home_canon="$HOME"
|
|
fi
|
|
_default_home_canon="$_default_home"
|
|
if [ -n "$_default_home_canon" ] && [ -d "$_default_home_canon" ]; then
|
|
_default_home_canon=$(CDPATH= cd -P -- "$_default_home_canon" 2>/dev/null && pwd -P) || _default_home_canon="$_default_home"
|
|
fi
|
|
if [ -n "$_default_home_canon" ] && [ "$_home_canon" != "$_default_home_canon" ]; then
|
|
STUDIO_HOME="$HOME/.unsloth/studio"
|
|
DATA_DIR="$HOME/.local/share/unsloth"
|
|
_LOCAL_BIN="$HOME/.local/bin"
|
|
_STUDIO_HOME_REDIRECT=home
|
|
substep "HOME redirected ($HOME); install follows \$HOME"
|
|
return 0
|
|
fi
|
|
STUDIO_HOME="$HOME/.unsloth/studio"
|
|
DATA_DIR="$HOME/.local/share/unsloth"
|
|
_LOCAL_BIN="$HOME/.local/bin"
|
|
_STUDIO_HOME_REDIRECT=default
|
|
}
|
|
_resolve_studio_destinations
|
|
VENV_DIR="$STUDIO_HOME/unsloth_studio"
|
|
_VENV_ROLLBACK_DIR=""
|
|
_VENV_ROLLBACK_TARGET="$VENV_DIR"
|
|
_VENV_ROLLBACK_ACTIVE=false
|
|
|
|
_start_studio_venv_replacement() {
|
|
_existing_dir="$1"
|
|
_stamp=$(date +%Y%m%d%H%M%S 2>/dev/null || echo "time")
|
|
_candidate="$STUDIO_HOME/unsloth_studio.rollback.$_stamp.$$"
|
|
_suffix=0
|
|
while [ -e "$_candidate" ]; do
|
|
_suffix=$((_suffix + 1))
|
|
_candidate="$STUDIO_HOME/unsloth_studio.rollback.$_stamp.$$.$_suffix"
|
|
done
|
|
mv "$_existing_dir" "$_candidate"
|
|
_VENV_ROLLBACK_DIR="$_candidate"
|
|
_VENV_ROLLBACK_TARGET="$_existing_dir"
|
|
_VENV_ROLLBACK_ACTIVE=true
|
|
substep "previous environment preserved for rollback"
|
|
}
|
|
|
|
_restore_studio_venv_replacement() {
|
|
[ "$_VENV_ROLLBACK_ACTIVE" = true ] || return 0
|
|
[ -n "$_VENV_ROLLBACK_DIR" ] && [ -d "$_VENV_ROLLBACK_DIR" ] || {
|
|
_VENV_ROLLBACK_ACTIVE=false
|
|
return 0
|
|
}
|
|
substep "restoring previous environment after failed install..." "$C_WARN"
|
|
rm -rf "$_VENV_ROLLBACK_TARGET"
|
|
if mv "$_VENV_ROLLBACK_DIR" "$_VENV_ROLLBACK_TARGET"; then
|
|
substep "restored previous environment"
|
|
_VENV_ROLLBACK_ACTIVE=false
|
|
_VENV_ROLLBACK_DIR=""
|
|
else
|
|
echo "⚠️ Could not restore previous environment from $_VENV_ROLLBACK_DIR to $_VENV_ROLLBACK_TARGET" >&2
|
|
fi
|
|
}
|
|
|
|
_commit_studio_venv_replacement() {
|
|
[ "$_VENV_ROLLBACK_ACTIVE" = true ] || return 0
|
|
if [ -n "$_VENV_ROLLBACK_DIR" ] && [ -d "$_VENV_ROLLBACK_DIR" ]; then
|
|
rm -rf "$_VENV_ROLLBACK_DIR" || true
|
|
fi
|
|
_VENV_ROLLBACK_ACTIVE=false
|
|
_VENV_ROLLBACK_DIR=""
|
|
}
|
|
|
|
_on_install_exit() {
|
|
_status=$?
|
|
if [ "$_status" -ne 0 ]; then
|
|
_restore_studio_venv_replacement
|
|
fi
|
|
exit "$_status"
|
|
}
|
|
trap _on_install_exit EXIT
|
|
|
|
# ── Helper: download a URL to a file (supports curl and wget) ──
|
|
download() {
|
|
if command -v curl >/dev/null 2>&1; then
|
|
curl -LsSf "$1" -o "$2"
|
|
elif command -v wget >/dev/null 2>&1; then
|
|
wget -qO "$2" "$1"
|
|
else
|
|
echo "Error: neither curl nor wget found. Install one and re-run."
|
|
exit 1
|
|
fi
|
|
}
|
|
|
|
# ── Helper: check if a single package is available on the system ──
|
|
_is_pkg_installed() {
|
|
case "$1" in
|
|
build-essential) command -v gcc >/dev/null 2>&1 ;;
|
|
libcurl4-openssl-dev)
|
|
command -v dpkg >/dev/null 2>&1 && dpkg -s "$1" >/dev/null 2>&1 ;;
|
|
pciutils)
|
|
command -v lspci >/dev/null 2>&1 ;;
|
|
*) command -v "$1" >/dev/null 2>&1 ;;
|
|
esac
|
|
}
|
|
|
|
# ── Helper: install packages via apt, escalating to sudo only if needed ──
|
|
# Usage: _smart_apt_install pkg1 pkg2 pkg3 ...
|
|
_smart_apt_install() {
|
|
_PKGS="$*"
|
|
|
|
# Step 1: Try installing without sudo (works when already root)
|
|
apt-get update -y </dev/null >/dev/null 2>&1 || true
|
|
apt-get install -y $_PKGS </dev/null >/dev/null 2>&1 || true
|
|
|
|
# Step 2: Check which packages are still missing
|
|
_STILL_MISSING=""
|
|
for _pkg in $_PKGS; do
|
|
if ! _is_pkg_installed "$_pkg"; then
|
|
_STILL_MISSING="$_STILL_MISSING $_pkg"
|
|
fi
|
|
done
|
|
_STILL_MISSING=$(echo "$_STILL_MISSING" | sed 's/^ *//')
|
|
|
|
if [ -z "$_STILL_MISSING" ]; then
|
|
return 0
|
|
fi
|
|
|
|
# In Tauri mode, report needed packages and exit — Rust handles elevation
|
|
if [ "$TAURI_MODE" = true ]; then
|
|
tauri_log "NEED_SUDO" "$_STILL_MISSING"
|
|
exit 2
|
|
fi
|
|
|
|
# Step 3: Escalate -- need elevated permissions for remaining packages
|
|
if command -v sudo >/dev/null 2>&1; then
|
|
echo ""
|
|
echo " !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!"
|
|
echo " WARNING: We require sudo elevated permissions to install:"
|
|
echo " $_STILL_MISSING"
|
|
echo " If you accept, we'll run sudo now, and it'll prompt your password."
|
|
echo " !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!"
|
|
echo ""
|
|
printf " Accept? [Y/n] "
|
|
if [ -r /dev/tty ]; then
|
|
read -r REPLY </dev/tty || REPLY="y"
|
|
else
|
|
REPLY="y"
|
|
fi
|
|
case "$REPLY" in
|
|
[nN]*)
|
|
echo ""
|
|
echo " Please install these packages first, then re-run Unsloth Studio setup:"
|
|
echo " sudo apt-get update -y && sudo apt-get install -y $_STILL_MISSING"
|
|
exit 1
|
|
;;
|
|
*)
|
|
sudo apt-get update -y </dev/null
|
|
sudo apt-get install -y $_STILL_MISSING </dev/null
|
|
;;
|
|
esac
|
|
else
|
|
echo ""
|
|
echo " sudo is not available on this system."
|
|
echo " Please install these packages as root, then re-run Unsloth Studio setup:"
|
|
echo " apt-get update -y && apt-get install -y $_STILL_MISSING"
|
|
exit 1
|
|
fi
|
|
}
|
|
|
|
# ── Helper: create desktop shortcuts and launcher script ──
|
|
# Usage: create_studio_shortcuts <unsloth_exe> <os>
|
|
# Creates ~/.local/share/unsloth/launch-studio.sh (shared launcher),
|
|
# plus platform-specific shortcuts (Linux .desktop / macOS .app bundle /
|
|
# WSL Windows Desktop+Start Menu .lnk).
|
|
create_studio_shortcuts() {
|
|
_css_exe="$1"
|
|
_css_os="$2"
|
|
|
|
# Validate exe
|
|
if [ ! -x "$_css_exe" ]; then
|
|
echo "[WARN] Cannot create shortcuts: unsloth not found at $_css_exe"
|
|
return 0
|
|
fi
|
|
|
|
# Resolve absolute path
|
|
_css_exe_dir=$(cd "$(dirname "$_css_exe")" && pwd)
|
|
_css_exe="$_css_exe_dir/$(basename "$_css_exe")"
|
|
|
|
_css_data_dir="$DATA_DIR"
|
|
_css_launcher="$_css_data_dir/launch-studio.sh"
|
|
_css_icon_png="$_css_data_dir/unsloth-studio.png"
|
|
_css_gem_png="$_css_data_dir/unsloth-gem.png"
|
|
|
|
mkdir -p "$_css_data_dir"
|
|
|
|
# Same-install discriminator: per-install opaque id written once at install
|
|
# time and read by both this launcher and the backend (/api/health). Replaces
|
|
# the older sha256(canonical $STUDIO_HOME) scheme to (a) avoid leaking the
|
|
# install path on -H 0.0.0.0 deployments and (b) sidestep launcher/backend
|
|
# canonicalization drift (cd -P vs Path.resolve() symlink/junction handling).
|
|
# Lives at $STUDIO_HOME/share/ (not $DATA_DIR) so the backend can find it
|
|
# via _STUDIO_ROOT_RESOLVED / "share" / "studio_install_id" regardless of
|
|
# mode (in env-mode $STUDIO_HOME/share == $DATA_DIR; in default mode they
|
|
# diverge but the backend only knows the studio_root). 32 bytes of urandom
|
|
# -> 64 hex chars, byte-compatible with the prior digest so launcher
|
|
# placeholder, _check_health, and tests stay length-agnostic.
|
|
_css_id_dir="$STUDIO_HOME/share"
|
|
mkdir -p "$_css_id_dir"
|
|
_css_id_file="$_css_id_dir/studio_install_id"
|
|
if [ ! -s "$_css_id_file" ]; then
|
|
if [ -r /dev/urandom ]; then
|
|
_css_new_id=$(od -An -N32 -tx1 /dev/urandom 2>/dev/null | tr -d ' \n')
|
|
fi
|
|
if [ -z "${_css_new_id:-}" ] && command -v python3 >/dev/null 2>&1; then
|
|
_css_new_id=$(python3 -c 'import secrets; print(secrets.token_hex(32))' 2>/dev/null)
|
|
fi
|
|
if [ -z "${_css_new_id:-}" ]; then
|
|
echo "[WARN] Cannot create launcher: no entropy source for studio_install_id" >&2
|
|
return 1
|
|
fi
|
|
# Atomic write so a partial install can't leave a half-written id.
|
|
_css_id_tmp="$_css_id_file.$$.tmp"
|
|
printf '%s' "$_css_new_id" > "$_css_id_tmp" \
|
|
&& mv "$_css_id_tmp" "$_css_id_file"
|
|
chmod 600 "$_css_id_file" 2>/dev/null || true
|
|
unset _css_new_id _css_id_tmp
|
|
fi
|
|
_css_studio_root_id=$(cat "$_css_id_file" 2>/dev/null)
|
|
if [ -z "$_css_studio_root_id" ]; then
|
|
echo "[WARN] Cannot create launcher: failed to read $_css_id_file" >&2
|
|
return 1
|
|
fi
|
|
_css_is_env_mode=false
|
|
[ "$_STUDIO_HOME_REDIRECT" = "env" ] && _css_is_env_mode=true
|
|
|
|
# ── Write launcher script ──
|
|
# Single-quoted heredoc; @@DATA_DIR@@, @@STUDIO_ROOT_ID@@, and
|
|
# @@INSTALLED_IS_ENV_MODE@@ are substituted via sed below.
|
|
cat > "$_css_launcher" << 'LAUNCHER_EOF'
|
|
#!/usr/bin/env bash
|
|
# Unsloth Studio Launcher
|
|
# Auto-generated by install.sh -- do not edit manually.
|
|
set -euo pipefail
|
|
|
|
DATA_DIR='@@DATA_DIR@@'
|
|
_EXPECTED_STUDIO_ROOT_ID='@@STUDIO_ROOT_ID@@'
|
|
_INSTALLED_IS_ENV_MODE='@@INSTALLED_IS_ENV_MODE@@'
|
|
|
|
# Read exe path from config written at install time.
|
|
# Sourcing is safe: the config file is written by install.sh, not user input.
|
|
if [ -f "$DATA_DIR/studio.conf" ]; then
|
|
. "$DATA_DIR/studio.conf"
|
|
fi
|
|
if [ -z "${UNSLOTH_EXE:-}" ] || [ ! -x "${UNSLOTH_EXE:-}" ]; then
|
|
echo "Error: UNSLOTH_EXE not set or not executable. Re-run the installer." >&2
|
|
exit 1
|
|
fi
|
|
|
|
BASE_PORT=8888
|
|
MAX_PORT_OFFSET=20
|
|
TIMEOUT_SEC=60
|
|
POLL_INTERVAL_SEC=1
|
|
LOG_FILE="$DATA_DIR/studio.log"
|
|
# why: in env-override mode multiple installs share an OS user; namespace the
|
|
# lock and remember our own healthy port so we never attach to an unrelated
|
|
# Studio listening on the global 8888..8908 range.
|
|
LOCK_DIR="${XDG_RUNTIME_DIR:-/tmp}/unsloth-studio-launcher-$(id -u).lock"
|
|
PORT_FILE=""
|
|
# why: gate on the install-time mode (baked above) instead of the runtime env
|
|
# var; sourcing a custom-root studio.conf in shell must not flip a default-mode
|
|
# launcher into env-mode behavior with stale state.
|
|
if [ "$_INSTALLED_IS_ENV_MODE" = "true" ]; then
|
|
if command -v cksum >/dev/null 2>&1; then
|
|
_LOCK_KEY=$(printf '%s' "$DATA_DIR" | cksum | awk '{print $1}')
|
|
else
|
|
_LOCK_KEY=""
|
|
fi
|
|
[ -n "$_LOCK_KEY" ] && LOCK_DIR="${XDG_RUNTIME_DIR:-/tmp}/unsloth-studio-launcher-$(id -u)-${_LOCK_KEY}.lock"
|
|
PORT_FILE="$DATA_DIR/studio.port"
|
|
fi
|
|
|
|
# ── HTTP GET helper (supports curl and wget) ──
|
|
_http_get() {
|
|
_url="$1"
|
|
if command -v curl >/dev/null 2>&1; then
|
|
curl -fsS --max-time 1 "$_url" 2>/dev/null
|
|
elif command -v wget >/dev/null 2>&1; then
|
|
wget -qO- --timeout=1 "$_url" 2>/dev/null
|
|
else
|
|
return 1
|
|
fi
|
|
}
|
|
|
|
# ── Health check ──
|
|
_check_health() {
|
|
_port=$1
|
|
_resp=$(_http_get "http://127.0.0.1:$_port/api/health") || return 1
|
|
case "$_resp" in
|
|
*'"status"'*'"healthy"'*'"service"'*'"Unsloth UI Backend"'*) ;;
|
|
*'"service"'*'"Unsloth UI Backend"'*'"status"'*'"healthy"'*) ;;
|
|
*) return 1 ;;
|
|
esac
|
|
# why: verify the backend belongs to THIS install. Baked hex digest avoids
|
|
# JSON-escape mismatches on paths with `\`/`"` and avoids leaking the raw
|
|
# install path to unauthenticated callers.
|
|
if [ -n "$_EXPECTED_STUDIO_ROOT_ID" ]; then
|
|
case "$_resp" in
|
|
*"\"studio_root_id\":\"$_EXPECTED_STUDIO_ROOT_ID\""*|*"\"studio_root_id\": \"$_EXPECTED_STUDIO_ROOT_ID\""*) return 0 ;;
|
|
*) return 1 ;;
|
|
esac
|
|
fi
|
|
return 0
|
|
}
|
|
|
|
# ── Port scanning ──
|
|
_candidate_ports() {
|
|
echo "$BASE_PORT"
|
|
_max_port=$((BASE_PORT + MAX_PORT_OFFSET))
|
|
if command -v ss >/dev/null 2>&1; then
|
|
ss -tlnH 2>/dev/null | awk '{print $4}' | grep -oE '[0-9]+$' | \
|
|
awk -v lo="$BASE_PORT" -v hi="$_max_port" '$1 >= lo && $1 <= hi && $1 != lo {print}' || true
|
|
elif command -v lsof >/dev/null 2>&1; then
|
|
lsof -iTCP -sTCP:LISTEN -nP 2>/dev/null | awk '{print $9}' | grep -oE '[0-9]+$' | \
|
|
awk -v lo="$BASE_PORT" -v hi="$_max_port" '$1 >= lo && $1 <= hi && $1 != lo {print}' || true
|
|
else
|
|
_offset=1
|
|
while [ "$_offset" -le "$MAX_PORT_OFFSET" ]; do
|
|
echo $((BASE_PORT + _offset))
|
|
_offset=$((_offset + 1))
|
|
done
|
|
fi
|
|
}
|
|
|
|
_find_healthy_port() {
|
|
if [ -n "$PORT_FILE" ] && [ -f "$PORT_FILE" ]; then
|
|
# why: env-mode installs only attach to a port we previously launched
|
|
# ourselves; never to a sibling Studio that happens to be healthy.
|
|
_p=$(cat "$PORT_FILE" 2>/dev/null || true)
|
|
case "$_p" in
|
|
''|*[!0-9]*) ;;
|
|
*)
|
|
if _check_health "$_p"; then
|
|
echo "$_p"
|
|
return 0
|
|
fi
|
|
rm -f "$PORT_FILE"
|
|
;;
|
|
esac
|
|
return 1
|
|
fi
|
|
if [ -n "$PORT_FILE" ]; then
|
|
return 1
|
|
fi
|
|
for _p in $(_candidate_ports | sort -un); do
|
|
if _check_health "$_p"; then
|
|
echo "$_p"
|
|
return 0
|
|
fi
|
|
done
|
|
return 1
|
|
}
|
|
|
|
# ── Check if a port is busy ──
|
|
_is_port_busy() {
|
|
_port=$1
|
|
if command -v ss >/dev/null 2>&1; then
|
|
ss -tlnH 2>/dev/null | awk '{print $4}' | grep -qE "[.:]$_port$"
|
|
elif command -v lsof >/dev/null 2>&1; then
|
|
lsof -iTCP:"$_port" -sTCP:LISTEN -nP >/dev/null 2>&1
|
|
else
|
|
return 1
|
|
fi
|
|
}
|
|
|
|
# ── Find a free port in range ──
|
|
_find_launch_port() {
|
|
_offset=0
|
|
while [ "$_offset" -le "$MAX_PORT_OFFSET" ]; do
|
|
_candidate=$((BASE_PORT + _offset))
|
|
if ! _is_port_busy "$_candidate"; then
|
|
echo "$_candidate"
|
|
return 0
|
|
fi
|
|
_offset=$((_offset + 1))
|
|
done
|
|
return 1
|
|
}
|
|
|
|
# ── Open browser ──
|
|
_open_browser() {
|
|
_url="$1"
|
|
if [ "$(uname)" = "Darwin" ] && command -v open >/dev/null 2>&1; then
|
|
open "$_url"
|
|
elif grep -qi microsoft /proc/version 2>/dev/null; then
|
|
# WSL: xdg-open is unreliable; use Windows browser via PowerShell or cmd
|
|
if command -v powershell.exe >/dev/null 2>&1; then
|
|
powershell.exe -NoProfile -Command "Start-Process '$_url'" >/dev/null 2>&1 &
|
|
elif command -v cmd.exe >/dev/null 2>&1; then
|
|
cmd.exe /c start "" "$_url" >/dev/null 2>&1 &
|
|
elif command -v xdg-open >/dev/null 2>&1; then
|
|
xdg-open "$_url" >/dev/null 2>&1 &
|
|
else
|
|
echo "Open in your browser: $_url" >&2
|
|
fi
|
|
elif command -v xdg-open >/dev/null 2>&1; then
|
|
xdg-open "$_url" >/dev/null 2>&1 &
|
|
else
|
|
echo "Open in your browser: $_url" >&2
|
|
fi
|
|
}
|
|
|
|
# ── Spawn terminal with studio command ──
|
|
_spawn_terminal() {
|
|
_cmd="$1"
|
|
_os=$(uname)
|
|
if [ "$_os" = "Darwin" ]; then
|
|
# Escape backslashes and double-quotes for AppleScript string
|
|
_cmd_escaped=$(printf '%s' "$_cmd" | sed 's/\\/\\\\/g; s/"/\\"/g')
|
|
osascript -e "tell application \"Terminal\" to do script \"$_cmd_escaped\"" >/dev/null 2>&1 && return 0
|
|
else
|
|
for _term in gnome-terminal konsole xfce4-terminal mate-terminal lxterminal xterm; do
|
|
if command -v "$_term" >/dev/null 2>&1; then
|
|
case "$_term" in
|
|
gnome-terminal) "$_term" -- sh -c "$_cmd" & return 0 ;;
|
|
konsole) "$_term" -e sh -c "$_cmd" & return 0 ;;
|
|
xterm) "$_term" -e sh -c "$_cmd" & return 0 ;;
|
|
*) "$_term" -e sh -c "$_cmd" & return 0 ;;
|
|
esac
|
|
fi
|
|
done
|
|
fi
|
|
# Fallback: background with log
|
|
echo "No terminal emulator found; running in background. Logs: $LOG_FILE" >&2
|
|
nohup sh -c "$_cmd" >> "$LOG_FILE" 2>&1 &
|
|
return 0
|
|
}
|
|
|
|
# ── Atomic directory-based single-instance guard ──
|
|
_acquire_lock() {
|
|
if mkdir "$LOCK_DIR" 2>/dev/null; then
|
|
echo "$$" > "$LOCK_DIR/pid"
|
|
return 0
|
|
fi
|
|
|
|
# Lock dir exists -- check if owner is still alive
|
|
_old_pid=$(cat "$LOCK_DIR/pid" 2>/dev/null || true)
|
|
if [ -n "$_old_pid" ] && kill -0 "$_old_pid" 2>/dev/null; then
|
|
# Another launcher is running; wait for it to bring Studio up
|
|
_deadline=$(($(date +%s) + TIMEOUT_SEC))
|
|
while [ "$(date +%s)" -lt "$_deadline" ]; do
|
|
_port=$(_find_healthy_port) && {
|
|
_open_browser "http://localhost:$_port"
|
|
exit 0
|
|
}
|
|
sleep "$POLL_INTERVAL_SEC"
|
|
done
|
|
echo "Timed out waiting for other launcher (PID $_old_pid)" >&2
|
|
exit 0
|
|
fi
|
|
|
|
# Stale lock -- reclaim
|
|
rm -rf "$LOCK_DIR"
|
|
mkdir "$LOCK_DIR" 2>/dev/null || return 1
|
|
echo "$$" > "$LOCK_DIR/pid"
|
|
}
|
|
|
|
_release_lock() {
|
|
[ -d "$LOCK_DIR" ] || return 0
|
|
[ "$(cat "$LOCK_DIR/pid" 2>/dev/null)" = "$$" ] || return 0
|
|
rm -rf "$LOCK_DIR"
|
|
}
|
|
|
|
# ── Main ──
|
|
# Fast path: already healthy
|
|
_port=$(_find_healthy_port) && {
|
|
_open_browser "http://localhost:$_port"
|
|
exit 0
|
|
}
|
|
|
|
_acquire_lock
|
|
trap '_release_lock' EXIT INT TERM
|
|
|
|
# Post-lock re-check (handles race with another launcher)
|
|
_port=$(_find_healthy_port) && {
|
|
_open_browser "http://localhost:$_port"
|
|
exit 0
|
|
}
|
|
|
|
# Find a free port in range
|
|
_launch_port=$(_find_launch_port) || {
|
|
echo "No free port found in range ${BASE_PORT}-$((BASE_PORT + MAX_PORT_OFFSET))" >&2
|
|
exit 1
|
|
}
|
|
|
|
if [ -t 1 ]; then
|
|
# ── Foreground mode (TTY available) ──
|
|
# Background subshell: wait for studio to become healthy, release the
|
|
# single-instance lock, then open the browser. The lock stays held until
|
|
# health is confirmed so a second launcher cannot race during startup.
|
|
(
|
|
_obwr_deadline=$(($(date +%s) + TIMEOUT_SEC))
|
|
while [ "$(date +%s)" -lt "$_obwr_deadline" ]; do
|
|
if _check_health "$_launch_port"; then
|
|
[ -n "$PORT_FILE" ] && printf '%s\n' "$_launch_port" > "$PORT_FILE" 2>/dev/null || true
|
|
_release_lock
|
|
_open_browser "http://localhost:$_launch_port"
|
|
exit 0
|
|
fi
|
|
sleep "$POLL_INTERVAL_SEC"
|
|
done
|
|
# Timed out -- release the lock anyway so future launches are not blocked
|
|
_release_lock
|
|
) &
|
|
# Clear traps so exec does not trigger _release_lock (the subshell owns it)
|
|
trap - EXIT INT TERM
|
|
exec "$UNSLOTH_EXE" studio -p "$_launch_port"
|
|
else
|
|
# ── Background mode (no TTY) ──
|
|
# Used by macOS .app and headless invocations.
|
|
_launch_cmd=$(printf '%q ' "$UNSLOTH_EXE" studio -p "$_launch_port")
|
|
_launch_cmd=${_launch_cmd% }
|
|
_spawn_terminal "$_launch_cmd"
|
|
|
|
# Poll for health on the specific port we launched on
|
|
_deadline=$(($(date +%s) + TIMEOUT_SEC))
|
|
while [ "$(date +%s)" -lt "$_deadline" ]; do
|
|
if _check_health "$_launch_port"; then
|
|
[ -n "$PORT_FILE" ] && printf '%s\n' "$_launch_port" > "$PORT_FILE" 2>/dev/null || true
|
|
_open_browser "http://localhost:$_launch_port"
|
|
exit 0
|
|
fi
|
|
sleep "$POLL_INTERVAL_SEC"
|
|
done
|
|
|
|
echo "Unsloth Studio did not become healthy within ${TIMEOUT_SEC}s." >&2
|
|
echo "Check logs at: $LOG_FILE" >&2
|
|
exit 1
|
|
fi
|
|
LAUNCHER_EOF
|
|
|
|
# why: bake non-user-controlled placeholders FIRST so a literal
|
|
# `@@STUDIO_ROOT_ID@@` inside $DATA_DIR cannot be rewritten below.
|
|
sed -e "s|@@STUDIO_ROOT_ID@@|$_css_studio_root_id|g" \
|
|
-e "s|@@INSTALLED_IS_ENV_MODE@@|$_css_is_env_mode|g" \
|
|
"$_css_launcher" > "$_css_launcher.tmp" \
|
|
&& mv "$_css_launcher.tmp" "$_css_launcher"
|
|
|
|
# Env-mode bakes an absolute DATA_DIR (root fixed at install time);
|
|
# default / HOME-redirect keeps the literal $HOME/.local/share/unsloth
|
|
# so behavior is byte-identical to pre-override.
|
|
if [ "$_STUDIO_HOME_REDIRECT" = "env" ]; then
|
|
# Two-stage escape: (1) `'` -> `'\''` for shell single-quote embedding,
|
|
# (2) backslash/&/| escape so the value survives the s|...|VALUE| sed
|
|
# below. Verified end-to-end with apostrophes, spaces, &, |, $.
|
|
_sq_escaped=$(printf '%s' "$DATA_DIR" | sed "s/'/'\\\\''/g")
|
|
_sed_safe=$(printf '%s' "$_sq_escaped" | sed 's/[\\&|]/\\&/g')
|
|
sed "s|@@DATA_DIR@@|$_sed_safe|g" "$_css_launcher" > "$_css_launcher.tmp" \
|
|
&& mv "$_css_launcher.tmp" "$_css_launcher"
|
|
else
|
|
sed "s|DATA_DIR='@@DATA_DIR@@'|DATA_DIR=\"\$HOME/.local/share/unsloth\"|" \
|
|
"$_css_launcher" > "$_css_launcher.tmp" \
|
|
&& mv "$_css_launcher.tmp" "$_css_launcher"
|
|
fi
|
|
|
|
chmod +x "$_css_launcher"
|
|
|
|
# studio.conf: exe path + (env-mode only) persisted env vars so fresh
|
|
# shells launch the right install without re-exporting.
|
|
_css_quoted_exe=$(printf '%s' "$_css_exe" | sed "s/'/'\\\\''/g")
|
|
{
|
|
printf '%s\n' "UNSLOTH_EXE='$_css_quoted_exe'"
|
|
if [ "$_STUDIO_HOME_REDIRECT" = "env" ]; then
|
|
# When an override resolves to the legacy default, llama.cpp
|
|
# still lives at ~/.unsloth/llama.cpp (one shared build).
|
|
# Canonicalize the legacy side so a symlinked $HOME doesn't
|
|
# break the comparison.
|
|
_css_legacy_studio="$HOME/.unsloth/studio"
|
|
if [ -d "$_css_legacy_studio" ]; then
|
|
_css_legacy_studio=$(CDPATH= cd -P -- "$_css_legacy_studio" 2>/dev/null && pwd -P) \
|
|
|| _css_legacy_studio="$HOME/.unsloth/studio"
|
|
fi
|
|
if [ "$STUDIO_HOME" = "$_css_legacy_studio" ]; then
|
|
_css_llama_path="$HOME/.unsloth/llama.cpp"
|
|
else
|
|
_css_llama_path="$STUDIO_HOME/llama.cpp"
|
|
fi
|
|
_css_quoted_home=$(printf '%s' "$STUDIO_HOME" | sed "s/'/'\\\\''/g")
|
|
_css_quoted_llama=$(printf '%s' "$_css_llama_path" | sed "s/'/'\\\\''/g")
|
|
printf '%s\n' "export UNSLOTH_STUDIO_HOME='$_css_quoted_home'"
|
|
# UNSLOTH_LLAMA_CPP_PATH is a pre-existing user-controlled
|
|
# llama.cpp dir override; only default it if unset.
|
|
printf '%s\n' 'if [ -z "${UNSLOTH_LLAMA_CPP_PATH:-}" ]; then'
|
|
printf '%s\n' " export UNSLOTH_LLAMA_CPP_PATH='$_css_quoted_llama'"
|
|
printf '%s\n' 'fi'
|
|
fi
|
|
} > "$_css_data_dir/studio.conf"
|
|
|
|
# ── Icon: try bundled, then download ──
|
|
# rounded-512.png used for both Linux and macOS icons
|
|
_css_script_dir=""
|
|
if [ -n "${0:-}" ] && [ -f "$0" ]; then
|
|
_css_script_dir=$(cd "$(dirname "$0")" 2>/dev/null && pwd) || true
|
|
fi
|
|
|
|
# Try to find rounded-512.png from installed package (site-packages) or local repo
|
|
_css_found_icon=""
|
|
_css_venv_dir=$(dirname "$(dirname "$_css_exe")")
|
|
# Check site-packages
|
|
for _sp in "$_css_venv_dir"/lib/python*/site-packages/unsloth/studio/frontend/public; do
|
|
if [ -f "$_sp/rounded-512.png" ]; then
|
|
_css_found_icon="$_sp/rounded-512.png"
|
|
fi
|
|
done
|
|
# Check local repo (when running from clone)
|
|
if [ -z "$_css_found_icon" ] && [ -n "$_css_script_dir" ] && [ -f "$_css_script_dir/studio/frontend/public/rounded-512.png" ]; then
|
|
_css_found_icon="$_css_script_dir/studio/frontend/public/rounded-512.png"
|
|
fi
|
|
|
|
# Copy or download rounded-512.png (used for both Linux icon and macOS icns)
|
|
if [ -n "$_css_found_icon" ]; then
|
|
cp "$_css_found_icon" "$_css_icon_png" 2>/dev/null || true
|
|
cp "$_css_found_icon" "$_css_gem_png" 2>/dev/null || true
|
|
else
|
|
download "https://raw.githubusercontent.com/unslothai/unsloth/main/studio/frontend/public/rounded-512.png" "$_css_icon_png" 2>/dev/null || true
|
|
cp "$_css_icon_png" "$_css_gem_png" 2>/dev/null || true
|
|
fi
|
|
|
|
# Validate PNG header (first 4 bytes: \x89PNG)
|
|
_css_validate_png() {
|
|
[ -f "$1" ] || return 1
|
|
_hdr=$(od -An -tx1 -N4 "$1" 2>/dev/null | tr -d ' ')
|
|
[ "$_hdr" = "89504e47" ]
|
|
}
|
|
if [ -f "$_css_icon_png" ] && ! _css_validate_png "$_css_icon_png"; then
|
|
rm -f "$_css_icon_png"
|
|
fi
|
|
if [ -f "$_css_gem_png" ] && ! _css_validate_png "$_css_gem_png"; then
|
|
rm -f "$_css_gem_png"
|
|
fi
|
|
|
|
# ── Platform-specific shortcuts ──
|
|
# Env-mode installs are workspace-scoped: skip persistent desktop /
|
|
# Start-Menu / dock launchers that may point at a deleted workspace.
|
|
# Runtime launcher + studio.conf + icon are still written above.
|
|
if [ "$_STUDIO_HOME_REDIRECT" = "env" ]; then
|
|
substep "wrote launcher at $_css_launcher (persistent shortcuts skipped in env-override mode)"
|
|
return 0
|
|
fi
|
|
|
|
_css_created=0
|
|
|
|
if [ "$_css_os" = "linux" ]; then
|
|
# ── Linux: .desktop file ──
|
|
_css_app_dir="$HOME/.local/share/applications"
|
|
mkdir -p "$_css_app_dir"
|
|
|
|
_css_desktop="$_css_app_dir/unsloth-studio.desktop"
|
|
# Escape backslashes and double-quotes for .desktop Exec= field
|
|
_css_exec_escaped=$(printf '%s' "$_css_launcher" | sed 's/\\/\\\\/g; s/"/\\"/g')
|
|
_css_icon_escaped=$(printf '%s' "$_css_icon_png" | sed 's/\\/\\\\/g; s/"/\\"/g')
|
|
cat > "$_css_desktop" << DESKTOP_EOF
|
|
[Desktop Entry]
|
|
Version=1.0
|
|
Type=Application
|
|
Name=Unsloth Studio
|
|
Comment=Launch Unsloth Studio
|
|
Exec="$_css_exec_escaped"
|
|
Icon=$_css_icon_escaped
|
|
Terminal=true
|
|
StartupNotify=true
|
|
Categories=Development;Science;
|
|
DESKTOP_EOF
|
|
chmod +x "$_css_desktop"
|
|
|
|
# Copy to ~/Desktop if it exists
|
|
if [ -d "$HOME/Desktop" ]; then
|
|
cp "$_css_desktop" "$HOME/Desktop/unsloth-studio.desktop" 2>/dev/null || true
|
|
chmod +x "$HOME/Desktop/unsloth-studio.desktop" 2>/dev/null || true
|
|
# Mark as trusted so GNOME/Nautilus allows launching via double-click
|
|
if command -v gio >/dev/null 2>&1; then
|
|
gio set "$HOME/Desktop/unsloth-studio.desktop" metadata::trusted true 2>/dev/null || true
|
|
fi
|
|
fi
|
|
|
|
# Best-effort update database
|
|
update-desktop-database "$_css_app_dir" 2>/dev/null || true
|
|
_css_created=1
|
|
|
|
elif [ "$_css_os" = "macos" ]; then
|
|
# ── macOS: .app bundle ──
|
|
_css_app="$HOME/Applications/Unsloth Studio.app"
|
|
_css_contents="$_css_app/Contents"
|
|
_css_macos_dir="$_css_contents/MacOS"
|
|
_css_res_dir="$_css_contents/Resources"
|
|
mkdir -p "$_css_macos_dir" "$_css_res_dir"
|
|
|
|
# Info.plist
|
|
cat > "$_css_contents/Info.plist" << 'PLIST_EOF'
|
|
<?xml version="1.0" encoding="UTF-8"?>
|
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
<plist version="1.0">
|
|
<dict>
|
|
<key>CFBundleIdentifier</key>
|
|
<string>ai.unsloth.studio</string>
|
|
<key>CFBundleName</key>
|
|
<string>Unsloth Studio</string>
|
|
<key>CFBundleDisplayName</key>
|
|
<string>Unsloth Studio</string>
|
|
<key>CFBundleExecutable</key>
|
|
<string>launch-studio</string>
|
|
<key>CFBundleIconFile</key>
|
|
<string>AppIcon</string>
|
|
<key>CFBundlePackageType</key>
|
|
<string>APPL</string>
|
|
<key>CFBundleVersion</key>
|
|
<string>1.0</string>
|
|
<key>CFBundleShortVersionString</key>
|
|
<string>1.0</string>
|
|
<key>LSMinimumSystemVersion</key>
|
|
<string>10.15</string>
|
|
<key>NSHighResolutionCapable</key>
|
|
<true/>
|
|
</dict>
|
|
</plist>
|
|
PLIST_EOF
|
|
|
|
# Executable stub: same single-quoted-heredoc + sed-substitute
|
|
# pattern as launch-studio.sh so $-vars in $_css_data_dir don't
|
|
# expand at .app launch time.
|
|
_css_sq_dir=$(printf '%s' "$_css_data_dir" | sed "s/'/'\\\\''/g")
|
|
_css_sed_dir=$(printf '%s' "$_css_sq_dir" | sed 's/[\\&|]/\\&/g')
|
|
cat > "$_css_macos_dir/launch-studio" << 'STUB_EOF'
|
|
#!/bin/sh
|
|
exec '@@DATA_DIR@@/launch-studio.sh' "$@"
|
|
STUB_EOF
|
|
sed "s|@@DATA_DIR@@|$_css_sed_dir|g" "$_css_macos_dir/launch-studio" \
|
|
> "$_css_macos_dir/launch-studio.tmp" \
|
|
&& mv "$_css_macos_dir/launch-studio.tmp" "$_css_macos_dir/launch-studio"
|
|
chmod +x "$_css_macos_dir/launch-studio"
|
|
|
|
# Build AppIcon.icns from unsloth-gem.png (2240x2240)
|
|
if [ -f "$_css_gem_png" ] && command -v sips >/dev/null 2>&1 && command -v iconutil >/dev/null 2>&1; then
|
|
_css_tmpdir=$(mktemp -d 2>/dev/null)
|
|
if [ -d "$_css_tmpdir" ]; then
|
|
_css_iconset="$_css_tmpdir/AppIcon.iconset"
|
|
mkdir -p "$_css_iconset"
|
|
_css_icon_ok=true
|
|
for _sz in 16 32 128 256 512; do
|
|
_sz2=$((_sz * 2))
|
|
sips -z "$_sz" "$_sz" "$_css_gem_png" --out "$_css_iconset/icon_${_sz}x${_sz}.png" >/dev/null 2>&1 || _css_icon_ok=false
|
|
sips -z "$_sz2" "$_sz2" "$_css_gem_png" --out "$_css_iconset/icon_${_sz}x${_sz}@2x.png" >/dev/null 2>&1 || _css_icon_ok=false
|
|
done
|
|
if [ "$_css_icon_ok" = "true" ]; then
|
|
iconutil -c icns "$_css_iconset" -o "$_css_res_dir/AppIcon.icns" 2>/dev/null || true
|
|
fi
|
|
rm -rf "$_css_tmpdir"
|
|
fi
|
|
fi
|
|
# Fallback: copy PNG as icon
|
|
if [ ! -f "$_css_res_dir/AppIcon.icns" ] && [ -f "$_css_icon_png" ]; then
|
|
cp "$_css_icon_png" "$_css_res_dir/AppIcon.icns" 2>/dev/null || true
|
|
fi
|
|
|
|
# Touch so Finder indexes it
|
|
touch "$_css_app"
|
|
|
|
# Symlink on Desktop
|
|
if [ -d "$HOME/Desktop" ]; then
|
|
ln -sf "$_css_app" "$HOME/Desktop/Unsloth Studio" 2>/dev/null || true
|
|
fi
|
|
_css_created=1
|
|
|
|
elif [ "$_css_os" = "wsl" ]; then
|
|
# ── WSL: create Windows Desktop and Start Menu shortcuts ──
|
|
# Detect current WSL distro for targeted shortcut
|
|
_css_distro="${WSL_DISTRO_NAME:-}"
|
|
|
|
# Build the wsl.exe arguments.
|
|
# Double-quote distro name and launcher path for Windows command line
|
|
# parsing so values with spaces (e.g. "Ubuntu Preview") are kept as
|
|
# single arguments.
|
|
_css_wsl_args=""
|
|
if [ -n "$_css_distro" ]; then
|
|
_css_wsl_args="-d \"$_css_distro\" "
|
|
fi
|
|
_css_wsl_args="${_css_wsl_args}-- bash -l -c \"exec \\\"$_css_launcher\\\"\""
|
|
|
|
# Detect whether Windows Terminal (wt.exe) is available (better UX)
|
|
_css_use_wt=false
|
|
if command -v wt.exe >/dev/null 2>&1; then
|
|
_css_use_wt=true
|
|
fi
|
|
|
|
if [ "$_css_use_wt" = true ]; then
|
|
_css_sc_target='wt.exe'
|
|
_css_sc_args="wsl.exe $_css_wsl_args"
|
|
else
|
|
_css_sc_target='wsl.exe'
|
|
_css_sc_args="$_css_wsl_args"
|
|
fi
|
|
|
|
# Escape single quotes for PowerShell single-quoted string embedding
|
|
_css_sc_args_ps=$(printf '%s' "$_css_sc_args" | sed "s/'/''/g")
|
|
|
|
# Create shortcuts via a temp PowerShell script to avoid escaping issues
|
|
_css_ps1_tmp=$(mktemp /tmp/unsloth-shortcut-XXXXXX.ps1 2>/dev/null) || true
|
|
if [ -n "$_css_ps1_tmp" ]; then
|
|
cat > "$_css_ps1_tmp" << WSLPS1_EOF
|
|
\$WshShell = New-Object -ComObject WScript.Shell
|
|
\$targetExe = (Get-Command '$_css_sc_target' -ErrorAction SilentlyContinue).Source
|
|
if (-not \$targetExe) { exit 1 }
|
|
\$locations = @(
|
|
[Environment]::GetFolderPath('Desktop'),
|
|
(Join-Path \$env:APPDATA 'Microsoft\Windows\Start Menu\Programs')
|
|
)
|
|
foreach (\$dir in \$locations) {
|
|
if (-not \$dir -or -not (Test-Path \$dir)) { continue }
|
|
\$linkPath = Join-Path \$dir 'Unsloth Studio.lnk'
|
|
\$shortcut = \$WshShell.CreateShortcut(\$linkPath)
|
|
\$shortcut.TargetPath = \$targetExe
|
|
\$shortcut.Arguments = '$_css_sc_args_ps'
|
|
\$shortcut.Description = 'Launch Unsloth Studio'
|
|
\$shortcut.Save()
|
|
}
|
|
WSLPS1_EOF
|
|
|
|
# Convert WSL path to Windows path for powershell.exe
|
|
_css_ps1_win=$(wslpath -w "$_css_ps1_tmp" 2>/dev/null)
|
|
if [ -n "$_css_ps1_win" ]; then
|
|
powershell.exe -NoProfile -ExecutionPolicy Bypass -File "$_css_ps1_win" >/dev/null 2>&1 && _css_created=1
|
|
fi
|
|
rm -f "$_css_ps1_tmp"
|
|
fi
|
|
fi
|
|
|
|
if [ "$_css_created" -eq 1 ]; then
|
|
substep "Created Unsloth Studio shortcut"
|
|
fi
|
|
}
|
|
|
|
echo ""
|
|
printf " ${C_TITLE}%s${C_RST}\n" "🦥 Unsloth Studio Installer"
|
|
printf " ${C_DIM}%s${C_RST}\n" "$RULE"
|
|
echo ""
|
|
|
|
# ── Detect platform ──
|
|
tauri_log "STEP" "Detecting platform"
|
|
OS="linux"
|
|
if [ "$(uname)" = "Darwin" ]; then
|
|
OS="macos"
|
|
elif grep -qi microsoft /proc/version 2>/dev/null; then
|
|
OS="wsl"
|
|
fi
|
|
step "platform" "$OS"
|
|
|
|
# ── Architecture detection & Python version ──
|
|
_ARCH=$(uname -m)
|
|
MAC_INTEL=false
|
|
if [ "$OS" = "macos" ] && [ "$_ARCH" = "x86_64" ]; then
|
|
# Guard against Apple Silicon running under Rosetta (reports x86_64).
|
|
# sysctl hw.optional.arm64 returns "1" on Apple Silicon even in Rosetta.
|
|
if [ "$(sysctl -in hw.optional.arm64 2>/dev/null || echo 0)" = "1" ]; then
|
|
echo ""
|
|
echo " WARNING: Apple Silicon detected, but this shell is running under Rosetta (x86_64)."
|
|
echo " Re-run install.sh from a native arm64 terminal for full PyTorch support."
|
|
echo " Continuing in GGUF-only mode for now."
|
|
echo ""
|
|
fi
|
|
MAC_INTEL=true
|
|
fi
|
|
|
|
if [ -n "$_USER_PYTHON" ]; then
|
|
PYTHON_VERSION="$_USER_PYTHON"
|
|
echo " Using user-specified Python $PYTHON_VERSION (--python override)"
|
|
elif [ "$MAC_INTEL" = true ]; then
|
|
PYTHON_VERSION="3.12"
|
|
else
|
|
PYTHON_VERSION="3.13"
|
|
fi
|
|
|
|
if [ "$MAC_INTEL" = true ]; then
|
|
echo ""
|
|
echo " NOTE: Intel Mac (x86_64) detected."
|
|
echo " PyTorch is unavailable for this platform (dropped Jan 2024)."
|
|
echo " Studio will install in GGUF-only mode."
|
|
echo " Chat, inference via GGUF, and data recipes will work."
|
|
echo " Training requires Apple Silicon or Linux with GPU."
|
|
echo ""
|
|
fi
|
|
|
|
# ── Unified SKIP_TORCH: --no-torch flag OR Intel Mac auto-detection ──
|
|
SKIP_TORCH=false
|
|
if [ "$_NO_TORCH_FLAG" = true ] || [ "$MAC_INTEL" = true ]; then
|
|
SKIP_TORCH=true
|
|
fi
|
|
|
|
_TAURI_INITIAL_GPU_BRANCH="unknown"
|
|
if [ "$SKIP_TORCH" = true ]; then
|
|
_TAURI_INITIAL_GPU_BRANCH="no_torch"
|
|
elif [ "$OS" = "macos" ]; then
|
|
_TAURI_INITIAL_GPU_BRANCH="mac"
|
|
fi
|
|
tauri_diag_marker "$_TAURI_INITIAL_GPU_BRANCH" "none"
|
|
|
|
# ── Check system dependencies ──
|
|
# cmake and git are needed by unsloth studio setup to build the GGUF inference
|
|
# engine (llama.cpp). build-essential and libcurl-dev are also needed on Linux.
|
|
tauri_log "STEP" "Checking system dependencies"
|
|
MISSING=""
|
|
|
|
command -v cmake >/dev/null 2>&1 || MISSING="$MISSING cmake"
|
|
command -v git >/dev/null 2>&1 || MISSING="$MISSING git"
|
|
|
|
case "$OS" in
|
|
macos)
|
|
# Xcode Command Line Tools provide the C/C++ compiler
|
|
if ! xcode-select -p >/dev/null 2>&1; then
|
|
echo ""
|
|
echo "==> Xcode Command Line Tools are required."
|
|
echo " Installing (a system dialog will appear)..."
|
|
xcode-select --install </dev/null 2>/dev/null || true
|
|
echo " After the installation completes, please re-run this script."
|
|
exit 1
|
|
fi
|
|
;;
|
|
linux|wsl)
|
|
# curl or wget is needed for downloads; check both
|
|
if ! command -v curl >/dev/null 2>&1 && ! command -v wget >/dev/null 2>&1; then
|
|
MISSING="$MISSING curl"
|
|
fi
|
|
command -v gcc >/dev/null 2>&1 || MISSING="$MISSING build-essential"
|
|
# libcurl dev headers for llama.cpp HTTPS support
|
|
command -v curl-config >/dev/null 2>&1 || MISSING="$MISSING libcurl4-openssl-dev"
|
|
;;
|
|
esac
|
|
|
|
MISSING=$(echo "$MISSING" | sed 's/^ *//')
|
|
|
|
if [ -n "$MISSING" ]; then
|
|
echo ""
|
|
step "deps" "missing: $MISSING" "$C_WARN"
|
|
substep "These are needed to build the GGUF inference engine."
|
|
|
|
case "$OS" in
|
|
macos)
|
|
if ! command -v brew >/dev/null 2>&1; then
|
|
echo ""
|
|
echo " Homebrew is required to install them."
|
|
echo " Install Homebrew from https://brew.sh then re-run this script."
|
|
exit 1
|
|
fi
|
|
brew install $MISSING </dev/null
|
|
;;
|
|
linux|wsl)
|
|
if command -v apt-get >/dev/null 2>&1; then
|
|
_smart_apt_install $MISSING
|
|
else
|
|
echo " Automatic system package installation is supported on apt-based"
|
|
echo " Linux distributions (Ubuntu/Debian) only. Please install the"
|
|
echo " missing dependencies with your package manager, then re-run setup:"
|
|
echo " $MISSING"
|
|
echo ""
|
|
echo " Examples:"
|
|
echo " Fedora/RHEL: sudo dnf install cmake git gcc gcc-c++ make libcurl-devel"
|
|
echo " Arch: sudo pacman -S --needed cmake git base-devel curl"
|
|
echo " openSUSE: sudo zypper install cmake git gcc gcc-c++ make libcurl-devel"
|
|
exit 1
|
|
fi
|
|
;;
|
|
esac
|
|
echo ""
|
|
else
|
|
step "deps" "all system dependencies found"
|
|
fi
|
|
|
|
# ── Install uv ──
|
|
tauri_log "STEP" "Installing uv package manager"
|
|
UV_MIN_VERSION="0.7.14"
|
|
|
|
version_ge() {
|
|
# returns 0 if $1 >= $2
|
|
_a=$1
|
|
_b=$2
|
|
|
|
while [ -n "$_a" ] || [ -n "$_b" ]; do
|
|
_a_part=${_a%%.*}
|
|
_b_part=${_b%%.*}
|
|
|
|
[ "$_a" = "$_a_part" ] && _a="" || _a=${_a#*.}
|
|
[ "$_b" = "$_b_part" ] && _b="" || _b=${_b#*.}
|
|
|
|
[ -z "$_a_part" ] && _a_part=0
|
|
[ -z "$_b_part" ] && _b_part=0
|
|
|
|
if [ "$_a_part" -gt "$_b_part" ]; then
|
|
return 0
|
|
fi
|
|
if [ "$_a_part" -lt "$_b_part" ]; then
|
|
return 1
|
|
fi
|
|
done
|
|
|
|
return 0
|
|
}
|
|
|
|
_uv_version_ok() {
|
|
_raw=$("$1" --version 2>/dev/null | awk '{print $2}') || return 1
|
|
[ -n "$_raw" ] || return 1
|
|
_ver=${_raw%%[-+]*}
|
|
case "$_ver" in
|
|
''|*[!0-9.]*) return 1 ;;
|
|
esac
|
|
version_ge "$_ver" "$UV_MIN_VERSION" || return 1
|
|
# Prerelease of the exact minimum (e.g. 0.7.14-rc1) is still below stable 0.7.14
|
|
[ "$_ver" = "$UV_MIN_VERSION" ] && [ "$_raw" != "$_ver" ] && return 1
|
|
return 0
|
|
}
|
|
|
|
if ! command -v uv >/dev/null 2>&1 || ! _uv_version_ok uv; then
|
|
substep "installing uv package manager..."
|
|
_uv_tmp=$(mktemp)
|
|
download "https://astral.sh/uv/install.sh" "$_uv_tmp"
|
|
run_maybe_quiet sh "$_uv_tmp" </dev/null
|
|
rm -f "$_uv_tmp"
|
|
if [ -f "$HOME/.local/bin/env" ]; then
|
|
. "$HOME/.local/bin/env"
|
|
fi
|
|
export PATH="$HOME/.local/bin:$PATH"
|
|
fi
|
|
|
|
# ── Create venv (migrate old layout if possible, otherwise fresh) ──
|
|
tauri_log "STEP" "Creating virtual environment"
|
|
mkdir -p "$STUDIO_HOME"
|
|
|
|
_MIGRATED=false
|
|
|
|
if [ -x "$VENV_DIR/bin/python" ]; then
|
|
# why: matching guard to the .venv branch below -- in env-mode
|
|
# $STUDIO_HOME is a user-chosen workspace, so refuse to nuke an
|
|
# existing $STUDIO_HOME/unsloth_studio that lacks Studio sentinels.
|
|
# Accept the in-VENV ownership marker so partial-install retries are
|
|
# not blocked. Sentinels must be regular files: -f follows symlinks
|
|
# to files (the legitimate ln -s shim shape) but rejects directories
|
|
# and broken/dir-targeted symlinks.
|
|
if [ "$_STUDIO_HOME_REDIRECT" = "env" ] \
|
|
&& [ ! -f "$VENV_DIR/.unsloth-studio-owned" ] \
|
|
&& [ ! -f "$STUDIO_HOME/share/studio.conf" ] \
|
|
&& [ ! -f "$STUDIO_HOME/bin/unsloth" ]; then
|
|
echo "ERROR: $VENV_DIR already exists but does not look like an Unsloth Studio install." >&2
|
|
echo " Move it aside or choose an empty UNSLOTH_STUDIO_HOME." >&2
|
|
exit 1
|
|
fi
|
|
# New layout already exists — replace only after preserving rollback copy.
|
|
substep "preserving existing environment for rollback..."
|
|
_start_studio_venv_replacement "$VENV_DIR"
|
|
elif [ "$_STUDIO_HOME_REDIRECT" != "env" ] && [ -x "$STUDIO_HOME/.venv/bin/python" ]; then
|
|
# Old layout exists — validate before migrating.
|
|
# Skip in env-mode so we don't rm -rf an unrelated .venv at the
|
|
# workspace root (e.g. user's existing project Python venv).
|
|
# In no-torch mode, a missing torch package is expected; validate Python only.
|
|
substep "found legacy Studio environment, validating..."
|
|
_legacy_ok=false
|
|
if [ "$SKIP_TORCH" = true ]; then
|
|
if "$STUDIO_HOME/.venv/bin/python" -c "import sys; print(sys.executable)" >/dev/null 2>&1; then
|
|
_legacy_ok=true
|
|
fi
|
|
elif "$STUDIO_HOME/.venv/bin/python" -c "
|
|
import torch
|
|
device = 'cuda' if torch.cuda.is_available() else 'cpu'
|
|
A = torch.ones((10, 10), device=device)
|
|
B = torch.ones((10, 10), device=device)
|
|
C = torch.ones((10, 10), device=device)
|
|
D = A + B
|
|
E = D @ C
|
|
torch.testing.assert_close(torch.unique(E), torch.tensor((20,), device=E.device, dtype=E.dtype))
|
|
" >/dev/null 2>&1; then
|
|
_legacy_ok=true
|
|
fi
|
|
if [ "$_legacy_ok" = true ]; then
|
|
echo "✅ Legacy environment is healthy — migrating..."
|
|
mv "$STUDIO_HOME/.venv" "$VENV_DIR"
|
|
echo " Moved ~/.unsloth/studio/.venv → $VENV_DIR"
|
|
_MIGRATED=true
|
|
else
|
|
echo "⚠️ Legacy environment failed validation — creating fresh environment"
|
|
_invalid_venv="$STUDIO_HOME/.venv.invalid.$(date +%Y%m%d%H%M%S 2>/dev/null || echo time).$$"
|
|
mv "$STUDIO_HOME/.venv" "$_invalid_venv" 2>/dev/null || true
|
|
fi
|
|
fi
|
|
|
|
# If an Intel Mac has a stale 3.13 venv from a previous failed install, recreate
|
|
# (skip when the user explicitly chose a version via --python)
|
|
if [ "$SKIP_TORCH" = true ] && [ "$MAC_INTEL" = true ] && [ -z "$_USER_PYTHON" ] && [ -x "$VENV_DIR/bin/python" ]; then
|
|
_PY_MM=$("$VENV_DIR/bin/python" -c \
|
|
"import sys; print('{}.{}'.format(*sys.version_info[:2]))" 2>/dev/null || echo "")
|
|
if [ "$_PY_MM" != "3.12" ]; then
|
|
echo " Recreating Intel Mac environment with Python 3.12 (was $_PY_MM)..."
|
|
rm -rf "$VENV_DIR"
|
|
fi
|
|
fi
|
|
|
|
if [ ! -x "$VENV_DIR/bin/python" ]; then
|
|
step "venv" "creating Python ${PYTHON_VERSION} virtual environment"
|
|
substep "$VENV_DIR"
|
|
run_install_cmd "create venv" uv venv "$VENV_DIR" --python "$PYTHON_VERSION"
|
|
fi
|
|
|
|
# Mark the freshly-created venv as Studio-owned so a partial install can be
|
|
# repaired by re-running install.sh; the env-mode deletion guard above accepts
|
|
# this marker as the primary sentinel.
|
|
if [ -x "$VENV_DIR/bin/python" ]; then
|
|
: > "$VENV_DIR/.unsloth-studio-owned" 2>/dev/null || true
|
|
fi
|
|
|
|
# Guard against Python 3.13.8 torch import bug on Apple Silicon
|
|
# (skip when the user explicitly chose a version via --python)
|
|
if [ -z "$_USER_PYTHON" ] && [ "$OS" = "macos" ] && [ "$_ARCH" = "arm64" ]; then
|
|
_PY_VER=$("$VENV_DIR/bin/python" -c \
|
|
"import sys; print('{}.{}.{}'.format(*sys.version_info[:3]))" 2>/dev/null || echo "")
|
|
if [ "$_PY_VER" = "3.13.8" ]; then
|
|
echo " WARNING: Python 3.13.8 has a known torch import bug."
|
|
echo " Recreating venv with Python 3.12..."
|
|
rm -rf "$VENV_DIR"
|
|
PYTHON_VERSION="3.12"
|
|
run_install_cmd "recreate venv" uv venv "$VENV_DIR" --python "$PYTHON_VERSION"
|
|
if [ -x "$VENV_DIR/bin/python" ]; then
|
|
: > "$VENV_DIR/.unsloth-studio-owned" 2>/dev/null || true
|
|
fi
|
|
fi
|
|
fi
|
|
|
|
if [ -x "$VENV_DIR/bin/python" ]; then
|
|
step "venv" "using environment"
|
|
substep "${VENV_DIR}"
|
|
fi
|
|
|
|
# Default torch constraint -- tightened for Python 3.13+ on arm64 macOS
|
|
# (torch <2.6 has no cp313 macOS arm64 wheels)
|
|
TORCH_CONSTRAINT="torch>=2.4,<2.11.0"
|
|
if [ "$SKIP_TORCH" = false ] && [ "$OS" = "macos" ] && [ "$_ARCH" = "arm64" ]; then
|
|
_PY_MINOR=$("$VENV_DIR/bin/python" -c \
|
|
"import sys; print(sys.version_info.minor)" 2>/dev/null || echo "0")
|
|
if [ "$_PY_MINOR" -ge 13 ] 2>/dev/null; then
|
|
TORCH_CONSTRAINT="torch>=2.6,<2.11.0"
|
|
fi
|
|
fi
|
|
|
|
# ── Resolve repo root (for --local installs) ──
|
|
_REPO_ROOT="$(cd "$(dirname "$0" 2>/dev/null || echo ".")" && pwd)"
|
|
|
|
# ── Helper: find no-torch-runtime.txt (local repo or site-packages) ──
|
|
_find_no_torch_runtime() {
|
|
# Check local repo first (for --local installs)
|
|
if [ -f "$_REPO_ROOT/studio/backend/requirements/no-torch-runtime.txt" ]; then
|
|
echo "$_REPO_ROOT/studio/backend/requirements/no-torch-runtime.txt"
|
|
return
|
|
fi
|
|
# Check inside installed package
|
|
_rt=$(find "$VENV_DIR" -path "*/studio/backend/requirements/no-torch-runtime.txt" -print -quit 2>/dev/null || echo "")
|
|
if [ -n "$_rt" ]; then
|
|
echo "$_rt"
|
|
return
|
|
fi
|
|
}
|
|
|
|
# ── AMD ROCm GPU detection helper ──
|
|
# Returns 0 (true) if an actual AMD GPU is present, 1 (false) otherwise.
|
|
# Checks rocminfo for gfx[1-9]* (excludes gfx000 CPU agent) and
|
|
# amd-smi list for GPU data rows (excludes header-only output).
|
|
_has_amd_rocm_gpu() {
|
|
if command -v rocminfo >/dev/null 2>&1 && \
|
|
rocminfo 2>/dev/null | awk '/Name:[[:space:]]*gfx[0-9]/ && !/Name:[[:space:]]*gfx000/{found=1} END{exit !found}'; then
|
|
return 0
|
|
elif command -v amd-smi >/dev/null 2>&1 && \
|
|
amd-smi list 2>/dev/null | awk '/^GPU[[:space:]]*[:\[][[:space:]]*[0-9]/{ found=1 } END{ exit !found }'; then
|
|
return 0
|
|
fi
|
|
return 1
|
|
}
|
|
|
|
# ── NVIDIA usable-GPU helper ──
|
|
# Returns 0 (true) only if nvidia-smi is present AND actually lists a GPU.
|
|
# Prevents AMD-only hosts with a stale nvidia-smi on PATH from being routed
|
|
# into the CUDA branch.
|
|
_has_usable_nvidia_gpu() {
|
|
_nvsmi=""
|
|
if command -v nvidia-smi >/dev/null 2>&1; then
|
|
_nvsmi="nvidia-smi"
|
|
elif [ -x "/usr/bin/nvidia-smi" ]; then
|
|
_nvsmi="/usr/bin/nvidia-smi"
|
|
else
|
|
return 1
|
|
fi
|
|
"$_nvsmi" -L 2>/dev/null | awk '/^GPU[[:space:]]+[0-9]+:/{found=1} END{exit !found}'
|
|
}
|
|
|
|
# ── Detect GPU and choose PyTorch index URL ──
|
|
# Mirrors Get-TorchIndexUrl in install.ps1.
|
|
# On CPU-only machines this returns the cpu index, avoiding the solver
|
|
# dead-end where --torch-backend=auto resolves to unsloth==2024.8.
|
|
get_torch_index_url() {
|
|
_base="${UNSLOTH_PYTORCH_MIRROR:-https://download.pytorch.org/whl}"
|
|
_base="${_base%/}"
|
|
# macOS: always CPU (no CUDA support)
|
|
case "$(uname -s)" in Darwin) echo "$_base/cpu"; return ;; esac
|
|
# Try nvidia-smi -- require the binary to actually list a usable GPU.
|
|
# Presence of the binary alone (container leftovers, stale driver
|
|
# packages) is not sufficient: otherwise an AMD-only host would
|
|
# silently install CUDA wheels.
|
|
_smi=""
|
|
if _has_usable_nvidia_gpu; then
|
|
if command -v nvidia-smi >/dev/null 2>&1; then
|
|
_smi="nvidia-smi"
|
|
elif [ -x "/usr/bin/nvidia-smi" ]; then
|
|
_smi="/usr/bin/nvidia-smi"
|
|
fi
|
|
fi
|
|
if [ -z "$_smi" ]; then
|
|
# No NVIDIA GPU -- check for AMD ROCm GPU.
|
|
# PyTorch only publishes ROCm wheels for linux-x86_64; skip the
|
|
# ROCm branch entirely on aarch64 / arm64 / other architectures
|
|
# so non-x86_64 Linux hosts fall back cleanly to CPU wheels.
|
|
case "$(uname -m)" in
|
|
x86_64|amd64) : ;;
|
|
*) echo "$_base/cpu"; return ;;
|
|
esac
|
|
if ! _has_amd_rocm_gpu; then
|
|
echo "$_base/cpu"; return
|
|
fi
|
|
# AMD GPU confirmed -- detect ROCm version
|
|
_rocm_tag=""
|
|
_rocm_tag=$({ command -v amd-smi >/dev/null 2>&1 && \
|
|
amd-smi version 2>/dev/null | awk -F'ROCm version: ' \
|
|
'NF>1{gsub(/[^0-9.]/, "", $2); split($2,a,"."); print "rocm"a[1]"."a[2]; ok=1; exit} END{exit !ok}'; } || \
|
|
{ [ -r /opt/rocm/.info/version ] && \
|
|
awk -F. '{print "rocm"$1"."$2; exit}' /opt/rocm/.info/version; } || \
|
|
{ command -v hipconfig >/dev/null 2>&1 && \
|
|
hipconfig --version 2>/dev/null | awk 'NR==1 && /^[0-9]/{split($1,a,"."); if(a[1]+0>0){print "rocm"a[1]"."a[2]; found=1}} END{exit !found}'; } || \
|
|
{ command -v dpkg-query >/dev/null 2>&1 && \
|
|
ver="$(dpkg-query -W -f='${Version}\n' rocm-core 2>/dev/null)" && \
|
|
[ -n "$ver" ] && \
|
|
printf '%s\n' "$ver" | sed 's/^[0-9]*://' | awk -F'[.-]' '{print "rocm"$1"."$2; exit}'; } || \
|
|
{ command -v rpm >/dev/null 2>&1 && \
|
|
ver="$(rpm -q --qf '%{VERSION}\n' rocm-core 2>/dev/null)" && \
|
|
[ -n "$ver" ] && \
|
|
printf '%s\n' "$ver" | awk -F'[.-]' '{print "rocm"$1"."$2; exit}'; }) 2>/dev/null
|
|
# Validate _rocm_tag: must match "rocmX.Y" with major >= 1
|
|
case "$_rocm_tag" in
|
|
rocm[1-9]*.[0-9]*) : ;; # valid (major >= 1)
|
|
*) _rocm_tag="" ;; # reject malformed (empty, garbled, or major=0)
|
|
esac
|
|
if [ -n "$_rocm_tag" ]; then
|
|
# Minimum supported: ROCm 6.0 (no PyTorch wheels exist for older)
|
|
case "$_rocm_tag" in
|
|
rocm[1-5].*) echo "$_base/cpu"; return ;;
|
|
esac
|
|
# ROCm 7.2 only has torch 2.11.0 which exceeds current bounds
|
|
# (<2.11.0). Fall back to rocm7.1 index which has torch 2.10.0.
|
|
# Enumerate explicit versions rather than matching rocm6.* so
|
|
# a host on ROCm 6.5 or 6.6 (no PyTorch wheels published) is
|
|
# clipped down to the last supported 6.x (rocm6.4) instead of
|
|
# constructing https://download.pytorch.org/whl/rocm6.5 which
|
|
# returns HTTP 403. PyTorch only ships: rocm5.7, 6.0, 6.1, 6.2,
|
|
# 6.3, 6.4, 7.0, 7.1, 7.2 (and 5.7 is below our minimum).
|
|
# TODO: uncomment rocm7.2 when the torch upper bound is bumped
|
|
# to >=2.11.0.
|
|
case "$_rocm_tag" in
|
|
rocm6.0|rocm6.0.*|rocm6.1|rocm6.1.*|rocm6.2|rocm6.2.*|rocm6.3|rocm6.3.*|rocm6.4|rocm6.4.*|rocm7.0|rocm7.0.*|rocm7.1|rocm7.1.*)
|
|
echo "$_base/$_rocm_tag" ;;
|
|
rocm6.*)
|
|
# ROCm 6.5+ (no published PyTorch wheels): clip down
|
|
# to the last supported 6.x wheel set.
|
|
echo "$_base/rocm6.4" ;;
|
|
*)
|
|
# ROCm 7.2+ (including future 10.x+): cap to rocm7.1
|
|
echo "$_base/rocm7.1" ;;
|
|
esac
|
|
return
|
|
fi
|
|
echo "$_base/cpu"; return
|
|
fi
|
|
# Parse CUDA version from nvidia-smi output (POSIX-safe, no grep -P)
|
|
_cuda_ver=$(LC_ALL=C $_smi 2>/dev/null \
|
|
| sed -n 's/.*CUDA Version:[[:space:]]*\([0-9][0-9]*\.[0-9][0-9]*\).*/\1/p' \
|
|
| head -1)
|
|
if [ -z "$_cuda_ver" ]; then
|
|
echo "[WARN] Could not determine CUDA version from nvidia-smi, defaulting to cu126" >&2
|
|
echo "$_base/cu126"; return
|
|
fi
|
|
_major=${_cuda_ver%%.*}
|
|
_minor=${_cuda_ver#*.}
|
|
if [ "$_major" -ge 13 ]; then echo "$_base/cu130"
|
|
elif [ "$_major" -eq 12 ] && [ "$_minor" -ge 8 ]; then echo "$_base/cu128"
|
|
elif [ "$_major" -eq 12 ] && [ "$_minor" -ge 6 ]; then echo "$_base/cu126"
|
|
elif [ "$_major" -ge 12 ]; then echo "$_base/cu124"
|
|
elif [ "$_major" -ge 11 ]; then echo "$_base/cu118"
|
|
else echo "$_base/cpu"; fi
|
|
}
|
|
|
|
get_radeon_wheel_url() {
|
|
# Only meaningful on Linux. Picks a repo.radeon.com base URL whose listing
|
|
# contains torch wheels. Tries paths like rocm-rel-7.2.1/, rocm-rel-7.2/,
|
|
# rocm-rel-7.1.1/, rocm-rel-7.1/ (AMD publishes both M.m and M.m.p dirs).
|
|
# Accepts both X.Y and X.Y.Z host versions since /opt/rocm/.info/version
|
|
# and hipconfig --version can return either shape.
|
|
case "$(uname -s)" in Linux) ;; *) echo ""; return ;; esac
|
|
|
|
# Detect ROCm version (X.Y or X.Y.Z) -- try amd-smi, then
|
|
# /opt/rocm/.info/version, then hipconfig.
|
|
_full_ver=""
|
|
_full_ver=$({ command -v amd-smi >/dev/null 2>&1 && \
|
|
amd-smi version 2>/dev/null | awk -F'ROCm version: ' \
|
|
'NF>1{if(match($2,/[0-9]+\.[0-9]+(\.[0-9]+)?/)){print substr($2,RSTART,RLENGTH); ok=1; exit}} END{exit !ok}'; } || \
|
|
{ [ -r /opt/rocm/.info/version ] && \
|
|
awk 'match($0,/[0-9]+\.[0-9]+(\.[0-9]+)?/){print substr($0,RSTART,RLENGTH); found=1; exit} END{exit !found}' /opt/rocm/.info/version; } || \
|
|
{ command -v hipconfig >/dev/null 2>&1 && \
|
|
hipconfig --version 2>/dev/null | awk 'NR==1 && match($0,/[0-9]+\.[0-9]+(\.[0-9]+)?/){print substr($0,RSTART,RLENGTH); found=1} END{exit !found}'; }) 2>/dev/null
|
|
|
|
# Validate: must be X.Y or X.Y.Z with X >= 1
|
|
case "$_full_ver" in
|
|
[1-9]*.[0-9]*.[0-9]*) : ;; # X.Y.Z
|
|
[1-9]*.[0-9]*) : ;; # X.Y
|
|
*) echo ""; return ;;
|
|
esac
|
|
echo "https://repo.radeon.com/rocm/manylinux/rocm-rel-${_full_ver}/"
|
|
}
|
|
|
|
# ── Radeon repo wheel selection helpers ──────────────────────────────────────
|
|
# Fetches the Radeon repo directory listing once into _RADEON_LISTING (global).
|
|
# _RADEON_PYTAG holds the CPython tag for the running interpreter (e.g. cp312).
|
|
# _RADEON_BASE_URL holds the base URL for relative-href resolution.
|
|
_RADEON_LISTING=""
|
|
_RADEON_PYTAG=""
|
|
_RADEON_BASE_URL=""
|
|
|
|
_radeon_fetch_listing() {
|
|
# Usage: _radeon_fetch_listing BASE_URL
|
|
# Populates _RADEON_LISTING, _RADEON_PYTAG, _RADEON_BASE_URL.
|
|
_RADEON_BASE_URL="$1"
|
|
_RADEON_PYTAG=$("$_VENV_PY" -c "
|
|
import sys
|
|
print('cp{}{}'.format(sys.version_info.major, sys.version_info.minor))
|
|
" 2>/dev/null) || return 1
|
|
if command -v curl >/dev/null 2>&1; then
|
|
_RADEON_LISTING=$(curl -fsSL --max-time 20 "$_RADEON_BASE_URL" 2>/dev/null)
|
|
elif command -v wget >/dev/null 2>&1; then
|
|
_RADEON_LISTING=$(wget -qO- --timeout=20 "$_RADEON_BASE_URL" 2>/dev/null)
|
|
fi
|
|
[ -n "$_RADEON_LISTING" ] || return 1
|
|
}
|
|
|
|
_pick_radeon_wheel() {
|
|
# Usage: _pick_radeon_wheel PACKAGE_NAME
|
|
# Scans $_RADEON_LISTING for the newest wheel whose filename starts exactly
|
|
# with PACKAGE_NAME- and matches _RADEON_PYTAG + linux_x86_64.
|
|
# Prints the full URL (resolving relative hrefs against _RADEON_BASE_URL).
|
|
#
|
|
# POSIX-compliant pipeline: all href parsing, filtering, and version
|
|
# selection is done inside a single awk script rather than reaching
|
|
# for GNU extensions (grep -o, sort -V) that would break under BSD
|
|
# or BusyBox coreutils.
|
|
_pkg="$1"
|
|
[ -n "$_RADEON_LISTING" ] || return 1
|
|
[ -n "$_RADEON_PYTAG" ] || return 1
|
|
_tag="$_RADEON_PYTAG"
|
|
_href=$(printf '%s\n' "$_RADEON_LISTING" \
|
|
| awk -v pkg="$_pkg" -v tag="$_tag" '
|
|
BEGIN { max_pad = ""; max_url = "" }
|
|
{
|
|
line = $0
|
|
while (match(line, /href="[^"]*"/)) {
|
|
# Strip the leading href=" (6 chars) and trailing " (1 char)
|
|
url = substr(line, RSTART + 6, RLENGTH - 7)
|
|
line = substr(line, RSTART + RLENGTH)
|
|
|
|
# Extract basename, strip query / fragment
|
|
n = split(url, p, "/")
|
|
base = p[n]
|
|
sub(/[?#].*/, "", base)
|
|
|
|
prefix = pkg "-"
|
|
# Match cpXY-cpXY or cpXY-abi3 with any linux x86_64
|
|
# platform tag (linux_x86_64, manylinux_2_28_x86_64,
|
|
# manylinux2014_x86_64, etc.)
|
|
if (substr(base, 1, length(prefix)) == prefix &&
|
|
index(base, "-" tag "-") > 0 &&
|
|
match(base, /x86_64\.whl$/)) {
|
|
# Extract the version component (first
|
|
# dotted-number run) and pad each piece so a
|
|
# plain lexical comparison gives us the newest.
|
|
if (match(base, /[0-9]+\.[0-9]+(\.[0-9]+)?/)) {
|
|
ver = substr(base, RSTART, RLENGTH)
|
|
m = split(ver, v, ".")
|
|
pad = ""
|
|
for (i = 1; i <= m; i++)
|
|
pad = pad sprintf("%08d", v[i])
|
|
if (pad > max_pad) {
|
|
max_pad = pad
|
|
max_url = url
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
END { if (max_url != "") print max_url }')
|
|
[ -z "$_href" ] && return 1
|
|
case "$_href" in
|
|
http*) printf '%s\n' "$_href" ;;
|
|
*) printf '%s\n' "${_RADEON_BASE_URL%/}/${_href#/}" ;;
|
|
esac
|
|
}
|
|
|
|
TORCH_INDEX_URL=$(get_torch_index_url)
|
|
|
|
# Auto-detect GPU for AMD ROCm based
|
|
# get_torch_index_url must have chosen */rocm*
|
|
# (gfx in rocminfo or amd-smi list). Then require rocminfo "Marketing Name:.*Radeon".
|
|
_amd_gpu_radeon=false
|
|
case "$TORCH_INDEX_URL" in
|
|
*/rocm*)
|
|
if _has_amd_rocm_gpu && command -v rocminfo >/dev/null 2>&1 && \
|
|
rocminfo 2>/dev/null | grep -q 'Marketing Name:.*Radeon'; then
|
|
_amd_gpu_radeon=true
|
|
fi
|
|
;;
|
|
esac
|
|
_TAURI_TORCH_INDEX_FAMILY=$(_tauri_torch_index_family "$TORCH_INDEX_URL")
|
|
if [ "$_amd_gpu_radeon" = true ] && [ "$SKIP_TORCH" = false ]; then
|
|
_TAURI_TORCH_INDEX_FAMILY="radeon"
|
|
fi
|
|
_TAURI_GPU_BRANCH=$(_tauri_gpu_branch "$_TAURI_TORCH_INDEX_FAMILY" "$_amd_gpu_radeon")
|
|
tauri_diag_marker "$_TAURI_GPU_BRANCH" "$_TAURI_TORCH_INDEX_FAMILY"
|
|
|
|
# ── Print CPU-only hint when no GPU detected ──
|
|
case "$TORCH_INDEX_URL" in
|
|
*/cpu)
|
|
if [ "$SKIP_TORCH" = false ] && [ "$OS" != "macos" ]; then
|
|
echo ""
|
|
echo " NOTE: No GPU detected (nvidia-smi and ROCm not found)."
|
|
echo " Installing CPU-only PyTorch. If you only need GGUF chat/inference,"
|
|
echo " re-run with --no-torch for a faster, lighter install:"
|
|
echo " curl -fsSL https://unsloth.ai/install.sh | sh -s -- --no-torch"
|
|
echo " AMD ROCm users: see https://docs.unsloth.ai/get-started/install-and-update/amd"
|
|
echo ""
|
|
fi
|
|
;;
|
|
*/rocm*)
|
|
echo ""
|
|
if [ "$_amd_gpu_radeon" = true ]; then
|
|
echo " AMD Radeon + ROCm detected -- installing PyTorch wheels from repo.radeon.com"
|
|
else
|
|
echo " AMD ROCm detected -- installing ROCm-enabled PyTorch ($TORCH_INDEX_URL)"
|
|
fi
|
|
echo ""
|
|
;;
|
|
esac
|
|
|
|
# ── Install unsloth directly into the venv (no activation needed) ──
|
|
tauri_log "STEP" "Installing PyTorch"
|
|
_VENV_PY="$VENV_DIR/bin/python"
|
|
if [ "$_MIGRATED" = true ]; then
|
|
# Migrated env: force-reinstall unsloth+unsloth-zoo to ensure clean state
|
|
# in the new venv location, while preserving existing torch/CUDA
|
|
substep "upgrading unsloth in migrated environment..."
|
|
if [ "$SKIP_TORCH" = true ]; then
|
|
# No-torch: install unsloth + unsloth-zoo with --no-deps (current
|
|
# PyPI metadata still declares torch as a hard dep), then install
|
|
# runtime deps (typer, safetensors, transformers, etc.) with --no-deps
|
|
# to prevent transitive torch resolution.
|
|
run_install_cmd "install unsloth (migrated no-torch)" uv pip install --python "$_VENV_PY" --no-deps \
|
|
--reinstall-package unsloth --reinstall-package unsloth-zoo \
|
|
"unsloth>=2026.5.2" unsloth-zoo
|
|
_NO_TORCH_RT="$(_find_no_torch_runtime)"
|
|
if [ -n "$_NO_TORCH_RT" ]; then
|
|
run_install_cmd "install no-torch runtime deps" uv pip install --python "$_VENV_PY" --no-deps -r "$_NO_TORCH_RT"
|
|
fi
|
|
else
|
|
run_install_cmd "install unsloth (migrated)" uv pip install --python "$_VENV_PY" \
|
|
--reinstall-package unsloth --reinstall-package unsloth-zoo \
|
|
"unsloth>=2026.5.2" unsloth-zoo
|
|
fi
|
|
if [ "$STUDIO_LOCAL_INSTALL" = true ]; then
|
|
substep "overlaying local repo (editable)..."
|
|
run_install_cmd "overlay local repo" uv pip install --python "$_VENV_PY" -e "$_REPO_ROOT" --no-deps
|
|
substep "overlaying unsloth-zoo from git main..."
|
|
run_install_cmd "overlay unsloth-zoo (git main)" uv pip install --python "$_VENV_PY" \
|
|
--no-deps --reinstall-package unsloth-zoo \
|
|
"unsloth-zoo @ git+https://github.com/unslothai/unsloth-zoo"
|
|
fi
|
|
# AMD ROCm: install bitsandbytes even in migrated environments so
|
|
# existing ROCm installs gain the AMD bitsandbytes build without a
|
|
# fresh reinstall.
|
|
if [ "$SKIP_TORCH" = false ]; then
|
|
case "$TORCH_INDEX_URL" in
|
|
*/rocm*)
|
|
_install_bnb_rocm "install bitsandbytes (AMD)" "$_VENV_PY"
|
|
# Repair ROCm torch if overwritten during migrated install
|
|
_has_hip=$("$_VENV_PY" -c "import torch; print(getattr(torch.version,'hip','') or '')" 2>/dev/null || true)
|
|
if [ -z "$_has_hip" ]; then
|
|
substep "repairing ROCm torch (overwritten by dependency resolution)..."
|
|
run_install_cmd "repair ROCm torch" uv pip install --python "$_VENV_PY" \
|
|
"$TORCH_CONSTRAINT" torchvision torchaudio \
|
|
--index-url "$TORCH_INDEX_URL" \
|
|
--force-reinstall
|
|
fi
|
|
;;
|
|
esac
|
|
fi
|
|
elif [ -n "$TORCH_INDEX_URL" ]; then
|
|
# Fresh: Step 1 - install torch from explicit index (skip when --no-torch or Intel Mac)
|
|
if [ "$SKIP_TORCH" = true ]; then
|
|
substep "skipping PyTorch (--no-torch or Intel Mac x86_64)." "$C_WARN"
|
|
elif [ "$_amd_gpu_radeon" = true ]; then
|
|
_radeon_url=$(get_radeon_wheel_url)
|
|
if [ -n "$_radeon_url" ]; then
|
|
_radeon_listing_ok=false
|
|
if _radeon_fetch_listing "$_radeon_url" 2>/dev/null; then
|
|
_radeon_listing_ok=true
|
|
else
|
|
# Try shorter X.Y path (AMD publishes both X.Y.Z and X.Y dirs)
|
|
_radeon_url_short=$(printf '%s\n' "$_radeon_url" \
|
|
| sed 's|rocm-rel-\([0-9]*\)\.\([0-9]*\)\.[0-9]*/|rocm-rel-\1.\2/|')
|
|
if [ "$_radeon_url_short" != "$_radeon_url" ] && \
|
|
_radeon_fetch_listing "$_radeon_url_short" 2>/dev/null; then
|
|
_radeon_listing_ok=true
|
|
fi
|
|
fi
|
|
|
|
if [ "$_radeon_listing_ok" = true ]; then
|
|
# Require torch, torchvision, torchaudio wheels to all resolve
|
|
# from the Radeon listing. If any is missing for this Python
|
|
# tag, fall through to the standard ROCm index instead of
|
|
# silently mixing Radeon wheels with PyPI defaults.
|
|
_torch_whl=$(_pick_radeon_wheel "torch" 2>/dev/null) || _torch_whl=""
|
|
_tv_whl=$(_pick_radeon_wheel "torchvision" 2>/dev/null) || _tv_whl=""
|
|
_ta_whl=$(_pick_radeon_wheel "torchaudio" 2>/dev/null) || _ta_whl=""
|
|
_tri_whl=$(_pick_radeon_wheel "triton" 2>/dev/null) || _tri_whl=""
|
|
# Sanity-check torch / torchvision / torchaudio are a
|
|
# matching release. The Radeon repo publishes multiple
|
|
# generations simultaneously, so picking the highest-version
|
|
# wheel for each package independently can assemble a
|
|
# mismatched trio (e.g. torch 2.9.1 + torchvision 0.23.0 +
|
|
# torchaudio 2.9.0 from the current rocm-rel-7.2.1 index).
|
|
# Check that torch and torchaudio share the same X.Y public
|
|
# version prefix, and that torchvision's minor correctly
|
|
# pairs with torch's minor (torchvision = torch.minor - 5
|
|
# since torch 2.4 -> torchvision 0.19 -> torch 2.9 ->
|
|
# torchvision 0.24).
|
|
# URL-decode each wheel name so %2B -> + before version
|
|
# extraction. Real Radeon wheel hrefs are percent-encoded
|
|
# (torch-2.10.0%2Brocm7.2.0...), so a plain [+-] terminator
|
|
# in the sed regex below would never match and
|
|
# _radeon_versions_match would stay false for every real
|
|
# listing, silently forcing a fallback to the generic
|
|
# ROCm index.
|
|
_torch_ver=""
|
|
_tv_ver=""
|
|
_ta_ver=""
|
|
if [ -n "$_torch_whl" ]; then
|
|
_torch_name=$(printf '%s' "${_torch_whl##*/}" | sed 's/%2[Bb]/+/g')
|
|
_torch_ver=$(printf '%s\n' "$_torch_name" | sed -n 's|^torch-\([0-9][0-9]*\.[0-9][0-9]*\)\(\.[0-9][0-9]*\)\{0,1\}[+-].*|\1|p')
|
|
fi
|
|
if [ -n "$_tv_whl" ]; then
|
|
_tv_name=$(printf '%s' "${_tv_whl##*/}" | sed 's/%2[Bb]/+/g')
|
|
_tv_ver=$(printf '%s\n' "$_tv_name" | sed -n 's|^torchvision-\([0-9][0-9]*\.[0-9][0-9]*\)\(\.[0-9][0-9]*\)\{0,1\}[+-].*|\1|p')
|
|
fi
|
|
if [ -n "$_ta_whl" ]; then
|
|
_ta_name=$(printf '%s' "${_ta_whl##*/}" | sed 's/%2[Bb]/+/g')
|
|
_ta_ver=$(printf '%s\n' "$_ta_name" | sed -n 's|^torchaudio-\([0-9][0-9]*\.[0-9][0-9]*\)\(\.[0-9][0-9]*\)\{0,1\}[+-].*|\1|p')
|
|
fi
|
|
_radeon_versions_match=false
|
|
if [ -n "$_torch_ver" ] && [ -n "$_tv_ver" ] && [ -n "$_ta_ver" ]; then
|
|
_torch_major=${_torch_ver%%.*}
|
|
_torch_minor=${_torch_ver#*.}
|
|
_ta_major=${_ta_ver%%.*}
|
|
_ta_minor=${_ta_ver#*.}
|
|
_tv_major=${_tv_ver%%.*}
|
|
_tv_minor=${_tv_ver#*.}
|
|
# torchvision expected minor (e.g. torch 2.9 -> 0.24)
|
|
_expected_tv_minor=$((_torch_minor + 15))
|
|
if [ "$_torch_major" = "$_ta_major" ] && \
|
|
[ "$_torch_minor" = "$_ta_minor" ] && \
|
|
[ "$_tv_major" = "0" ] && \
|
|
[ "$_tv_minor" = "$_expected_tv_minor" ]; then
|
|
_radeon_versions_match=true
|
|
fi
|
|
fi
|
|
if [ -z "$_torch_whl" ] || [ -z "$_tv_whl" ] || [ -z "$_ta_whl" ] || \
|
|
[ "$_radeon_versions_match" != true ]; then
|
|
substep "[WARN] Radeon repo lacks a compatible wheel set for this Python; falling back to ROCm index ($TORCH_INDEX_URL)" "$C_WARN"
|
|
run_install_cmd "install PyTorch" uv pip install --python "$_VENV_PY" \
|
|
"$TORCH_CONSTRAINT" torchvision torchaudio \
|
|
--index-url "$TORCH_INDEX_URL"
|
|
else
|
|
substep "installing PyTorch from Radeon repo (${_RADEON_BASE_URL})..."
|
|
# Pass explicit wheel URLs so the matched trio is
|
|
# installed together. --find-links lets uv discover
|
|
# the Radeon listing for any local lookup, and PyPI
|
|
# (not disabled) provides transitive deps like
|
|
# filelock / sympy / networkx which are not in the
|
|
# Radeon listing.
|
|
if [ -n "$_tri_whl" ]; then
|
|
run_install_cmd "install triton + PyTorch" uv pip install --python "$_VENV_PY" \
|
|
--find-links "$_RADEON_BASE_URL" \
|
|
"$_tri_whl" "$_torch_whl" "$_tv_whl" "$_ta_whl"
|
|
else
|
|
run_install_cmd "install PyTorch" uv pip install --python "$_VENV_PY" \
|
|
--find-links "$_RADEON_BASE_URL" \
|
|
"$_torch_whl" "$_tv_whl" "$_ta_whl"
|
|
fi
|
|
fi
|
|
else
|
|
substep "[WARN] Radeon repo unavailable; falling back to ROCm index ($TORCH_INDEX_URL)" "$C_WARN"
|
|
run_install_cmd "install PyTorch" uv pip install --python "$_VENV_PY" \
|
|
"$TORCH_CONSTRAINT" torchvision torchaudio \
|
|
--index-url "$TORCH_INDEX_URL"
|
|
fi
|
|
else
|
|
substep "[WARN] Radeon GPU detected but could not detect full ROCm version; falling back to ROCm index" "$C_WARN"
|
|
run_install_cmd "install PyTorch" uv pip install --python "$_VENV_PY" \
|
|
"$TORCH_CONSTRAINT" torchvision torchaudio \
|
|
--index-url "$TORCH_INDEX_URL"
|
|
fi
|
|
else
|
|
substep "installing PyTorch ($TORCH_INDEX_URL)..."
|
|
run_install_cmd "install PyTorch" uv pip install --python "$_VENV_PY" "$TORCH_CONSTRAINT" torchvision torchaudio \
|
|
--index-url "$TORCH_INDEX_URL"
|
|
fi
|
|
# AMD ROCm: install bitsandbytes (once, after torch, for all ROCm paths).
|
|
# Gate on SKIP_TORCH=false so a user running with --no-torch on a ROCm
|
|
# host stays in GGUF-only mode rather than pulling in bitsandbytes,
|
|
# which is only useful once torch is present for training.
|
|
if [ "$SKIP_TORCH" = false ]; then
|
|
case "$TORCH_INDEX_URL" in
|
|
*/rocm*)
|
|
_install_bnb_rocm "install bitsandbytes (AMD)" "$_VENV_PY"
|
|
;;
|
|
esac
|
|
fi
|
|
# Fresh: Step 2 - install unsloth, preserving pre-installed torch
|
|
tauri_log "STEP" "Installing Unsloth"
|
|
substep "installing unsloth (this may take a few minutes)..."
|
|
if [ "$SKIP_TORCH" = true ]; then
|
|
# No-torch: install unsloth + unsloth-zoo with --no-deps, then
|
|
# runtime deps (typer, safetensors, transformers, etc.) with --no-deps.
|
|
run_install_cmd "install unsloth (no-torch)" uv pip install --python "$_VENV_PY" --no-deps \
|
|
--upgrade-package unsloth --upgrade-package unsloth-zoo \
|
|
"unsloth>=2026.5.2" unsloth-zoo
|
|
_NO_TORCH_RT="$(_find_no_torch_runtime)"
|
|
if [ -n "$_NO_TORCH_RT" ]; then
|
|
run_install_cmd "install no-torch runtime deps" uv pip install --python "$_VENV_PY" --no-deps -r "$_NO_TORCH_RT"
|
|
fi
|
|
if [ "$STUDIO_LOCAL_INSTALL" = true ]; then
|
|
substep "overlaying local repo (editable)..."
|
|
run_install_cmd "overlay local repo" uv pip install --python "$_VENV_PY" -e "$_REPO_ROOT" --no-deps
|
|
substep "overlaying unsloth-zoo from git main..."
|
|
run_install_cmd "overlay unsloth-zoo (git main)" uv pip install --python "$_VENV_PY" \
|
|
--no-deps --reinstall-package unsloth-zoo \
|
|
"unsloth-zoo @ git+https://github.com/unslothai/unsloth-zoo"
|
|
fi
|
|
elif [ "$STUDIO_LOCAL_INSTALL" = true ]; then
|
|
run_install_cmd "install unsloth (local)" uv pip install --python "$_VENV_PY" \
|
|
--upgrade-package unsloth "unsloth>=2026.5.2" unsloth-zoo
|
|
substep "overlaying local repo (editable)..."
|
|
run_install_cmd "overlay local repo" uv pip install --python "$_VENV_PY" -e "$_REPO_ROOT" --no-deps
|
|
substep "overlaying unsloth-zoo from git main..."
|
|
run_install_cmd "overlay unsloth-zoo (git main)" uv pip install --python "$_VENV_PY" \
|
|
--no-deps --reinstall-package unsloth-zoo \
|
|
"unsloth-zoo @ git+https://github.com/unslothai/unsloth-zoo"
|
|
else
|
|
run_install_cmd "install unsloth" uv pip install --python "$_VENV_PY" \
|
|
--upgrade-package unsloth -- "$PACKAGE_NAME"
|
|
fi
|
|
# AMD ROCm: repair torch if the unsloth/unsloth-zoo install pulled in
|
|
# CUDA torch from PyPI, overwriting the ROCm wheels installed in Step 1.
|
|
if [ "$SKIP_TORCH" = false ]; then
|
|
case "$TORCH_INDEX_URL" in
|
|
*/rocm*)
|
|
_has_hip=$("$_VENV_PY" -c "import torch; print(getattr(torch.version,'hip','') or '')" 2>/dev/null || true)
|
|
if [ -z "$_has_hip" ]; then
|
|
substep "repairing ROCm torch (overwritten by dependency resolution)..."
|
|
run_install_cmd "repair ROCm torch" uv pip install --python "$_VENV_PY" \
|
|
"$TORCH_CONSTRAINT" torchvision torchaudio \
|
|
--index-url "$TORCH_INDEX_URL" \
|
|
--force-reinstall
|
|
fi
|
|
;;
|
|
esac
|
|
fi
|
|
else
|
|
# Fallback: GPU detection failed to produce a URL -- let uv resolve torch
|
|
tauri_log "STEP" "Installing Unsloth"
|
|
substep "installing unsloth (this may take a few minutes)..."
|
|
if [ "$STUDIO_LOCAL_INSTALL" = true ]; then
|
|
run_install_cmd "install unsloth (auto torch backend)" uv pip install --python "$_VENV_PY" unsloth-zoo "unsloth>=2026.5.2" --torch-backend=auto
|
|
substep "overlaying local repo (editable)..."
|
|
run_install_cmd "overlay local repo" uv pip install --python "$_VENV_PY" -e "$_REPO_ROOT" --no-deps
|
|
substep "overlaying unsloth-zoo from git main..."
|
|
run_install_cmd "overlay unsloth-zoo (git main)" uv pip install --python "$_VENV_PY" \
|
|
--no-deps --reinstall-package unsloth-zoo \
|
|
"unsloth-zoo @ git+https://github.com/unslothai/unsloth-zoo"
|
|
else
|
|
run_install_cmd "install unsloth (auto torch backend)" uv pip install --python "$_VENV_PY" --torch-backend=auto -- "$PACKAGE_NAME"
|
|
fi
|
|
fi
|
|
|
|
# ── Run studio setup ──
|
|
tauri_log "STEP" "Running Studio setup"
|
|
# When --local, use the repo's own setup.sh directly.
|
|
# Otherwise, find it inside the installed package.
|
|
SETUP_SH=""
|
|
if [ "$STUDIO_LOCAL_INSTALL" = true ] && [ -f "$_REPO_ROOT/studio/setup.sh" ]; then
|
|
SETUP_SH="$_REPO_ROOT/studio/setup.sh"
|
|
fi
|
|
|
|
if [ -z "$SETUP_SH" ] || [ ! -f "$SETUP_SH" ]; then
|
|
SETUP_SH=$("$VENV_DIR/bin/python" -c "
|
|
import importlib.resources
|
|
print(importlib.resources.files('studio') / 'setup.sh')
|
|
" 2>/dev/null || echo "")
|
|
fi
|
|
|
|
# Fallback: search site-packages
|
|
if [ -z "$SETUP_SH" ] || [ ! -f "$SETUP_SH" ]; then
|
|
SETUP_SH=$(find "$VENV_DIR" -path "*/studio/setup.sh" -print -quit 2>/dev/null || echo "")
|
|
fi
|
|
|
|
if [ -z "$SETUP_SH" ] || [ ! -f "$SETUP_SH" ]; then
|
|
tauri_log "ERROR" "Could not find studio/setup.sh in the installed package"
|
|
echo "❌ ERROR: Could not find studio/setup.sh in the installed package."
|
|
exit 1
|
|
fi
|
|
|
|
# Ensure the venv's Python is on PATH so setup.sh can find it.
|
|
VENV_ABS_BIN="$(cd "$VENV_DIR/bin" && pwd)"
|
|
if [ -n "$VENV_ABS_BIN" ]; then
|
|
export PATH="$VENV_ABS_BIN:$PATH"
|
|
fi
|
|
|
|
if ! command -v bash >/dev/null 2>&1; then
|
|
step "setup" "bash is required to run studio setup" "$C_ERR"
|
|
substep "Please install bash and re-run install.sh"
|
|
exit 1
|
|
fi
|
|
|
|
step "setup" "running unsloth studio update..."
|
|
_SKIP_BASE=1
|
|
_SETUP_EXIT=0
|
|
# Tauri desktop app bundles its own frontend — skip Node/npm/frontend build
|
|
_SKIP_FRONTEND=0
|
|
if [ "$TAURI_MODE" = true ]; then
|
|
_SKIP_FRONTEND=1
|
|
fi
|
|
# Prepend UNSLOTH_STUDIO_HOME=$STUDIO_HOME to "$@" for env-override installs
|
|
# without word-splitting on whitespace paths.
|
|
_run_setup_with_studio_home() {
|
|
if [ "$_STUDIO_HOME_REDIRECT" = "env" ]; then
|
|
UNSLOTH_STUDIO_HOME="$STUDIO_HOME" "$@"
|
|
else
|
|
"$@"
|
|
fi
|
|
}
|
|
if [ "$STUDIO_LOCAL_INSTALL" = true ]; then
|
|
_run_setup_with_studio_home env \
|
|
SKIP_STUDIO_BASE="$_SKIP_BASE" \
|
|
SKIP_STUDIO_FRONTEND="$_SKIP_FRONTEND" \
|
|
STUDIO_PACKAGE_NAME="$PACKAGE_NAME" \
|
|
STUDIO_LOCAL_INSTALL=1 \
|
|
STUDIO_LOCAL_REPO="$_REPO_ROOT" \
|
|
UNSLOTH_NO_TORCH="$SKIP_TORCH" \
|
|
bash "$SETUP_SH" </dev/null || _SETUP_EXIT=$?
|
|
else
|
|
# Explicitly reset STUDIO_LOCAL_INSTALL / STUDIO_LOCAL_REPO so a stale
|
|
# value inherited from the parent shell (e.g. a previous --local run in
|
|
# the same session) does not silently flip a normal install onto the
|
|
# local-dev path in setup.sh and install_python_stack.py. Mirrors the
|
|
# reset already done in install.ps1 for PowerShell.
|
|
_run_setup_with_studio_home env \
|
|
SKIP_STUDIO_BASE="$_SKIP_BASE" \
|
|
SKIP_STUDIO_FRONTEND="$_SKIP_FRONTEND" \
|
|
STUDIO_PACKAGE_NAME="$PACKAGE_NAME" \
|
|
STUDIO_LOCAL_INSTALL=0 \
|
|
STUDIO_LOCAL_REPO= \
|
|
UNSLOTH_NO_TORCH="$SKIP_TORCH" \
|
|
bash "$SETUP_SH" </dev/null || _SETUP_EXIT=$?
|
|
fi
|
|
|
|
# ── Make 'unsloth' available via $_LOCAL_BIN (resolved earlier) ──
|
|
# Env-mode: $_LOCAL_BIN is $STUDIO_HOME/bin; skip shell-rc PATH append so we
|
|
# don't pollute the user's profile with a workspace-scoped path.
|
|
mkdir -p "$_LOCAL_BIN"
|
|
# ln -sf into an existing dir creates link inside it. Refuse to delete a
|
|
# real directory at the shim path -- that could destroy unrelated user data.
|
|
_shim_path="$_LOCAL_BIN/unsloth"
|
|
if [ -d "$_shim_path" ] && [ ! -L "$_shim_path" ]; then
|
|
echo "ERROR: $_shim_path is a directory; refusing to delete it." >&2
|
|
echo " Move or remove it manually, then re-run the installer." >&2
|
|
exit 1
|
|
fi
|
|
# why: -sfn is atomic and -n prevents descent into a symlink-to-directory at
|
|
# the shim path (the directory guard above already rejects a real directory).
|
|
ln -sfn "$VENV_DIR/bin/unsloth" "$_shim_path"
|
|
|
|
case ":$PATH:" in
|
|
*":$_LOCAL_BIN:"*) ;; # already on PATH
|
|
*)
|
|
if [ "$_STUDIO_HOME_REDIRECT" = "env" ]; then
|
|
export PATH="$_LOCAL_BIN:$PATH"
|
|
step "path" "exported $_LOCAL_BIN for this session (no rc-file append in env-override mode)"
|
|
else
|
|
_SHELL_PROFILE=""
|
|
if [ -n "${ZSH_VERSION:-}" ] || [ "$(basename "${SHELL:-}")" = "zsh" ]; then
|
|
_SHELL_PROFILE="$HOME/.zshrc"
|
|
elif [ -f "$HOME/.bashrc" ]; then
|
|
_SHELL_PROFILE="$HOME/.bashrc"
|
|
elif [ -f "$HOME/.profile" ]; then
|
|
_SHELL_PROFILE="$HOME/.profile"
|
|
fi
|
|
if [ -n "$_SHELL_PROFILE" ]; then
|
|
if ! grep -q '\.local/bin' "$_SHELL_PROFILE" 2>/dev/null; then
|
|
echo '' >> "$_SHELL_PROFILE"
|
|
echo '# Added by Unsloth installer' >> "$_SHELL_PROFILE"
|
|
echo 'export PATH="$HOME/.local/bin:$PATH"' >> "$_SHELL_PROFILE"
|
|
step "path" "added ~/.local/bin to PATH in $_SHELL_PROFILE"
|
|
fi
|
|
fi
|
|
export PATH="$_LOCAL_BIN:$PATH"
|
|
fi
|
|
;;
|
|
esac
|
|
|
|
# Non-Tauri installs keep shortcuts even if setup reports failure.
|
|
# create_studio_shortcuts gates persistent menu shortcuts on env-mode;
|
|
# launcher + studio.conf + icon are always written.
|
|
if [ "$TAURI_MODE" != true ]; then
|
|
create_studio_shortcuts "$VENV_ABS_BIN/unsloth" "$OS"
|
|
fi
|
|
|
|
# If setup.sh failed, report and exit now.
|
|
# PATH and shortcuts are already set up so the user can fix and retry.
|
|
if [ "$_SETUP_EXIT" -ne 0 ]; then
|
|
echo ""
|
|
step "error" "studio setup failed (exit code $_SETUP_EXIT)" "$C_ERR"
|
|
echo ""
|
|
exit "$_SETUP_EXIT"
|
|
fi
|
|
|
|
_commit_studio_venv_replacement
|
|
|
|
# ── Tauri mode: done, skip shortcuts and auto-launch ──
|
|
if [ "$TAURI_MODE" = true ]; then
|
|
tauri_log "DONE" ""
|
|
exit 0
|
|
fi
|
|
|
|
echo ""
|
|
printf " ${C_TITLE}%s${C_RST}\n" "Unsloth Studio installed!"
|
|
printf " ${C_DIM}%s${C_RST}\n" "$RULE"
|
|
echo ""
|
|
|
|
# In interactive terminals, ask the user before starting Studio.
|
|
# In non-interactive environments (Docker, CI, cloud-init) just print instructions.
|
|
if [ -t 1 ]; then
|
|
echo ""
|
|
printf " Start Unsloth Studio now? [Y/n] "
|
|
if [ -r /dev/tty ]; then
|
|
read -r _reply </dev/tty || _reply="y"
|
|
else
|
|
_reply="y"
|
|
fi
|
|
case "${_reply:-y}" in
|
|
[Yy]*|"")
|
|
step "launch" "starting Unsloth Studio..."
|
|
"$VENV_DIR/bin/unsloth" studio -p 8888
|
|
_LAUNCH_EXIT=$?
|
|
if [ "$_LAUNCH_EXIT" -ne 0 ] && [ "$_MIGRATED" = true ]; then
|
|
echo ""
|
|
echo "⚠️ Unsloth Studio failed to start after migration."
|
|
echo " Your migrated environment may be incompatible."
|
|
echo " To fix, remove the environment and reinstall:"
|
|
echo ""
|
|
echo " rm -rf $VENV_DIR"
|
|
echo " curl -fsSL https://unsloth.ai/install.sh | sh"
|
|
echo ""
|
|
fi
|
|
exit "$_LAUNCH_EXIT"
|
|
;;
|
|
*)
|
|
step "launch" "to start later, run:"
|
|
substep "unsloth studio -p 8888"
|
|
substep "(add -H 0.0.0.0 to allow network / cloud access)"
|
|
echo ""
|
|
;;
|
|
esac
|
|
else
|
|
step "launch" "manual commands:"
|
|
# Single-quote-escape so paths with spaces / apostrophes copy-paste cleanly.
|
|
_li_shim_q="'$(printf '%s' "${_LOCAL_BIN}/unsloth" | sed "s/'/'\\\\''/g")'"
|
|
_li_act_q="'$(printf '%s' "${VENV_DIR}/bin/activate" | sed "s/'/'\\\\''/g")'"
|
|
if [ "$_STUDIO_HOME_REDIRECT" = "env" ]; then
|
|
# Env-mode skips the rc PATH append, so print the absolute shim path.
|
|
substep "$_li_shim_q studio -p 8888"
|
|
substep "or activate env first:"
|
|
substep "source $_li_act_q"
|
|
substep "unsloth studio -p 8888"
|
|
else
|
|
substep "unsloth studio -p 8888"
|
|
substep "or activate env first:"
|
|
substep "source $_li_act_q"
|
|
substep "unsloth studio -p 8888"
|
|
fi
|
|
substep "(add -H 0.0.0.0 to allow network / cloud access)"
|
|
echo ""
|
|
fi
|