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:
parent
c163b213e4
commit
cc43a956ba
|
|
@ -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))
|
||||
|
||||
|
|
|
|||
|
|
@ -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)}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
))}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue