install: support STUDIO_HOME / UNSLOTH_STUDIO_HOME for custom install paths (#5190)
* 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>
This commit is contained in:
parent
3927ec0a68
commit
7be10852cb
19 changed files with 2424 additions and 173 deletions
390
install.ps1
390
install.ps1
|
|
@ -3,6 +3,11 @@
|
|||
# Local: Set-ExecutionPolicy -Scope Process -ExecutionPolicy Bypass; .\install.ps1 --local
|
||||
# NoTorch: .\install.ps1 --no-torch (skip PyTorch, GGUF-only mode)
|
||||
# Test: .\install.ps1 --package roland-sloth
|
||||
#
|
||||
# Env vars (priority: UNSLOTH_STUDIO_HOME > STUDIO_HOME > USERPROFILE-redirect > default):
|
||||
# UNSLOTH_STUDIO_HOME / STUDIO_HOME = path -> install under that path
|
||||
# (DataDir nests inside; user PATH not modified persistently).
|
||||
# Default ($USERPROFILE\.unsloth\studio) is preserved when no env var is set.
|
||||
|
||||
function Install-UnslothStudio {
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
|
@ -126,7 +131,94 @@ function Install-UnslothStudio {
|
|||
}
|
||||
|
||||
$PythonVersion = "3.13"
|
||||
$StudioHome = Join-Path $env:USERPROFILE ".unsloth\studio"
|
||||
|
||||
# Resolve install destinations. Priority: UNSLOTH_STUDIO_HOME, then
|
||||
# STUDIO_HOME alias, then USERPROFILE-redirect, then default.
|
||||
# Reject whitespace-only values so " " is treated as unset (matches the
|
||||
# Python resolvers' .strip()), preventing install/runtime layout drift.
|
||||
$envOverrideVar = $null
|
||||
$envOverride = $null
|
||||
if (-not [string]::IsNullOrWhiteSpace($env:UNSLOTH_STUDIO_HOME)) {
|
||||
$envOverrideVar = "UNSLOTH_STUDIO_HOME"
|
||||
$envOverride = $env:UNSLOTH_STUDIO_HOME.Trim()
|
||||
} elseif (-not [string]::IsNullOrWhiteSpace($env:STUDIO_HOME)) {
|
||||
$envOverrideVar = "STUDIO_HOME"
|
||||
$envOverride = $env:STUDIO_HOME.Trim()
|
||||
}
|
||||
|
||||
# Custom Studio roots are not supported with --tauri (desktop app still
|
||||
# resolves %USERPROFILE%\.unsloth\studio). Pass through if override == legacy.
|
||||
if ($TauriMode -and $envOverride) {
|
||||
$_tauriOverride = $envOverride
|
||||
if ($_tauriOverride -eq "~" -or $_tauriOverride -like "~/*" -or $_tauriOverride -like "~\*") {
|
||||
$_tauriOverride = (Join-Path $env:USERPROFILE $_tauriOverride.Substring(1).TrimStart('/','\'))
|
||||
}
|
||||
try {
|
||||
$_tauriOverride = [System.IO.Path]::GetFullPath($_tauriOverride)
|
||||
} catch {}
|
||||
$_legacyTauriRoot = Join-Path $env:USERPROFILE ".unsloth\studio"
|
||||
try {
|
||||
$_legacyTauriRoot = [System.IO.Path]::GetFullPath($_legacyTauriRoot)
|
||||
} catch {}
|
||||
# Strip trailing separators so ".../studio\" matches ".../studio".
|
||||
$_trimSeps = @(
|
||||
[System.IO.Path]::DirectorySeparatorChar,
|
||||
[System.IO.Path]::AltDirectorySeparatorChar
|
||||
)
|
||||
$_tauriOverride = $_tauriOverride.TrimEnd($_trimSeps)
|
||||
$_legacyTauriRoot = $_legacyTauriRoot.TrimEnd($_trimSeps)
|
||||
if ($_tauriOverride -ne $_legacyTauriRoot) {
|
||||
Write-Host "ERROR: $envOverrideVar is not supported with --tauri." -ForegroundColor Red
|
||||
Write-Host " The desktop app still uses the legacy %USERPROFILE%\.unsloth\studio root." -ForegroundColor Red
|
||||
Write-Host " Run install.ps1 without --tauri for custom-root shell installs," -ForegroundColor Yellow
|
||||
Write-Host " or unset the env var for default desktop installs." -ForegroundColor Yellow
|
||||
throw "$envOverrideVar is not supported with --tauri."
|
||||
}
|
||||
}
|
||||
|
||||
$defaultProfile = $null
|
||||
try { $defaultProfile = [Environment]::GetFolderPath("UserProfile") } catch {}
|
||||
|
||||
# LOCALAPPDATA may be unset in service / CI contexts; Join-Path would abort
|
||||
# under ErrorActionPreference=Stop without this guard.
|
||||
$defaultDataDir = if ($env:LOCALAPPDATA -and -not [string]::IsNullOrWhiteSpace($env:LOCALAPPDATA)) {
|
||||
Join-Path $env:LOCALAPPDATA "Unsloth Studio"
|
||||
} else { $null }
|
||||
|
||||
if ($envOverride) {
|
||||
# Tilde expansion: env vars aren't subject to it when quoted on assignment.
|
||||
if ($envOverride -eq "~" -or $envOverride -like "~/*" -or $envOverride -like "~\*") {
|
||||
$envOverride = (Join-Path $env:USERPROFILE $envOverride.Substring(1).TrimStart('/','\'))
|
||||
}
|
||||
try {
|
||||
# .NET API: New-Item -Path treats brackets as wildcards and has no
|
||||
# -LiteralPath in PS 5.1, so a root like C:\studio[abc] would fail.
|
||||
[System.IO.Directory]::CreateDirectory($envOverride) | Out-Null
|
||||
$StudioHome = (Resolve-Path -LiteralPath $envOverride).Path
|
||||
} catch {
|
||||
Write-Host "ERROR: $envOverrideVar=$envOverride cannot be created or accessed." -ForegroundColor Red
|
||||
throw "$envOverrideVar=$envOverride cannot be created or accessed."
|
||||
}
|
||||
$probe = Join-Path $StudioHome (".unsloth-write-probe-" + [guid]::NewGuid())
|
||||
try {
|
||||
# WriteAllText: literal-path safe + closes handle so Remove-Item works.
|
||||
[System.IO.File]::WriteAllText($probe, "")
|
||||
Remove-Item -LiteralPath $probe -Force -ErrorAction SilentlyContinue
|
||||
} catch {
|
||||
Write-Host "ERROR: $envOverrideVar=$StudioHome is not writable." -ForegroundColor Red
|
||||
throw "$envOverrideVar=$StudioHome is not writable."
|
||||
}
|
||||
$StudioDataDir = Join-Path $StudioHome "share"
|
||||
$StudioRedirectMode = 'env'
|
||||
} elseif ($defaultProfile -and $env:USERPROFILE -and ($env:USERPROFILE -ne $defaultProfile)) {
|
||||
$StudioHome = Join-Path $env:USERPROFILE ".unsloth\studio"
|
||||
$StudioDataDir = $defaultDataDir
|
||||
$StudioRedirectMode = 'profile'
|
||||
} else {
|
||||
$StudioHome = Join-Path $env:USERPROFILE ".unsloth\studio"
|
||||
$StudioDataDir = $defaultDataDir
|
||||
$StudioRedirectMode = 'default'
|
||||
}
|
||||
$VenvDir = Join-Path $StudioHome "unsloth_studio"
|
||||
|
||||
$Rule = [string]::new([char]0x2500, 52)
|
||||
|
|
@ -378,24 +470,24 @@ function Install-UnslothStudio {
|
|||
[Parameter(Mandatory = $true)][string]$UnslothExePath
|
||||
)
|
||||
|
||||
if (-not (Test-Path $UnslothExePath)) {
|
||||
if (-not (Test-Path -LiteralPath $UnslothExePath)) {
|
||||
substep "cannot create shortcuts, unsloth.exe not found at $UnslothExePath" "Yellow"
|
||||
return
|
||||
}
|
||||
try {
|
||||
# Persist an absolute path in launcher scripts so shortcut working
|
||||
# directory changes do not break process startup.
|
||||
$UnslothExePath = (Resolve-Path $UnslothExePath).Path
|
||||
$UnslothExePath = (Resolve-Path -LiteralPath $UnslothExePath).Path
|
||||
# Escape for single-quoted embedding in generated launcher script.
|
||||
# This prevents runtime variable expansion for paths containing '$'.
|
||||
$SingleQuotedExePath = $UnslothExePath -replace "'", "''"
|
||||
|
||||
$localAppDataDir = $env:LOCALAPPDATA
|
||||
if (-not $localAppDataDir -or [string]::IsNullOrWhiteSpace($localAppDataDir)) {
|
||||
substep "LOCALAPPDATA path unavailable; skipped shortcut creation" "Yellow"
|
||||
# $StudioDataDir = LOCALAPPDATA\Unsloth Studio, or $StudioHome\share in env-mode.
|
||||
if (-not $StudioDataDir -or [string]::IsNullOrWhiteSpace($StudioDataDir)) {
|
||||
substep "DataDir path unavailable; skipped shortcut creation" "Yellow"
|
||||
return
|
||||
}
|
||||
$appDir = Join-Path $localAppDataDir "Unsloth Studio"
|
||||
$appDir = $StudioDataDir
|
||||
$launcherPs1 = Join-Path $appDir "launch-studio.ps1"
|
||||
$launcherVbs = Join-Path $appDir "launch-studio.vbs"
|
||||
$desktopDir = [Environment]::GetFolderPath("Desktop")
|
||||
|
|
@ -427,23 +519,89 @@ function Install-UnslothStudio {
|
|||
}
|
||||
$iconUrl = "https://raw.githubusercontent.com/unslothai/unsloth/main/studio/frontend/public/unsloth.ico"
|
||||
|
||||
if (-not (Test-Path $appDir)) {
|
||||
New-Item -ItemType Directory -Path $appDir -Force | Out-Null
|
||||
if (-not (Test-Path -LiteralPath $appDir)) {
|
||||
[System.IO.Directory]::CreateDirectory($appDir) | Out-Null
|
||||
}
|
||||
|
||||
# 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(resolved $StudioHome)
|
||||
# scheme to (a) avoid leaking the install path on -H 0.0.0.0
|
||||
# deployments and (b) sidestep launcher/backend canonicalization
|
||||
# drift (Resolve-Path vs Path.resolve() junction handling). Lives
|
||||
# at $StudioHome\share\ (not $appDir) so the backend can find it
|
||||
# via _STUDIO_ROOT_RESOLVED / "share" / "studio_install_id"
|
||||
# regardless of mode. 32 bytes of crypto random -> 64 hex chars.
|
||||
$_studioIdDir = Join-Path $StudioHome "share"
|
||||
if (-not (Test-Path -LiteralPath $_studioIdDir)) {
|
||||
[System.IO.Directory]::CreateDirectory($_studioIdDir) | Out-Null
|
||||
}
|
||||
$_studioIdFile = Join-Path $_studioIdDir "studio_install_id"
|
||||
$_studioRootId = ""
|
||||
if ((Test-Path -LiteralPath $_studioIdFile) -and `
|
||||
((Get-Item -LiteralPath $_studioIdFile).Length -gt 0)) {
|
||||
$_studioRootId = ([System.IO.File]::ReadAllText($_studioIdFile)).Trim()
|
||||
}
|
||||
if (-not $_studioRootId) {
|
||||
$_idBytes = New-Object byte[] 32
|
||||
[Security.Cryptography.RandomNumberGenerator]::Create().GetBytes($_idBytes)
|
||||
$_studioRootId = -join ($_idBytes | ForEach-Object { $_.ToString('x2') })
|
||||
# Atomic write: write to a temp sibling then rename, so a partial
|
||||
# install cannot leave a half-written id.
|
||||
$_idTmp = $_studioIdFile + ".$PID.tmp"
|
||||
[System.IO.File]::WriteAllText($_idTmp, $_studioRootId)
|
||||
Move-Item -LiteralPath $_idTmp -Destination $_studioIdFile -Force
|
||||
}
|
||||
|
||||
# Env-mode: persist UNSLOTH_STUDIO_HOME (and llama path) so fresh
|
||||
# shells don't need to re-export, and bake per-install $portFile /
|
||||
# $mutexName so concurrent custom-root launchers cannot serialize
|
||||
# through one global mutex on 8888..8908. Default installs get an
|
||||
# empty prefix to match pre-PR behavior.
|
||||
$studioHomeExport = if ($StudioRedirectMode -eq 'env') {
|
||||
# When override == legacy default, llama.cpp stays at
|
||||
# ~/.unsloth/llama.cpp (one shared build). Canonicalize the
|
||||
# legacy side so the comparison survives path normalization.
|
||||
$_legacyStudio = Join-Path $env:USERPROFILE ".unsloth\studio"
|
||||
if (Test-Path -LiteralPath $_legacyStudio -PathType Container) {
|
||||
$_legacyStudio = (Resolve-Path -LiteralPath $_legacyStudio).Path
|
||||
}
|
||||
$_llamaPath = if ($StudioHome -eq $_legacyStudio) {
|
||||
Join-Path $env:USERPROFILE ".unsloth\llama.cpp"
|
||||
} else {
|
||||
Join-Path $StudioHome "llama.cpp"
|
||||
}
|
||||
$_sq = $StudioHome -replace "'", "''"
|
||||
$_llama = $_llamaPath -replace "'", "''"
|
||||
$_appDirSq = $appDir -replace "'", "''"
|
||||
$_appBytes = [Text.Encoding]::UTF8.GetBytes($appDir)
|
||||
$_appHash = ([BitConverter]::ToString(
|
||||
[Security.Cryptography.SHA256]::Create().ComputeHash($_appBytes)
|
||||
) -replace '-', '').Substring(0, 16)
|
||||
# UNSLOTH_LLAMA_CPP_PATH is a pre-existing user override; only default if unset.
|
||||
"`$env:UNSLOTH_STUDIO_HOME = '$_sq'`nif (-not `$env:UNSLOTH_LLAMA_CPP_PATH) {`n `$env:UNSLOTH_LLAMA_CPP_PATH = '$_llama'`n}`n`$portFile = '$_appDirSq\studio.port'`n`$mutexName = 'Local\UnslothStudioLauncher-$_appHash'`n"
|
||||
} else {
|
||||
"`$portFile = `$null`n`$mutexName = 'Local\UnslothStudioLauncher'`n"
|
||||
}
|
||||
|
||||
$launcherContent = @"
|
||||
`$ErrorActionPreference = 'Stop'
|
||||
$studioHomeExport`$ErrorActionPreference = 'Stop'
|
||||
`$basePort = 8888
|
||||
`$maxPortOffset = 20
|
||||
`$timeoutSec = 60
|
||||
`$pollIntervalMs = 1000
|
||||
`$_ExpectedStudioRootId = '$_studioRootId'
|
||||
|
||||
function Test-StudioHealth {
|
||||
param([Parameter(Mandatory = `$true)][int]`$Port)
|
||||
try {
|
||||
`$url = "http://127.0.0.1:`$Port/api/health"
|
||||
`$resp = Invoke-RestMethod -Uri `$url -TimeoutSec 1 -Method Get
|
||||
return (`$resp -and `$resp.status -eq 'healthy' -and `$resp.service -eq 'Unsloth UI Backend')
|
||||
if (-not (`$resp -and `$resp.status -eq 'healthy' -and `$resp.service -eq 'Unsloth UI Backend')) { return `$false }
|
||||
# why: verify the backend belongs to THIS install via the install-time
|
||||
# hex digest; raw path is not leaked over /api/health.
|
||||
if (`$_ExpectedStudioRootId -and `$resp.studio_root_id -ne `$_ExpectedStudioRootId) { return `$false }
|
||||
return `$true
|
||||
} catch {
|
||||
return `$false
|
||||
}
|
||||
|
|
@ -469,6 +627,17 @@ function Get-CandidatePorts {
|
|||
}
|
||||
|
||||
function Find-HealthyStudioPort {
|
||||
if (`$portFile) {
|
||||
if (Test-Path -LiteralPath `$portFile) {
|
||||
`$cached = Get-Content -LiteralPath `$portFile -ErrorAction SilentlyContinue | Select-Object -First 1
|
||||
if (`$cached -match '^\d+`$') {
|
||||
`$cachedPort = [int]`$cached
|
||||
if (Test-StudioHealth -Port `$cachedPort) { return `$cachedPort }
|
||||
Remove-Item -LiteralPath `$portFile -Force -ErrorAction SilentlyContinue
|
||||
}
|
||||
}
|
||||
return `$null
|
||||
}
|
||||
foreach (`$candidate in (Get-CandidatePorts)) {
|
||||
if (Test-StudioHealth -Port `$candidate) {
|
||||
return `$candidate
|
||||
|
|
@ -522,7 +691,7 @@ if (`$existingPort) {
|
|||
exit 0
|
||||
}
|
||||
|
||||
`$launchMutex = [System.Threading.Mutex]::new(`$false, 'Local\UnslothStudioLauncher')
|
||||
`$launchMutex = [System.Threading.Mutex]::new(`$false, `$mutexName)
|
||||
`$haveMutex = `$false
|
||||
try {
|
||||
try {
|
||||
|
|
@ -552,7 +721,9 @@ try {
|
|||
} catch {}
|
||||
exit 1
|
||||
}
|
||||
`$studioCommand = '& "' + `$studioExe + '" studio -p ' + `$launchPort
|
||||
# Single-quote the path in the child -Command so `$` / backtick in custom
|
||||
# roots don't get reparsed; double any apostrophes so 'O''Brien' survives.
|
||||
`$studioCommand = "& '" + (`$studioExe -replace "'", "''") + "' studio -p " + `$launchPort
|
||||
`$launchArgs = @(
|
||||
'-NoExit',
|
||||
'-NoProfile',
|
||||
|
|
@ -576,9 +747,13 @@ try {
|
|||
`$browserOpened = `$false
|
||||
`$deadline = (Get-Date).AddSeconds(`$timeoutSec)
|
||||
while ((Get-Date) -lt `$deadline) {
|
||||
`$healthyPort = Find-HealthyStudioPort
|
||||
if (`$healthyPort) {
|
||||
Start-Process "http://localhost:`$healthyPort"
|
||||
if (Test-StudioHealth -Port `$launchPort) {
|
||||
if (`$portFile) {
|
||||
try {
|
||||
[System.IO.File]::WriteAllText(`$portFile, "`$launchPort`n")
|
||||
} catch {}
|
||||
}
|
||||
Start-Process "http://localhost:`$launchPort"
|
||||
`$browserOpened = `$true
|
||||
break
|
||||
}
|
||||
|
|
@ -613,19 +788,19 @@ cmd = "powershell -NoProfile -ExecutionPolicy Bypass -WindowStyle Hidden -File "
|
|||
shell.Run cmd, 0, False
|
||||
"@
|
||||
# WSH handles UTF-16LE reliably for .vbs files with non-ASCII paths.
|
||||
Set-Content -Path $launcherVbs -Value $vbsContent -Encoding Unicode -Force
|
||||
Set-Content -LiteralPath $launcherVbs -Value $vbsContent -Encoding Unicode -Force
|
||||
|
||||
# Prefer bundled icon from local clone/dev installs.
|
||||
# If not available, best-effort download from raw GitHub.
|
||||
# We only attach the icon if the resulting file has a valid ICO header.
|
||||
$hasValidIcon = $false
|
||||
if ($bundledIcon -and (Test-Path $bundledIcon)) {
|
||||
if ($bundledIcon -and (Test-Path -LiteralPath $bundledIcon)) {
|
||||
try {
|
||||
Copy-Item -Path $bundledIcon -Destination $iconPath -Force
|
||||
Copy-Item -LiteralPath $bundledIcon -Destination $iconPath -Force
|
||||
} catch {
|
||||
Write-Host "[DEBUG] Error copying bundled icon: $($_.Exception.Message)" -ForegroundColor DarkGray
|
||||
}
|
||||
} elseif (-not (Test-Path $iconPath)) {
|
||||
} elseif (-not (Test-Path -LiteralPath $iconPath)) {
|
||||
try {
|
||||
Invoke-WebRequest -Uri $iconUrl -OutFile $iconPath -UseBasicParsing
|
||||
} catch {
|
||||
|
|
@ -633,7 +808,7 @@ shell.Run cmd, 0, False
|
|||
}
|
||||
}
|
||||
|
||||
if (Test-Path $iconPath) {
|
||||
if (Test-Path -LiteralPath $iconPath) {
|
||||
try {
|
||||
$bytes = [System.IO.File]::ReadAllBytes($iconPath)
|
||||
if (
|
||||
|
|
@ -645,14 +820,21 @@ shell.Run cmd, 0, False
|
|||
) {
|
||||
$hasValidIcon = $true
|
||||
} else {
|
||||
Remove-Item $iconPath -Force -ErrorAction SilentlyContinue
|
||||
Remove-Item -LiteralPath $iconPath -Force -ErrorAction SilentlyContinue
|
||||
}
|
||||
} catch {
|
||||
Write-Host "[DEBUG] Error validating or removing icon: $($_.Exception.Message)" -ForegroundColor DarkGray
|
||||
Remove-Item $iconPath -Force -ErrorAction SilentlyContinue
|
||||
Remove-Item -LiteralPath $iconPath -Force -ErrorAction SilentlyContinue
|
||||
}
|
||||
}
|
||||
|
||||
# Env-mode: skip persistent Desktop / Start Menu .lnk shortcuts
|
||||
# that may point at a deleted workspace; launcher + icon stay.
|
||||
if ($StudioRedirectMode -eq 'env') {
|
||||
substep "wrote launcher at $launcherPs1 (persistent shortcuts skipped in env-override mode)"
|
||||
return
|
||||
}
|
||||
|
||||
$wscriptExe = Join-Path $env:SystemRoot "System32\wscript.exe"
|
||||
$shortcutArgs = "//B //Nologo `"$launcherVbs`""
|
||||
|
||||
|
|
@ -850,8 +1032,9 @@ shell.Run cmd, 0, False
|
|||
# Pass the resolved executable path to uv so it does not re-resolve
|
||||
# a version string back to a conda interpreter.
|
||||
Write-TauriLog "STEP" "Creating virtual environment"
|
||||
if (-not (Test-Path $StudioHome)) {
|
||||
New-Item -ItemType Directory -Path $StudioHome -Force | Out-Null
|
||||
if (-not (Test-Path -LiteralPath $StudioHome)) {
|
||||
# .NET API: New-Item -Path treats brackets as wildcards.
|
||||
[System.IO.Directory]::CreateDirectory($StudioHome) | Out-Null
|
||||
}
|
||||
|
||||
$VenvPython = Join-Path $VenvDir "Scripts\python.exe"
|
||||
|
|
@ -865,11 +1048,13 @@ shell.Run cmd, 0, False
|
|||
$stamp = Get-Date -Format "yyyyMMddHHmmss"
|
||||
$candidate = Join-Path $StudioHome "unsloth_studio.rollback.$stamp.$PID"
|
||||
$suffix = 0
|
||||
while (Test-Path $candidate) {
|
||||
# -LiteralPath: a custom $StudioHome may contain [ ] * ? which
|
||||
# plain Test-Path / Move-Item would interpret as wildcards.
|
||||
while (Test-Path -LiteralPath $candidate) {
|
||||
$suffix++
|
||||
$candidate = Join-Path $StudioHome "unsloth_studio.rollback.$stamp.$PID.$suffix"
|
||||
}
|
||||
Move-Item -Path $ExistingDir -Destination $candidate -ErrorAction Stop
|
||||
Move-Item -LiteralPath $ExistingDir -Destination $candidate -ErrorAction Stop
|
||||
$script:StudioVenvRollbackDir = $candidate
|
||||
$script:StudioVenvRollbackTarget = $ExistingDir
|
||||
$script:StudioVenvRollbackActive = $true
|
||||
|
|
@ -880,16 +1065,16 @@ shell.Run cmd, 0, False
|
|||
if (-not $script:StudioVenvRollbackActive) { return }
|
||||
$backup = $script:StudioVenvRollbackDir
|
||||
$target = $script:StudioVenvRollbackTarget
|
||||
if (-not $backup -or -not (Test-Path $backup)) {
|
||||
if (-not $backup -or -not (Test-Path -LiteralPath $backup)) {
|
||||
$script:StudioVenvRollbackActive = $false
|
||||
return
|
||||
}
|
||||
substep "restoring previous environment after failed install..." "Yellow"
|
||||
try {
|
||||
if (Test-Path $target) {
|
||||
Remove-Item -Recurse -Force $target -ErrorAction SilentlyContinue
|
||||
if (Test-Path -LiteralPath $target) {
|
||||
Remove-Item -LiteralPath $target -Recurse -Force -ErrorAction SilentlyContinue
|
||||
}
|
||||
Move-Item -Path $backup -Destination $target -Force -ErrorAction Stop
|
||||
Move-Item -LiteralPath $backup -Destination $target -Force -ErrorAction Stop
|
||||
substep "restored previous environment"
|
||||
$script:StudioVenvRollbackActive = $false
|
||||
$script:StudioVenvRollbackDir = $null
|
||||
|
|
@ -902,14 +1087,29 @@ shell.Run cmd, 0, False
|
|||
function Complete-StudioVenvRollback {
|
||||
if (-not $script:StudioVenvRollbackActive) { return }
|
||||
$backup = $script:StudioVenvRollbackDir
|
||||
if ($backup -and (Test-Path $backup)) {
|
||||
Remove-Item -Recurse -Force $backup -ErrorAction SilentlyContinue
|
||||
if ($backup -and (Test-Path -LiteralPath $backup)) {
|
||||
Remove-Item -LiteralPath $backup -Recurse -Force -ErrorAction SilentlyContinue
|
||||
}
|
||||
$script:StudioVenvRollbackActive = $false
|
||||
$script:StudioVenvRollbackDir = $null
|
||||
}
|
||||
|
||||
if (Test-Path $VenvPython) {
|
||||
if (Test-Path -LiteralPath $VenvPython) {
|
||||
# why: matching guard to the .venv branch below -- in env-mode
|
||||
# $StudioHome is a user-chosen workspace, so refuse to nuke an
|
||||
# existing $StudioHome\unsloth_studio that lacks Studio sentinels.
|
||||
# -PathType Leaf rejects a directory at the sentinel path. Accept the
|
||||
# in-VENV ownership marker so partial-install retries are not blocked.
|
||||
if (
|
||||
$StudioRedirectMode -eq 'env' -and
|
||||
-not (Test-Path -LiteralPath (Join-Path $VenvDir ".unsloth-studio-owned") -PathType Leaf) -and
|
||||
-not (Test-Path -LiteralPath (Join-Path $StudioHome "share\studio.conf") -PathType Leaf) -and
|
||||
-not (Test-Path -LiteralPath (Join-Path $StudioHome "bin\unsloth.exe") -PathType Leaf)
|
||||
) {
|
||||
Write-Host "[ERROR] $VenvDir already exists but does not look like an Unsloth Studio install." -ForegroundColor Red
|
||||
Write-Host " Move it aside or choose an empty UNSLOTH_STUDIO_HOME." -ForegroundColor Yellow
|
||||
throw "Refusing to delete non-Studio venv at $VenvDir"
|
||||
}
|
||||
# New layout already exists -- replace only after preserving rollback copy.
|
||||
substep "preserving existing environment for rollback..."
|
||||
try {
|
||||
|
|
@ -918,8 +1118,13 @@ shell.Run cmd, 0, False
|
|||
Write-Host "[ERROR] Could not prepare existing environment for reinstall: $($_.Exception.Message)" -ForegroundColor Red
|
||||
return (Exit-InstallFailure "Could not prepare existing environment for reinstall")
|
||||
}
|
||||
} elseif (Test-Path (Join-Path $StudioHome ".venv\Scripts\python.exe")) {
|
||||
# Old layout (~/.unsloth/studio/.venv) exists -- validate before migrating
|
||||
} elseif (
|
||||
$StudioRedirectMode -ne 'env' `
|
||||
-and (Test-Path -LiteralPath (Join-Path $StudioHome ".venv\Scripts\python.exe"))
|
||||
) {
|
||||
# Old layout (~/.unsloth/studio/.venv) exists -- validate before migrating.
|
||||
# Skip in env-mode so we don't blow away an unrelated .venv at the
|
||||
# workspace root (e.g. user's existing project Python venv).
|
||||
$OldVenv = Join-Path $StudioHome ".venv"
|
||||
$OldPy = Join-Path $OldVenv "Scripts\python.exe"
|
||||
substep "found legacy Studio environment, validating..."
|
||||
|
|
@ -936,24 +1141,29 @@ shell.Run cmd, 0, False
|
|||
$ErrorActionPreference = $prevEAP2
|
||||
if ($legacyOk) {
|
||||
substep "legacy environment is healthy -- migrating..."
|
||||
Move-Item -Path $OldVenv -Destination $VenvDir -Force
|
||||
Move-Item -LiteralPath $OldVenv -Destination $VenvDir -Force
|
||||
substep "moved .venv -> unsloth_studio"
|
||||
$_Migrated = $true
|
||||
} else {
|
||||
substep "legacy environment failed validation -- creating fresh environment" "Yellow"
|
||||
$invalidVenv = Join-Path $StudioHome (".venv.invalid.{0}.{1}" -f (Get-Date -Format "yyyyMMddHHmmss"), $PID)
|
||||
Move-Item -Path $OldVenv -Destination $invalidVenv -Force -ErrorAction SilentlyContinue
|
||||
Move-Item -LiteralPath $OldVenv -Destination $invalidVenv -Force -ErrorAction SilentlyContinue
|
||||
}
|
||||
} elseif (Test-Path (Join-Path $env:USERPROFILE "unsloth_studio\Scripts\python.exe")) {
|
||||
# CWD-relative venv from old install.ps1 -- migrate to absolute path
|
||||
} elseif (
|
||||
$StudioRedirectMode -ne 'env' `
|
||||
-and (Test-Path -LiteralPath (Join-Path $env:USERPROFILE "unsloth_studio\Scripts\python.exe"))
|
||||
) {
|
||||
# CWD-relative venv from old install.ps1 -> migrate to absolute path.
|
||||
# Skip in env-mode so we don't relocate the default-install venv into
|
||||
# the workspace root.
|
||||
$CwdVenv = Join-Path $env:USERPROFILE "unsloth_studio"
|
||||
substep "found CWD-relative Studio environment, migrating to $VenvDir..."
|
||||
Move-Item -Path $CwdVenv -Destination $VenvDir -Force
|
||||
Move-Item -LiteralPath $CwdVenv -Destination $VenvDir -Force
|
||||
substep "moved ~/unsloth_studio -> ~/.unsloth/studio/unsloth_studio"
|
||||
$_Migrated = $true
|
||||
}
|
||||
|
||||
if (-not (Test-Path $VenvPython)) {
|
||||
if (-not (Test-Path -LiteralPath $VenvPython)) {
|
||||
step "venv" "creating Python $($DetectedPython.Version) virtual environment"
|
||||
substep "$VenvDir"
|
||||
$venvExit = Invoke-InstallCommand { uv venv $VenvDir --python "$($DetectedPython.Path)" }
|
||||
|
|
@ -966,6 +1176,13 @@ shell.Run cmd, 0, False
|
|||
substep "$VenvDir"
|
||||
}
|
||||
|
||||
# Mark the freshly-created venv as Studio-owned so a partial install can be
|
||||
# repaired by re-running install.ps1; the env-mode deletion guard above
|
||||
# accepts this marker as the primary sentinel.
|
||||
if (Test-Path -LiteralPath $VenvDir -PathType Container) {
|
||||
try { [System.IO.File]::WriteAllText((Join-Path $VenvDir ".unsloth-studio-owned"), "") } catch {}
|
||||
}
|
||||
|
||||
# ── Detect GPU (robust: PATH + hardcoded fallback paths, mirrors setup.ps1) ──
|
||||
$HasNvidiaSmi = $false
|
||||
$NvidiaSmiExe = $null
|
||||
|
|
@ -1054,7 +1271,7 @@ shell.Run cmd, 0, False
|
|||
if ($StudioLocalInstall -and (Test-Path (Join-Path $RepoRoot "studio\backend\requirements\no-torch-runtime.txt"))) {
|
||||
return Join-Path $RepoRoot "studio\backend\requirements\no-torch-runtime.txt"
|
||||
}
|
||||
$installed = Get-ChildItem -Path $VenvDir -Recurse -Filter "no-torch-runtime.txt" -ErrorAction SilentlyContinue |
|
||||
$installed = Get-ChildItem -LiteralPath $VenvDir -Recurse -Filter "no-torch-runtime.txt" -ErrorAction SilentlyContinue |
|
||||
Where-Object { $_.FullName -like "*studio*backend*requirements*no-torch-runtime.txt" } |
|
||||
Select-Object -ExpandProperty FullName -First 1
|
||||
return $installed
|
||||
|
|
@ -1192,23 +1409,25 @@ shell.Run cmd, 0, False
|
|||
foreach ($rel in $overlayMap.Keys) {
|
||||
$src = Join-Path $scriptDir $rel
|
||||
$dst = Join-Path $VenvDir $overlayMap[$rel]
|
||||
if (-not (Test-Path $src)) { continue }
|
||||
# -LiteralPath: $VenvDir derives from $StudioHome which may
|
||||
# contain [ ] * ? when the user overrode UNSLOTH_STUDIO_HOME.
|
||||
if (-not (Test-Path -LiteralPath $src)) { continue }
|
||||
$dstParent = Split-Path -Parent $dst
|
||||
if (-not (Test-Path $dstParent)) {
|
||||
if (-not (Test-Path -LiteralPath $dstParent)) {
|
||||
Write-Host "[WARN] Overlay target dir missing: $dstParent; studio setup may use stale bundled file" -ForegroundColor Yellow
|
||||
continue
|
||||
}
|
||||
try {
|
||||
if (-not (Test-Path $dst)) {
|
||||
if (-not (Test-Path -LiteralPath $dst)) {
|
||||
# Backfill: target file missing but parent dir exists.
|
||||
Copy-Item $src $dst -Force
|
||||
Copy-Item -LiteralPath $src -Destination $dst -Force
|
||||
substep ("backfilled bundled " + (Split-Path -Leaf $rel))
|
||||
} else {
|
||||
# Hash-compare so re-runs are no-ops when files already match.
|
||||
$srcHash = (Get-FileHash $src -Algorithm SHA256).Hash
|
||||
$dstHash = (Get-FileHash $dst -Algorithm SHA256).Hash
|
||||
$srcHash = (Get-FileHash -LiteralPath $src -Algorithm SHA256).Hash
|
||||
$dstHash = (Get-FileHash -LiteralPath $dst -Algorithm SHA256).Hash
|
||||
if ($srcHash -ne $dstHash) {
|
||||
Copy-Item $src $dst -Force
|
||||
Copy-Item -LiteralPath $src -Destination $dst -Force
|
||||
substep ("applied bundled " + (Split-Path -Leaf $rel))
|
||||
}
|
||||
}
|
||||
|
|
@ -1225,7 +1444,8 @@ shell.Run cmd, 0, False
|
|||
Write-TauriLog "STEP" "Running studio setup"
|
||||
step "setup" "running unsloth studio setup..."
|
||||
$UnslothExe = Join-Path $VenvDir "Scripts\unsloth.exe"
|
||||
if (-not (Test-Path $UnslothExe)) {
|
||||
if (-not (Test-Path -LiteralPath $UnslothExe)) {
|
||||
Write-TauriLog "ERROR" "unsloth CLI was not installed correctly"
|
||||
Write-Host "[ERROR] unsloth CLI was not installed correctly." -ForegroundColor Red
|
||||
Write-Host " Expected: $UnslothExe" -ForegroundColor Yellow
|
||||
Write-Host " This usually means an older unsloth version was installed that does not include the Studio CLI." -ForegroundColor Yellow
|
||||
|
|
@ -1250,6 +1470,15 @@ shell.Run cmd, 0, False
|
|||
# Use 'studio setup' (not 'studio update') because 'update' pops
|
||||
# SKIP_STUDIO_BASE, which would cause redundant package reinstallation
|
||||
# and bypass the fast-path version check from PR #4667.
|
||||
# Propagate UNSLOTH_STUDIO_HOME only for env-override installs; otherwise
|
||||
# an inherited value would put llama.cpp in the wrong place.
|
||||
$previousUnslothStudioHome = $env:UNSLOTH_STUDIO_HOME
|
||||
$hadPreviousUnslothStudioHome = ($null -ne $previousUnslothStudioHome)
|
||||
if ($StudioRedirectMode -eq 'env') {
|
||||
$env:UNSLOTH_STUDIO_HOME = $StudioHome
|
||||
} else {
|
||||
Remove-Item Env:UNSLOTH_STUDIO_HOME -ErrorAction SilentlyContinue
|
||||
}
|
||||
$studioArgs = @('studio', 'setup')
|
||||
if ($script:UnslothVerbose) { $studioArgs += '--verbose' }
|
||||
$env:UNSLOTH_INSTALL_ROLLBACK_MANAGED = "1"
|
||||
|
|
@ -1257,6 +1486,11 @@ shell.Run cmd, 0, False
|
|||
& $UnslothExe @studioArgs
|
||||
$setupExit = $LASTEXITCODE
|
||||
} finally {
|
||||
if ($hadPreviousUnslothStudioHome) {
|
||||
$env:UNSLOTH_STUDIO_HOME = $previousUnslothStudioHome
|
||||
} else {
|
||||
Remove-Item Env:UNSLOTH_STUDIO_HOME -ErrorAction SilentlyContinue
|
||||
}
|
||||
Remove-Item Env:UNSLOTH_INSTALL_ROLLBACK_MANAGED -ErrorAction SilentlyContinue
|
||||
}
|
||||
if ($setupExit -ne 0) {
|
||||
|
|
@ -1301,20 +1535,32 @@ shell.Run cmd, 0, False
|
|||
}
|
||||
} catch { }
|
||||
$ShimDir = Join-Path $StudioHome "bin"
|
||||
New-Item -ItemType Directory -Force -Path $ShimDir | Out-Null
|
||||
[System.IO.Directory]::CreateDirectory($ShimDir) | Out-Null
|
||||
$ShimExe = Join-Path $ShimDir "unsloth.exe"
|
||||
# Fatal preflight outside the lock-handling try/catch -- a directory at
|
||||
# the shim path must not be downgraded to "Continuing with the existing
|
||||
# launcher", or the install finishes with no usable shim.
|
||||
if (Test-Path -LiteralPath $ShimExe -PathType Container) {
|
||||
Write-Host "[ERROR] Cannot create unsloth launcher: $ShimExe is a directory." -ForegroundColor Red
|
||||
Write-Host " Move or remove it manually, then re-run the installer." -ForegroundColor Yellow
|
||||
throw "Cannot create unsloth launcher: $ShimExe is a directory."
|
||||
}
|
||||
# try/catch: if unsloth.exe is locked (Studio running), keep the old shim.
|
||||
$shimUpdated = $false
|
||||
try {
|
||||
if (Test-Path $ShimExe) { Remove-Item $ShimExe -Force -ErrorAction Stop }
|
||||
if (Test-Path -LiteralPath $ShimExe) { Remove-Item -LiteralPath $ShimExe -Force -ErrorAction Stop }
|
||||
try {
|
||||
# New-Item -ItemType HardLink does NOT accept -LiteralPath in any
|
||||
# PowerShell version, so use -Path. Wildcards in $ShimExe (e.g.
|
||||
# brackets in custom roots) glob-expand here and fall through to
|
||||
# the Copy-Item -LiteralPath fallback below.
|
||||
New-Item -ItemType HardLink -Path $ShimExe -Target $UnslothExe -ErrorAction Stop | Out-Null
|
||||
} catch {
|
||||
Copy-Item -Path $UnslothExe -Destination $ShimExe -Force -ErrorAction Stop # fallback: copy
|
||||
Copy-Item -LiteralPath $UnslothExe -Destination $ShimExe -Force -ErrorAction Stop # fallback: copy
|
||||
}
|
||||
$shimUpdated = $true
|
||||
} catch {
|
||||
if (Test-Path $ShimExe) {
|
||||
if (Test-Path -LiteralPath $ShimExe) {
|
||||
Write-Host "[WARN] Could not refresh unsloth launcher at $ShimExe." -ForegroundColor Yellow
|
||||
Write-Host " This usually means a running 'unsloth studio' process still holds the file open." -ForegroundColor Yellow
|
||||
Write-Host " Close Studio and re-run the installer to pick up the latest launcher." -ForegroundColor Yellow
|
||||
|
|
@ -1325,10 +1571,13 @@ shell.Run cmd, 0, False
|
|||
Write-Host " Launch unsloth studio directly via '$UnslothExe' until the next successful install." -ForegroundColor Yellow
|
||||
}
|
||||
}
|
||||
# Only add to PATH when the launcher actually exists on disk.
|
||||
# Add to PATH only when launcher exists. Env-mode: session-only export,
|
||||
# no registry change (workspace path may be deleted later).
|
||||
$pathAdded = $false
|
||||
if (Test-Path $ShimExe) {
|
||||
$pathAdded = Add-ToUserPath -Directory $ShimDir -Position 'Prepend'
|
||||
if (Test-Path -LiteralPath $ShimExe) {
|
||||
if ($StudioRedirectMode -ne 'env') {
|
||||
$pathAdded = Add-ToUserPath -Directory $ShimDir -Position 'Prepend'
|
||||
}
|
||||
}
|
||||
if ($shimUpdated -and $pathAdded) {
|
||||
step "path" "added unsloth launcher to PATH"
|
||||
|
|
@ -1336,12 +1585,20 @@ shell.Run cmd, 0, False
|
|||
Refresh-SessionPath # sync current session with registry
|
||||
Complete-StudioVenvRollback
|
||||
|
||||
# Env-mode session export AFTER Refresh-SessionPath; otherwise a legacy
|
||||
# User PATH entry (Machine > User > current $env:Path) would win.
|
||||
if ($StudioRedirectMode -eq 'env' -and (Test-Path -LiteralPath $ShimExe)) {
|
||||
$env:Path = "$ShimDir;$env:Path"
|
||||
step "path" "exported $ShimDir for this session (no registry PATH change in env-override mode)"
|
||||
}
|
||||
|
||||
# ── Tauri mode: done, skip shortcuts and auto-launch ──
|
||||
if ($TauriMode) {
|
||||
Write-TauriLog "DONE" ""
|
||||
return
|
||||
}
|
||||
|
||||
# New-StudioShortcuts gates the .lnk shortcuts on env-mode internally.
|
||||
New-StudioShortcuts -UnslothExePath $UnslothExe
|
||||
|
||||
# In interactive terminals, ask the user before starting Studio.
|
||||
|
|
@ -1360,8 +1617,21 @@ shell.Run cmd, 0, False
|
|||
}
|
||||
} else {
|
||||
step "launch" "manual commands:"
|
||||
substep "& `"$VenvDir\Scripts\Activate.ps1`""
|
||||
substep "unsloth studio -p 8888"
|
||||
# Single-quote the printed paths so $-vars / backticks in custom roots
|
||||
# do not reparse when the user pastes the command.
|
||||
$_actLiteral = "'" + ((Join-Path $VenvDir "Scripts\Activate.ps1") -replace "'", "''") + "'"
|
||||
if ($StudioRedirectMode -eq 'env') {
|
||||
# Env-mode skips registry PATH; print the absolute shim path.
|
||||
$_shim = Join-Path $StudioHome "bin\unsloth.exe"
|
||||
$_shimLiteral = "'" + ($_shim -replace "'", "''") + "'"
|
||||
substep "& $_shimLiteral studio -p 8888"
|
||||
substep "or activate env first:"
|
||||
substep "& $_actLiteral"
|
||||
substep "unsloth studio -p 8888"
|
||||
} else {
|
||||
substep "& $_actLiteral"
|
||||
substep "unsloth studio -p 8888"
|
||||
}
|
||||
substep "(add -H 0.0.0.0 to allow network / cloud access)"
|
||||
Write-Host ""
|
||||
}
|
||||
|
|
|
|||
415
install.sh
415
install.sh
|
|
@ -6,6 +6,12 @@
|
|||
# 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) ──
|
||||
|
|
@ -66,6 +72,56 @@ 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" ]
|
||||
}
|
||||
|
|
@ -219,7 +275,67 @@ _tauri_gpu_branch() {
|
|||
}
|
||||
|
||||
PYTHON_VERSION="" # resolved after platform detection
|
||||
STUDIO_HOME="$HOME/.unsloth/studio"
|
||||
|
||||
# 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"
|
||||
|
|
@ -383,23 +499,65 @@ create_studio_shortcuts() {
|
|||
_css_exe_dir=$(cd "$(dirname "$_css_exe")" && pwd)
|
||||
_css_exe="$_css_exe_dir/$(basename "$_css_exe")"
|
||||
|
||||
_css_data_dir="$HOME/.local/share/unsloth"
|
||||
_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 ──
|
||||
# The launcher is Bash (not POSIX sh).
|
||||
# We write it with a placeholder and substitute the exe path via sed.
|
||||
# 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="$HOME/.local/share/unsloth"
|
||||
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.
|
||||
|
|
@ -416,7 +574,23 @@ 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() {
|
||||
|
|
@ -435,10 +609,20 @@ _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"'*) return 0 ;;
|
||||
*'"service"'*'"Unsloth UI Backend"'*'"status"'*'"healthy"'*) return 0 ;;
|
||||
*'"status"'*'"healthy"'*'"service"'*'"Unsloth UI Backend"'*) ;;
|
||||
*'"service"'*'"Unsloth UI Backend"'*'"status"'*'"healthy"'*) ;;
|
||||
*) return 1 ;;
|
||||
esac
|
||||
return 1
|
||||
# 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 ──
|
||||
|
|
@ -461,6 +645,25 @@ _candidate_ports() {
|
|||
}
|
||||
|
||||
_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"
|
||||
|
|
@ -611,6 +814,7 @@ if [ -t 1 ]; then
|
|||
_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
|
||||
|
|
@ -634,6 +838,7 @@ else
|
|||
_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
|
||||
|
|
@ -646,13 +851,62 @@ else
|
|||
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"
|
||||
|
||||
# Write the exe path to a separate conf file sourced by the launcher.
|
||||
# Using single-quote wrapping with the standard '\'' escape for any
|
||||
# embedded apostrophes. This avoids all sed metacharacter issues.
|
||||
# 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'" > "$_css_data_dir/studio.conf"
|
||||
{
|
||||
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
|
||||
|
|
@ -698,6 +952,14 @@ LAUNCHER_EOF
|
|||
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
|
||||
|
|
@ -775,11 +1037,18 @@ DESKTOP_EOF
|
|||
</plist>
|
||||
PLIST_EOF
|
||||
|
||||
# Executable stub
|
||||
cat > "$_css_macos_dir/launch-studio" << STUB_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 "$HOME/.local/share/unsloth/launch-studio.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)
|
||||
|
|
@ -1079,11 +1348,28 @@ 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 [ -x "$STUDIO_HOME/.venv/bin/python" ]; then
|
||||
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
|
||||
|
|
@ -1132,6 +1418,13 @@ if [ ! -x "$VENV_DIR/bin/python" ]; then
|
|||
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
|
||||
|
|
@ -1143,6 +1436,9 @@ if [ -z "$_USER_PYTHON" ] && [ "$OS" = "macos" ] && [ "$_ARCH" = "arm64" ]; then
|
|||
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
|
||||
|
||||
|
|
@ -1768,7 +2064,17 @@ _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" \
|
||||
|
|
@ -1782,6 +2088,7 @@ else
|
|||
# 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" \
|
||||
|
|
@ -1791,36 +2098,53 @@ else
|
|||
bash "$SETUP_SH" </dev/null || _SETUP_EXIT=$?
|
||||
fi
|
||||
|
||||
# ── Make 'unsloth' available globally via ~/.local/bin ──
|
||||
mkdir -p "$HOME/.local/bin"
|
||||
ln -sf "$VENV_DIR/bin/unsloth" "$HOME/.local/bin/unsloth"
|
||||
# ── 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"
|
||||
|
||||
_LOCAL_BIN="$HOME/.local/bin"
|
||||
case ":$PATH:" in
|
||||
*":$_LOCAL_BIN:"*) ;; # already on PATH
|
||||
*)
|
||||
_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"
|
||||
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
|
||||
export PATH="$_LOCAL_BIN:$PATH"
|
||||
;;
|
||||
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
|
||||
|
|
@ -1883,10 +2207,21 @@ if [ -t 1 ]; then
|
|||
esac
|
||||
else
|
||||
step "launch" "manual commands:"
|
||||
substep "unsloth studio -p 8888"
|
||||
substep "or activate env first:"
|
||||
substep "source ${VENV_DIR}/bin/activate"
|
||||
substep "unsloth studio -p 8888"
|
||||
# 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
|
||||
|
|
|
|||
|
|
@ -732,22 +732,46 @@ class LlamaCppBackend:
|
|||
if win_bin.is_file():
|
||||
return str(win_bin)
|
||||
|
||||
# 2–4. ~/.unsloth/llama.cpp (primary — setup.sh / setup.ps1 build here)
|
||||
unsloth_home = Path.home() / ".unsloth" / "llama.cpp"
|
||||
# Root dir (make builds copy binaries here)
|
||||
home_root = unsloth_home / binary_name
|
||||
if home_root.is_file():
|
||||
return str(home_root)
|
||||
# build/bin/ (cmake builds on Linux)
|
||||
home_linux = unsloth_home / "build" / "bin" / binary_name
|
||||
if home_linux.is_file():
|
||||
return str(home_linux)
|
||||
# 2-4. Match installer layout: env-mode -> $STUDIO_HOME/llama.cpp;
|
||||
# default/HOME-redirect -> ~/.unsloth/llama.cpp (sibling of studio).
|
||||
legacy_llama = Path.home() / ".unsloth" / "llama.cpp"
|
||||
try:
|
||||
from utils.paths.storage_roots import studio_root as _sr # noqa: WPS433
|
||||
|
||||
# 3. Windows MSVC build has Release subdir
|
||||
if sys.platform == "win32":
|
||||
home_win = unsloth_home / "build" / "bin" / "Release" / binary_name
|
||||
if home_win.is_file():
|
||||
return str(home_win)
|
||||
_resolved_sr = _sr()
|
||||
_legacy_studio = Path.home() / ".unsloth" / "studio"
|
||||
try:
|
||||
_is_legacy = _resolved_sr.resolve() == _legacy_studio.resolve()
|
||||
except (OSError, ValueError):
|
||||
_is_legacy = _resolved_sr == _legacy_studio
|
||||
if _is_legacy:
|
||||
search_roots = [legacy_llama]
|
||||
else:
|
||||
# why: _kill_orphaned_servers excludes the legacy root in custom
|
||||
# mode; discovery must match so we never spawn a server we then
|
||||
# refuse to clean up. UNSLOTH_LLAMA_CPP_PATH (handled earlier)
|
||||
# is the explicit way to share a build across roots.
|
||||
search_roots = [_resolved_sr / "llama.cpp"]
|
||||
except (ImportError, OSError, ValueError):
|
||||
search_roots = [legacy_llama]
|
||||
_seen_roots: set[str] = set()
|
||||
_unique_roots: list[Path] = []
|
||||
for r in search_roots:
|
||||
k = str(r)
|
||||
if k not in _seen_roots:
|
||||
_seen_roots.add(k)
|
||||
_unique_roots.append(r)
|
||||
for unsloth_home in _unique_roots:
|
||||
home_root = unsloth_home / binary_name
|
||||
if home_root.is_file():
|
||||
return str(home_root)
|
||||
home_linux = unsloth_home / "build" / "bin" / binary_name
|
||||
if home_linux.is_file():
|
||||
return str(home_linux)
|
||||
if sys.platform == "win32":
|
||||
home_win = unsloth_home / "build" / "bin" / "Release" / binary_name
|
||||
if home_win.is_file():
|
||||
return str(home_win)
|
||||
|
||||
# 5–6. Legacy: in-tree build (older setup.sh / setup.ps1 versions)
|
||||
project_root = Path(__file__).resolve().parents[4]
|
||||
|
|
@ -2592,8 +2616,27 @@ class LlamaCppBackend:
|
|||
# (binary must be *under* one of these)
|
||||
install_roots: list[Path] = []
|
||||
|
||||
# Primary install dir (setup.sh / prebuilt installer)
|
||||
install_roots.append(Path.home() / ".unsloth" / "llama.cpp")
|
||||
# Env-mode custom root (mirrors _find_llama_server_binary).
|
||||
_is_custom_root = False
|
||||
try:
|
||||
from utils.paths.storage_roots import studio_root as _sr # noqa: WPS433
|
||||
|
||||
_resolved_sr = _sr()
|
||||
_legacy_studio = Path.home() / ".unsloth" / "studio"
|
||||
try:
|
||||
_is_custom_root = _resolved_sr.resolve() != _legacy_studio.resolve()
|
||||
except (OSError, ValueError):
|
||||
_is_custom_root = _resolved_sr != _legacy_studio
|
||||
if _is_custom_root:
|
||||
install_roots.append(_resolved_sr / "llama.cpp")
|
||||
except (ImportError, OSError, ValueError):
|
||||
pass
|
||||
|
||||
# Primary install dir (default mode only). Env-mode skips this so
|
||||
# a custom-root Studio cannot kill a concurrent default-install
|
||||
# Studio's llama-server (same OS user, different install).
|
||||
if not _is_custom_root:
|
||||
install_roots.append(Path.home() / ".unsloth" / "llama.cpp")
|
||||
|
||||
# Legacy in-tree build dirs (older setup.sh versions)
|
||||
project_root = Path(__file__).resolve().parents[4]
|
||||
|
|
|
|||
|
|
@ -23,12 +23,67 @@ if _backend_dir not in sys.path:
|
|||
# See: https://github.com/python/cpython/issues/102396
|
||||
import _platform_compat # noqa: F401
|
||||
|
||||
# Direct `uvicorn main:app` launches bypass run.py, so re-export here too
|
||||
# (mirrors run.py). Required BEFORE the unsloth-zoo import below, since
|
||||
# its LLAMA_CPP_DEFAULT_DIR binding is import-time.
|
||||
from utils.paths.storage_roots import studio_root as _studio_root
|
||||
|
||||
try:
|
||||
_LEGACY_STUDIO_ROOT = (_Path.home() / ".unsloth" / "studio").resolve()
|
||||
except (OSError, ValueError):
|
||||
_LEGACY_STUDIO_ROOT = _Path.home() / ".unsloth" / "studio"
|
||||
try:
|
||||
_STUDIO_ROOT_RESOLVED = _studio_root().resolve()
|
||||
except (OSError, ValueError):
|
||||
_STUDIO_ROOT_RESOLVED = _studio_root()
|
||||
if _STUDIO_ROOT_RESOLVED != _LEGACY_STUDIO_ROOT:
|
||||
if not os.environ.get("UNSLOTH_STUDIO_HOME"):
|
||||
os.environ["UNSLOTH_STUDIO_HOME"] = str(_STUDIO_ROOT_RESOLVED)
|
||||
if not os.environ.get("UNSLOTH_LLAMA_CPP_PATH"):
|
||||
os.environ["UNSLOTH_LLAMA_CPP_PATH"] = str(_STUDIO_ROOT_RESOLVED / "llama.cpp")
|
||||
|
||||
import mimetypes
|
||||
import re as _re
|
||||
import shutil
|
||||
import warnings
|
||||
from contextlib import asynccontextmanager
|
||||
from importlib.metadata import PackageNotFoundError, version as package_version
|
||||
|
||||
|
||||
_STUDIO_INSTALL_ID_RE = _re.compile(r"^[0-9a-f]{64}$")
|
||||
|
||||
|
||||
def _read_studio_install_id() -> str:
|
||||
"""Per-install opaque id written by install.sh / install.ps1 at
|
||||
$STUDIO_HOME/share/studio_install_id. Returns "" when the file is
|
||||
absent (pre-PR install, fresh tree never run through the installer)
|
||||
or contains anything other than a 64-char lowercase-hex token --
|
||||
in which case /api/health emits "" and the launcher's _check_health
|
||||
falls back to the existing "no baked id, accept any healthy
|
||||
Unsloth backend" path. This intentionally replaces a previous
|
||||
sha256(resolved_install_path) so the field carries no install-path
|
||||
information for callers reaching /api/health (relevant when Studio
|
||||
is run with -H 0.0.0.0)."""
|
||||
try:
|
||||
token = (
|
||||
(_STUDIO_ROOT_RESOLVED / "share" / "studio_install_id").read_text().strip()
|
||||
)
|
||||
except (OSError, ValueError):
|
||||
return ""
|
||||
return token if _STUDIO_INSTALL_ID_RE.fullmatch(token) else ""
|
||||
|
||||
|
||||
_STUDIO_ROOT_ID_CACHE: str = _read_studio_install_id()
|
||||
|
||||
|
||||
def _studio_root_id() -> str:
|
||||
"""Same-install discriminator for /api/health: a per-install opaque
|
||||
token written once by the installer and read once at module import.
|
||||
Empty when no installer-written token is present; the launcher
|
||||
contract treats "" as "no baked id, accept any healthy backend"."""
|
||||
return _STUDIO_ROOT_ID_CACHE
|
||||
|
||||
|
||||
# Fix broken Windows registry MIME types. Some Windows installs map .js to
|
||||
# "text/plain" in the registry (HKCR\.js\Content Type). Python's mimetypes
|
||||
# module reads from the registry, and FastAPI/Starlette's StaticFiles uses
|
||||
|
|
@ -245,6 +300,10 @@ async def health_check():
|
|||
"chat_only": _hw_module.CHAT_ONLY,
|
||||
"desktop_protocol_version": 1,
|
||||
"supports_desktop_auth": True,
|
||||
# why: launchers compare against an install-time hash so a sibling
|
||||
# Studio on the same port is rejected; hex digest avoids leaking the
|
||||
# raw install path on -H 0.0.0.0.
|
||||
"studio_root_id": _studio_root_id(),
|
||||
"native_path_leases_supported": native_path_leases_supported(),
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -159,7 +159,27 @@ def _find_free_port(host: str, start: int, max_attempts: int = 20) -> int:
|
|||
)
|
||||
|
||||
|
||||
_PID_FILE = Path.home() / ".unsloth" / "studio" / "studio.pid"
|
||||
from utils.paths.storage_roots import studio_root as _studio_root
|
||||
|
||||
_PID_FILE = _studio_root() / "studio.pid"
|
||||
|
||||
# Direct backend launches bypass the CLI's env re-export; do it here for
|
||||
# real custom roots so unsloth-zoo's import-time LLAMA_CPP_DEFAULT_DIR
|
||||
# picks up the custom build. Skip for legacy-default to avoid flipping
|
||||
# default-mode installs into env-override.
|
||||
try:
|
||||
_LEGACY_STUDIO_ROOT = (Path.home() / ".unsloth" / "studio").resolve()
|
||||
except (OSError, ValueError):
|
||||
_LEGACY_STUDIO_ROOT = Path.home() / ".unsloth" / "studio"
|
||||
try:
|
||||
_STUDIO_ROOT_RESOLVED = _studio_root().resolve()
|
||||
except (OSError, ValueError):
|
||||
_STUDIO_ROOT_RESOLVED = _studio_root()
|
||||
if _STUDIO_ROOT_RESOLVED != _LEGACY_STUDIO_ROOT:
|
||||
if not os.environ.get("UNSLOTH_STUDIO_HOME"):
|
||||
os.environ["UNSLOTH_STUDIO_HOME"] = str(_STUDIO_ROOT_RESOLVED)
|
||||
if not os.environ.get("UNSLOTH_LLAMA_CPP_PATH"):
|
||||
os.environ["UNSLOTH_LLAMA_CPP_PATH"] = str(_STUDIO_ROOT_RESOLVED / "llama.cpp")
|
||||
|
||||
|
||||
def _write_pid_file():
|
||||
|
|
|
|||
|
|
@ -500,7 +500,9 @@ _VLM_MODEL_TYPES = {
|
|||
|
||||
# Pre-computed .venv_t5 paths and backend dir for subprocess version switching.
|
||||
# Vision check uses 5.5.0 (newest, recognizes all architectures).
|
||||
_VENV_T5_DIR = str(Path.home() / ".unsloth" / "studio" / ".venv_t5_550")
|
||||
from utils.paths.storage_roots import studio_root as _studio_root # noqa: E402
|
||||
|
||||
_VENV_T5_DIR = str(_studio_root() / ".venv_t5_550")
|
||||
_BACKEND_DIR = str(Path(__file__).resolve().parent.parent.parent)
|
||||
|
||||
# Inline script executed in a subprocess with transformers 5.x activated.
|
||||
|
|
|
|||
|
|
@ -5,17 +5,59 @@ from __future__ import annotations
|
|||
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
import tempfile
|
||||
|
||||
|
||||
def _infer_studio_home_from_venv() -> Path | None:
|
||||
"""Return parent dir of sys.prefix as STUDIO_HOME if running from an
|
||||
installer-managed unsloth_studio venv. Sentinel-gated (share/studio.conf
|
||||
or bin shim) so a developer venv named unsloth_studio is not misidentified.
|
||||
"""
|
||||
try:
|
||||
prefix = Path(sys.prefix).resolve()
|
||||
except (OSError, ValueError):
|
||||
return None
|
||||
if prefix.name != "unsloth_studio":
|
||||
return None
|
||||
candidate = prefix.parent
|
||||
shim_name = "unsloth.exe" if os.name == "nt" else "unsloth"
|
||||
try:
|
||||
has_sentinel = (candidate / "share" / "studio.conf").is_file() or (
|
||||
candidate / "bin" / shim_name
|
||||
).is_file()
|
||||
except OSError:
|
||||
return None
|
||||
if has_sentinel:
|
||||
return candidate
|
||||
return None
|
||||
|
||||
|
||||
def studio_root() -> Path:
|
||||
"""Studio install root.
|
||||
|
||||
Priority: UNSLOTH_STUDIO_HOME, then STUDIO_HOME alias, then sys.prefix
|
||||
inference, then legacy ~/.unsloth/studio. UNSLOTH_STUDIO_HOME wins when
|
||||
both are set (the more specific signal beats the generic alias).
|
||||
"""
|
||||
override = (os.environ.get("UNSLOTH_STUDIO_HOME") or "").strip()
|
||||
if not override:
|
||||
override = (os.environ.get("STUDIO_HOME") or "").strip()
|
||||
if override:
|
||||
try:
|
||||
return Path(override).expanduser().resolve()
|
||||
except (OSError, ValueError):
|
||||
return Path(override).expanduser()
|
||||
inferred = _infer_studio_home_from_venv()
|
||||
if inferred is not None:
|
||||
return inferred
|
||||
return Path.home() / ".unsloth" / "studio"
|
||||
|
||||
|
||||
def cache_root() -> Path:
|
||||
"""Central cache directory for all studio downloads (models, datasets, etc.)."""
|
||||
return Path.home() / ".unsloth" / "studio" / "cache"
|
||||
return studio_root() / "cache"
|
||||
|
||||
|
||||
def assets_root() -> Path:
|
||||
|
|
|
|||
|
|
@ -95,9 +95,11 @@ TRANSFORMERS_DEFAULT_VERSION = "4.57.6"
|
|||
# Consumers should prefer TRANSFORMERS_530_VERSION / TRANSFORMERS_550_VERSION.
|
||||
TRANSFORMERS_5_VERSION = TRANSFORMERS_550_VERSION
|
||||
|
||||
# Pre-installed directories — created by setup.sh / setup.ps1
|
||||
_VENV_T5_530_DIR = str(Path.home() / ".unsloth" / "studio" / ".venv_t5_530")
|
||||
_VENV_T5_550_DIR = str(Path.home() / ".unsloth" / "studio" / ".venv_t5_550")
|
||||
# Pre-installed directories — created by setup.sh / setup.ps1.
|
||||
from utils.paths.storage_roots import studio_root as _studio_root # noqa: E402
|
||||
|
||||
_VENV_T5_530_DIR = str(_studio_root() / ".venv_t5_530")
|
||||
_VENV_T5_550_DIR = str(_studio_root() / ".venv_t5_550")
|
||||
# Backwards-compat alias
|
||||
_VENV_T5_DIR = _VENV_T5_550_DIR
|
||||
|
||||
|
|
|
|||
211
studio/setup.ps1
211
studio/setup.ps1
|
|
@ -1492,9 +1492,79 @@ if (-not $PythonCmd) {
|
|||
|
||||
substep "Using $PythonCmd ($(& $PythonCmd --version 2>&1))"
|
||||
|
||||
# The venv must already exist (created by install.ps1).
|
||||
# This script (setup.ps1 / "unsloth studio update") only updates packages.
|
||||
$VenvDir = Join-Path $env:USERPROFILE ".unsloth\studio\unsloth_studio"
|
||||
# The venv must already exist (created by install.ps1); this script only
|
||||
# updates packages. UNSLOTH_STUDIO_HOME (or STUDIO_HOME alias) overrides the
|
||||
# root. UNSLOTH_STUDIO_HOME wins when both are set. Whitespace-only values
|
||||
# are treated as unset to match Python .strip() semantics.
|
||||
$_studioOverrideVar = $null
|
||||
$_studioOverride = $null
|
||||
if (-not [string]::IsNullOrWhiteSpace($env:UNSLOTH_STUDIO_HOME)) {
|
||||
$_studioOverrideVar = "UNSLOTH_STUDIO_HOME"
|
||||
$_studioOverride = $env:UNSLOTH_STUDIO_HOME.Trim()
|
||||
} elseif (-not [string]::IsNullOrWhiteSpace($env:STUDIO_HOME)) {
|
||||
$_studioOverrideVar = "STUDIO_HOME"
|
||||
$_studioOverride = $env:STUDIO_HOME.Trim()
|
||||
}
|
||||
if ($_studioOverride) {
|
||||
if ($_studioOverride -eq "~" -or $_studioOverride -like "~/*" -or $_studioOverride -like "~\*") {
|
||||
$_studioOverride = (Join-Path $env:USERPROFILE $_studioOverride.Substring(1).TrimStart('/','\'))
|
||||
}
|
||||
if (Test-Path -LiteralPath $_studioOverride -PathType Container) {
|
||||
$StudioHome = (Resolve-Path -LiteralPath $_studioOverride).Path
|
||||
# why: mirror setup.sh:417 and install.ps1:130 -- fail fast when the
|
||||
# custom root is read-only instead of erroring later while creating
|
||||
# sidecar venvs / installing packages.
|
||||
$_setupWriteProbe = Join-Path $StudioHome (".unsloth-write-probe-" + [guid]::NewGuid())
|
||||
try {
|
||||
[System.IO.File]::WriteAllText($_setupWriteProbe, "")
|
||||
Remove-Item -LiteralPath $_setupWriteProbe -Force -ErrorAction SilentlyContinue
|
||||
} catch {
|
||||
Write-Host "ERROR: $_studioOverrideVar=$StudioHome is not writable." -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
} else {
|
||||
Write-Host "ERROR: $_studioOverrideVar=$_studioOverride does not exist." -ForegroundColor Red
|
||||
Write-Host " Run install.ps1 to create the install root before 'unsloth studio update'." -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
} else {
|
||||
$StudioHome = Join-Path $env:USERPROFILE ".unsloth\studio"
|
||||
}
|
||||
$VenvDir = Join-Path $StudioHome "unsloth_studio"
|
||||
|
||||
# why: in env-override mode $StudioHome is user-chosen; require the
|
||||
# ownership marker before Remove-Item so unrelated dirs survive. Gated on
|
||||
# the canonical comparison so an override pointing at the legacy default
|
||||
# still behaves like a default install.
|
||||
$StudioOwnedMarker = ".unsloth-studio-owned"
|
||||
$LegacyStudioHome = Join-Path $env:USERPROFILE ".unsloth\studio"
|
||||
$_studioHomeCanon = $StudioHome
|
||||
if (Test-Path -LiteralPath $_studioHomeCanon -PathType Container) {
|
||||
$_studioHomeCanon = (Resolve-Path -LiteralPath $_studioHomeCanon).Path
|
||||
}
|
||||
if (Test-Path -LiteralPath $LegacyStudioHome -PathType Container) {
|
||||
$LegacyStudioHome = (Resolve-Path -LiteralPath $LegacyStudioHome).Path
|
||||
}
|
||||
$StudioHomeIsCustom = ($_studioHomeCanon -ne $LegacyStudioHome)
|
||||
function Assert-StudioOwnedOrAbsent {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)][string]$Path,
|
||||
[Parameter(Mandatory = $true)][string]$Label
|
||||
)
|
||||
if (-not (Test-Path -LiteralPath $Path -PathType Container)) { return }
|
||||
if ($StudioHomeIsCustom -and -not (Test-Path -LiteralPath (Join-Path $Path $StudioOwnedMarker) -PathType Leaf)) {
|
||||
Write-Host "[ERROR] $Path already exists and is not marked as a Studio-owned $Label." -ForegroundColor Red
|
||||
Write-Host " Move it aside or choose an empty UNSLOTH_STUDIO_HOME before re-running." -ForegroundColor Yellow
|
||||
exit 1
|
||||
}
|
||||
}
|
||||
function Mark-StudioOwned {
|
||||
param([Parameter(Mandatory = $true)][string]$Path)
|
||||
if (-not (Test-Path -LiteralPath $Path -PathType Container)) { return }
|
||||
try {
|
||||
[System.IO.File]::WriteAllText((Join-Path $Path $StudioOwnedMarker), "")
|
||||
} catch {}
|
||||
}
|
||||
|
||||
# Stale-venv detection: if the venv exists but its torch flavor no longer
|
||||
# matches the current machine, repair according to invocation context.
|
||||
|
|
@ -1504,12 +1574,12 @@ $VenvDir = Join-Path $env:USERPROFILE ".unsloth\studio\unsloth_studio"
|
|||
# In no-torch mode, a missing torch package is expected.
|
||||
$NoTorchMode = $env:UNSLOTH_NO_TORCH -match '^(?i:true|1|yes)$'
|
||||
$InstallerManagedSetup = $env:UNSLOTH_INSTALL_ROLLBACK_MANAGED -match '^(?i:true|1|yes)$'
|
||||
if ((Test-Path $VenvDir -PathType Container) -and -not $NoTorchMode) {
|
||||
if ((Test-Path -LiteralPath $VenvDir -PathType Container) -and -not $NoTorchMode) {
|
||||
$VenvPyExe = Join-Path $VenvDir "Scripts\python.exe"
|
||||
$installedTorchTag = $null
|
||||
$shouldRebuild = $false
|
||||
|
||||
if (Test-Path $VenvPyExe) {
|
||||
if (Test-Path -LiteralPath $VenvPyExe) {
|
||||
try {
|
||||
$psi = New-Object System.Diagnostics.ProcessStartInfo
|
||||
$psi.FileName = $VenvPyExe
|
||||
|
|
@ -1558,8 +1628,21 @@ if ((Test-Path $VenvDir -PathType Container) -and -not $NoTorchMode) {
|
|||
exit 1
|
||||
}
|
||||
substep "Stale venv detected ($reason) -- rebuilding..." "Yellow"
|
||||
# why: mirror install.ps1 env-mode guard so an update against a custom
|
||||
# UNSLOTH_STUDIO_HOME never wipes an unrelated unsloth_studio venv;
|
||||
# -PathType Leaf rejects a directory masquerading as the sentinel.
|
||||
if (
|
||||
$StudioHomeIsCustom -and
|
||||
-not (Test-Path -LiteralPath (Join-Path $VenvDir $StudioOwnedMarker) -PathType Leaf) -and
|
||||
-not (Test-Path -LiteralPath (Join-Path $StudioHome "share\studio.conf") -PathType Leaf) -and
|
||||
-not (Test-Path -LiteralPath (Join-Path $StudioHome "bin\unsloth.exe") -PathType Leaf)
|
||||
) {
|
||||
Write-Host "[ERROR] $VenvDir already exists but does not look like an Unsloth Studio install." -ForegroundColor Red
|
||||
Write-Host " Move it aside or choose an empty UNSLOTH_STUDIO_HOME before re-running." -ForegroundColor Yellow
|
||||
exit 1
|
||||
}
|
||||
try {
|
||||
Remove-Item $VenvDir -Recurse -Force -ErrorAction Stop
|
||||
Remove-Item -LiteralPath $VenvDir -Recurse -Force -ErrorAction Stop
|
||||
} catch {
|
||||
Write-Host " [ERROR] Could not remove stale venv: $($_.Exception.Message)" -ForegroundColor Red
|
||||
Write-Host " Close any running Studio/Python processes and re-run setup." -ForegroundColor Red
|
||||
|
|
@ -1568,7 +1651,7 @@ if ((Test-Path $VenvDir -PathType Container) -and -not $NoTorchMode) {
|
|||
}
|
||||
}
|
||||
|
||||
if (-not (Test-Path $VenvDir)) {
|
||||
if (-not (Test-Path -LiteralPath $VenvDir)) {
|
||||
Write-Host "[ERROR] Virtual environment not found at $VenvDir" -ForegroundColor Red
|
||||
Write-Host " Run install.ps1 first to create the environment:" -ForegroundColor Yellow
|
||||
Write-Host " irm https://unsloth.ai/install.ps1 | iex" -ForegroundColor Yellow
|
||||
|
|
@ -1759,17 +1842,19 @@ if ($stackExit -ne 0) {
|
|||
# ── Pre-install transformers 5.x into .venv_t5_530/ and .venv_t5_550/ ──
|
||||
# Runs outside the deps fast-path gate so that upgrades from the legacy
|
||||
# single .venv_t5 are always migrated to the tiered layout.
|
||||
$VenvT5_530Dir = Join-Path $env:USERPROFILE ".unsloth\studio\.venv_t5_530"
|
||||
$VenvT5_550Dir = Join-Path $env:USERPROFILE ".unsloth\studio\.venv_t5_550"
|
||||
$VenvT5Legacy = Join-Path $env:USERPROFILE ".unsloth\studio\.venv_t5"
|
||||
# T5 sidecar venvs live under the resolved $StudioHome so custom installs are self-contained.
|
||||
$VenvT5_530Dir = Join-Path $StudioHome ".venv_t5_530"
|
||||
$VenvT5_550Dir = Join-Path $StudioHome ".venv_t5_550"
|
||||
$VenvT5Legacy = Join-Path $StudioHome ".venv_t5"
|
||||
|
||||
$_NeedT5Install = $false
|
||||
if (Test-Path $VenvT5Legacy) {
|
||||
Remove-Item -Recurse -Force $VenvT5Legacy
|
||||
if (Test-Path -LiteralPath $VenvT5Legacy) {
|
||||
Assert-StudioOwnedOrAbsent -Path $VenvT5Legacy -Label "legacy transformers sidecar venv"
|
||||
Remove-Item -LiteralPath $VenvT5Legacy -Recurse -Force
|
||||
$_NeedT5Install = $true
|
||||
}
|
||||
if (-not (Test-Path $VenvT5_530Dir)) { $_NeedT5Install = $true }
|
||||
if (-not (Test-Path $VenvT5_550Dir)) { $_NeedT5Install = $true }
|
||||
if (-not (Test-Path -LiteralPath $VenvT5_530Dir)) { $_NeedT5Install = $true }
|
||||
if (-not (Test-Path -LiteralPath $VenvT5_550Dir)) { $_NeedT5Install = $true }
|
||||
# Also reinstall when python deps were updated
|
||||
if (-not $SkipPythonDeps) { $_NeedT5Install = $true }
|
||||
|
||||
|
|
@ -1781,8 +1866,10 @@ $ErrorActionPreference = "Continue"
|
|||
|
||||
# --- .venv_t5_530 (transformers 5.3.0) ---
|
||||
substep "pre-installing transformers 5.3.0 for newer model support..."
|
||||
if (Test-Path $VenvT5_530Dir) { Remove-Item -Recurse -Force $VenvT5_530Dir }
|
||||
New-Item -ItemType Directory -Path $VenvT5_530Dir -Force | Out-Null
|
||||
Assert-StudioOwnedOrAbsent -Path $VenvT5_530Dir -Label "transformers 5.3 sidecar venv"
|
||||
if (Test-Path -LiteralPath $VenvT5_530Dir) { Remove-Item -LiteralPath $VenvT5_530Dir -Recurse -Force }
|
||||
[System.IO.Directory]::CreateDirectory($VenvT5_530Dir) | Out-Null
|
||||
Mark-StudioOwned -Path $VenvT5_530Dir
|
||||
foreach ($pkg in @("transformers==5.3.0", "huggingface_hub==1.8.0", "hf_xet==1.4.2")) {
|
||||
if ($script:UnslothVerbose) {
|
||||
Fast-Install --target $VenvT5_530Dir --no-deps $pkg
|
||||
|
|
@ -1814,8 +1901,10 @@ step "transformers" "5.3.0 pre-installed"
|
|||
|
||||
# --- .venv_t5_550 (transformers 5.5.0) ---
|
||||
substep "pre-installing transformers 5.5.0 for Gemma 4 support..."
|
||||
if (Test-Path $VenvT5_550Dir) { Remove-Item -Recurse -Force $VenvT5_550Dir }
|
||||
New-Item -ItemType Directory -Path $VenvT5_550Dir -Force | Out-Null
|
||||
Assert-StudioOwnedOrAbsent -Path $VenvT5_550Dir -Label "transformers 5.5 sidecar venv"
|
||||
if (Test-Path -LiteralPath $VenvT5_550Dir) { Remove-Item -LiteralPath $VenvT5_550Dir -Recurse -Force }
|
||||
[System.IO.Directory]::CreateDirectory($VenvT5_550Dir) | Out-Null
|
||||
Mark-StudioOwned -Path $VenvT5_550Dir
|
||||
foreach ($pkg in @("transformers==5.5.0", "huggingface_hub==1.8.0", "hf_xet==1.4.2")) {
|
||||
if ($script:UnslothVerbose) {
|
||||
Fast-Install --target $VenvT5_550Dir --no-deps $pkg
|
||||
|
|
@ -1851,8 +1940,15 @@ step "transformers" "5.5.0 pre-installed"
|
|||
# ==========================================================================
|
||||
# PHASE 3.4: Prefer prebuilt llama.cpp bundles before source build
|
||||
# ==========================================================================
|
||||
$UnslothHome = Join-Path $env:USERPROFILE ".unsloth"
|
||||
if (-not (Test-Path $UnslothHome)) { New-Item -ItemType Directory -Force $UnslothHome | Out-Null }
|
||||
# Nest llama.cpp under $StudioHome only for real env-overrides, never the
|
||||
# legacy default. Reuses $StudioHomeIsCustom from the canonical comparison
|
||||
# computed above so the llama.cpp nest matches ownership-guard semantics.
|
||||
if ($StudioHomeIsCustom) {
|
||||
$UnslothHome = $StudioHome
|
||||
} else {
|
||||
$UnslothHome = Join-Path $env:USERPROFILE ".unsloth"
|
||||
}
|
||||
if (-not (Test-Path -LiteralPath $UnslothHome)) { [System.IO.Directory]::CreateDirectory($UnslothHome) | Out-Null }
|
||||
$LlamaCppDir = Join-Path $UnslothHome "llama.cpp"
|
||||
$NeedLlamaSourceBuild = $false
|
||||
$SkipPrebuiltInstall = $false
|
||||
|
|
@ -1954,9 +2050,15 @@ if ($env:UNSLOTH_LLAMA_FORCE_COMPILE -eq "1") {
|
|||
} else {
|
||||
Write-Host ""
|
||||
substep "installing prebuilt llama.cpp bundle (preferred path)..."
|
||||
if (Test-Path $LlamaCppDir) {
|
||||
if (Test-Path -LiteralPath $LlamaCppDir) {
|
||||
substep "Existing llama.cpp install detected -- validating staged prebuilt update before replacement"
|
||||
}
|
||||
# why: install_llama_prebuilt.py uses os.replace(), which would displace
|
||||
# an unrelated $env:UNSLOTH_STUDIO_HOME\llama.cpp before the source-build
|
||||
# ownership check below ever runs.
|
||||
if ($StudioHomeIsCustom) {
|
||||
Assert-StudioOwnedOrAbsent -Path $LlamaCppDir -Label "llama.cpp install"
|
||||
}
|
||||
$prebuiltArgs = @(
|
||||
"$PSScriptRoot\install_llama_prebuilt.py",
|
||||
"--install-dir", $LlamaCppDir,
|
||||
|
|
@ -2001,6 +2103,9 @@ if ($env:UNSLOTH_LLAMA_FORCE_COMPILE -eq "1") {
|
|||
} else {
|
||||
step "llama.cpp" "prebuilt installed and validated"
|
||||
}
|
||||
if ($StudioHomeIsCustom -and (Test-Path -LiteralPath $LlamaCppDir -PathType Container)) {
|
||||
Mark-StudioOwned -Path $LlamaCppDir
|
||||
}
|
||||
$installedRelease = Get-InstalledLlamaPrebuiltRelease -InstallDir $LlamaCppDir
|
||||
if ($installedRelease) {
|
||||
substep $installedRelease
|
||||
|
|
@ -2008,7 +2113,7 @@ if ($env:UNSLOTH_LLAMA_FORCE_COMPILE -eq "1") {
|
|||
} elseif ($prebuiltExit -eq 3) {
|
||||
step "llama.cpp" "install blocked by active llama.cpp process" "Yellow"
|
||||
Write-LlamaFailureLog -Output $prebuiltOutput
|
||||
if (Test-Path $LlamaCppDir) {
|
||||
if (Test-Path -LiteralPath $LlamaCppDir) {
|
||||
substep "Existing install was restored" "Yellow"
|
||||
}
|
||||
substep "Close Studio or other llama.cpp users and retry" "Yellow"
|
||||
|
|
@ -2016,7 +2121,7 @@ if ($env:UNSLOTH_LLAMA_FORCE_COMPILE -eq "1") {
|
|||
} else {
|
||||
step "llama.cpp" "prebuilt install failed (continuing)" "Yellow"
|
||||
Write-LlamaFailureLog -Output $prebuiltOutput
|
||||
if (Test-Path $LlamaCppDir) {
|
||||
if (Test-Path -LiteralPath $LlamaCppDir) {
|
||||
substep "Prebuilt update failed; existing install was restored or cleaned before source build fallback" "Yellow"
|
||||
}
|
||||
substep "Prebuilt llama.cpp path unavailable or failed validation -- falling back to source build" "Yellow"
|
||||
|
|
@ -2092,10 +2197,10 @@ $HasCmakeForBuild = $null -ne (Get-Command cmake -ErrorAction SilentlyContinue)
|
|||
# Check if existing llama-server matches current GPU mode. A CUDA-built binary
|
||||
# on a now-CPU-only machine (or vice versa) needs to be rebuilt.
|
||||
$NeedRebuild = $false
|
||||
if (Test-Path $LlamaServerBin) {
|
||||
if (Test-Path -LiteralPath $LlamaServerBin) {
|
||||
$CmakeCacheFile = Join-Path $BuildDir "CMakeCache.txt"
|
||||
if (Test-Path $CmakeCacheFile) {
|
||||
$cachedCuda = Select-String -Path $CmakeCacheFile -Pattern 'GGML_CUDA:BOOL=ON' -Quiet
|
||||
if (Test-Path -LiteralPath $CmakeCacheFile) {
|
||||
$cachedCuda = Select-String -LiteralPath $CmakeCacheFile -Pattern 'GGML_CUDA:BOOL=ON' -Quiet
|
||||
if ($HasNvidiaSmi -and -not $cachedCuda) {
|
||||
Write-Host " Existing llama-server is CPU-only but GPU is available -- rebuilding" -ForegroundColor Yellow
|
||||
$NeedRebuild = $true
|
||||
|
|
@ -2109,7 +2214,7 @@ if (Test-Path $LlamaServerBin) {
|
|||
if (-not $NeedLlamaSourceBuild) {
|
||||
Write-Host ""
|
||||
step "llama.cpp" "prebuilt (validated)"
|
||||
} elseif ((Test-Path $LlamaServerBin) -and -not $NeedRebuild -and $RequestedLlamaTag -ne "master") {
|
||||
} elseif ((Test-Path -LiteralPath $LlamaServerBin) -and -not $NeedRebuild -and $RequestedLlamaTag -ne "master") {
|
||||
# Skip rebuild only for pinned tags (e.g. b8635). When the requested
|
||||
# tag is "master" (a moving target), always rebuild so the binary picks
|
||||
# up new model architecture support (e.g. Gemma 4).
|
||||
|
|
@ -2211,7 +2316,13 @@ if (-not $NeedLlamaSourceBuild) {
|
|||
|
||||
$UseConcreteRef = ($ResolvedSourceRef -ne "latest" -and -not [string]::IsNullOrWhiteSpace($ResolvedSourceRef))
|
||||
|
||||
if (Test-Path (Join-Path $LlamaCppDir ".git")) {
|
||||
if (Test-Path -LiteralPath (Join-Path $LlamaCppDir ".git")) {
|
||||
# why: in-place git mutation (remote set-url, checkout -B, clean -fdx)
|
||||
# rewrites $LlamaCppDir; mirror the prebuilt and temp-dir-swap guards
|
||||
# so an unrelated workspace .git tree is never silently overwritten.
|
||||
if ($StudioHomeIsCustom) {
|
||||
Assert-StudioOwnedOrAbsent -Path $LlamaCppDir -Label "llama.cpp install"
|
||||
}
|
||||
Write-Host " Syncing llama.cpp to $ResolvedSourceRef..." -ForegroundColor Gray
|
||||
# Always sync the remote URL so switching between default/fork sources works
|
||||
Invoke-SetupCommand -AlwaysQuiet { git -C $LlamaCppDir remote set-url origin "$ResolvedSourceUrl.git" } | Out-Null
|
||||
|
|
@ -2282,24 +2393,30 @@ if (-not $NeedLlamaSourceBuild) {
|
|||
}
|
||||
}
|
||||
}
|
||||
# why: in-place git-sync (the temp-dir clone path calls Mark-StudioOwned
|
||||
# at swap-time) must mark the existing tree so a subsequent prebuilt
|
||||
# update path's Assert-StudioOwnedOrAbsent does not exit on the same root.
|
||||
if ($BuildOk -and $StudioHomeIsCustom) {
|
||||
Mark-StudioOwned -Path $LlamaCppDir
|
||||
}
|
||||
} else {
|
||||
Write-Host " Cloning llama.cpp @ $ResolvedSourceRef..." -ForegroundColor Gray
|
||||
$buildTmp = "$LlamaCppDir.build.$PID"
|
||||
$null = New-Item -ItemType Directory -Force -Path (Split-Path $LlamaCppDir -Parent)
|
||||
if (Test-Path $buildTmp) { Remove-Item -Recurse -Force $buildTmp }
|
||||
$null = [System.IO.Directory]::CreateDirectory((Split-Path -LiteralPath $LlamaCppDir))
|
||||
if (Test-Path -LiteralPath $buildTmp) { Remove-Item -LiteralPath $buildTmp -Recurse -Force }
|
||||
if ($LlamaPr) {
|
||||
$cloneExit = Invoke-SetupCommand -AlwaysQuiet { git clone --depth 1 "$LlamaSource.git" $buildTmp }
|
||||
if ($cloneExit -ne 0) {
|
||||
$BuildOk = $false
|
||||
$FailedStep = "git clone"
|
||||
if (Test-Path $buildTmp) { Remove-Item -Recurse -Force $buildTmp }
|
||||
if (Test-Path -LiteralPath $buildTmp) { Remove-Item -LiteralPath $buildTmp -Recurse -Force }
|
||||
}
|
||||
if ($BuildOk) {
|
||||
$fetchExit = Invoke-SetupCommand -AlwaysQuiet { git -C $buildTmp fetch --depth 1 origin "pull/$LlamaPr/head:pr-$LlamaPr" }
|
||||
if ($fetchExit -ne 0) {
|
||||
$BuildOk = $false
|
||||
$FailedStep = "git fetch PR #$LlamaPr"
|
||||
if (Test-Path $buildTmp) { Remove-Item -Recurse -Force $buildTmp }
|
||||
if (Test-Path -LiteralPath $buildTmp) { Remove-Item -LiteralPath $buildTmp -Recurse -Force }
|
||||
}
|
||||
}
|
||||
if ($BuildOk) {
|
||||
|
|
@ -2307,7 +2424,7 @@ if (-not $NeedLlamaSourceBuild) {
|
|||
if ($checkoutExit -ne 0) {
|
||||
$BuildOk = $false
|
||||
$FailedStep = "git checkout PR #$LlamaPr"
|
||||
if (Test-Path $buildTmp) { Remove-Item -Recurse -Force $buildTmp }
|
||||
if (Test-Path -LiteralPath $buildTmp) { Remove-Item -LiteralPath $buildTmp -Recurse -Force }
|
||||
}
|
||||
}
|
||||
} elseif ($ResolvedSourceRefKind -eq "pull") {
|
||||
|
|
@ -2315,14 +2432,14 @@ if (-not $NeedLlamaSourceBuild) {
|
|||
if ($cloneExit -ne 0) {
|
||||
$BuildOk = $false
|
||||
$FailedStep = "git clone"
|
||||
if (Test-Path $buildTmp) { Remove-Item -Recurse -Force $buildTmp }
|
||||
if (Test-Path -LiteralPath $buildTmp) { Remove-Item -LiteralPath $buildTmp -Recurse -Force }
|
||||
}
|
||||
if ($BuildOk) {
|
||||
$fetchExit = Invoke-SetupCommand -AlwaysQuiet { git -C $buildTmp fetch --depth 1 origin $ResolvedSourceRef }
|
||||
if ($fetchExit -ne 0) {
|
||||
$BuildOk = $false
|
||||
$FailedStep = "git fetch source PR ref"
|
||||
if (Test-Path $buildTmp) { Remove-Item -Recurse -Force $buildTmp }
|
||||
if (Test-Path -LiteralPath $buildTmp) { Remove-Item -LiteralPath $buildTmp -Recurse -Force }
|
||||
}
|
||||
}
|
||||
if ($BuildOk) {
|
||||
|
|
@ -2330,7 +2447,7 @@ if (-not $NeedLlamaSourceBuild) {
|
|||
if ($checkoutExit -ne 0) {
|
||||
$BuildOk = $false
|
||||
$FailedStep = "git checkout source PR ref"
|
||||
if (Test-Path $buildTmp) { Remove-Item -Recurse -Force $buildTmp }
|
||||
if (Test-Path -LiteralPath $buildTmp) { Remove-Item -LiteralPath $buildTmp -Recurse -Force }
|
||||
}
|
||||
}
|
||||
} elseif ($ResolvedSourceRefKind -eq "commit") {
|
||||
|
|
@ -2338,14 +2455,14 @@ if (-not $NeedLlamaSourceBuild) {
|
|||
if ($cloneExit -ne 0) {
|
||||
$BuildOk = $false
|
||||
$FailedStep = "git clone"
|
||||
if (Test-Path $buildTmp) { Remove-Item -Recurse -Force $buildTmp }
|
||||
if (Test-Path -LiteralPath $buildTmp) { Remove-Item -LiteralPath $buildTmp -Recurse -Force }
|
||||
}
|
||||
if ($BuildOk) {
|
||||
$fetchExit = Invoke-SetupCommand -AlwaysQuiet { git -C $buildTmp fetch --depth 1 origin $ResolvedSourceRef }
|
||||
if ($fetchExit -ne 0) {
|
||||
$BuildOk = $false
|
||||
$FailedStep = "git fetch source commit"
|
||||
if (Test-Path $buildTmp) { Remove-Item -Recurse -Force $buildTmp }
|
||||
if (Test-Path -LiteralPath $buildTmp) { Remove-Item -LiteralPath $buildTmp -Recurse -Force }
|
||||
}
|
||||
}
|
||||
if ($BuildOk) {
|
||||
|
|
@ -2353,7 +2470,7 @@ if (-not $NeedLlamaSourceBuild) {
|
|||
if ($checkoutExit -ne 0) {
|
||||
$BuildOk = $false
|
||||
$FailedStep = "git checkout source commit"
|
||||
if (Test-Path $buildTmp) { Remove-Item -Recurse -Force $buildTmp }
|
||||
if (Test-Path -LiteralPath $buildTmp) { Remove-Item -LiteralPath $buildTmp -Recurse -Force }
|
||||
}
|
||||
}
|
||||
} else {
|
||||
|
|
@ -2366,7 +2483,7 @@ if (-not $NeedLlamaSourceBuild) {
|
|||
if ($cloneExit -ne 0) {
|
||||
$BuildOk = $false
|
||||
$FailedStep = "git clone"
|
||||
if (Test-Path $buildTmp) { Remove-Item -Recurse -Force $buildTmp }
|
||||
if (Test-Path -LiteralPath $buildTmp) { Remove-Item -LiteralPath $buildTmp -Recurse -Force }
|
||||
}
|
||||
}
|
||||
# Use temp dir for build; swap into $LlamaCppDir only after build succeeds
|
||||
|
|
@ -2482,14 +2599,16 @@ if (-not $NeedLlamaSourceBuild) {
|
|||
|
||||
# Swap temp build dir into final location (only if we built in a temp dir)
|
||||
if ($BuildOk -and $LlamaCppDir -ne $OriginalLlamaCppDir) {
|
||||
if (Test-Path $OriginalLlamaCppDir) { Remove-Item -Recurse -Force $OriginalLlamaCppDir }
|
||||
Move-Item $LlamaCppDir $OriginalLlamaCppDir
|
||||
Assert-StudioOwnedOrAbsent -Path $OriginalLlamaCppDir -Label "llama.cpp install"
|
||||
if (Test-Path -LiteralPath $OriginalLlamaCppDir) { Remove-Item -LiteralPath $OriginalLlamaCppDir -Recurse -Force }
|
||||
Move-Item -LiteralPath $LlamaCppDir -Destination $OriginalLlamaCppDir
|
||||
$LlamaCppDir = $OriginalLlamaCppDir
|
||||
$BuildDir = Join-Path $LlamaCppDir "build"
|
||||
$LlamaServerBin = Join-Path $BuildDir "bin\Release\llama-server.exe"
|
||||
Mark-StudioOwned -Path $LlamaCppDir
|
||||
} elseif (-not $BuildOk -and $LlamaCppDir -ne $OriginalLlamaCppDir) {
|
||||
# Build failed -- clean up temp dir, preserve existing install
|
||||
if (Test-Path $LlamaCppDir) { Remove-Item -Recurse -Force $LlamaCppDir }
|
||||
if (Test-Path -LiteralPath $LlamaCppDir) { Remove-Item -LiteralPath $LlamaCppDir -Recurse -Force }
|
||||
$LlamaCppDir = $OriginalLlamaCppDir
|
||||
$BuildDir = Join-Path $LlamaCppDir "build"
|
||||
$LlamaServerBin = Join-Path $BuildDir "bin\Release\llama-server.exe"
|
||||
|
|
@ -2504,16 +2623,16 @@ if (-not $NeedLlamaSourceBuild) {
|
|||
$totalSec = [math]::Round($totalSw.Elapsed.TotalSeconds % 60, 1)
|
||||
|
||||
# -- Summary --
|
||||
if ($BuildOk -and (Test-Path $LlamaServerBin)) {
|
||||
if ($BuildOk -and (Test-Path -LiteralPath $LlamaServerBin)) {
|
||||
step "llama.cpp" "built"
|
||||
$QuantizeBin = Join-Path $BuildDir "bin\Release\llama-quantize.exe"
|
||||
if (Test-Path $QuantizeBin) {
|
||||
if (Test-Path -LiteralPath $QuantizeBin) {
|
||||
step "llama-quantize" "built"
|
||||
}
|
||||
step "build time" "${totalMin}m ${totalSec}s" "DarkGray"
|
||||
} else {
|
||||
$altBin = Join-Path $BuildDir "bin\llama-server.exe"
|
||||
if ($BuildOk -and (Test-Path $altBin)) {
|
||||
if ($BuildOk -and (Test-Path -LiteralPath $altBin)) {
|
||||
step "llama.cpp" "built"
|
||||
step "build time" "${totalMin}m ${totalSec}s" "DarkGray"
|
||||
} else {
|
||||
|
|
|
|||
|
|
@ -417,7 +417,36 @@ if [ -d "$SCRIPT_DIR/backend/core/data_recipe/oxc-validator" ] && command -v npm
|
|||
fi
|
||||
|
||||
# ── Python venv + deps ──
|
||||
STUDIO_HOME="$HOME/.unsloth/studio"
|
||||
# UNSLOTH_STUDIO_HOME (or STUDIO_HOME alias) overrides the install root
|
||||
# (mirrors install.sh). UNSLOTH_STUDIO_HOME wins when both are set.
|
||||
_studio_override_var=""
|
||||
_studio_override="${UNSLOTH_STUDIO_HOME:-}"
|
||||
if [ -n "$_studio_override" ]; then
|
||||
_studio_override_var="UNSLOTH_STUDIO_HOME"
|
||||
else
|
||||
_studio_override="${STUDIO_HOME:-}"
|
||||
[ -n "$_studio_override" ] && _studio_override_var="STUDIO_HOME"
|
||||
fi
|
||||
# Strip whitespace so " " is treated as unset (matches Python .strip()).
|
||||
_studio_override=$(printf '%s' "$_studio_override" | sed -e 's/^[[:space:]]*//' -e 's/[[:space:]]*$//')
|
||||
case "$_studio_override" in
|
||||
"~") _studio_override="$HOME" ;;
|
||||
"~/"*) _studio_override="$HOME/${_studio_override#'~/'}" ;;
|
||||
esac
|
||||
if [ -n "$_studio_override" ]; then
|
||||
# setup.sh runs against an existing install (via 'unsloth studio update');
|
||||
# a typo in the override must fail fast instead of materializing an
|
||||
# empty workspace dir. Mirrors setup.ps1 behavior.
|
||||
if [ ! -d "$_studio_override" ]; then
|
||||
echo "ERROR: $_studio_override_var=$_studio_override does not exist." >&2
|
||||
echo " Run install.sh to create the install root before 'unsloth studio update'." >&2
|
||||
exit 1
|
||||
fi
|
||||
[ -w "$_studio_override" ] || { echo "ERROR: $_studio_override_var=$_studio_override is not writable." >&2; exit 1; }
|
||||
STUDIO_HOME="$(CDPATH= cd -P -- "$_studio_override" && pwd -P)" || exit 1
|
||||
else
|
||||
STUDIO_HOME="$HOME/.unsloth/studio"
|
||||
fi
|
||||
VENV_DIR="$STUDIO_HOME/unsloth_studio"
|
||||
VENV_T5_530_DIR="$STUDIO_HOME/.venv_t5_530"
|
||||
VENV_T5_550_DIR="$STUDIO_HOME/.venv_t5_550"
|
||||
|
|
@ -542,9 +571,39 @@ fi
|
|||
#
|
||||
# Runs outside the _SKIP_PYTHON_DEPS gate so that upgrades from legacy
|
||||
# single .venv_t5 are always migrated to the tiered layout.
|
||||
# why: in env-override mode $STUDIO_HOME is user-chosen; require the
|
||||
# ownership marker before rm -rf so unrelated dirs survive. Gated on the
|
||||
# canonical comparison so an override pointing at the legacy default still
|
||||
# behaves like a default install.
|
||||
_STUDIO_OWNED_MARKER=".unsloth-studio-owned"
|
||||
_LEGACY_STUDIO_HOME="$HOME/.unsloth/studio"
|
||||
_studio_home_canon="$STUDIO_HOME"
|
||||
if [ -d "$_studio_home_canon" ]; then
|
||||
_studio_home_canon=$(CDPATH= cd -P -- "$_studio_home_canon" 2>/dev/null && pwd -P) \
|
||||
|| _studio_home_canon="$STUDIO_HOME"
|
||||
fi
|
||||
if [ -d "$_LEGACY_STUDIO_HOME" ]; then
|
||||
_LEGACY_STUDIO_HOME=$(CDPATH= cd -P -- "$_LEGACY_STUDIO_HOME" 2>/dev/null && pwd -P) \
|
||||
|| _LEGACY_STUDIO_HOME="$HOME/.unsloth/studio"
|
||||
fi
|
||||
_STUDIO_HOME_IS_CUSTOM=false
|
||||
if [ "$_studio_home_canon" != "$_LEGACY_STUDIO_HOME" ]; then
|
||||
_STUDIO_HOME_IS_CUSTOM=true
|
||||
fi
|
||||
_assert_studio_owned_or_absent() {
|
||||
_aso_dir="$1"
|
||||
_aso_label="$2"
|
||||
[ -d "$_aso_dir" ] || return 0
|
||||
if [ "$_STUDIO_HOME_IS_CUSTOM" = true ] && [ ! -f "$_aso_dir/$_STUDIO_OWNED_MARKER" ]; then
|
||||
echo "ERROR: $_aso_dir already exists and is not marked as a Studio-owned $_aso_label." >&2
|
||||
echo " Move it aside or choose an empty UNSLOTH_STUDIO_HOME before re-running." >&2
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
_NEED_T5_INSTALL=false
|
||||
if [ -d "$STUDIO_HOME/.venv_t5" ]; then
|
||||
# Legacy layout — migrate
|
||||
_assert_studio_owned_or_absent "$STUDIO_HOME/.venv_t5" "legacy transformers sidecar venv"
|
||||
rm -rf "$STUDIO_HOME/.venv_t5"
|
||||
_NEED_T5_INSTALL=true
|
||||
fi
|
||||
|
|
@ -554,16 +613,20 @@ fi
|
|||
[ "$_SKIP_PYTHON_DEPS" = false ] && _NEED_T5_INSTALL=true
|
||||
|
||||
if [ "$_NEED_T5_INSTALL" = true ]; then
|
||||
_assert_studio_owned_or_absent "$VENV_T5_530_DIR" "transformers 5.3 sidecar venv"
|
||||
[ -d "$VENV_T5_530_DIR" ] && rm -rf "$VENV_T5_530_DIR"
|
||||
mkdir -p "$VENV_T5_530_DIR"
|
||||
: > "$VENV_T5_530_DIR/$_STUDIO_OWNED_MARKER" 2>/dev/null || true
|
||||
run_quiet "install transformers 5.3.0" fast_install --target "$VENV_T5_530_DIR" --no-deps "transformers==5.3.0"
|
||||
run_quiet "install huggingface_hub for t5_530" fast_install --target "$VENV_T5_530_DIR" --no-deps "huggingface_hub==1.8.0"
|
||||
run_quiet "install hf_xet for t5_530" fast_install --target "$VENV_T5_530_DIR" --no-deps "hf_xet==1.4.2"
|
||||
run_quiet "install tiktoken for t5_530" fast_install --target "$VENV_T5_530_DIR" "tiktoken"
|
||||
step "transformers" "5.3.0 pre-installed"
|
||||
|
||||
_assert_studio_owned_or_absent "$VENV_T5_550_DIR" "transformers 5.5 sidecar venv"
|
||||
[ -d "$VENV_T5_550_DIR" ] && rm -rf "$VENV_T5_550_DIR"
|
||||
mkdir -p "$VENV_T5_550_DIR"
|
||||
: > "$VENV_T5_550_DIR/$_STUDIO_OWNED_MARKER" 2>/dev/null || true
|
||||
run_quiet "install transformers 5.5.0" fast_install --target "$VENV_T5_550_DIR" --no-deps "transformers==5.5.0"
|
||||
run_quiet "install huggingface_hub for t5_550" fast_install --target "$VENV_T5_550_DIR" --no-deps "huggingface_hub==1.8.0"
|
||||
run_quiet "install hf_xet for t5_550" fast_install --target "$VENV_T5_550_DIR" --no-deps "hf_xet==1.4.2"
|
||||
|
|
@ -573,7 +636,13 @@ fi
|
|||
fi
|
||||
|
||||
# ── 7. Prefer prebuilt llama.cpp bundles before any source build path ──
|
||||
UNSLOTH_HOME="$HOME/.unsloth"
|
||||
# Nest llama.cpp under $STUDIO_HOME only for real env-overrides; legacy
|
||||
# default keeps ~/.unsloth/llama.cpp so pre-PR builds are still discovered.
|
||||
if [ "$_STUDIO_HOME_IS_CUSTOM" = true ]; then
|
||||
UNSLOTH_HOME="$STUDIO_HOME"
|
||||
else
|
||||
UNSLOTH_HOME="$HOME/.unsloth"
|
||||
fi
|
||||
mkdir -p "$UNSLOTH_HOME"
|
||||
LLAMA_CPP_DIR="$UNSLOTH_HOME/llama.cpp"
|
||||
LLAMA_SERVER_BIN="$LLAMA_CPP_DIR/build/bin/llama-server"
|
||||
|
|
@ -635,6 +704,12 @@ else
|
|||
if [ -d "$LLAMA_CPP_DIR" ]; then
|
||||
substep "existing install detected -- validating update"
|
||||
fi
|
||||
# why: install_llama_prebuilt.py uses os.replace(), which would displace
|
||||
# an unrelated $UNSLOTH_STUDIO_HOME/llama.cpp before the source-build
|
||||
# ownership check below ever runs.
|
||||
if [ "$_STUDIO_HOME_IS_CUSTOM" = true ]; then
|
||||
_assert_studio_owned_or_absent "$LLAMA_CPP_DIR" "llama.cpp install"
|
||||
fi
|
||||
_PREBUILT_CMD=(
|
||||
python "$SCRIPT_DIR/install_llama_prebuilt.py"
|
||||
--install-dir "$LLAMA_CPP_DIR"
|
||||
|
|
@ -662,6 +737,9 @@ else
|
|||
else
|
||||
step "llama.cpp" "prebuilt installed and validated"
|
||||
fi
|
||||
if [ "$_STUDIO_HOME_IS_CUSTOM" = true ] && [ -d "$LLAMA_CPP_DIR" ]; then
|
||||
: > "$LLAMA_CPP_DIR/$_STUDIO_OWNED_MARKER" 2>/dev/null || true
|
||||
fi
|
||||
print_installed_llama_prebuilt_release "$LLAMA_CPP_DIR"
|
||||
verbose_substep "llama.cpp install dir: $LLAMA_CPP_DIR"
|
||||
rm -f "$_PREBUILT_LOG"
|
||||
|
|
@ -1032,8 +1110,10 @@ else
|
|||
|
||||
# Swap only after build succeeds -- preserves existing install on failure
|
||||
if [ "$BUILD_OK" = true ]; then
|
||||
_assert_studio_owned_or_absent "$LLAMA_CPP_DIR" "llama.cpp install"
|
||||
rm -rf "$LLAMA_CPP_DIR"
|
||||
mv "$_BUILD_TMP" "$LLAMA_CPP_DIR"
|
||||
: > "$LLAMA_CPP_DIR/$_STUDIO_OWNED_MARKER" 2>/dev/null || true
|
||||
# Symlink to llama.cpp root -- check_llama_cpp() looks for the binary there
|
||||
QUANTIZE_BIN="$LLAMA_CPP_DIR/build/bin/llama-quantize"
|
||||
if [ -f "$QUANTIZE_BIN" ]; then
|
||||
|
|
|
|||
|
|
@ -60,6 +60,11 @@ pub async fn check_install_status() -> bool {
|
|||
cmd.env_remove("PYTHONPATH");
|
||||
}
|
||||
|
||||
// Tauri uses the legacy root regardless of UNSLOTH_STUDIO_HOME / STUDIO_HOME;
|
||||
// probe subprocesses must follow the same isolation as process.rs.
|
||||
cmd.env_remove("UNSLOTH_STUDIO_HOME");
|
||||
cmd.env_remove("STUDIO_HOME");
|
||||
|
||||
let mut child = match cmd.spawn() {
|
||||
Ok(c) => c,
|
||||
Err(e) => {
|
||||
|
|
|
|||
|
|
@ -203,6 +203,11 @@ async fn provision_desktop_auth() -> Result<(), String> {
|
|||
cmd.env_remove("PYTHONHOME");
|
||||
cmd.env_remove("PYTHONPATH");
|
||||
}
|
||||
|
||||
// Tauri uses the legacy root regardless of UNSLOTH_STUDIO_HOME / STUDIO_HOME.
|
||||
// Scrub so provisioning writes match what the Rust auth code reads.
|
||||
cmd.env_remove("UNSLOTH_STUDIO_HOME");
|
||||
cmd.env_remove("STUDIO_HOME");
|
||||
#[cfg(windows)]
|
||||
{
|
||||
use std::os::windows::process::CommandExt;
|
||||
|
|
|
|||
|
|
@ -196,6 +196,11 @@ fn spawn_script(
|
|||
cmd.env_remove("PYTHONPATH");
|
||||
}
|
||||
|
||||
// Tauri only does default-root installs; install.sh / install.ps1 reject
|
||||
// these under --tauri. Scrub so an inherited value can't trip the guard.
|
||||
cmd.env_remove("UNSLOTH_STUDIO_HOME");
|
||||
cmd.env_remove("STUDIO_HOME");
|
||||
|
||||
// On Windows, launch the installer directly with CREATE_NO_WINDOW.
|
||||
// The app process is assigned to a KILL_ON_JOB_CLOSE job in main.rs, so
|
||||
// child cleanup on crash comes from inherited job membership instead.
|
||||
|
|
|
|||
|
|
@ -102,6 +102,11 @@ async fn run_cli_probe(bin: &std::path::Path, args: &[&str]) -> bool {
|
|||
cmd.env_remove("PYTHONPATH");
|
||||
}
|
||||
|
||||
// Tauri uses the legacy root regardless of UNSLOTH_STUDIO_HOME / STUDIO_HOME;
|
||||
// probe subprocesses must follow the same isolation as process.rs.
|
||||
cmd.env_remove("UNSLOTH_STUDIO_HOME");
|
||||
cmd.env_remove("STUDIO_HOME");
|
||||
|
||||
#[cfg(windows)]
|
||||
{
|
||||
use std::os::windows::process::CommandExt;
|
||||
|
|
@ -135,6 +140,11 @@ async fn probe_cli_capability(bin: &std::path::Path) -> Option<DesktopCapability
|
|||
cmd.env_remove("PYTHONPATH");
|
||||
}
|
||||
|
||||
// Tauri uses the legacy root regardless of UNSLOTH_STUDIO_HOME / STUDIO_HOME;
|
||||
// probe subprocesses must follow the same isolation as process.rs.
|
||||
cmd.env_remove("UNSLOTH_STUDIO_HOME");
|
||||
cmd.env_remove("STUDIO_HOME");
|
||||
|
||||
#[cfg(windows)]
|
||||
{
|
||||
use std::os::windows::process::CommandExt;
|
||||
|
|
|
|||
|
|
@ -316,6 +316,12 @@ pub fn start_backend(
|
|||
cmd.env_remove("PYTHONPATH");
|
||||
}
|
||||
|
||||
// Tauri uses the legacy root regardless of UNSLOTH_STUDIO_HOME / STUDIO_HOME;
|
||||
// scrub so the spawned Python backend can't diverge. UNSLOTH_LLAMA_CPP_PATH
|
||||
// is a pre-existing user-controlled llama.cpp dir override; keep it.
|
||||
cmd.env_remove("UNSLOTH_STUDIO_HOME");
|
||||
cmd.env_remove("STUDIO_HOME");
|
||||
|
||||
// On Windows, launch the backend directly with hidden-window flags.
|
||||
// The app process is assigned to a KILL_ON_JOB_CLOSE job in main.rs, so
|
||||
// children inherit crash-safe cleanup without the buggy per-child JobObject wrapper.
|
||||
|
|
|
|||
|
|
@ -61,6 +61,11 @@ fn spawn_update(
|
|||
cmd.env_remove("PYTHONPATH");
|
||||
}
|
||||
|
||||
// Tauri manages the legacy root; scrub so 'unsloth studio update' targets
|
||||
// the same install the desktop app uses, not an inherited custom root.
|
||||
cmd.env_remove("UNSLOTH_STUDIO_HOME");
|
||||
cmd.env_remove("STUDIO_HOME");
|
||||
|
||||
#[cfg(windows)]
|
||||
let mut child: Box<dyn ChildWrapper + Send> = {
|
||||
use std::os::windows::process::CommandExt;
|
||||
|
|
|
|||
1021
tests/test_studio_install_workspace_guard.py
Normal file
1021
tests/test_studio_install_workspace_guard.py
Normal file
File diff suppressed because it is too large
Load diff
154
tests/test_studio_root_resilience.py
Normal file
154
tests/test_studio_root_resilience.py
Normal file
|
|
@ -0,0 +1,154 @@
|
|||
"""Resilience checks for Studio install-root inference under hostile
|
||||
filesystem conditions:
|
||||
- _infer_studio_home_from_venv must NOT propagate PermissionError /
|
||||
OSError out through studio_root() (it would crash module import in
|
||||
run.py / main.py / transformers_version.py / model_config.py).
|
||||
- _kill_orphaned_servers must catch (ImportError, OSError, ValueError)
|
||||
on the studio_root() probe so a transient resolve / sentinel failure
|
||||
cannot crash server startup.
|
||||
- _find_llama_server_binary must keep the custom-root in search_roots
|
||||
when the inner resolve() comparison itself fails."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib.util
|
||||
import re
|
||||
import sys
|
||||
import textwrap
|
||||
from pathlib import Path
|
||||
from unittest import mock
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[1]
|
||||
STORAGE_ROOTS = (
|
||||
REPO_ROOT / "studio" / "backend" / "utils" / "paths" / "storage_roots.py"
|
||||
)
|
||||
LLAMA_CPP = REPO_ROOT / "studio" / "backend" / "core" / "inference" / "llama_cpp.py"
|
||||
|
||||
|
||||
def _load(name: str, path: Path):
|
||||
spec = importlib.util.spec_from_file_location(name, path)
|
||||
assert spec is not None and spec.loader is not None
|
||||
mod = importlib.util.module_from_spec(spec)
|
||||
sys.modules[name] = mod
|
||||
spec.loader.exec_module(mod)
|
||||
return mod
|
||||
|
||||
|
||||
def test_infer_studio_home_swallows_permission_error(tmp_path, monkeypatch):
|
||||
candidate = tmp_path / "fake_root"
|
||||
venv = candidate / "unsloth_studio"
|
||||
venv.mkdir(parents = True)
|
||||
monkeypatch.setattr(sys, "prefix", str(venv))
|
||||
sys.modules.pop("sr_perm", None)
|
||||
mod = _load("sr_perm", STORAGE_ROOTS)
|
||||
with mock.patch.object(Path, "is_file", side_effect = PermissionError("denied")):
|
||||
# Must NOT raise.
|
||||
assert mod._infer_studio_home_from_venv() is None
|
||||
|
||||
|
||||
def test_studio_root_does_not_crash_on_permission_error(tmp_path, monkeypatch):
|
||||
"""studio_root() must remain callable even when the venv inference
|
||||
encounters a restricted filesystem; it should fall through to the
|
||||
legacy default."""
|
||||
candidate = tmp_path / "fake_root"
|
||||
venv = candidate / "unsloth_studio"
|
||||
venv.mkdir(parents = True)
|
||||
monkeypatch.setattr(sys, "prefix", str(venv))
|
||||
monkeypatch.delenv("UNSLOTH_STUDIO_HOME", raising = False)
|
||||
monkeypatch.delenv("STUDIO_HOME", raising = False)
|
||||
sys.modules.pop("sr_studio_perm", None)
|
||||
mod = _load("sr_studio_perm", STORAGE_ROOTS)
|
||||
with mock.patch.object(Path, "is_file", side_effect = OSError("ebusy")):
|
||||
result = mod.studio_root()
|
||||
assert result == Path.home() / ".unsloth" / "studio"
|
||||
|
||||
|
||||
def test_kill_orphan_catches_oserror_from_studio_root():
|
||||
"""_kill_orphaned_servers must catch (ImportError, OSError, ValueError)
|
||||
on the studio_root() probe specifically; the sister function
|
||||
_find_llama_server_binary uses the same broader catch on its own probe."""
|
||||
src = LLAMA_CPP.read_text()
|
||||
fn_start = src.index("def _kill_orphaned_servers")
|
||||
fn_body = src[fn_start : fn_start + 4000]
|
||||
# The studio_root() probe in this fn is the one that imports as `_sr`
|
||||
# and assigns `_resolved_sr = _sr()`. Find the except that closes it.
|
||||
probe_idx = fn_body.index("storage_roots import studio_root as _sr")
|
||||
# The matching except is the next `except ...:` after the inner
|
||||
# OSError/ValueError block that wraps resolve().
|
||||
after = fn_body[probe_idx:]
|
||||
# Skip over the inner `except (OSError, ValueError):` that wraps resolve().
|
||||
inner_idx = after.index("except (OSError, ValueError):")
|
||||
after_inner = after[inner_idx + len("except (OSError, ValueError):") :]
|
||||
outer_match = re.search(r"except\s*\(?[^)]*?\)?:", after_inner)
|
||||
assert outer_match, "outer except for studio_root probe missing"
|
||||
clause = outer_match.group(0)
|
||||
assert (
|
||||
"OSError" in clause and "ValueError" in clause
|
||||
), f"_kill_orphaned_servers studio_root probe catch too narrow: {clause!r}"
|
||||
|
||||
|
||||
def _exec_search_roots_block(
|
||||
home: Path, studio_root_value: Path, resolve_raises: bool
|
||||
) -> list[Path]:
|
||||
"""Extract _find_llama_server_binary's env-mode search_roots block
|
||||
and execute it with controlled inputs."""
|
||||
src = LLAMA_CPP.read_text()
|
||||
block_start = src.index('legacy_llama = Path.home() / ".unsloth" / "llama.cpp"')
|
||||
block_end = src.index("_seen_roots: set[str]", block_start)
|
||||
raw = src[block_start:block_end]
|
||||
indent = " " * 8
|
||||
block = textwrap.dedent(indent + raw)
|
||||
fake_module = type(sys)("fake_storage_roots")
|
||||
fake_module.studio_root = lambda: studio_root_value
|
||||
sys.modules["utils.paths.storage_roots"] = fake_module
|
||||
try:
|
||||
original_resolve = Path.resolve
|
||||
|
||||
def _resolve(self, *a, **k):
|
||||
if resolve_raises:
|
||||
raise OSError("ebusy")
|
||||
return original_resolve(self, *a, **k)
|
||||
|
||||
with (
|
||||
mock.patch.object(Path, "home", classmethod(lambda cls: home)),
|
||||
mock.patch.object(Path, "resolve", _resolve),
|
||||
):
|
||||
ns: dict = {"Path": Path}
|
||||
exec(block, ns) # noqa: S102
|
||||
return ns["search_roots"]
|
||||
finally:
|
||||
sys.modules.pop("utils.paths.storage_roots", None)
|
||||
|
||||
|
||||
def test_search_roots_keeps_custom_when_resolve_fails(tmp_path):
|
||||
home = tmp_path / "home"
|
||||
home.mkdir()
|
||||
custom = tmp_path / "custom_studio"
|
||||
custom.mkdir()
|
||||
roots = _exec_search_roots_block(
|
||||
home = home, studio_root_value = custom, resolve_raises = True
|
||||
)
|
||||
# On resolve() failure, the inner except falls back to direct equality;
|
||||
# custom != legacy_studio so the custom root must remain in search_roots.
|
||||
assert (
|
||||
custom / "llama.cpp" in roots
|
||||
), f"custom root dropped on resolve() failure: {roots}"
|
||||
# custom-mode discovery excludes the legacy tree to match _kill_orphaned_servers.
|
||||
assert (
|
||||
(home / ".unsloth" / "llama.cpp") not in roots
|
||||
), f"legacy llama path must not appear in custom-mode search_roots: {roots}"
|
||||
|
||||
|
||||
def test_search_roots_default_mode_uses_legacy_only(tmp_path):
|
||||
home = tmp_path / "home"
|
||||
home.mkdir()
|
||||
legacy = home / ".unsloth" / "studio"
|
||||
legacy.mkdir(parents = True)
|
||||
roots = _exec_search_roots_block(
|
||||
home = home, studio_root_value = legacy, resolve_raises = False
|
||||
)
|
||||
# Default mode: only legacy_llama.
|
||||
assert roots == [home / ".unsloth" / "llama.cpp"]
|
||||
|
|
@ -20,7 +20,73 @@ import typer
|
|||
|
||||
studio_app = typer.Typer(help = "Unsloth Studio commands.")
|
||||
|
||||
STUDIO_HOME = Path.home() / ".unsloth" / "studio"
|
||||
|
||||
# Resolve install root: UNSLOTH_STUDIO_HOME, then STUDIO_HOME alias, then
|
||||
# sys.prefix inference (so a direct call to <root>/bin/unsloth resolves after
|
||||
# the installer's env var has expired), then legacy ~/.unsloth/studio.
|
||||
# UNSLOTH_STUDIO_HOME wins when both env vars are set.
|
||||
def _looks_like_installer_managed_studio_home(candidate: Path) -> bool:
|
||||
"""Sentinel check (studio.conf or bin shim) so a dev venv named
|
||||
unsloth_studio is not misidentified as a custom Studio root.
|
||||
"""
|
||||
shim_name = "unsloth.exe" if platform.system() == "Windows" else "unsloth"
|
||||
return (candidate / "share" / "studio.conf").is_file() or (
|
||||
candidate / "bin" / shim_name
|
||||
).is_file()
|
||||
|
||||
|
||||
def _resolve_studio_home() -> tuple[Path, bool]:
|
||||
override = (os.environ.get("UNSLOTH_STUDIO_HOME") or "").strip()
|
||||
if not override:
|
||||
override = (os.environ.get("STUDIO_HOME") or "").strip()
|
||||
if override:
|
||||
try:
|
||||
return Path(override).expanduser().resolve(), True
|
||||
except (OSError, ValueError):
|
||||
return Path(override).expanduser(), True
|
||||
try:
|
||||
prefix = Path(sys.prefix).resolve()
|
||||
if prefix.name == "unsloth_studio":
|
||||
inferred = prefix.parent
|
||||
legacy = (Path.home() / ".unsloth" / "studio").resolve()
|
||||
if inferred != legacy and _looks_like_installer_managed_studio_home(
|
||||
inferred
|
||||
):
|
||||
return inferred, True
|
||||
except (OSError, ValueError):
|
||||
pass
|
||||
return Path.home() / ".unsloth" / "studio", False
|
||||
|
||||
|
||||
STUDIO_HOME, _STUDIO_HOME_IS_CUSTOM = _resolve_studio_home()
|
||||
|
||||
|
||||
def _ensure_studio_env_exported() -> None:
|
||||
"""Re-export UNSLOTH_STUDIO_HOME / UNSLOTH_LLAMA_CPP_PATH only for real
|
||||
custom roots so subprocesses inherit the right install. Called from each
|
||||
studio subcommand entry rather than at import time, to avoid leaking env
|
||||
state into unrelated importers (tests, --help, CLI introspection).
|
||||
"""
|
||||
if not _STUDIO_HOME_IS_CUSTOM:
|
||||
return
|
||||
# Truthy-check (not setdefault) so a blank UNSLOTH_STUDIO_HOME= does not
|
||||
# suppress the inferred custom root.
|
||||
if not os.environ.get("UNSLOTH_STUDIO_HOME"):
|
||||
os.environ["UNSLOTH_STUDIO_HOME"] = str(STUDIO_HOME)
|
||||
# When override == legacy default, llama.cpp stays at ~/.unsloth/llama.cpp.
|
||||
try:
|
||||
_legacy_studio = (Path.home() / ".unsloth" / "studio").resolve()
|
||||
_is_legacy = STUDIO_HOME.resolve() == _legacy_studio
|
||||
except (OSError, ValueError):
|
||||
_is_legacy = STUDIO_HOME == (Path.home() / ".unsloth" / "studio")
|
||||
if _is_legacy:
|
||||
_llama_dir = Path.home() / ".unsloth" / "llama.cpp"
|
||||
else:
|
||||
_llama_dir = STUDIO_HOME / "llama.cpp"
|
||||
if not os.environ.get("UNSLOTH_LLAMA_CPP_PATH"):
|
||||
os.environ["UNSLOTH_LLAMA_CPP_PATH"] = str(_llama_dir)
|
||||
|
||||
|
||||
BOOTSTRAP_PASSWORD_FILE = ".bootstrap_password"
|
||||
DESKTOP_SECRET_FILE = ".desktop_secret"
|
||||
DEFAULT_ADMIN_USERNAME = "unsloth"
|
||||
|
|
@ -427,6 +493,8 @@ def studio_default(
|
|||
),
|
||||
):
|
||||
"""Launch the Unsloth Studio server."""
|
||||
# Runs before any subcommand; covers run/setup/update/etc in one place.
|
||||
_ensure_studio_env_exported()
|
||||
if ctx.invoked_subcommand is not None:
|
||||
return
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue