feat(pro): add plugin marketplace with catalog, install, and update support

Marketplace backend (agor-pro/src/marketplace.rs): fetch catalog from
GitHub, download+verify+extract plugins, install/uninstall/update with
SHA-256 checksum verification and path traversal protection. 6 Tauri
plugin commands.

PluginMarketplace.svelte: Browse/Installed tabs, search, plugin cards
with permission badges, one-click install/uninstall/update.

Plugin catalog repo: agents-orchestrator/agor-plugins (3 seed plugins).
Plugin scaffolding: scripts/plugin-init.sh.
7 marketplace vitest tests, 3 Rust tests.
This commit is contained in:
Hibryda 2026-03-17 02:20:10 +01:00
parent a98d061b04
commit 5300c09157
8 changed files with 1109 additions and 0 deletions

View file

@ -0,0 +1,503 @@
// SPDX-License-Identifier: LicenseRef-Commercial
<script lang="ts">
import {
proMarketplaceFetchCatalog,
proMarketplaceInstalled,
proMarketplaceInstall,
proMarketplaceUninstall,
proMarketplaceCheckUpdates,
proMarketplaceUpdate,
type CatalogPlugin,
type InstalledPlugin,
} from './pro-bridge';
type Tab = 'browse' | 'installed';
let tab = $state<Tab>('browse');
let search = $state('');
let toast = $state<string | null>(null);
let toastTimer: ReturnType<typeof setTimeout> | undefined;
// Browse state
let catalog = $state<CatalogPlugin[]>([]);
let catalogLoading = $state(true);
let catalogError = $state<string | null>(null);
let selectedPlugin = $state<CatalogPlugin | null>(null);
let installing = $state<Set<string>>(new Set());
// Installed state
let installed = $state<InstalledPlugin[]>([]);
let installedLoading = $state(true);
let installedError = $state<string | null>(null);
let checking = $state(false);
let uninstalling = $state<Set<string>>(new Set());
let updating = $state<Set<string>>(new Set());
let confirmUninstall = $state<string | null>(null);
let installedIds = $derived(new Set(installed.map((p) => p.id)));
let filtered = $derived(
catalog.filter((p) => {
if (!search) return true;
const q = search.toLowerCase();
return (
p.name.toLowerCase().includes(q) ||
p.description.toLowerCase().includes(q) ||
(p.tags ?? []).some((t) => t.toLowerCase().includes(q))
);
})
);
function showToast(msg: string) {
toast = msg;
clearTimeout(toastTimer);
toastTimer = setTimeout(() => (toast = null), 3000);
}
async function loadCatalog() {
catalogLoading = true;
catalogError = null;
try {
catalog = await proMarketplaceFetchCatalog();
} catch (e) {
catalogError = e instanceof Error ? e.message : String(e);
} finally {
catalogLoading = false;
}
}
async function loadInstalled() {
installedLoading = true;
installedError = null;
try {
installed = await proMarketplaceInstalled();
} catch (e) {
installedError = e instanceof Error ? e.message : String(e);
} finally {
installedLoading = false;
}
}
async function handleInstall(pluginId: string) {
installing = new Set([...installing, pluginId]);
try {
const result = await proMarketplaceInstall(pluginId);
installed = [...installed, result];
showToast(`Installed ${result.name} v${result.version}`);
} catch (e) {
showToast(`Install failed: ${e instanceof Error ? e.message : String(e)}`);
} finally {
const next = new Set(installing);
next.delete(pluginId);
installing = next;
}
}
async function handleUninstall(pluginId: string) {
confirmUninstall = null;
uninstalling = new Set([...uninstalling, pluginId]);
const name = installed.find((p) => p.id === pluginId)?.name ?? pluginId;
try {
await proMarketplaceUninstall(pluginId);
installed = installed.filter((p) => p.id !== pluginId);
showToast(`Uninstalled ${name}`);
} catch (e) {
showToast(`Uninstall failed: ${e instanceof Error ? e.message : String(e)}`);
} finally {
const next = new Set(uninstalling);
next.delete(pluginId);
uninstalling = next;
}
}
async function handleCheckUpdates() {
checking = true;
try {
const updated = await proMarketplaceCheckUpdates();
installed = updated;
const count = updated.filter((p) => p.hasUpdate).length;
showToast(count > 0 ? `${count} update(s) available` : 'All plugins up to date');
} catch (e) {
showToast(`Check failed: ${e instanceof Error ? e.message : String(e)}`);
} finally {
checking = false;
}
}
async function handleUpdate(pluginId: string) {
updating = new Set([...updating, pluginId]);
try {
const result = await proMarketplaceUpdate(pluginId);
installed = installed.map((p) => (p.id === pluginId ? result : p));
showToast(`Updated ${result.name} to v${result.version}`);
} catch (e) {
showToast(`Update failed: ${e instanceof Error ? e.message : String(e)}`);
} finally {
const next = new Set(updating);
next.delete(pluginId);
updating = next;
}
}
function fmtDownloads(n: number | null): string {
if (n == null) return '';
if (n >= 1000) return `${(n / 1000).toFixed(1)}k`;
return String(n);
}
function stars(rating: number | null): string {
if (rating == null) return '';
const full = Math.round(rating);
return '\u2605'.repeat(full) + '\u2606'.repeat(5 - full);
}
$effect(() => {
loadCatalog();
loadInstalled();
});
</script>
<div class="mp-root">
<!-- Toast -->
{#if toast}
<div class="mp-toast">{toast}</div>
{/if}
<!-- Tabs -->
<div class="mp-tabs">
<button class="mp-tab" class:active={tab === 'browse'} onclick={() => (tab = 'browse')}>Browse</button>
<button class="mp-tab" class:active={tab === 'installed'} onclick={() => (tab = 'installed')}>
Installed{installed.length > 0 ? ` (${installed.length})` : ''}
</button>
</div>
<!-- Browse Tab -->
{#if tab === 'browse'}
<div class="mp-search">
<input type="text" placeholder="Search plugins..." bind:value={search} class="mp-search-input" />
</div>
{#if catalogLoading}
<div class="mp-state">Loading catalog...</div>
{:else if catalogError}
<div class="mp-state mp-error">{catalogError}</div>
{:else if filtered.length === 0}
<div class="mp-state">{search ? 'No plugins match your search' : 'No plugins available'}</div>
{:else}
<div class="mp-grid">
{#each filtered as plugin (plugin.id)}
<div class="mp-card" onclick={() => (selectedPlugin = selectedPlugin?.id === plugin.id ? null : plugin)}>
<div class="mp-card-header">
<span class="mp-card-name">{plugin.name}</span>
<span class="mp-card-version">v{plugin.version}</span>
</div>
<div class="mp-card-author">by {plugin.author}</div>
<div class="mp-card-desc">{plugin.description}</div>
<div class="mp-card-meta">
{#if plugin.tags}
<div class="mp-card-tags">
{#each plugin.tags.slice(0, 3) as t}
<span class="mp-tag">{t}</span>
{/each}
</div>
{/if}
<div class="mp-card-stats">
{#if plugin.downloads != null}<span>{fmtDownloads(plugin.downloads)} dl</span>{/if}
{#if plugin.rating != null}<span class="mp-stars">{stars(plugin.rating)}</span>{/if}
</div>
</div>
{#if plugin.permissions.length > 0}
<div class="mp-card-perms">
{#each plugin.permissions.slice(0, 3) as perm}
<span class="mp-perm">{perm}</span>
{/each}
{#if plugin.permissions.length > 3}
<span class="mp-perm">+{plugin.permissions.length - 3}</span>
{/if}
</div>
{/if}
<div class="mp-card-actions">
<button
class="mp-btn mp-btn-install"
disabled={installedIds.has(plugin.id) || installing.has(plugin.id)}
onclick={(e) => { e.stopPropagation(); handleInstall(plugin.id); }}
>
{#if installing.has(plugin.id)}
Installing...
{:else if installedIds.has(plugin.id)}
Installed
{:else}
Install
{/if}
</button>
</div>
</div>
{/each}
</div>
{/if}
<!-- Detail Panel -->
{#if selectedPlugin}
<div class="mp-detail">
<div class="mp-detail-header">
<h3 class="mp-detail-name">{selectedPlugin.name}</h3>
<button class="mp-btn-close" onclick={() => (selectedPlugin = null)}>x</button>
</div>
<p class="mp-detail-desc">{selectedPlugin.description}</p>
<div class="mp-detail-fields">
<div class="mp-field"><span class="mp-field-label">Version</span> {selectedPlugin.version}</div>
<div class="mp-field"><span class="mp-field-label">Author</span> {selectedPlugin.author}</div>
{#if selectedPlugin.license}<div class="mp-field"><span class="mp-field-label">License</span> {selectedPlugin.license}</div>{/if}
{#if selectedPlugin.sizeBytes != null}<div class="mp-field"><span class="mp-field-label">Size</span> {(selectedPlugin.sizeBytes / 1024).toFixed(0)} KB</div>{/if}
{#if selectedPlugin.minAgorVersion}<div class="mp-field"><span class="mp-field-label">Min Version</span> {selectedPlugin.minAgorVersion}</div>{/if}
</div>
{#if selectedPlugin.permissions.length > 0}
<div class="mp-detail-section">Permissions</div>
<div class="mp-detail-perms">{#each selectedPlugin.permissions as p}<span class="mp-perm">{p}</span>{/each}</div>
{/if}
<div class="mp-detail-links">
{#if selectedPlugin.homepage}<a href={selectedPlugin.homepage} target="_blank" rel="noopener" class="mp-link">Homepage</a>{/if}
{#if selectedPlugin.repository}<a href={selectedPlugin.repository} target="_blank" rel="noopener" class="mp-link">Repository</a>{/if}
</div>
</div>
{/if}
<!-- Installed Tab -->
{:else}
<div class="mp-installed-bar">
<button class="mp-btn" onclick={handleCheckUpdates} disabled={checking}>
{checking ? 'Checking...' : 'Check for Updates'}
</button>
</div>
{#if installedLoading}
<div class="mp-state">Loading installed plugins...</div>
{:else if installedError}
<div class="mp-state mp-error">{installedError}</div>
{:else if installed.length === 0}
<div class="mp-state">No plugins installed. Browse the catalog to get started.</div>
{:else}
<div class="mp-installed-list">
{#each installed as plugin (plugin.id)}
<div class="mp-installed-item">
<div class="mp-installed-info">
<div class="mp-installed-top">
<span class="mp-card-name">{plugin.name}</span>
<span class="mp-card-version">v{plugin.version}</span>
{#if plugin.hasUpdate && plugin.latestVersion}
<span class="mp-update-badge">v{plugin.latestVersion} available</span>
{/if}
</div>
<div class="mp-card-author">by {plugin.author}</div>
<div class="mp-card-desc">{plugin.description}</div>
{#if plugin.permissions.length > 0}
<div class="mp-card-perms">
{#each plugin.permissions as perm}<span class="mp-perm">{perm}</span>{/each}
</div>
{/if}
</div>
<div class="mp-installed-actions">
{#if plugin.hasUpdate}
<button class="mp-btn mp-btn-install" disabled={updating.has(plugin.id)} onclick={() => handleUpdate(plugin.id)}>
{updating.has(plugin.id) ? 'Updating...' : 'Update'}
</button>
{/if}
{#if confirmUninstall === plugin.id}
<button class="mp-btn mp-btn-danger" onclick={() => handleUninstall(plugin.id)}>Confirm</button>
<button class="mp-btn" onclick={() => (confirmUninstall = null)}>Cancel</button>
{:else}
<button
class="mp-btn mp-btn-danger"
disabled={uninstalling.has(plugin.id)}
onclick={() => (confirmUninstall = plugin.id)}
>{uninstalling.has(plugin.id) ? 'Removing...' : 'Uninstall'}</button>
{/if}
</div>
</div>
{/each}
</div>
{/if}
{/if}
</div>
<style>
.mp-root {
position: relative;
display: flex;
flex-direction: column;
gap: 0.75rem;
padding: 0.75rem;
height: 100%;
overflow-y: auto;
color: var(--ctp-text);
font-family: var(--ui-font-family, system-ui, sans-serif);
font-size: var(--ui-font-size, 0.8125rem);
}
.mp-toast {
position: fixed;
top: 0.75rem;
right: 0.75rem;
padding: 0.5rem 1rem;
background: var(--ctp-surface1);
color: var(--ctp-text);
border: 1px solid var(--ctp-surface2);
border-radius: 0.375rem;
font-size: 0.75rem;
z-index: 999;
animation: mp-fade-in 0.15s ease-out;
}
@keyframes mp-fade-in { from { opacity: 0; transform: translateY(-0.5rem); } }
.mp-tabs {
display: flex;
gap: 0.25rem;
border-bottom: 1px solid var(--ctp-surface0);
padding-bottom: 0.25rem;
}
.mp-tab {
padding: 0.375rem 0.75rem;
background: none;
border: none;
border-bottom: 2px solid transparent;
color: var(--ctp-subtext0);
cursor: pointer;
font-size: 0.8125rem;
}
.mp-tab:hover { color: var(--ctp-text); }
.mp-tab.active { color: var(--ctp-blue); border-bottom-color: var(--ctp-blue); }
.mp-search { display: flex; }
.mp-search-input {
flex: 1;
padding: 0.375rem 0.625rem;
background: var(--ctp-surface0);
border: 1px solid var(--ctp-surface1);
border-radius: 0.375rem;
color: var(--ctp-text);
font-size: 0.8125rem;
outline: none;
}
.mp-search-input::placeholder { color: var(--ctp-overlay0); }
.mp-search-input:focus { border-color: var(--ctp-blue); }
.mp-state { padding: 2rem; text-align: center; color: var(--ctp-subtext0); }
.mp-error { color: var(--ctp-red); }
.mp-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(16rem, 1fr));
gap: 0.5rem;
}
.mp-card {
display: flex;
flex-direction: column;
gap: 0.25rem;
padding: 0.625rem;
background: var(--ctp-surface0);
border: 1px solid var(--ctp-surface1);
border-radius: 0.375rem;
cursor: pointer;
transition: border-color 0.1s;
}
.mp-card:hover { border-color: var(--ctp-blue); }
.mp-card-header { display: flex; align-items: baseline; gap: 0.375rem; }
.mp-card-name { font-weight: 600; color: var(--ctp-text); }
.mp-card-version { font-size: 0.6875rem; color: var(--ctp-overlay0); font-family: monospace; }
.mp-card-author { font-size: 0.6875rem; color: var(--ctp-subtext0); }
.mp-card-desc {
font-size: 0.75rem;
color: var(--ctp-subtext1);
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.mp-card-meta { display: flex; justify-content: space-between; align-items: center; gap: 0.25rem; }
.mp-card-tags { display: flex; gap: 0.25rem; flex-wrap: wrap; }
.mp-tag {
font-size: 0.625rem;
padding: 0.0625rem 0.375rem;
background: color-mix(in srgb, var(--ctp-blue) 15%, transparent);
color: var(--ctp-blue);
border-radius: 0.25rem;
}
.mp-card-stats { display: flex; gap: 0.5rem; font-size: 0.6875rem; color: var(--ctp-overlay1); }
.mp-stars { color: var(--ctp-yellow); letter-spacing: 0.05em; }
.mp-card-perms { display: flex; gap: 0.25rem; flex-wrap: wrap; }
.mp-perm {
font-size: 0.625rem;
padding: 0.0625rem 0.375rem;
background: color-mix(in srgb, var(--ctp-peach) 12%, transparent);
color: var(--ctp-peach);
border-radius: 0.25rem;
}
.mp-card-actions { margin-top: 0.25rem; display: flex; justify-content: flex-end; }
.mp-btn {
padding: 0.25rem 0.75rem;
border: 1px solid var(--ctp-surface1);
border-radius: 0.25rem;
background: var(--ctp-surface0);
color: var(--ctp-subtext0);
cursor: pointer;
font-size: 0.75rem;
}
.mp-btn:hover:not(:disabled) { background: var(--ctp-surface1); color: var(--ctp-text); }
.mp-btn:disabled { opacity: 0.4; cursor: default; }
.mp-btn-install {
background: var(--ctp-blue);
color: var(--ctp-base);
border-color: var(--ctp-blue);
}
.mp-btn-install:hover:not(:disabled) { opacity: 0.9; background: var(--ctp-blue); color: var(--ctp-base); }
.mp-btn-danger { color: var(--ctp-red); border-color: var(--ctp-red); background: none; }
.mp-btn-danger:hover:not(:disabled) {
background: color-mix(in srgb, var(--ctp-red) 12%, transparent);
color: var(--ctp-red);
}
.mp-btn-close {
background: none;
border: none;
color: var(--ctp-overlay0);
cursor: pointer;
font-size: 1rem;
padding: 0.125rem 0.375rem;
}
.mp-btn-close:hover { color: var(--ctp-text); }
.mp-detail {
background: var(--ctp-mantle);
border: 1px solid var(--ctp-surface1);
border-radius: 0.375rem;
padding: 0.75rem;
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.mp-detail-header { display: flex; justify-content: space-between; align-items: center; }
.mp-detail-name { margin: 0; font-size: 1rem; font-weight: 600; color: var(--ctp-text); }
.mp-detail-desc { font-size: 0.8125rem; color: var(--ctp-subtext1); margin: 0; line-height: 1.5; }
.mp-detail-fields { display: flex; flex-wrap: wrap; gap: 0.375rem 1rem; font-size: 0.75rem; color: var(--ctp-subtext1); }
.mp-field-label { color: var(--ctp-subtext0); margin-right: 0.25rem; }
.mp-detail-section { font-size: 0.6875rem; color: var(--ctp-subtext0); text-transform: uppercase; letter-spacing: 0.04em; }
.mp-detail-perms { display: flex; gap: 0.25rem; flex-wrap: wrap; }
.mp-detail-links { display: flex; gap: 0.75rem; }
.mp-link { font-size: 0.75rem; color: var(--ctp-blue); text-decoration: none; }
.mp-link:hover { text-decoration: underline; }
.mp-installed-bar { display: flex; justify-content: flex-end; }
.mp-installed-list { display: flex; flex-direction: column; gap: 0.375rem; }
.mp-installed-item {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 0.75rem;
padding: 0.625rem;
background: var(--ctp-surface0);
border: 1px solid var(--ctp-surface1);
border-radius: 0.375rem;
}
.mp-installed-info { flex: 1; display: flex; flex-direction: column; gap: 0.25rem; }
.mp-installed-top { display: flex; align-items: baseline; gap: 0.375rem; }
.mp-update-badge {
font-size: 0.625rem;
padding: 0.0625rem 0.375rem;
background: color-mix(in srgb, var(--ctp-green) 15%, transparent);
color: var(--ctp-green);
border-radius: 0.25rem;
}
.mp-installed-actions { display: flex; gap: 0.375rem; flex-shrink: 0; align-items: center; }
</style>

View file

@ -93,6 +93,59 @@ export const proGetActiveAccount = () =>
export const proSetActiveAccount = (profileId: string) =>
invoke<ActiveAccount>('plugin:agor-pro|pro_set_active_account', { profileId });
// --- Marketplace ---
export interface CatalogPlugin {
id: string;
name: string;
version: string;
author: string;
description: string;
license: string | null;
homepage: string | null;
repository: string | null;
downloadUrl: string;
checksumSha256: string;
sizeBytes: number | null;
permissions: string[];
tags: string[] | null;
minAgorVersion: string | null;
downloads: number | null;
rating: number | null;
createdAt: string | null;
updatedAt: string | null;
}
export interface InstalledPlugin {
id: string;
name: string;
version: string;
author: string;
description: string;
permissions: string[];
installPath: string;
hasUpdate: boolean;
latestVersion: string | null;
}
export const proMarketplaceFetchCatalog = () =>
invoke<CatalogPlugin[]>('plugin:agor-pro|pro_marketplace_fetch_catalog');
export const proMarketplaceInstalled = () =>
invoke<InstalledPlugin[]>('plugin:agor-pro|pro_marketplace_installed');
export const proMarketplaceInstall = (pluginId: string) =>
invoke<InstalledPlugin>('plugin:agor-pro|pro_marketplace_install', { pluginId });
export const proMarketplaceUninstall = (pluginId: string) =>
invoke<void>('plugin:agor-pro|pro_marketplace_uninstall', { pluginId });
export const proMarketplaceCheckUpdates = () =>
invoke<InstalledPlugin[]>('plugin:agor-pro|pro_marketplace_check_updates');
export const proMarketplaceUpdate = (pluginId: string) =>
invoke<InstalledPlugin>('plugin:agor-pro|pro_marketplace_update', { pluginId });
// --- Status ---
export const proStatus = () =>