mirror of
https://github.com/unslothai/unsloth.git
synced 2026-05-22 02:50:03 +00:00
* 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>
1640 lines
80 KiB
PowerShell
1640 lines
80 KiB
PowerShell
# Unsloth Studio Installer for Windows PowerShell
|
|
# Usage: irm https://raw.githubusercontent.com/unslothai/unsloth/main/install.ps1 | iex
|
|
# 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"
|
|
$script:UnslothVerbose = ($env:UNSLOTH_VERBOSE -eq "1")
|
|
|
|
# ── Tauri structured output ──
|
|
function Write-TauriLog {
|
|
param([string]$Tag, [string]$Message)
|
|
if ($TauriMode) {
|
|
Write-Host "[TAURI:$Tag] $Message"
|
|
}
|
|
}
|
|
|
|
function Format-TauriDiagBool {
|
|
param([bool]$Value)
|
|
if ($Value) { return "true" }
|
|
return "false"
|
|
}
|
|
|
|
function Get-TauriDiagArch {
|
|
$arch = [string]$env:PROCESSOR_ARCHITECTURE
|
|
if ([string]::IsNullOrWhiteSpace($arch)) {
|
|
try { $arch = [System.Runtime.InteropServices.RuntimeInformation]::OSArchitecture.ToString() } catch { $arch = "unknown" }
|
|
}
|
|
$arch = $arch.ToLowerInvariant()
|
|
switch ($arch) {
|
|
"amd64" { return "x86_64" }
|
|
"x64" { return "x86_64" }
|
|
"arm64" { return "arm64" }
|
|
"x86" { return "x86" }
|
|
default { return ($arch -replace '[^a-z0-9_.-]', '_') }
|
|
}
|
|
}
|
|
|
|
function Get-TauriTorchIndexFamily {
|
|
param([string]$TorchIndexUrl)
|
|
if ($SkipTorch) { return "none" }
|
|
if ([string]::IsNullOrWhiteSpace($TorchIndexUrl)) { return "none" }
|
|
$leaf = ($TorchIndexUrl.TrimEnd('/') -split '/')[-1].ToLowerInvariant()
|
|
if (@("cpu", "cu118", "cu124", "cu126", "cu128", "cu130") -contains $leaf) { return $leaf }
|
|
if ($leaf -match '^rocm[0-9]+\.[0-9]+$') { return $leaf }
|
|
return "auto"
|
|
}
|
|
|
|
function Get-TauriGpuBranch {
|
|
param([string]$TorchIndexFamily)
|
|
if ($SkipTorch) { return "no_torch" }
|
|
if ($TorchIndexFamily -like "cu*") { return "cuda" }
|
|
if ($TorchIndexFamily -like "rocm*") { return "rocm" }
|
|
if ($TorchIndexFamily -eq "cpu") { return "cpu" }
|
|
return "unknown"
|
|
}
|
|
|
|
function Write-TauriDiag {
|
|
param(
|
|
[string]$GpuBranch = "unknown",
|
|
[string]$TorchIndexFamily = "none",
|
|
[string]$PythonVersionForDiag = $PythonVersion
|
|
)
|
|
if ([string]::IsNullOrWhiteSpace($PythonVersionForDiag)) { $PythonVersionForDiag = "unknown" }
|
|
Write-TauriLog "DIAG" "diag_schema=1 platform=windows arch=$(Get-TauriDiagArch) python_version=$($PythonVersionForDiag.ToLowerInvariant()) skip_torch=$(Format-TauriDiagBool $SkipTorch) mac_intel=false gpu_branch=$GpuBranch torch_index_family=$TorchIndexFamily"
|
|
}
|
|
|
|
function Exit-InstallFailure {
|
|
param(
|
|
[Parameter(Mandatory = $true)][string]$Message,
|
|
[int]$Code = 1
|
|
)
|
|
if ($Code -eq 0) { $Code = 1 }
|
|
Write-TauriLog "ERROR" $Message
|
|
if (Get-Command Restore-StudioVenvRollback -CommandType Function -ErrorAction SilentlyContinue) {
|
|
Restore-StudioVenvRollback
|
|
}
|
|
if ($TauriMode) {
|
|
exit $Code
|
|
}
|
|
}
|
|
|
|
# ── Parse flags ──
|
|
$StudioLocalInstall = $false
|
|
$PackageName = "unsloth"
|
|
$RepoRoot = ""
|
|
$TauriMode = $false
|
|
$SkipTorch = $false
|
|
$argList = $args
|
|
for ($i = 0; $i -lt $argList.Count; $i++) {
|
|
switch ($argList[$i]) {
|
|
"--local" { $StudioLocalInstall = $true }
|
|
"--tauri" { $TauriMode = $true }
|
|
"--no-torch" { $SkipTorch = $true }
|
|
"--verbose" { $script:UnslothVerbose = $true }
|
|
"-v" { $script:UnslothVerbose = $true }
|
|
"--package" {
|
|
$i++
|
|
if ($i -ge $argList.Count) {
|
|
Write-Host "[ERROR] --package requires an argument." -ForegroundColor Red
|
|
return (Exit-InstallFailure "--package requires an argument.")
|
|
}
|
|
$PackageName = $argList[$i]
|
|
}
|
|
}
|
|
}
|
|
# Propagate to child processes so they also respect verbose mode.
|
|
# Process-scoped -- does not persist.
|
|
if ($script:UnslothVerbose) {
|
|
$env:UNSLOTH_VERBOSE = '1'
|
|
}
|
|
|
|
if ($StudioLocalInstall) {
|
|
$RepoRoot = (Resolve-Path (Split-Path -Parent $PSCommandPath)).Path
|
|
if (-not (Test-Path (Join-Path $RepoRoot "pyproject.toml"))) {
|
|
Write-Host "[ERROR] --local must be run from the unsloth repo root (pyproject.toml not found at $RepoRoot)" -ForegroundColor Red
|
|
return (Exit-InstallFailure "--local must be run from the unsloth repo root")
|
|
}
|
|
}
|
|
|
|
# Validate --package to prevent injection into shell/Python commands
|
|
if ($PackageName -notmatch '^[a-zA-Z0-9][a-zA-Z0-9._-]*$') {
|
|
Write-Host "[ERROR] --package name contains invalid characters (allowed: a-z A-Z 0-9 . _ -)" -ForegroundColor Red
|
|
return (Exit-InstallFailure "--package name contains invalid characters")
|
|
}
|
|
|
|
$PythonVersion = "3.13"
|
|
|
|
# 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)
|
|
$Sloth = [char]::ConvertFromUtf32(0x1F9A5)
|
|
|
|
function Enable-StudioVirtualTerminal {
|
|
if ($env:NO_COLOR) { return $false }
|
|
try {
|
|
if (-not ("StudioVT.Native" -as [type])) {
|
|
Add-Type -Namespace StudioVT -Name Native -MemberDefinition @'
|
|
[DllImport("kernel32.dll")] public static extern IntPtr GetStdHandle(int nStdHandle);
|
|
[DllImport("kernel32.dll")] public static extern bool GetConsoleMode(IntPtr h, out uint m);
|
|
[DllImport("kernel32.dll")] public static extern bool SetConsoleMode(IntPtr h, uint m);
|
|
'@ -ErrorAction Stop
|
|
}
|
|
$h = [StudioVT.Native]::GetStdHandle(-11)
|
|
[uint32]$mode = 0
|
|
if (-not [StudioVT.Native]::GetConsoleMode($h, [ref]$mode)) { return $false }
|
|
$mode = $mode -bor 0x0004
|
|
return [StudioVT.Native]::SetConsoleMode($h, $mode)
|
|
} catch {
|
|
return $false
|
|
}
|
|
}
|
|
$script:StudioVtOk = Enable-StudioVirtualTerminal
|
|
|
|
function Get-StudioAnsi {
|
|
param(
|
|
[Parameter(Mandatory = $true)]
|
|
[ValidateSet('Title', 'Dim', 'Ok', 'Warn', 'Err', 'Reset')]
|
|
[string]$Kind
|
|
)
|
|
$e = [char]27
|
|
switch ($Kind) {
|
|
'Title' { return "${e}[38;5;150m" }
|
|
'Dim' { return "${e}[38;5;245m" }
|
|
'Ok' { return "${e}[38;5;108m" }
|
|
'Warn' { return "${e}[38;5;136m" }
|
|
'Err' { return "${e}[91m" }
|
|
'Reset' { return "${e}[0m" }
|
|
}
|
|
}
|
|
|
|
Write-Host ""
|
|
if ($script:StudioVtOk -and -not $env:NO_COLOR) {
|
|
Write-Host (" " + (Get-StudioAnsi Title) + $Sloth + " Unsloth Studio Installer (Windows)" + (Get-StudioAnsi Reset))
|
|
Write-Host (" {0}{1}{2}" -f (Get-StudioAnsi Dim), $Rule, (Get-StudioAnsi Reset))
|
|
} else {
|
|
Write-Host (" {0} Unsloth Studio Installer (Windows)" -f $Sloth) -ForegroundColor DarkGreen
|
|
Write-Host " $Rule" -ForegroundColor DarkGray
|
|
}
|
|
Write-Host ""
|
|
|
|
# ── Helper: refresh PATH from registry (deduplicating entries) ──
|
|
# Merge order: venv Scripts (if active) > Machine > User > current $env:Path.
|
|
# Dedup compares both raw and expanded forms (%VAR% vs literal).
|
|
function Refresh-SessionPath {
|
|
$machine = [System.Environment]::GetEnvironmentVariable("Path", "Machine")
|
|
$user = [System.Environment]::GetEnvironmentVariable("Path", "User")
|
|
$venvScripts = if ($env:VIRTUAL_ENV) { Join-Path $env:VIRTUAL_ENV "Scripts" } else { $null }
|
|
$sources = @()
|
|
if ($venvScripts) { $sources += $venvScripts }
|
|
$sources += @($machine, $user, $env:Path)
|
|
$merged = ($sources | Where-Object { $_ }) -join ";"
|
|
$seen = @{}
|
|
$unique = New-Object System.Collections.Generic.List[string]
|
|
foreach ($p in $merged -split ";") {
|
|
$rawKey = $p.Trim().Trim('"').TrimEnd("\").ToLowerInvariant()
|
|
$expKey = [Environment]::ExpandEnvironmentVariables($p).Trim().Trim('"').TrimEnd("\").ToLowerInvariant()
|
|
if ($rawKey -and -not $seen.ContainsKey($rawKey) -and -not $seen.ContainsKey($expKey)) {
|
|
$seen[$rawKey] = $true
|
|
if ($expKey -and $expKey -ne $rawKey) { $seen[$expKey] = $true }
|
|
$unique.Add($p)
|
|
}
|
|
}
|
|
$env:Path = $unique -join ";"
|
|
}
|
|
|
|
# ── Helper: safely add a directory to the persistent User PATH ──
|
|
# Direct registry access preserves REG_EXPAND_SZ (avoids dotnet/runtime#1442).
|
|
# Append (default) keeps existing tools first; Prepend for must-win entries.
|
|
function Add-ToUserPath {
|
|
param(
|
|
[Parameter(Mandatory = $true)][string]$Directory,
|
|
[ValidateSet('Append','Prepend')]
|
|
[string]$Position = 'Append'
|
|
)
|
|
try {
|
|
$regKey = [Microsoft.Win32.Registry]::CurrentUser.CreateSubKey('Environment')
|
|
try {
|
|
$rawPath = $regKey.GetValue('Path', '', [Microsoft.Win32.RegistryValueOptions]::DoNotExpandEnvironmentNames)
|
|
[string[]]$entries = if ($rawPath) { $rawPath -split ';' } else { @() } # string[] prevents scalar collapse
|
|
$normalDir = $Directory.Trim().Trim('"').TrimEnd('\').ToLowerInvariant()
|
|
$expNormalDir = [Environment]::ExpandEnvironmentVariables($Directory).Trim().Trim('"').TrimEnd('\').ToLowerInvariant()
|
|
$kept = New-Object System.Collections.Generic.List[string]
|
|
$matchIndices = New-Object System.Collections.Generic.List[int]
|
|
for ($i = 0; $i -lt $entries.Count; $i++) {
|
|
$stripped = $entries[$i].Trim().Trim('"')
|
|
$rawNorm = $stripped.TrimEnd('\').ToLowerInvariant()
|
|
$expNorm = [Environment]::ExpandEnvironmentVariables($stripped).TrimEnd('\').ToLowerInvariant()
|
|
$isMatch = ($rawNorm -and ($rawNorm -eq $normalDir -or $rawNorm -eq $expNormalDir)) -or
|
|
($expNorm -and ($expNorm -eq $normalDir -or $expNorm -eq $expNormalDir))
|
|
if ($isMatch) {
|
|
$matchIndices.Add($i)
|
|
continue
|
|
}
|
|
$kept.Add($entries[$i])
|
|
}
|
|
$alreadyPresent = $matchIndices.Count -gt 0
|
|
if ($alreadyPresent -and $Position -eq 'Append') { # Append: idempotent no-op
|
|
return $false
|
|
}
|
|
if ($alreadyPresent -and $Position -eq 'Prepend' -and # Prepend: no-op if already at front
|
|
$matchIndices.Count -eq 1 -and $matchIndices[0] -eq 0) {
|
|
return $false
|
|
}
|
|
# One-time backup under HKCU\Software\Unsloth\PathBackup
|
|
if ($rawPath) {
|
|
try {
|
|
$backupKey = [Microsoft.Win32.Registry]::CurrentUser.CreateSubKey('Software\Unsloth')
|
|
try {
|
|
$existingBackup = $backupKey.GetValue('PathBackup', $null)
|
|
if (-not $existingBackup) {
|
|
$backupKey.SetValue('PathBackup', $rawPath, [Microsoft.Win32.RegistryValueKind]::ExpandString)
|
|
}
|
|
} finally {
|
|
$backupKey.Close()
|
|
}
|
|
} catch { }
|
|
}
|
|
if (-not $rawPath) {
|
|
Write-Host "[WARN] User PATH is empty - initializing with $Directory" -ForegroundColor Yellow
|
|
}
|
|
$newPath = if ($rawPath) {
|
|
if ($Position -eq 'Prepend') {
|
|
(@($Directory) + $kept) -join ';'
|
|
} else {
|
|
($kept + @($Directory)) -join ';'
|
|
}
|
|
} else {
|
|
$Directory
|
|
}
|
|
if ($newPath -ceq $rawPath) { # no actual change
|
|
return $false
|
|
}
|
|
$regKey.SetValue('Path', $newPath, [Microsoft.Win32.RegistryValueKind]::ExpandString)
|
|
# Broadcast WM_SETTINGCHANGE via dummy env-var roundtrip.
|
|
# [NullString]::Value avoids PS 7.5+/.NET 9 $null-to-"" coercion.
|
|
try {
|
|
$d = "UnslothPathRefresh_$([guid]::NewGuid().ToString('N').Substring(0,8))"
|
|
[Environment]::SetEnvironmentVariable($d, '1', 'User')
|
|
[Environment]::SetEnvironmentVariable($d, [NullString]::Value, 'User')
|
|
} catch { }
|
|
return $true
|
|
} finally {
|
|
$regKey.Close()
|
|
}
|
|
} catch {
|
|
Write-Host "[WARN] Could not update User PATH: $($_.Exception.Message)" -ForegroundColor Yellow
|
|
return $false
|
|
}
|
|
}
|
|
|
|
function step {
|
|
param(
|
|
[Parameter(Mandatory = $true)][string]$Label,
|
|
[Parameter(Mandatory = $true)][string]$Value,
|
|
[string]$Color = "Green"
|
|
)
|
|
if ($script:StudioVtOk -and -not $env:NO_COLOR) {
|
|
$dim = Get-StudioAnsi Dim
|
|
$rst = Get-StudioAnsi Reset
|
|
$val = switch ($Color) {
|
|
'Green' { Get-StudioAnsi Ok }
|
|
'Yellow' { Get-StudioAnsi Warn }
|
|
'Red' { Get-StudioAnsi Err }
|
|
'DarkGray' { Get-StudioAnsi Dim }
|
|
default { Get-StudioAnsi Ok }
|
|
}
|
|
$padded = if ($Label.Length -ge 15) { $Label.Substring(0, 15) } else { $Label.PadRight(15) }
|
|
Write-Host (" {0}{1}{2}{3}{4}{2}" -f $dim, $padded, $rst, $val, $Value)
|
|
} else {
|
|
$padded = if ($Label.Length -ge 15) { $Label.Substring(0, 15) } else { $Label.PadRight(15) }
|
|
Write-Host (" {0}" -f $padded) -NoNewline -ForegroundColor DarkGray
|
|
$fc = switch ($Color) {
|
|
'Green' { 'DarkGreen' }
|
|
'Yellow' { 'Yellow' }
|
|
'Red' { 'Red' }
|
|
'DarkGray' { 'DarkGray' }
|
|
default { 'DarkGreen' }
|
|
}
|
|
Write-Host $Value -ForegroundColor $fc
|
|
}
|
|
}
|
|
|
|
function substep {
|
|
param(
|
|
[Parameter(Mandatory = $true)][string]$Message,
|
|
[string]$Color = "DarkGray"
|
|
)
|
|
if ($script:StudioVtOk -and -not $env:NO_COLOR) {
|
|
$msgCol = switch ($Color) {
|
|
'Yellow' { (Get-StudioAnsi Warn) }
|
|
'Red' { (Get-StudioAnsi Err) }
|
|
default { (Get-StudioAnsi Dim) }
|
|
}
|
|
$pad = "".PadRight(15)
|
|
Write-Host (" {0}{1}{2}{3}" -f $msgCol, $pad, $Message, (Get-StudioAnsi Reset))
|
|
} else {
|
|
$fc = switch ($Color) {
|
|
'Yellow' { 'Yellow' }
|
|
'Red' { 'Red' }
|
|
default { 'DarkGray' }
|
|
}
|
|
Write-Host (" {0,-15}{1}" -f "", $Message) -ForegroundColor $fc
|
|
}
|
|
}
|
|
|
|
# Run native commands quietly by default to match install.sh behavior.
|
|
# Full command output is shown only when --verbose / UNSLOTH_VERBOSE=1.
|
|
function Invoke-InstallCommand {
|
|
param(
|
|
[Parameter(Mandatory = $true)][ScriptBlock]$Command
|
|
)
|
|
$prevEap = $ErrorActionPreference
|
|
$ErrorActionPreference = "Continue"
|
|
try {
|
|
# Reset to avoid stale values from prior native commands.
|
|
$global:LASTEXITCODE = 0
|
|
if ($script:UnslothVerbose) {
|
|
# Merge stderr into stdout so progress/warning output stays visible
|
|
# without flipping $? on successful native commands (PS 5.1 treats
|
|
# stderr records as errors that set $? = $false even on exit code 0).
|
|
& $Command 2>&1 | Out-Host
|
|
} else {
|
|
$output = & $Command 2>&1 | Out-String
|
|
if ($LASTEXITCODE -ne 0) {
|
|
Write-Host $output -ForegroundColor Red
|
|
}
|
|
}
|
|
return [int]$LASTEXITCODE
|
|
} finally {
|
|
$ErrorActionPreference = $prevEap
|
|
}
|
|
}
|
|
|
|
function New-StudioShortcuts {
|
|
param(
|
|
[Parameter(Mandatory = $true)][string]$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 -LiteralPath $UnslothExePath).Path
|
|
# Escape for single-quoted embedding in generated launcher script.
|
|
# This prevents runtime variable expansion for paths containing '$'.
|
|
$SingleQuotedExePath = $UnslothExePath -replace "'", "''"
|
|
|
|
# $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 = $StudioDataDir
|
|
$launcherPs1 = Join-Path $appDir "launch-studio.ps1"
|
|
$launcherVbs = Join-Path $appDir "launch-studio.vbs"
|
|
$desktopDir = [Environment]::GetFolderPath("Desktop")
|
|
$desktopLink = if ($desktopDir -and $desktopDir.Trim()) {
|
|
Join-Path $desktopDir "Unsloth Studio.lnk"
|
|
} else {
|
|
$null
|
|
}
|
|
$startMenuDir = if ($env:APPDATA -and $env:APPDATA.Trim()) {
|
|
Join-Path $env:APPDATA "Microsoft\Windows\Start Menu\Programs"
|
|
} else {
|
|
$null
|
|
}
|
|
$startMenuLink = if ($startMenuDir -and $startMenuDir.Trim()) {
|
|
Join-Path $startMenuDir "Unsloth Studio.lnk"
|
|
} else {
|
|
$null
|
|
}
|
|
if (-not $desktopLink) {
|
|
substep "Desktop path unavailable; skipped desktop shortcut creation" "Yellow"
|
|
}
|
|
if (-not $startMenuLink) {
|
|
substep "APPDATA/Start Menu path unavailable; skipped Start menu shortcut creation" "Yellow"
|
|
}
|
|
$iconPath = Join-Path $appDir "unsloth.ico"
|
|
$bundledIcon = $null
|
|
if ($PSScriptRoot -and $PSScriptRoot.Trim()) {
|
|
$bundledIcon = Join-Path $PSScriptRoot "studio\frontend\public\unsloth.ico"
|
|
}
|
|
$iconUrl = "https://raw.githubusercontent.com/unslothai/unsloth/main/studio/frontend/public/unsloth.ico"
|
|
|
|
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 = @"
|
|
$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
|
|
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
|
|
}
|
|
}
|
|
|
|
function Get-CandidatePorts {
|
|
# Fast path: only probe base port + currently listening ports in range.
|
|
`$ports = @(`$basePort)
|
|
try {
|
|
`$maxPort = `$basePort + `$maxPortOffset
|
|
`$listening = Get-NetTCPConnection -State Listen -ErrorAction Stop |
|
|
Where-Object { `$_.LocalPort -ge `$basePort -and `$_.LocalPort -le `$maxPort } |
|
|
Select-Object -ExpandProperty LocalPort
|
|
`$ports = (@(`$basePort) + `$listening) | Sort-Object -Unique
|
|
} catch {
|
|
Write-Host "[DEBUG] Get-NetTCPConnection failed: `$(`$_.Exception.Message). Falling back to full port scan." -ForegroundColor DarkGray
|
|
# Fallback when Get-NetTCPConnection is unavailable/restricted.
|
|
for (`$offset = 1; `$offset -le `$maxPortOffset; `$offset++) {
|
|
`$ports += (`$basePort + `$offset)
|
|
}
|
|
}
|
|
return `$ports
|
|
}
|
|
|
|
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
|
|
}
|
|
}
|
|
return `$null
|
|
}
|
|
|
|
function Test-PortBusy {
|
|
param([Parameter(Mandatory = `$true)][int]`$Port)
|
|
`$listener = `$null
|
|
try {
|
|
`$listener = [System.Net.Sockets.TcpListener]::new([System.Net.IPAddress]::Any, `$Port)
|
|
`$listener.Start()
|
|
return `$false
|
|
} catch {
|
|
return `$true
|
|
} finally {
|
|
if (`$listener) { try { `$listener.Stop() } catch {} }
|
|
}
|
|
}
|
|
|
|
function Find-FreeLaunchPort {
|
|
`$maxPort = `$basePort + `$maxPortOffset
|
|
try {
|
|
`$listening = Get-NetTCPConnection -State Listen -ErrorAction Stop |
|
|
Where-Object { `$_.LocalPort -ge `$basePort -and `$_.LocalPort -le `$maxPort } |
|
|
Select-Object -ExpandProperty LocalPort
|
|
for (`$offset = 0; `$offset -le `$maxPortOffset; `$offset++) {
|
|
`$candidate = `$basePort + `$offset
|
|
if (`$candidate -notin `$listening) {
|
|
return `$candidate
|
|
}
|
|
}
|
|
} catch {
|
|
# Get-NetTCPConnection unavailable or restricted; probe ports directly
|
|
for (`$offset = 0; `$offset -le `$maxPortOffset; `$offset++) {
|
|
`$candidate = `$basePort + `$offset
|
|
if (-not (Test-PortBusy -Port `$candidate)) {
|
|
return `$candidate
|
|
}
|
|
}
|
|
}
|
|
return `$null
|
|
}
|
|
|
|
# If Studio is already healthy on any expected port, just open it and exit.
|
|
`$existingPort = Find-HealthyStudioPort
|
|
if (`$existingPort) {
|
|
Start-Process "http://localhost:`$existingPort"
|
|
exit 0
|
|
}
|
|
|
|
`$launchMutex = [System.Threading.Mutex]::new(`$false, `$mutexName)
|
|
`$haveMutex = `$false
|
|
try {
|
|
try {
|
|
`$haveMutex = `$launchMutex.WaitOne(0)
|
|
} catch [System.Threading.AbandonedMutexException] {
|
|
`$haveMutex = `$true
|
|
}
|
|
if (-not `$haveMutex) {
|
|
# Another launcher is already running; wait for it to bring Studio up
|
|
`$deadline = (Get-Date).AddSeconds(`$timeoutSec)
|
|
while ((Get-Date) -lt `$deadline) {
|
|
`$port = Find-HealthyStudioPort
|
|
if (`$port) { Start-Process "http://localhost:`$port"; exit 0 }
|
|
Start-Sleep -Milliseconds `$pollIntervalMs
|
|
}
|
|
exit 0
|
|
}
|
|
|
|
`$powershellExe = Join-Path `$env:SystemRoot 'System32\WindowsPowerShell\v1.0\powershell.exe'
|
|
`$studioExe = '$SingleQuotedExePath'
|
|
`$launchPort = Find-FreeLaunchPort
|
|
if (-not `$launchPort) {
|
|
`$msg = "No free port found in range `$basePort-`$(`$basePort + `$maxPortOffset)"
|
|
try {
|
|
Add-Type -AssemblyName System.Windows.Forms -ErrorAction Stop
|
|
[System.Windows.Forms.MessageBox]::Show(`$msg, 'Unsloth Studio') | Out-Null
|
|
} catch {}
|
|
exit 1
|
|
}
|
|
# 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',
|
|
'-ExecutionPolicy',
|
|
'Bypass',
|
|
'-Command',
|
|
`$studioCommand
|
|
)
|
|
|
|
try {
|
|
`$proc = Start-Process -FilePath `$powershellExe -ArgumentList `$launchArgs -WorkingDirectory `$env:USERPROFILE -PassThru
|
|
} catch {
|
|
`$msg = "Could not launch Unsloth Studio terminal.`n`nError: `$(`$_.Exception.Message)"
|
|
try {
|
|
Add-Type -AssemblyName System.Windows.Forms -ErrorAction Stop
|
|
[System.Windows.Forms.MessageBox]::Show(`$msg, 'Unsloth Studio') | Out-Null
|
|
} catch {}
|
|
exit 1
|
|
}
|
|
|
|
`$browserOpened = `$false
|
|
`$deadline = (Get-Date).AddSeconds(`$timeoutSec)
|
|
while ((Get-Date) -lt `$deadline) {
|
|
if (Test-StudioHealth -Port `$launchPort) {
|
|
if (`$portFile) {
|
|
try {
|
|
[System.IO.File]::WriteAllText(`$portFile, "`$launchPort`n")
|
|
} catch {}
|
|
}
|
|
Start-Process "http://localhost:`$launchPort"
|
|
`$browserOpened = `$true
|
|
break
|
|
}
|
|
if (`$proc.HasExited) { break }
|
|
Start-Sleep -Milliseconds `$pollIntervalMs
|
|
}
|
|
if (-not `$browserOpened) {
|
|
if (`$proc.HasExited) {
|
|
`$msg = "Unsloth Studio exited before becoming healthy. Check terminal output for errors."
|
|
} else {
|
|
`$msg = "Unsloth Studio is still starting but did not become healthy within `$timeoutSec seconds. Check the terminal window for the selected port and open it manually."
|
|
}
|
|
try {
|
|
Add-Type -AssemblyName System.Windows.Forms -ErrorAction Stop
|
|
[System.Windows.Forms.MessageBox]::Show(`$msg, 'Unsloth Studio') | Out-Null
|
|
} catch {}
|
|
}
|
|
} finally {
|
|
if (`$haveMutex) { `$launchMutex.ReleaseMutex() | Out-Null }
|
|
`$launchMutex.Dispose()
|
|
}
|
|
exit 0
|
|
"@
|
|
|
|
# Write UTF-8 with BOM for reliable decoding by Windows PowerShell 5.1,
|
|
# even when install.ps1 is executed from PowerShell 7.
|
|
$utf8Bom = New-Object System.Text.UTF8Encoding($true)
|
|
[System.IO.File]::WriteAllText($launcherPs1, $launcherContent, $utf8Bom)
|
|
$vbsContent = @"
|
|
Set shell = CreateObject("WScript.Shell")
|
|
cmd = "powershell -NoProfile -ExecutionPolicy Bypass -WindowStyle Hidden -File ""$launcherPs1"""
|
|
shell.Run cmd, 0, False
|
|
"@
|
|
# WSH handles UTF-16LE reliably for .vbs files with non-ASCII paths.
|
|
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 -LiteralPath $bundledIcon)) {
|
|
try {
|
|
Copy-Item -LiteralPath $bundledIcon -Destination $iconPath -Force
|
|
} catch {
|
|
Write-Host "[DEBUG] Error copying bundled icon: $($_.Exception.Message)" -ForegroundColor DarkGray
|
|
}
|
|
} elseif (-not (Test-Path -LiteralPath $iconPath)) {
|
|
try {
|
|
Invoke-WebRequest -Uri $iconUrl -OutFile $iconPath -UseBasicParsing
|
|
} catch {
|
|
Write-Host "[DEBUG] Error downloading icon: $($_.Exception.Message)" -ForegroundColor DarkGray
|
|
}
|
|
}
|
|
|
|
if (Test-Path -LiteralPath $iconPath) {
|
|
try {
|
|
$bytes = [System.IO.File]::ReadAllBytes($iconPath)
|
|
if (
|
|
$bytes.Length -ge 4 -and
|
|
$bytes[0] -eq 0 -and
|
|
$bytes[1] -eq 0 -and
|
|
$bytes[2] -eq 1 -and
|
|
$bytes[3] -eq 0
|
|
) {
|
|
$hasValidIcon = $true
|
|
} else {
|
|
Remove-Item -LiteralPath $iconPath -Force -ErrorAction SilentlyContinue
|
|
}
|
|
} catch {
|
|
Write-Host "[DEBUG] Error validating or removing icon: $($_.Exception.Message)" -ForegroundColor DarkGray
|
|
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`""
|
|
|
|
try {
|
|
$wshell = New-Object -ComObject WScript.Shell
|
|
$createdShortcutCount = 0
|
|
foreach ($linkPath in @($desktopLink, $startMenuLink)) {
|
|
if (-not $linkPath -or [string]::IsNullOrWhiteSpace($linkPath)) { continue }
|
|
try {
|
|
$shortcut = $wshell.CreateShortcut($linkPath)
|
|
$shortcut.TargetPath = $wscriptExe
|
|
$shortcut.Arguments = $shortcutArgs
|
|
$shortcut.WorkingDirectory = $appDir
|
|
$shortcut.Description = "Launch Unsloth Studio"
|
|
if ($hasValidIcon) {
|
|
$shortcut.IconLocation = "$iconPath,0"
|
|
}
|
|
$shortcut.Save()
|
|
$createdShortcutCount++
|
|
} catch {
|
|
substep "could not create shortcut at ${linkPath}: $($_.Exception.Message)" "Yellow"
|
|
}
|
|
}
|
|
if ($createdShortcutCount -gt 0) {
|
|
substep "Created Unsloth Studio shortcut"
|
|
} else {
|
|
substep "no Unsloth Studio shortcuts were created" "Yellow"
|
|
}
|
|
} catch {
|
|
substep "shortcut creation unavailable: $($_.Exception.Message)" "Yellow"
|
|
}
|
|
} catch {
|
|
substep "shortcut setup failed; skipping shortcuts: $($_.Exception.Message)" "Yellow"
|
|
}
|
|
}
|
|
|
|
# ── Check winget ──
|
|
Write-TauriLog "STEP" "Checking system dependencies"
|
|
if (-not (Get-Command winget -ErrorAction SilentlyContinue)) {
|
|
step "winget" "not available" "Red"
|
|
substep "Install it from https://aka.ms/getwinget" "Yellow"
|
|
substep "or install Python $PythonVersion and uv manually, then re-run." "Yellow"
|
|
return (Exit-InstallFailure "winget is not available")
|
|
}
|
|
|
|
# ── Helper: detect a working Python 3.11-3.13 on the system ──
|
|
# Returns the version string (e.g. "3.13") or "" if none found.
|
|
# Uses try-catch + stderr redirection so that App Execution Alias stubs
|
|
# (WindowsApps) and other non-functional executables are probed safely
|
|
# without triggering $ErrorActionPreference = "Stop".
|
|
#
|
|
# Skips Anaconda/Miniconda Python: conda-bundled CPython ships modified
|
|
# DLL search paths that break torch's c10.dll loading on Windows.
|
|
# Standalone CPython (python.org, winget, uv) does not have this issue.
|
|
#
|
|
# NOTE: A venv created from conda Python inherits conda's base_prefix
|
|
# even if the venv path does not contain "conda". We check both the
|
|
# executable path AND sys.base_prefix to catch this.
|
|
$script:CondaSkipPattern = '(?i)(conda|miniconda|anaconda|miniforge|mambaforge)'
|
|
|
|
function Test-IsCondaPython {
|
|
param([string]$Exe)
|
|
if ($Exe -match $script:CondaSkipPattern) { return $true }
|
|
try {
|
|
$basePrefix = (& $Exe -c "import sys; print(sys.base_prefix)" 2>$null | Out-String).Trim()
|
|
if ($basePrefix -match $script:CondaSkipPattern) { return $true }
|
|
} catch { }
|
|
return $false
|
|
}
|
|
|
|
# Returns @{ Version = "3.13"; Path = "C:\...\python.exe" } or $null.
|
|
# The resolved Path is passed to `uv venv --python` to prevent uv from
|
|
# re-resolving the version string back to a conda interpreter.
|
|
function Find-CompatiblePython {
|
|
# Try the Python Launcher first (most reliable on Windows)
|
|
# py.exe resolves to the standard CPython install, not conda.
|
|
$pyLauncher = Get-Command py -CommandType Application -ErrorAction SilentlyContinue
|
|
if ($pyLauncher -and $pyLauncher.Source -notmatch $script:CondaSkipPattern) {
|
|
foreach ($minor in @("3.13", "3.12", "3.11")) {
|
|
try {
|
|
$out = & $pyLauncher.Source "-$minor" --version 2>&1 | Out-String
|
|
if ($out -match "Python (3\.1[1-3])\.\d+") {
|
|
$ver = $Matches[1]
|
|
# Resolve the actual executable path and verify it is not conda-based
|
|
$resolvedExe = (& $pyLauncher.Source "-$minor" -c "import sys; print(sys.executable)" 2>$null | Out-String).Trim()
|
|
if ($resolvedExe -and (Test-Path $resolvedExe) -and -not (Test-IsCondaPython $resolvedExe)) {
|
|
return @{ Version = $ver; Path = $resolvedExe }
|
|
}
|
|
}
|
|
} catch {}
|
|
}
|
|
}
|
|
# Try python3 / python via Get-Command -All to look past stubs that
|
|
# might shadow a real Python further down PATH.
|
|
# Skip WindowsApps entries: the App Execution Alias stubs live there
|
|
# and can open the Microsoft Store as a side effect. Legitimate Store
|
|
# Python is already detected via the py launcher above (Store packages
|
|
# include py since Python 3.11).
|
|
# Skip Anaconda/Miniconda: check both path and sys.base_prefix.
|
|
foreach ($name in @("python3", "python")) {
|
|
foreach ($cmd in @(Get-Command $name -All -ErrorAction SilentlyContinue)) {
|
|
if (-not $cmd.Source) { continue }
|
|
if ($cmd.Source -like "*\WindowsApps\*") { continue }
|
|
if (Test-IsCondaPython $cmd.Source) { continue }
|
|
try {
|
|
$out = & $cmd.Source --version 2>&1 | Out-String
|
|
if ($out -match "Python (3\.1[1-3])\.\d+") {
|
|
return @{ Version = $Matches[1]; Path = $cmd.Source }
|
|
}
|
|
} catch {}
|
|
}
|
|
}
|
|
return $null
|
|
}
|
|
|
|
# ── Install Python if no compatible version (3.11-3.13) found ──
|
|
# Find-CompatiblePython returns @{ Version = "3.13"; Path = "C:\...\python.exe" } or $null.
|
|
Write-TauriLog "STEP" "Installing Python"
|
|
$DetectedPython = Find-CompatiblePython
|
|
if ($DetectedPython) {
|
|
step "python" "Python $($DetectedPython.Version) already installed"
|
|
}
|
|
if (-not $DetectedPython) {
|
|
substep "installing Python ${PythonVersion}..."
|
|
$pythonPackageId = "Python.Python.$PythonVersion"
|
|
# Temporarily lower ErrorActionPreference so that winget stderr
|
|
# (progress bars, warnings) does not become a terminating error
|
|
# on PowerShell 5.1 where native-command stderr is ErrorRecord.
|
|
$prevEAP = $ErrorActionPreference
|
|
$ErrorActionPreference = "Continue"
|
|
try {
|
|
winget install -e --id $pythonPackageId --accept-package-agreements --accept-source-agreements
|
|
$wingetExit = $LASTEXITCODE
|
|
} catch { $wingetExit = 1 }
|
|
$ErrorActionPreference = $prevEAP
|
|
Refresh-SessionPath
|
|
|
|
# Re-detect after install (PATH may have changed)
|
|
$DetectedPython = Find-CompatiblePython
|
|
|
|
if (-not $DetectedPython) {
|
|
# Python still not functional after winget -- force reinstall.
|
|
# This handles both real failures AND "already installed" codes where
|
|
# winget thinks Python is present but it's not actually on PATH
|
|
# (e.g. user partially uninstalled, or installed via a different method).
|
|
substep "Python not found on PATH after winget. Retrying with --force..." "Yellow"
|
|
$ErrorActionPreference = "Continue"
|
|
try {
|
|
winget install -e --id $pythonPackageId --accept-package-agreements --accept-source-agreements --force
|
|
$wingetExit = $LASTEXITCODE
|
|
} catch { $wingetExit = 1 }
|
|
$ErrorActionPreference = $prevEAP
|
|
Refresh-SessionPath
|
|
$DetectedPython = Find-CompatiblePython
|
|
}
|
|
|
|
if (-not $DetectedPython) {
|
|
Write-Host "[ERROR] Python installation failed (exit code $wingetExit)" -ForegroundColor Red
|
|
Write-Host " Please install Python $PythonVersion manually from https://www.python.org/downloads/" -ForegroundColor Yellow
|
|
Write-Host " Make sure to check 'Add Python to PATH' during installation." -ForegroundColor Yellow
|
|
Write-Host " Then re-run this installer." -ForegroundColor Yellow
|
|
return (Exit-InstallFailure "Python installation failed")
|
|
}
|
|
}
|
|
$DiagPythonVersion = $PythonVersion
|
|
if ($DetectedPython) { $DiagPythonVersion = $DetectedPython.Version }
|
|
$InitialGpuBranch = "unknown"
|
|
if ($SkipTorch) { $InitialGpuBranch = "no_torch" }
|
|
Write-TauriDiag -GpuBranch $InitialGpuBranch -TorchIndexFamily "none" -PythonVersionForDiag $DiagPythonVersion
|
|
|
|
# ── Install uv if not present ──
|
|
Write-TauriLog "STEP" "Installing uv package manager"
|
|
if (-not (Get-Command uv -ErrorAction SilentlyContinue)) {
|
|
substep "installing uv package manager..."
|
|
$prevEAP = $ErrorActionPreference
|
|
$ErrorActionPreference = "Continue"
|
|
try { winget install --id=astral-sh.uv -e --accept-package-agreements --accept-source-agreements } catch {}
|
|
$ErrorActionPreference = $prevEAP
|
|
Refresh-SessionPath
|
|
# Fallback: if winget didn't put uv on PATH, try the PowerShell installer
|
|
if (-not (Get-Command uv -ErrorAction SilentlyContinue)) {
|
|
substep "trying alternative uv installer..." "Yellow"
|
|
Invoke-Expression (Invoke-RestMethod -Uri "https://astral.sh/uv/install.ps1")
|
|
Refresh-SessionPath
|
|
}
|
|
}
|
|
|
|
if (-not (Get-Command uv -ErrorAction SilentlyContinue)) {
|
|
step "uv" "could not be installed" "Red"
|
|
substep "Install it from https://docs.astral.sh/uv/" "Yellow"
|
|
return (Exit-InstallFailure "uv could not be installed")
|
|
}
|
|
|
|
# ── Create venv (migrate old layout if possible, otherwise fresh) ──
|
|
# 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 -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"
|
|
$_Migrated = $false
|
|
$script:StudioVenvRollbackDir = $null
|
|
$script:StudioVenvRollbackTarget = $VenvDir
|
|
$script:StudioVenvRollbackActive = $false
|
|
|
|
function Start-StudioVenvRollback {
|
|
param([Parameter(Mandatory = $true)][string]$ExistingDir)
|
|
$stamp = Get-Date -Format "yyyyMMddHHmmss"
|
|
$candidate = Join-Path $StudioHome "unsloth_studio.rollback.$stamp.$PID"
|
|
$suffix = 0
|
|
# -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 -LiteralPath $ExistingDir -Destination $candidate -ErrorAction Stop
|
|
$script:StudioVenvRollbackDir = $candidate
|
|
$script:StudioVenvRollbackTarget = $ExistingDir
|
|
$script:StudioVenvRollbackActive = $true
|
|
substep "previous environment preserved for rollback"
|
|
}
|
|
|
|
function Restore-StudioVenvRollback {
|
|
if (-not $script:StudioVenvRollbackActive) { return }
|
|
$backup = $script:StudioVenvRollbackDir
|
|
$target = $script:StudioVenvRollbackTarget
|
|
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 -LiteralPath $target) {
|
|
Remove-Item -LiteralPath $target -Recurse -Force -ErrorAction SilentlyContinue
|
|
}
|
|
Move-Item -LiteralPath $backup -Destination $target -Force -ErrorAction Stop
|
|
substep "restored previous environment"
|
|
$script:StudioVenvRollbackActive = $false
|
|
$script:StudioVenvRollbackDir = $null
|
|
} catch {
|
|
Write-Host "[WARN] Could not restore previous environment from $backup to $target" -ForegroundColor Yellow
|
|
Write-Host " $($_.Exception.Message)" -ForegroundColor Yellow
|
|
}
|
|
}
|
|
|
|
function Complete-StudioVenvRollback {
|
|
if (-not $script:StudioVenvRollbackActive) { return }
|
|
$backup = $script:StudioVenvRollbackDir
|
|
if ($backup -and (Test-Path -LiteralPath $backup)) {
|
|
Remove-Item -LiteralPath $backup -Recurse -Force -ErrorAction SilentlyContinue
|
|
}
|
|
$script:StudioVenvRollbackActive = $false
|
|
$script:StudioVenvRollbackDir = $null
|
|
}
|
|
|
|
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 {
|
|
Start-StudioVenvRollback -ExistingDir $VenvDir
|
|
} catch {
|
|
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 (
|
|
$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..."
|
|
$prevEAP2 = $ErrorActionPreference
|
|
$ErrorActionPreference = "Continue"
|
|
try {
|
|
if ($SkipTorch) {
|
|
& $OldPy -c "import sys; print(sys.executable)" 2>$null | Out-Null
|
|
} else {
|
|
& $OldPy -c "import torch; A = torch.ones((2,2)); B = A + A" 2>$null | Out-Null
|
|
}
|
|
$legacyOk = ($LASTEXITCODE -eq 0)
|
|
} catch { $legacyOk = $false }
|
|
$ErrorActionPreference = $prevEAP2
|
|
if ($legacyOk) {
|
|
substep "legacy environment is healthy -- migrating..."
|
|
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 -LiteralPath $OldVenv -Destination $invalidVenv -Force -ErrorAction SilentlyContinue
|
|
}
|
|
} 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 -LiteralPath $CwdVenv -Destination $VenvDir -Force
|
|
substep "moved ~/unsloth_studio -> ~/.unsloth/studio/unsloth_studio"
|
|
$_Migrated = $true
|
|
}
|
|
|
|
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)" }
|
|
if ($venvExit -ne 0) {
|
|
Write-Host "[ERROR] Failed to create virtual environment (exit code $venvExit)" -ForegroundColor Red
|
|
return (Exit-InstallFailure "Failed to create virtual environment (exit code $venvExit)" $venvExit)
|
|
}
|
|
} else {
|
|
step "venv" "using migrated environment"
|
|
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
|
|
try {
|
|
$nvSmiCmd = Get-Command nvidia-smi -ErrorAction SilentlyContinue
|
|
if ($nvSmiCmd) {
|
|
& $nvSmiCmd.Source *> $null
|
|
if ($LASTEXITCODE -eq 0) { $HasNvidiaSmi = $true; $NvidiaSmiExe = $nvSmiCmd.Source }
|
|
}
|
|
} catch {}
|
|
if (-not $HasNvidiaSmi) {
|
|
foreach ($p in @(
|
|
"$env:ProgramFiles\NVIDIA Corporation\NVSMI\nvidia-smi.exe",
|
|
"$env:SystemRoot\System32\nvidia-smi.exe"
|
|
)) {
|
|
if (Test-Path $p) {
|
|
try {
|
|
& $p *> $null
|
|
if ($LASTEXITCODE -eq 0) { $HasNvidiaSmi = $true; $NvidiaSmiExe = $p; break }
|
|
} catch {}
|
|
}
|
|
}
|
|
}
|
|
if ($HasNvidiaSmi) {
|
|
step "gpu" "NVIDIA GPU detected"
|
|
} else {
|
|
step "gpu" "none (chat-only / GGUF)" "Yellow"
|
|
substep "Training and GPU inference require an NVIDIA GPU with drivers installed." "Yellow"
|
|
}
|
|
|
|
# ── Choose the correct PyTorch index URL based on driver CUDA version ──
|
|
# Mirrors Get-PytorchCudaTag in setup.ps1.
|
|
function Get-TorchIndexUrl {
|
|
$baseUrl = if ($env:UNSLOTH_PYTORCH_MIRROR) { $env:UNSLOTH_PYTORCH_MIRROR.TrimEnd('/') } else { "https://download.pytorch.org/whl" }
|
|
if (-not $NvidiaSmiExe) { return "$baseUrl/cpu" }
|
|
try {
|
|
$output = & $NvidiaSmiExe 2>&1 | Out-String
|
|
if ($output -match 'CUDA Version:\s+(\d+)\.(\d+)') {
|
|
$major = [int]$Matches[1]; $minor = [int]$Matches[2]
|
|
if ($major -ge 13) { return "$baseUrl/cu130" }
|
|
if ($major -eq 12 -and $minor -ge 8) { return "$baseUrl/cu128" }
|
|
if ($major -eq 12 -and $minor -ge 6) { return "$baseUrl/cu126" }
|
|
if ($major -ge 12) { return "$baseUrl/cu124" }
|
|
if ($major -ge 11) { return "$baseUrl/cu118" }
|
|
return "$baseUrl/cpu"
|
|
}
|
|
} catch {}
|
|
substep "could not determine CUDA version from nvidia-smi, defaulting to cu126" "Yellow"
|
|
return "$baseUrl/cu126"
|
|
}
|
|
$TorchIndexUrl = Get-TorchIndexUrl
|
|
$TorchIndexFamily = Get-TauriTorchIndexFamily $TorchIndexUrl
|
|
$GpuBranch = Get-TauriGpuBranch $TorchIndexFamily
|
|
Write-TauriDiag -GpuBranch $GpuBranch -TorchIndexFamily $TorchIndexFamily -PythonVersionForDiag $DetectedPython.Version
|
|
|
|
# ── Print CPU-only hint when no GPU detected ──
|
|
if (-not $SkipTorch -and $TorchIndexUrl -like "*/cpu") {
|
|
Write-Host ""
|
|
substep "No NVIDIA GPU detected." "Yellow"
|
|
substep "Installing CPU-only PyTorch. If you only need GGUF chat/inference," "Yellow"
|
|
substep "re-run with --no-torch for a faster, lighter install:" "Yellow"
|
|
substep ".\install.ps1 --no-torch" "Yellow"
|
|
Write-Host ""
|
|
}
|
|
|
|
# ── Install PyTorch first, then unsloth separately ──
|
|
#
|
|
# Why two steps?
|
|
# `uv pip install unsloth --torch-backend=cpu` on Windows resolves to
|
|
# unsloth==2024.8 (a pre-CLI release with no unsloth.exe) because the
|
|
# cpu-only solver cannot satisfy newer unsloth's dependencies.
|
|
# Installing torch first from the explicit CUDA index, then upgrading
|
|
# unsloth in a second step, avoids this solver dead-end.
|
|
#
|
|
# Why --upgrade-package instead of --upgrade?
|
|
# `--upgrade unsloth` re-resolves ALL dependencies including torch,
|
|
# pulling torch from default PyPI and stripping the +cuXXX suffix
|
|
# that step 1 installed (e.g. torch 2.5.1+cu124 -> 2.10.0 with no
|
|
# CUDA suffix). `--upgrade-package unsloth` upgrades ONLY unsloth
|
|
# to the latest version while preserving the already-pinned torch
|
|
# CUDA wheels. Missing dependencies (transformers, trl, peft, etc.)
|
|
# are still pulled in because they are new, not upgrades.
|
|
#
|
|
# ── Helper: find no-torch-runtime.txt ──
|
|
function Find-NoTorchRuntimeFile {
|
|
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 -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
|
|
}
|
|
|
|
if ($_Migrated) {
|
|
# Migrated env: force-reinstall unsloth+unsloth-zoo to ensure clean state
|
|
# in the new venv location, while preserving existing torch/CUDA
|
|
Write-TauriLog "STEP" "Installing unsloth"
|
|
substep "upgrading unsloth in migrated environment..."
|
|
if ($SkipTorch) {
|
|
# No-torch: install unsloth + unsloth-zoo with --no-deps, then
|
|
# runtime deps (typer, safetensors, transformers, etc.) with --no-deps.
|
|
$baseInstallExit = Invoke-InstallCommand { uv pip install --python $VenvPython --no-deps --reinstall-package unsloth --reinstall-package unsloth-zoo "unsloth>=2026.5.2" unsloth-zoo }
|
|
if ($baseInstallExit -eq 0) {
|
|
$NoTorchReq = Find-NoTorchRuntimeFile
|
|
if ($NoTorchReq) {
|
|
$baseInstallExit = Invoke-InstallCommand { uv pip install --python $VenvPython --no-deps -r $NoTorchReq }
|
|
}
|
|
}
|
|
} else {
|
|
$baseInstallExit = Invoke-InstallCommand { uv pip install --python $VenvPython --reinstall-package unsloth --reinstall-package unsloth-zoo "unsloth>=2026.5.2" unsloth-zoo }
|
|
}
|
|
if ($baseInstallExit -ne 0) {
|
|
Write-Host "[ERROR] Failed to install unsloth (exit code $baseInstallExit)" -ForegroundColor Red
|
|
return (Exit-InstallFailure "Failed to install unsloth (exit code $baseInstallExit)" $baseInstallExit)
|
|
}
|
|
if ($StudioLocalInstall) {
|
|
substep "overlaying local repo (editable)..."
|
|
$overlayExit = Invoke-InstallCommand { uv pip install --python $VenvPython -e $RepoRoot --no-deps }
|
|
if ($overlayExit -ne 0) {
|
|
Write-Host "[ERROR] Failed to overlay local repo (exit code $overlayExit)" -ForegroundColor Red
|
|
return (Exit-InstallFailure "Failed to overlay local repo (exit code $overlayExit)" $overlayExit)
|
|
}
|
|
substep "overlaying unsloth-zoo from git main..."
|
|
$zooOverlayExit = Invoke-InstallCommand { uv pip install --python $VenvPython --no-deps --reinstall-package unsloth-zoo "unsloth-zoo @ git+https://github.com/unslothai/unsloth-zoo" }
|
|
if ($zooOverlayExit -ne 0) {
|
|
Write-Host "[ERROR] Failed to overlay unsloth-zoo (exit code $zooOverlayExit)" -ForegroundColor Red
|
|
return (Exit-InstallFailure "Failed to overlay unsloth-zoo (exit code $zooOverlayExit)" $zooOverlayExit)
|
|
}
|
|
}
|
|
} elseif ($TorchIndexUrl) {
|
|
if ($SkipTorch) {
|
|
substep "skipping PyTorch (--no-torch flag set)." "Yellow"
|
|
} else {
|
|
Write-TauriLog "STEP" "Installing PyTorch"
|
|
substep "installing PyTorch ($TorchIndexUrl)..."
|
|
$torchInstallExit = Invoke-InstallCommand { uv pip install --python $VenvPython "torch>=2.4,<2.11.0" torchvision torchaudio --index-url $TorchIndexUrl }
|
|
if ($torchInstallExit -ne 0) {
|
|
Write-Host "[ERROR] Failed to install PyTorch (exit code $torchInstallExit)" -ForegroundColor Red
|
|
return (Exit-InstallFailure "Failed to install PyTorch (exit code $torchInstallExit)" $torchInstallExit)
|
|
}
|
|
}
|
|
|
|
Write-TauriLog "STEP" "Installing unsloth"
|
|
substep "installing unsloth (this may take a few minutes)..."
|
|
if ($SkipTorch) {
|
|
# No-torch: install unsloth + unsloth-zoo with --no-deps, then
|
|
# runtime deps (typer, safetensors, transformers, etc.) with --no-deps.
|
|
$baseInstallExit = Invoke-InstallCommand { uv pip install --python $VenvPython --no-deps --upgrade-package unsloth --upgrade-package unsloth-zoo "unsloth>=2026.5.2" unsloth-zoo }
|
|
if ($baseInstallExit -eq 0) {
|
|
$NoTorchReq = Find-NoTorchRuntimeFile
|
|
if ($NoTorchReq) {
|
|
$baseInstallExit = Invoke-InstallCommand { uv pip install --python $VenvPython --no-deps -r $NoTorchReq }
|
|
}
|
|
}
|
|
} elseif ($StudioLocalInstall) {
|
|
$baseInstallExit = Invoke-InstallCommand { uv pip install --python $VenvPython --upgrade-package unsloth "unsloth>=2026.5.2" unsloth-zoo }
|
|
} else {
|
|
$baseInstallExit = Invoke-InstallCommand { uv pip install --python $VenvPython --upgrade-package unsloth -- "$PackageName" }
|
|
}
|
|
if ($baseInstallExit -ne 0) {
|
|
Write-Host "[ERROR] Failed to install unsloth (exit code $baseInstallExit)" -ForegroundColor Red
|
|
return (Exit-InstallFailure "Failed to install unsloth (exit code $baseInstallExit)" $baseInstallExit)
|
|
}
|
|
|
|
if ($StudioLocalInstall) {
|
|
substep "overlaying local repo (editable)..."
|
|
$overlayExit = Invoke-InstallCommand { uv pip install --python $VenvPython -e $RepoRoot --no-deps }
|
|
if ($overlayExit -ne 0) {
|
|
Write-Host "[ERROR] Failed to overlay local repo (exit code $overlayExit)" -ForegroundColor Red
|
|
return (Exit-InstallFailure "Failed to overlay local repo (exit code $overlayExit)" $overlayExit)
|
|
}
|
|
substep "overlaying unsloth-zoo from git main..."
|
|
$zooOverlayExit = Invoke-InstallCommand { uv pip install --python $VenvPython --no-deps --reinstall-package unsloth-zoo "unsloth-zoo @ git+https://github.com/unslothai/unsloth-zoo" }
|
|
if ($zooOverlayExit -ne 0) {
|
|
Write-Host "[ERROR] Failed to overlay unsloth-zoo (exit code $zooOverlayExit)" -ForegroundColor Red
|
|
return (Exit-InstallFailure "Failed to overlay unsloth-zoo (exit code $zooOverlayExit)" $zooOverlayExit)
|
|
}
|
|
}
|
|
} else {
|
|
# Fallback: GPU detection failed to produce a URL -- let uv resolve torch
|
|
Write-TauriLog "STEP" "Installing unsloth"
|
|
substep "installing unsloth (this may take a few minutes)..."
|
|
if ($StudioLocalInstall) {
|
|
$baseInstallExit = Invoke-InstallCommand { uv pip install --python $VenvPython unsloth-zoo "unsloth>=2026.5.2" --torch-backend=auto }
|
|
if ($baseInstallExit -ne 0) {
|
|
Write-Host "[ERROR] Failed to install unsloth (exit code $baseInstallExit)" -ForegroundColor Red
|
|
return (Exit-InstallFailure "Failed to install unsloth (exit code $baseInstallExit)" $baseInstallExit)
|
|
}
|
|
substep "overlaying local repo (editable)..."
|
|
$overlayExit = Invoke-InstallCommand { uv pip install --python $VenvPython -e $RepoRoot --no-deps }
|
|
if ($overlayExit -ne 0) {
|
|
Write-Host "[ERROR] Failed to overlay local repo (exit code $overlayExit)" -ForegroundColor Red
|
|
return (Exit-InstallFailure "Failed to overlay local repo (exit code $overlayExit)" $overlayExit)
|
|
}
|
|
substep "overlaying unsloth-zoo from git main..."
|
|
$zooOverlayExit = Invoke-InstallCommand { uv pip install --python $VenvPython --no-deps --reinstall-package unsloth-zoo "unsloth-zoo @ git+https://github.com/unslothai/unsloth-zoo" }
|
|
if ($zooOverlayExit -ne 0) {
|
|
Write-Host "[ERROR] Failed to overlay unsloth-zoo (exit code $zooOverlayExit)" -ForegroundColor Red
|
|
return (Exit-InstallFailure "Failed to overlay unsloth-zoo (exit code $zooOverlayExit)" $zooOverlayExit)
|
|
}
|
|
} else {
|
|
$baseInstallExit = Invoke-InstallCommand { uv pip install --python $VenvPython --torch-backend=auto -- "$PackageName" }
|
|
if ($baseInstallExit -ne 0) {
|
|
Write-Host "[ERROR] Failed to install unsloth (exit code $baseInstallExit)" -ForegroundColor Red
|
|
return (Exit-InstallFailure "Failed to install unsloth (exit code $baseInstallExit)" $baseInstallExit)
|
|
}
|
|
}
|
|
}
|
|
|
|
# Overlay Tauri-bundled studio fixes that may be ahead of PyPI. Skipped
|
|
# for --local: the editable install above already makes _PACKAGE_ROOT in
|
|
# unsloth_cli/commands/studio.py resolve to the repo (PEP 660 __file__).
|
|
# Source paths match the Tauri bundle layout in studio/src-tauri/tauri.conf.json,
|
|
# which bundles install_python_stack.py at the bundle root next to install.ps1.
|
|
if ($TauriMode) {
|
|
$rawPath = if ($PSCommandPath) { $PSCommandPath } else { $MyInvocation.ScriptName }
|
|
if ($rawPath) {
|
|
# Strip leading \\?\ extended-length prefix if the launcher passed one.
|
|
$scriptDir = Split-Path -Parent ($rawPath -replace '^\\\\\?\\', '')
|
|
$overlayMap = [ordered]@{
|
|
"install_python_stack.py" = "Lib\site-packages\studio\install_python_stack.py"
|
|
}
|
|
foreach ($rel in $overlayMap.Keys) {
|
|
$src = Join-Path $scriptDir $rel
|
|
$dst = Join-Path $VenvDir $overlayMap[$rel]
|
|
# -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 -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 -LiteralPath $dst)) {
|
|
# Backfill: target file missing but parent dir exists.
|
|
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 -LiteralPath $src -Algorithm SHA256).Hash
|
|
$dstHash = (Get-FileHash -LiteralPath $dst -Algorithm SHA256).Hash
|
|
if ($srcHash -ne $dstHash) {
|
|
Copy-Item -LiteralPath $src -Destination $dst -Force
|
|
substep ("applied bundled " + (Split-Path -Leaf $rel))
|
|
}
|
|
}
|
|
} catch {
|
|
Write-Host "[WARN] Could not overlay $($rel): $($_.Exception.Message); studio setup may use stale bundled file" -ForegroundColor Yellow
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
# ── Run studio setup ──
|
|
# setup.ps1 will handle installing Git, CMake, Visual Studio Build Tools,
|
|
# CUDA Toolkit, Node.js, and other dependencies automatically via winget.
|
|
Write-TauriLog "STEP" "Running studio setup"
|
|
step "setup" "running unsloth studio setup..."
|
|
$UnslothExe = Join-Path $VenvDir "Scripts\unsloth.exe"
|
|
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
|
|
Write-Host " Try re-running the installer or see: https://github.com/unslothai/unsloth?tab=readme-ov-file#-quickstart" -ForegroundColor Yellow
|
|
return (Exit-InstallFailure "unsloth CLI was not installed correctly")
|
|
}
|
|
# Tell setup.ps1 to skip base package installation (install.ps1 already did it)
|
|
$env:SKIP_STUDIO_BASE = "1"
|
|
$env:STUDIO_PACKAGE_NAME = $PackageName
|
|
$env:UNSLOTH_NO_TORCH = if ($SkipTorch) { "true" } else { "false" }
|
|
# Tauri desktop app bundles its own frontend — skip Node/npm/frontend build
|
|
$env:SKIP_STUDIO_FRONTEND = if ($TauriMode) { "1" } else { "0" }
|
|
# Always set STUDIO_LOCAL_INSTALL explicitly to avoid stale values from
|
|
# a previous --local run in the same PowerShell session.
|
|
if ($StudioLocalInstall) {
|
|
$env:STUDIO_LOCAL_INSTALL = "1"
|
|
$env:STUDIO_LOCAL_REPO = $RepoRoot
|
|
} else {
|
|
$env:STUDIO_LOCAL_INSTALL = "0"
|
|
Remove-Item Env:STUDIO_LOCAL_REPO -ErrorAction SilentlyContinue
|
|
}
|
|
# 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"
|
|
try {
|
|
& $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) {
|
|
Write-Host "[ERROR] unsloth studio setup failed (exit code $setupExit)" -ForegroundColor Red
|
|
return (Exit-InstallFailure "unsloth studio setup failed (exit code $setupExit)" $setupExit)
|
|
}
|
|
|
|
# ── Expose `unsloth` via a shim dir containing only unsloth.exe ──
|
|
# We do NOT add the venv Scripts dir to PATH (it also holds python.exe
|
|
# and pip.exe, which would hijack the user's system interpreter).
|
|
# Hardlink preferred; falls back to copy if cross-volume or non-NTFS.
|
|
#
|
|
# Remove the legacy venv Scripts PATH entry that older installers wrote.
|
|
$LegacyScriptsDir = Join-Path $VenvDir "Scripts"
|
|
try {
|
|
$legacyKey = [Microsoft.Win32.Registry]::CurrentUser.CreateSubKey('Environment')
|
|
try {
|
|
$rawPath = $legacyKey.GetValue('Path', '', [Microsoft.Win32.RegistryValueOptions]::DoNotExpandEnvironmentNames)
|
|
if ($rawPath) {
|
|
[string[]]$pathEntries = $rawPath -split ';'
|
|
$normalLegacy = $LegacyScriptsDir.Trim().Trim('"').TrimEnd('\').ToLowerInvariant()
|
|
$expNormalLegacy = [Environment]::ExpandEnvironmentVariables($LegacyScriptsDir).Trim().Trim('"').TrimEnd('\').ToLowerInvariant()
|
|
$filtered = @($pathEntries | Where-Object {
|
|
$stripped = $_.Trim().Trim('"')
|
|
$rawNorm = $stripped.TrimEnd('\').ToLowerInvariant()
|
|
$expNorm = [Environment]::ExpandEnvironmentVariables($stripped).TrimEnd('\').ToLowerInvariant()
|
|
($rawNorm -ne $normalLegacy -and $rawNorm -ne $expNormalLegacy) -and
|
|
($expNorm -ne $normalLegacy -and $expNorm -ne $expNormalLegacy)
|
|
})
|
|
$cleanedPath = $filtered -join ';'
|
|
if ($cleanedPath -ne $rawPath) {
|
|
$legacyKey.SetValue('Path', $cleanedPath, [Microsoft.Win32.RegistryValueKind]::ExpandString)
|
|
try {
|
|
$d = "UnslothPathRefresh_$([guid]::NewGuid().ToString('N').Substring(0,8))"
|
|
[Environment]::SetEnvironmentVariable($d, '1', 'User')
|
|
[Environment]::SetEnvironmentVariable($d, [NullString]::Value, 'User')
|
|
} catch { }
|
|
}
|
|
}
|
|
} finally {
|
|
$legacyKey.Close()
|
|
}
|
|
} catch { }
|
|
$ShimDir = Join-Path $StudioHome "bin"
|
|
[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 -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 -LiteralPath $UnslothExe -Destination $ShimExe -Force -ErrorAction Stop # fallback: copy
|
|
}
|
|
$shimUpdated = $true
|
|
} catch {
|
|
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
|
|
Write-Host " Continuing with the existing launcher." -ForegroundColor Yellow
|
|
} else {
|
|
Write-Host "[WARN] Could not create unsloth launcher at $ShimExe" -ForegroundColor Yellow
|
|
Write-Host " $($_.Exception.Message)" -ForegroundColor Yellow
|
|
Write-Host " Launch unsloth studio directly via '$UnslothExe' until the next successful install." -ForegroundColor Yellow
|
|
}
|
|
}
|
|
# 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 -LiteralPath $ShimExe) {
|
|
if ($StudioRedirectMode -ne 'env') {
|
|
$pathAdded = Add-ToUserPath -Directory $ShimDir -Position 'Prepend'
|
|
}
|
|
}
|
|
if ($shimUpdated -and $pathAdded) {
|
|
step "path" "added unsloth launcher to PATH"
|
|
}
|
|
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.
|
|
# In non-interactive environments (CI, Docker) just print instructions.
|
|
$IsInteractive = [Environment]::UserInteractive -and (-not [Console]::IsInputRedirected)
|
|
if ($IsInteractive) {
|
|
Write-Host ""
|
|
$reply = Read-Host " Start Unsloth Studio now? [Y/n]"
|
|
if ([string]::IsNullOrWhiteSpace($reply) -or $reply -match '^[Yy]') {
|
|
& $UnslothExe studio -p 8888
|
|
} else {
|
|
step "launch" "to start later, run:"
|
|
substep "unsloth studio -p 8888"
|
|
substep "(add -H 0.0.0.0 to allow network / cloud access)"
|
|
Write-Host ""
|
|
}
|
|
} else {
|
|
step "launch" "manual commands:"
|
|
# 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 ""
|
|
}
|
|
}
|
|
|
|
Install-UnslothStudio @args
|