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
This commit is contained in:
ZhadowValker 2026-05-02 12:54:09 +05:30
parent c163b213e4
commit cc43a956ba
5 changed files with 265 additions and 61 deletions

View File

@ -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))

View File

@ -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)}

View File

@ -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 }) => (
<svg
className="ilib-spinner"
@ -37,40 +59,48 @@ export const InstallLibrariesModal: React.FC<InstallLibrariesModalProps> = ({
libraries,
}) => {
const [items, setItems] = useState<LibItem[]>(() =>
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<InstallLibrariesModalProps> = ({
{/* Library list */}
<div className="ilib-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 (
<div key={item.name} className={`ilib-item ilib-item--${item.status}`}>
<div key={item.spec} className={`ilib-item ilib-item--${item.status}`}>
<span className="ilib-item-name">
{displayName}
{item.name}
{item.version && (
<span className="ilib-version">v{item.version}</span>
)}
{isWokwiLib && (
<span className="ilib-badge ilib-badge--wokwi" title="Wokwi-hosted library">
wokwi

View File

@ -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<LibraryManagerModalProps> = ({ isOpen
const [loadingSearch, setLoadingSearch] = useState(false);
const [loadingInstalled, setLoadingInstalled] = useState(false);
const [installingLib, setInstallingLib] = useState<string | null>(null);
const [uninstallingLib, setUninstallingLib] = useState<string | null>(null);
/** Track user-selected version per library name */
const [selectedVersions, setSelectedVersions] = useState<Record<string, string>>({});
const [statusMsg, setStatusMsg] = useState<{ type: 'success' | 'error'; text: string } | null>(
null,
);
@ -89,11 +93,16 @@ export const LibraryManagerModal: React.FC<LibraryManagerModalProps> = ({ 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<LibraryManagerModalProps> = ({ 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<LibraryManagerModalProps> = ({ isOpen
{getLibDesc(lib) && <p className="lib-item-desc">{getLibDesc(lib)}</p>}
</div>
<div className="lib-item-actions">
{getLibVersion(lib) && (
<span className="lib-item-version">{getLibVersion(lib)}</span>
)}
{isInstalled(getLibName(lib)) ? (
<span className="lib-item-version lib-installed-badge">
INSTALLED
<svg
style={{
display: 'inline',
marginLeft: '4px',
verticalAlign: 'middle',
}}
width="12"
height="12"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="3"
strokeLinecap="round"
strokeLinejoin="round"
<>
<span className="lib-item-version lib-installed-badge">
{selectedVersions[lib.name] ?? lib.latest?.version ?? ''}
<svg style={{ display: 'inline', marginLeft: '4px', verticalAlign: 'middle' }} width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="3" strokeLinecap="round" strokeLinejoin="round"><polyline points="20 6 9 17 4 12" /></svg>
</span>
<button
className="lib-uninstall-btn"
onClick={() => handleUninstall(getLibName(lib))}
disabled={uninstallingLib !== null}
>
<polyline points="20 6 9 17 4 12" />
</svg>
</span>
{uninstallingLib === getLibName(lib) ? 'Uninstalling...' : 'UNINSTALL'}
</button>
</>
) : (
<button
className="lib-install-btn"
onClick={() => handleInstall(getLibName(lib))}
disabled={installingLib !== null}
>
{installingLib === getLibName(lib) ? (
<span className="lib-installing">Installing...</span>
) : (
'INSTALL'
<>
{lib.releases && Object.keys(lib.releases).length > 1 && (
<select
className="lib-version-select"
value={selectedVersions[lib.name] ?? lib.latest?.version ?? ''}
onChange={(e) => setSelectedVersions((prev) => ({ ...prev, [lib.name]: e.target.value }))}
>
{Object.entries(lib.releases).map(([ver]) => (
<option key={ver} value={ver}>{ver}</option>
))}
</select>
)}
</button>
<button
className="lib-install-btn"
onClick={() => handleInstall(getLibName(lib))}
disabled={installingLib !== null}
>
{installingLib === getLibName(lib) ? (
<span className="lib-installing">Installing...</span>
) : (
'INSTALL'
)}
</button>
</>
)}
</div>
</div>
@ -387,6 +417,13 @@ export const LibraryManagerModal: React.FC<LibraryManagerModalProps> = ({ isOpen
</svg>
</span>
)}
<button
className="lib-uninstall-btn"
onClick={() => handleUninstall(getInstalledName(lib))}
disabled={uninstallingLib !== null}
>
{uninstallingLib === getInstalledName(lib) ? 'Uninstalling...' : 'UNINSTALL'}
</button>
</div>
</div>
))}

View File

@ -43,10 +43,20 @@ export async function searchLibraries(query: string): Promise<ArduinoLibrary[]>
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<InstalledLibrary[]> {
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<string | null> {
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;
}
}