diff --git a/UNSLOTH_UPDATE_DETAILS.json b/UNSLOTH_UPDATE_DETAILS.json new file mode 100644 index 0000000000..5e66bfca7a --- /dev/null +++ b/UNSLOTH_UPDATE_DETAILS.json @@ -0,0 +1,9 @@ +{ + "schema_version": 1, + "unsloth_source": "pypi", + "unsloth_github_ref": "main", + "llama_cpp_source": "unslothai", + "llama_cpp_tag": "latest", + "CRITICAL_TIME": null, + "announcement": null +} diff --git a/studio/backend/main.py b/studio/backend/main.py index ad19ee9679..5a573f26a5 100644 --- a/studio/backend/main.py +++ b/studio/backend/main.py @@ -118,6 +118,25 @@ async def lifespan(app: FastAPI): threading.Thread(target = _precache, daemon = True).start() + # Fetch remote update manifest in the background and print a terminal + # banner when a critical update or announcement is present. + def _check_updates(): + try: + from utils.update_check import fetch_and_cache_update_status + + status = fetch_and_cache_update_status() + if status.critical: + print("\n" + "=" * 60) + print("CRITICAL UPDATE AVAILABLE") + print(" Run `unsloth studio update` then restart Studio.") + print("=" * 60 + "\n") + elif status.announcement_message: + print(f"\n [Update] {status.announcement_message}\n") + except Exception: + pass # non-critical + + threading.Thread(target = _check_updates, daemon = True).start() + if storage.ensure_default_admin(): bootstrap_pw = storage.get_bootstrap_password() app.state.bootstrap_password = bootstrap_pw @@ -203,6 +222,21 @@ async def health_check(): } +@app.get("/api/update-check") +async def update_check(): + """Return cached update status from the remote manifest (unauthenticated).""" + from utils.update_check import get_update_status + + s = get_update_status() + return { + "critical": s.critical, + "announcement_badge": s.announcement_badge, + "announcement_message": s.announcement_message, + "announcement_url": s.announcement_url, + "manifest_fetched": s.manifest_fetched, + } + + @app.post("/api/shutdown") async def shutdown_server( request: Request, diff --git a/studio/backend/utils/update_check.py b/studio/backend/utils/update_check.py new file mode 100644 index 0000000000..c943f59699 --- /dev/null +++ b/studio/backend/utils/update_check.py @@ -0,0 +1,103 @@ +# SPDX-License-Identifier: AGPL-3.0-only +# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0 + +"""Remote manifest fetch and update-status cache for Unsloth Studio. + +Uses only stdlib (urllib.request, json, time, calendar) so it can run +without any third-party dependencies. The module exposes two public +functions: + + fetch_and_cache_update_status() -- fetches the manifest, reads the + local UNSLOTH_STUDIO_INFO.json, compares CRITICAL_TIME, and caches + the result at module level. + + get_update_status() -- returns the cached UpdateStatus (or defaults + when the fetch has not completed yet). +""" + +from __future__ import annotations + +import calendar +import json +import time +import urllib.request +from dataclasses import dataclass, field +from pathlib import Path +from typing import Optional + +_MANIFEST_URL = ( + "https://raw.githubusercontent.com/unslothai/unsloth/main/" + "UNSLOTH_UPDATE_DETAILS.json" +) +_STUDIO_INFO_PATH = Path.home() / ".unsloth" / "studio" / "UNSLOTH_STUDIO_INFO.json" +_FETCH_TIMEOUT = 8 # seconds + + +@dataclass +class UpdateStatus: + critical: bool = False + announcement_badge: Optional[str] = None + announcement_message: Optional[str] = None + announcement_url: Optional[str] = None + manifest_fetched: bool = False + + +_cached_status: UpdateStatus = UpdateStatus() + + +def _parse_iso_utc(s: str) -> float: + """Parse an ISO-8601 UTC string (ending in Z) to a Unix timestamp.""" + s = s.strip().rstrip("Z") + try: + t = time.strptime(s, "%Y-%m-%dT%H:%M:%S") + except ValueError: + t = time.strptime(s[:19], "%Y-%m-%dT%H:%M:%S") + return float(calendar.timegm(t)) + + +def fetch_and_cache_update_status() -> UpdateStatus: + """Fetch the remote manifest, compare with local info, and cache.""" + global _cached_status + + try: + req = urllib.request.Request(_MANIFEST_URL, method="GET") + with urllib.request.urlopen(req, timeout=_FETCH_TIMEOUT) as resp: + manifest = json.loads(resp.read().decode("utf-8")) + except Exception: + return _cached_status + + status = UpdateStatus(manifest_fetched=True) + + # -- Critical time check -- + critical_time_str = manifest.get("CRITICAL_TIME") + if critical_time_str: + try: + critical_ts = _parse_iso_utc(critical_time_str) + installed_ts = 0.0 + if _STUDIO_INFO_PATH.is_file(): + try: + info = json.loads( + _STUDIO_INFO_PATH.read_text(encoding="utf-8") + ) + installed_ts = _parse_iso_utc(info.get("installed_at_utc", "")) + except Exception: + pass + if installed_ts < critical_ts: + status.critical = True + except Exception: + pass + + # -- Announcement -- + announcement = manifest.get("announcement") + if isinstance(announcement, dict): + status.announcement_badge = announcement.get("badge") or None + status.announcement_message = announcement.get("message") or None + status.announcement_url = announcement.get("url") or None + + _cached_status = status + return status + + +def get_update_status() -> UpdateStatus: + """Return the cached update status (safe to call before fetch completes).""" + return _cached_status diff --git a/studio/frontend/src/app/routes/__root.tsx b/studio/frontend/src/app/routes/__root.tsx index d7780c6743..eba4df6af4 100644 --- a/studio/frontend/src/app/routes/__root.tsx +++ b/studio/frontend/src/app/routes/__root.tsx @@ -2,6 +2,7 @@ // Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0 import { Navbar } from "@/components/navbar"; +import { useUpdateCheck } from "@/hooks/use-update-check"; import { usePlatformStore } from "@/config/env"; import { Outlet, @@ -33,12 +34,27 @@ export const Route = createRootRoute({ const HIDDEN_NAVBAR_ROUTES = ["/onboarding", "/login", "/change-password"]; +function CriticalUpdateBanner() { + const { critical } = useUpdateCheck(); + if (!critical) return null; + return ( +
+ unsloth studio update
+ {" "}
+ then restart Studio.
+