fix(plugins): support concurrent plugin updates

Replace single updatingPlugin string state with a Set to allow
multiple plugins to update simultaneously. Also disable the update
button and show a descriptive tooltip when a plugin has no git
remote configured.
This commit is contained in:
simosmik
2026-03-09 07:35:01 +00:00
parent efdee162c9
commit c368451891
3 changed files with 42 additions and 10 deletions

View File

@@ -31,9 +31,9 @@ export function getPluginsConfig() {
export function savePluginsConfig(config) { export function savePluginsConfig(config) {
const dir = path.dirname(PLUGINS_CONFIG_PATH); const dir = path.dirname(PLUGINS_CONFIG_PATH);
if (!fs.existsSync(dir)) { if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true }); fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
} }
fs.writeFileSync(PLUGINS_CONFIG_PATH, JSON.stringify(config, null, 2)); fs.writeFileSync(PLUGINS_CONFIG_PATH, JSON.stringify(config, null, 2), { mode: 0o600 });
} }
export function validateManifest(manifest) { export function validateManifest(manifest) {
@@ -60,6 +60,23 @@ export function validateManifest(manifest) {
return { valid: false, error: `Invalid plugin slot: ${manifest.slot}. Must be one of: ${ALLOWED_SLOTS.join(', ')}` }; return { valid: false, error: `Invalid plugin slot: ${manifest.slot}. Must be one of: ${ALLOWED_SLOTS.join(', ')}` };
} }
// Validate entry is a relative path without traversal
if (manifest.entry.includes('..') || path.isAbsolute(manifest.entry)) {
return { valid: false, error: 'Entry must be a relative path without ".."' };
}
if (manifest.server !== undefined && manifest.server !== null) {
if (typeof manifest.server !== 'string' || manifest.server.includes('..') || path.isAbsolute(manifest.server)) {
return { valid: false, error: 'Server entry must be a relative path string without ".."' };
}
}
if (manifest.permissions !== undefined) {
if (!Array.isArray(manifest.permissions) || !manifest.permissions.every(p => typeof p === 'string')) {
return { valid: false, error: 'Permissions must be an array of strings' };
}
}
return { valid: true }; return { valid: true };
} }
@@ -235,6 +252,13 @@ export function installPluginFromGit(url) {
return reject(new Error(`Invalid manifest: ${validation.error}`)); return reject(new Error(`Invalid manifest: ${validation.error}`));
} }
// Reject if another installed plugin already uses this name
const existing = scanPlugins().find(p => p.name === manifest.name);
if (existing) {
cleanupTemp();
return reject(new Error(`A plugin named "${manifest.name}" is already installed (in "${existing.dirName}")`));
}
// Run npm install if package.json exists. // Run npm install if package.json exists.
// --ignore-scripts prevents postinstall hooks from executing arbitrary code. // --ignore-scripts prevents postinstall hooks from executing arbitrary code.
const packageJsonPath = path.join(tempDir, 'package.json'); const packageJsonPath = path.join(tempDir, 'package.json');

View File

@@ -7,7 +7,7 @@ import PluginIcon from './PluginIcon';
const STARTER_PLUGIN_URL = 'https://github.com/cloudcli-ai/cloudcli-plugin-starter'; const STARTER_PLUGIN_URL = 'https://github.com/cloudcli-ai/cloudcli-plugin-starter';
/* ─── Toggle Switch ─────────────────────────────────────────────────────── */ /* ─── Toggle Switch ─────────────────────────────────────────────────────── */
function ToggleSwitch({ checked, onChange }: { checked: boolean; onChange: (v: boolean) => void }) { function ToggleSwitch({ checked, onChange, ariaLabel }: { checked: boolean; onChange: (v: boolean) => void; ariaLabel: string }) {
return ( return (
<label className="relative inline-flex cursor-pointer select-none items-center"> <label className="relative inline-flex cursor-pointer select-none items-center">
<input <input
@@ -15,6 +15,7 @@ function ToggleSwitch({ checked, onChange }: { checked: boolean; onChange: (v: b
className="peer sr-only" className="peer sr-only"
checked={checked} checked={checked}
onChange={(e) => onChange(e.target.checked)} onChange={(e) => onChange(e.target.checked)}
aria-label={ariaLabel}
/> />
<div <div
className={` className={`
@@ -141,8 +142,9 @@ function PluginCard({
<div className="flex flex-shrink-0 items-center gap-2"> <div className="flex flex-shrink-0 items-center gap-2">
<button <button
onClick={onUpdate} onClick={onUpdate}
disabled={updating} disabled={updating || !plugin.repoUrl}
title="Pull latest from git" title={plugin.repoUrl ? 'Pull latest from git' : 'No git remote — update not available'}
aria-label={`Update ${plugin.displayName}`}
className="rounded p-1.5 text-muted-foreground transition-colors hover:bg-muted hover:text-foreground disabled:opacity-40" className="rounded p-1.5 text-muted-foreground transition-colors hover:bg-muted hover:text-foreground disabled:opacity-40"
> >
{updating ? ( {updating ? (
@@ -155,6 +157,7 @@ function PluginCard({
<button <button
onClick={onUninstall} onClick={onUninstall}
title={confirmingUninstall ? 'Click again to confirm' : 'Uninstall plugin'} title={confirmingUninstall ? 'Click again to confirm' : 'Uninstall plugin'}
aria-label={`Uninstall ${plugin.displayName}`}
className={`rounded p-1.5 transition-colors ${ className={`rounded p-1.5 transition-colors ${
confirmingUninstall confirmingUninstall
? 'bg-red-50 text-red-500 hover:bg-red-100 dark:bg-red-900/20 dark:hover:bg-red-900/30' ? 'bg-red-50 text-red-500 hover:bg-red-100 dark:bg-red-900/20 dark:hover:bg-red-900/30'
@@ -164,7 +167,7 @@ function PluginCard({
<Trash2 className="h-3.5 w-3.5" /> <Trash2 className="h-3.5 w-3.5" />
</button> </button>
<ToggleSwitch checked={plugin.enabled} onChange={onToggle} /> <ToggleSwitch checked={plugin.enabled} onChange={onToggle} ariaLabel={`${plugin.enabled ? 'Disable' : 'Enable'} ${plugin.displayName}`} />
</div> </div>
</div> </div>
@@ -268,17 +271,17 @@ export default function PluginSettingsTab() {
const [installingStarter, setInstallingStarter] = useState(false); const [installingStarter, setInstallingStarter] = useState(false);
const [installError, setInstallError] = useState<string | null>(null); const [installError, setInstallError] = useState<string | null>(null);
const [confirmUninstall, setConfirmUninstall] = useState<string | null>(null); const [confirmUninstall, setConfirmUninstall] = useState<string | null>(null);
const [updatingPlugin, setUpdatingPlugin] = useState<string | null>(null); const [updatingPlugins, setUpdatingPlugins] = useState<Set<string>>(new Set());
const [updateErrors, setUpdateErrors] = useState<Record<string, string>>({}); const [updateErrors, setUpdateErrors] = useState<Record<string, string>>({});
const handleUpdate = async (name: string) => { const handleUpdate = async (name: string) => {
setUpdatingPlugin(name); setUpdatingPlugins((prev) => new Set(prev).add(name));
setUpdateErrors((prev) => { const next = { ...prev }; delete next[name]; return next; }); setUpdateErrors((prev) => { const next = { ...prev }; delete next[name]; return next; });
const result = await updatePlugin(name); const result = await updatePlugin(name);
if (!result.success) { if (!result.success) {
setUpdateErrors((prev) => ({ ...prev, [name]: result.error || 'Update failed' })); setUpdateErrors((prev) => ({ ...prev, [name]: result.error || 'Update failed' }));
} }
setUpdatingPlugin(null); setUpdatingPlugins((prev) => { const next = new Set(prev); next.delete(name); return next; });
}; };
const handleInstall = async () => { const handleInstall = async () => {
@@ -399,7 +402,7 @@ export default function PluginSettingsTab() {
onToggle={(enabled) => void togglePlugin(plugin.name, enabled)} onToggle={(enabled) => void togglePlugin(plugin.name, enabled)}
onUpdate={() => void handleUpdate(plugin.name)} onUpdate={() => void handleUpdate(plugin.name)}
onUninstall={() => void handleUninstall(plugin.name)} onUninstall={() => void handleUninstall(plugin.name)}
updating={updatingPlugin === plugin.name} updating={updatingPlugins.has(plugin.name)}
confirmingUninstall={confirmUninstall === plugin.name} confirmingUninstall={confirmUninstall === plugin.name}
onCancelUninstall={() => setConfirmUninstall(null)} onCancelUninstall={() => setConfirmUninstall(null)}
updateError={updateErrors[plugin.name] ?? null} updateError={updateErrors[plugin.name] ?? null}

View File

@@ -105,6 +105,11 @@ export default function PluginTabContent({
}; };
await mod.mount?.(container, api); await mod.mount?.(container, api);
if (!active) {
try { mod.unmount?.(container); } catch { /* ignore */ }
moduleRef.current = null;
return;
}
} catch (err) { } catch (err) {
if (!active) return; if (!active) return;
console.error(`[Plugin:${pluginName}] Failed to load:`, err); console.error(`[Plugin:${pluginName}] Failed to load:`, err);