diff --git a/backend/app/api/routes/libraries.py b/backend/app/api/routes/libraries.py index bc62cd98..b1352651 100644 --- a/backend/app/api/routes/libraries.py +++ b/backend/app/api/routes/libraries.py @@ -1,6 +1,7 @@ -from fastapi import APIRouter, HTTPException, Query +from fastapi import APIRouter, Depends, HTTPException, Query from pydantic import BaseModel from app.api.routes.compile import arduino_cli +from app.core.hooks import get_current_user_id, warm_library router = APIRouter() @@ -36,11 +37,30 @@ async def search_libraries(q: str = Query(..., description="Search query for lib raise HTTPException(status_code=500, detail=str(e)) @router.post("/install", response_model=InstallResponse) -async def install_library(request: InstallLibraryRequest): +async def install_library( + request: InstallLibraryRequest, + requester_id: str | None = Depends(get_current_user_id), +): """ Install a specific Arduino library by name. + + On velxio.dev this WARMS the shared content-addressed cache (the global + mutable libraries volume is being retired) rather than mutating that volume; + the overlay also enforces the anonymous policy. With no overlay (OSS + self-host) it falls back to the legacy arduino-cli global install. """ try: + # P2.1 — prefer warming the content-addressed cache (no global write). + warmed = await warm_library(request.name, request.version, requester_id) + if warmed is not None: + return InstallResponse( + success=bool(warmed.get("success")), + error=warmed.get("error"), + stdout=warmed.get("stdout"), + fallback=warmed.get("fallback"), + requested_version=warmed.get("requested_version"), + ) + # OSS / no overlay: legacy global install (self-host parity). spec = f"{request.name}@{request.version}" if request.version else request.name result = await arduino_cli.install_library(spec) if not result["success"]: diff --git a/backend/app/core/hooks.py b/backend/app/core/hooks.py index 41c6d8b1..8a0a3d0d 100644 --- a/backend/app/core/hooks.py +++ b/backend/app/core/hooks.py @@ -202,6 +202,41 @@ async def get_project_owner(project_id: Optional[str]) -> Optional[str]: return None +# ── warm_library ────────────────────────────────────────────────────────────── +# "Install" an index library by WARMING the shared content-addressed cache +# (install into a throwaway sketchbook -> publish to the cache) instead of +# mutating the single shared global libraries volume — so the global dir stops +# growing and can be retired. `requester_id` enforces the anon policy (an +# anonymous user may only use libraries already referenced by an example/project, +# i.e. already cached; warming a fresh uncached lib requires sign-in). Returns a +# result dict ({success, error?, ...}) or None when no overlay is loaded -> the +# OSS route falls back to its legacy arduino-cli global install (self-host parity). + +WarmLibraryHook = Callable[..., Awaitable[Optional[dict]]] + +_warm_library_hook: Optional[WarmLibraryHook] = None + + +def register_warm_library(hook: WarmLibraryHook) -> None: + """Install the cache-warm library installer. Called by overlays in register_pro.""" + global _warm_library_hook + _warm_library_hook = hook + + +async def warm_library( + name: str, version: Optional[str] = None, requester_id: Optional[str] = None +) -> Optional[dict]: + """Warm an index library into the shared cache. None -> no overlay (the OSS + route does its legacy global install). Never raises.""" + if _warm_library_hook is None: + return None + try: + return await _warm_library_hook(name=name, version=version, requester_id=requester_id) + except Exception: + logger.exception("warm_library hook failed") + return {"success": False, "error": "Library install failed."} + + # ── lifespan startup ────────────────────────────────────────────────────────── # Overlays that need to run async setup during FastAPI lifespan (DB init, # table creation, legacy column migrations, etc.) register a coroutine here.