From cc43a956babce136d8d15c4a7667a8a57b16cf81 Mon Sep 17 00:00:00 2001 From: ZhadowValker Date: Sat, 2 May 2026 12:54:09 +0530 Subject: [PATCH] feat: Add library version management and uninstall functionality Backend: - Add version field to InstallLibraryRequest - Add fallback and requested_version to InstallResponse - Add DELETE /api/libraries/uninstall endpoint - Enhance install_library() for versioned installs (LibName@version) - Add semver validation and fallback logic - Add uninstall_library() method - Fix _parse_version() to reject non-numeric version parts Frontend: - Update installLibrary() with optional version parameter - Add uninstallLibrary() and resolveLibraryVersion() helpers - Add version selector dropdown in Library Manager - Add UNINSTALL button for installed libraries - Show fallback messages when requested version unavailable - Add parseLibSpec() and version badges in InstallLibrariesModal --- backend/app/api/routes/libraries.py | 29 ++++- backend/app/services/arduino_cli.py | 80 ++++++++++++- .../simulator/InstallLibrariesModal.tsx | 71 ++++++++---- .../simulator/LibraryManagerModal.tsx | 107 ++++++++++++------ frontend/src/services/libraryService.ts | 39 ++++++- 5 files changed, 265 insertions(+), 61 deletions(-) diff --git a/backend/app/api/routes/libraries.py b/backend/app/api/routes/libraries.py index d9b1e1bd..bc62cd98 100644 --- a/backend/app/api/routes/libraries.py +++ b/backend/app/api/routes/libraries.py @@ -6,6 +6,7 @@ router = APIRouter() class InstallLibraryRequest(BaseModel): name: str + version: str | None = None class SearchResponse(BaseModel): success: bool @@ -16,6 +17,8 @@ class InstallResponse(BaseModel): success: bool stdout: str | None = None error: str | None = None + fallback: bool | None = None + requested_version: str | None = None @router.get("/search", response_model=SearchResponse) async def search_libraries(q: str = Query(..., description="Search query for library")): @@ -38,10 +41,32 @@ async def install_library(request: InstallLibraryRequest): Install a specific Arduino library by name. """ try: - result = await arduino_cli.install_library(request.name) + spec = f"{request.name}@{request.version}" if request.version else request.name + result = await arduino_cli.install_library(spec) if not result["success"]: return InstallResponse(success=False, error=result.get("error"), stdout=result.get("stdout")) - return InstallResponse(success=True, stdout=result.get("stdout")) + return InstallResponse(success=True, stdout=result.get("stdout"), fallback=result.get("fallback"), requested_version=result.get("requested_version")) + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + +class UninstallLibraryRequest(BaseModel): + name: str + +class UninstallResponse(BaseModel): + success: bool + stdout: str | None = None + error: str | None = None + +@router.delete("/uninstall", response_model=UninstallResponse) +async def uninstall_library(request: UninstallLibraryRequest): + """ + Uninstall a specific Arduino library by name. + """ + try: + result = await arduino_cli.uninstall_library(request.name) + if not result["success"]: + return UninstallResponse(success=False, error=result.get("error"), stdout=result.get("stdout")) + return UninstallResponse(success=True, stdout=result.get("stdout")) except Exception as e: raise HTTPException(status_code=500, detail=str(e)) diff --git a/backend/app/services/arduino_cli.py b/backend/app/services/arduino_cli.py index 5a6c065a..59f2f778 100644 --- a/backend/app/services/arduino_cli.py +++ b/backend/app/services/arduino_cli.py @@ -516,7 +516,11 @@ class ArduinoCLIService: # frontend can access lib.latest.version / author / sentence directly. def _parse_version(v: str): try: - return tuple(int(x) for x in v.split(".")) + parts = v.split(".") + # Reject if any part is not a digit (filters out "1_2_3", "beta", "latest") + if any(not p.isdigit() for p in parts): + return (0,) + return tuple(int(p) for p in parts) except Exception: return (0,) @@ -539,16 +543,41 @@ class ArduinoCLIService: Install an Arduino library. Handles standard library names as well as Wokwi-hosted entries in the form "LibName@wokwi:projectHash". + Also handles versioned installs via "LibName@version" syntax + (e.g. "Adafruit NeoPixel@1.11.0"). + + @latest is stripped — arduino-cli does not support it. + Malformed version strings (non-semver) fall back to plain name install. """ if '@wokwi:' in library_name: return await self._install_wokwi_library(library_name) + # Strip @latest — arduino-cli does not support this token + if library_name.endswith('@latest'): + library_name = library_name[:-7] + try: print(f"Installing library: {library_name}") + # Handle "Name@version" syntax for versioned installs + # Only quote if the version part is valid semver (major.minor.patch) + import re + lib_spec = library_name + if '@' in library_name: + parts = library_name.rsplit('@', 1) + if len(parts) == 2 and parts[1]: + version = parts[1] + # Validate semver: major.minor.patch (all numeric) + if re.fullmatch(r'\d+\.\d+\.\d+', version): + lib_spec = library_name # no quotes needed — subprocess passes args literally + else: + # Bad/empty version — fall back to plain name + library_name = parts[0] + lib_spec = library_name + def _run(): return subprocess.run( - [self.cli_path, "lib", "install", library_name], + [self.cli_path, "lib", "install", lib_spec], capture_output=True, text=True, encoding='utf-8', errors='replace' ) @@ -558,6 +587,27 @@ class ArduinoCLIService: print(f"Successfully installed {library_name}") return {"success": True, "stdout": result.stdout} else: + # If a specific version failed, retry with plain name (latest) in case + # the version string is valid semver but rejected by arduino-cli for + # other reasons (e.g. leading zeros, lib index corruption). + if '@' in library_name: + plain_name = library_name.rsplit('@', 1)[0] + version = library_name.rsplit('@', 1)[1] + print(f"Versioned install failed, retrying with plain name: {plain_name}") + def _run_plain(): + return subprocess.run( + [self.cli_path, "lib", "install", plain_name], + capture_output=True, text=True, encoding='utf-8', errors='replace' + ) + result = await asyncio.to_thread(_run_plain) + if result.returncode == 0: + print(f"Successfully installed {plain_name} (fallback to latest)") + return { + "success": True, + "stdout": result.stdout, + "fallback": True, + "requested_version": version, + } print(f"Failed to install {library_name}: {result.stderr}") return {"success": False, "error": result.stderr, "stdout": result.stdout} @@ -719,3 +769,29 @@ class ArduinoCLIService: except Exception as e: print(f"Exception listing libraries: {e}") return {"success": False, "error": str(e)} + + async def uninstall_library(self, library_name: str) -> dict: + """ + Uninstall an Arduino library. + """ + try: + print(f"Uninstalling library: {library_name}") + + def _run(): + return subprocess.run( + [self.cli_path, "lib", "uninstall", library_name], + capture_output=True, text=True, encoding='utf-8', errors='replace' + ) + + result = await asyncio.to_thread(_run) + + if result.returncode == 0: + print(f"Successfully uninstalled {library_name}") + return {"success": True, "stdout": result.stdout} + else: + print(f"Failed to uninstall {library_name}: {result.stderr}") + return {"success": False, "error": result.stderr, "stdout": result.stdout} + + except Exception as e: + print(f"Exception uninstalling library: {e}") + return {"success": False, "error": str(e)} diff --git a/frontend/src/components/simulator/InstallLibrariesModal.tsx b/frontend/src/components/simulator/InstallLibrariesModal.tsx index 385d893a..c062bc7e 100644 --- a/frontend/src/components/simulator/InstallLibrariesModal.tsx +++ b/frontend/src/components/simulator/InstallLibrariesModal.tsx @@ -11,11 +11,33 @@ interface InstallLibrariesModalProps { type ItemStatus = 'pending' | 'installing' | 'done' | 'error'; interface LibItem { + /** Full spec as read from libraries.txt — may contain "@version" suffix */ + spec: string; + /** Parsed library name (without @version or @wokwi:hash) */ name: string; + /** Version if present and valid semver, otherwise undefined */ + version?: string; status: ItemStatus; error?: string; } +/** Split "LibName@version" into { name, version }. + * Returns version=undefined if no valid semver suffix. + * Handles wokwi-hosted "LibName@wokwi:hash" — version stays undefined. */ +function parseLibSpec(spec: string): { name: string; version?: string } { + if (spec.includes('@wokwi:')) { + return { name: spec.split('@wokwi:')[0] }; + } + const idx = spec.lastIndexOf('@'); + if (idx > 0) { + const ver = spec.slice(idx + 1); + if (/^\d+\.\d+\.\d+$/.test(ver)) { + return { name: spec.slice(0, idx), version: ver }; + } + } + return { name: spec }; +} + const Spinner: React.FC<{ size?: number }> = ({ size = 16 }) => ( = ({ libraries, }) => { const [items, setItems] = useState(() => - libraries.map((name) => ({ name, status: 'pending' })), + libraries.map((spec) => { + const { name, version } = parseLibSpec(spec); + return { spec, name, version, status: 'pending' as ItemStatus }; + }), ); const [running, setRunning] = useState(false); const [doneCount, setDoneCount] = useState(0); // Sync items when the libraries prop changes (new import) React.useEffect(() => { - setItems(libraries.map((name) => ({ name, status: 'pending' }))); + setItems(libraries.map((spec) => { + const { name, version } = parseLibSpec(spec); + return { spec, name, version, status: 'pending' as ItemStatus }; + })); setDoneCount(0); setRunning(false); }, [libraries]); - const setItemStatus = useCallback((name: string, status: ItemStatus, error?: string) => { - setItems((prev) => prev.map((it) => (it.name === name ? { ...it, status, error } : it))); - }, []); + const setItemStatus = useCallback( + (spec: string, status: ItemStatus, error?: string) => { + setItems((prev) => + prev.map((it) => (it.spec === spec ? { ...it, status, error } : it)), + ); + }, + [], + ); const handleInstallAll = useCallback(async () => { setRunning(true); let completed = 0; for (const item of items) { - if (item.status === 'done') { - completed++; - continue; - } - setItemStatus(item.name, 'installing'); + if (item.status === 'done') { completed++; continue; } + setItemStatus(item.spec, 'installing'); try { - const result = await installLibrary(item.name); + const result = await installLibrary(item.spec); if (result.success) { - setItemStatus(item.name, 'done'); + setItemStatus(item.spec, 'done'); } else { - setItemStatus(item.name, 'error', result.error || 'Install failed'); + setItemStatus(item.spec, 'error', result.error || 'Install failed'); } } catch (e) { - setItemStatus(item.name, 'error', e instanceof Error ? e.message : 'Install failed'); + setItemStatus(item.spec, 'error', e instanceof Error ? e.message : 'Install failed'); } completed++; setDoneCount(completed); @@ -142,15 +172,14 @@ export const InstallLibrariesModal: React.FC = ({ {/* Library list */}
{items.map((item) => { - // For Wokwi-hosted libraries ("LibName@wokwi:hash"), show only the LibName - const displayName = item.name.includes('@wokwi:') - ? item.name.split('@wokwi:')[0] - : item.name; - const isWokwiLib = item.name.includes('@wokwi:'); + const isWokwiLib = item.spec.includes('@wokwi:'); return ( -
+
- {displayName} + {item.name} + {item.version && ( + v{item.version} + )} {isWokwiLib && ( wokwi diff --git a/frontend/src/components/simulator/LibraryManagerModal.tsx b/frontend/src/components/simulator/LibraryManagerModal.tsx index 297e533f..e8c39c2e 100644 --- a/frontend/src/components/simulator/LibraryManagerModal.tsx +++ b/frontend/src/components/simulator/LibraryManagerModal.tsx @@ -3,6 +3,7 @@ import { searchLibraries, installLibrary, getInstalledLibraries, + uninstallLibrary, } from '../../services/libraryService'; import type { ArduinoLibrary, InstalledLibrary } from '../../services/libraryService'; import { trackInstallLibrary } from '../../utils/analytics'; @@ -23,6 +24,9 @@ export const LibraryManagerModal: React.FC = ({ isOpen const [loadingSearch, setLoadingSearch] = useState(false); const [loadingInstalled, setLoadingInstalled] = useState(false); const [installingLib, setInstallingLib] = useState(null); + const [uninstallingLib, setUninstallingLib] = useState(null); + /** Track user-selected version per library name */ + const [selectedVersions, setSelectedVersions] = useState>({}); const [statusMsg, setStatusMsg] = useState<{ type: 'success' | 'error'; text: string } | null>( null, ); @@ -89,11 +93,16 @@ export const LibraryManagerModal: React.FC = ({ isOpen setInstallingLib(libName); setStatusMsg(null); try { - const result = await installLibrary(libName); + const version = selectedVersions[libName]; + const result = await installLibrary(libName, version); if (result.success) { trackInstallLibrary(libName); - setStatusMsg({ type: 'success', text: `"${libName}" installed successfully!` }); - fetchInstalled(); // Refresh installed list so search tab reflects new state + if (result.fallback) { + setStatusMsg({ type: 'success', text: `"${libName}" installed (latest — requested @${result.requested_version} was not available)` }); + } else { + setStatusMsg({ type: 'success', text: `"${libName}${version ? ' @' + version : ''}" installed successfully!` }); + } + fetchInstalled(); } else { setStatusMsg({ type: 'error', text: result.error || `Failed to install "${libName}"` }); } @@ -104,6 +113,24 @@ export const LibraryManagerModal: React.FC = ({ isOpen } }; + const handleUninstall = async (libName: string) => { + setUninstallingLib(libName); + setStatusMsg(null); + try { + const result = await uninstallLibrary(libName); + if (result.success) { + setStatusMsg({ type: 'success', text: `"${libName}" uninstalled successfully!` }); + fetchInstalled(); + } else { + setStatusMsg({ type: 'error', text: result.error || `Failed to uninstall "${libName}"` }); + } + } catch (e: unknown) { + setStatusMsg({ type: 'error', text: e instanceof Error ? e.message : 'Uninstall failed' }); + } finally { + setUninstallingLib(null); + } + }; + const handleClose = () => { onClose(); }; @@ -296,42 +323,45 @@ export const LibraryManagerModal: React.FC = ({ isOpen {getLibDesc(lib) &&

{getLibDesc(lib)}

}
- {getLibVersion(lib) && ( - {getLibVersion(lib)} - )} {isInstalled(getLibName(lib)) ? ( - - INSTALLED - + + {selectedVersions[lib.name] ?? lib.latest?.version ?? ''} + + + + ) : ( - + + )}
@@ -387,6 +417,13 @@ export const LibraryManagerModal: React.FC = ({ isOpen )} +
))} diff --git a/frontend/src/services/libraryService.ts b/frontend/src/services/libraryService.ts index a9442e11..4f7b3f75 100644 --- a/frontend/src/services/libraryService.ts +++ b/frontend/src/services/libraryService.ts @@ -43,10 +43,20 @@ export async function searchLibraries(query: string): Promise return data.libraries || []; } -export async function installLibrary(name: string): Promise<{ success: boolean; error?: string }> { +export async function installLibrary(name: string, version?: string): Promise<{ success: boolean; error?: string; fallback?: boolean; requested_version?: string }> { const res = await fetch(`${API_BASE}/install`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ name, version: version ?? null }), + }); + const data = await res.json(); + return data; +} + +export async function uninstallLibrary(name: string): Promise<{ success: boolean; error?: string }> { + const res = await fetch(`${API_BASE}/uninstall`, { + method: 'DELETE', + headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name }), }); const data = await res.json(); @@ -62,3 +72,30 @@ export async function getInstalledLibraries(): Promise { const data = await res.json(); return data.libraries || []; } + +/** + * Resolve a library name to "Name@X.Y.Z" using arduino-cli lib search. + * Returns null if the library is not found in the index. + * Used during export to resolve versions for libraries that are not locally installed. + */ +export async function resolveLibraryVersion(libName: string): Promise { + try { + const results = await searchLibraries(libName); + // Find best match: exact name or name without underscores/spaces + const normalised = libName.replace(/[\s_]+/g, '').toLowerCase(); + const match = results.find( + (r) => + r.name.replace(/[\s_]+/g, '').toLowerCase() === normalised || + normalised.includes(r.name.replace(/[\s_]+/g, '').toLowerCase()) || + r.name.replace(/[\s_]+/g, '').toLowerCase().includes(normalised), + ); + if (!match) return null; + const latest = match.latest?.version; + if (latest && /^\d+\.\d+\.\d+$/.test(latest)) { + return `${match.name}@${latest}`; + } + return null; + } catch { + return null; + } +}