FN-6071: add plugin registry browsing and install actions

Add a curated plugin registry surface to the dashboard plugin manager.

- expose GET /api/plugins/registry with search, category filtering, and installed-state annotations from the dashboard registry manifest
- add registry fetch/install support to the dashboard plugin manager UI, including search, install/manage actions, and related tests
- document plugin registry entries and registry installability in plugin authoring docs and shared concepts

Files changed:
 CONCEPTS.md                                        |   6 +
 docs/PLUGIN_AUTHORING.md                           |  23 ++
 packages/dashboard/app/api/legacy.ts               |  36 +++
 .../dashboard/app/components/PluginManager.css     | 185 ++++++++++++++-
 .../dashboard/app/components/PluginManager.tsx     | 162 ++++++++++++-
 .../PluginManager.install-browse.test.tsx          |   5 +-
 .../__tests__/PluginManager.registry.test.tsx      | 260 +++++++++++++++++++++
 .../components/__tests__/PluginManager.test.tsx    |   3 +-
 .../__tests__/PluginManager.toggle.test.tsx        |   5 +-
 .../src/__tests__/routes-plugin-registry.test.ts   | 156 +++++++++++++
 packages/dashboard/src/plugin-routes.ts            | 115 +++++++++
 packages/dashboard/src/registry-manifest.json      | 143 ++++++++++++
 12 files changed, 1091 insertions(+), 8 deletions(-)

Fusion-Task-Id: FN-6071

Fusion-Task-Lineage: 72c7bdfd-5832-4323-b675-63d0680167d8
This commit is contained in:
gsxdsm
2026-06-09 04:27:37 -07:00
parent f51e29bb29
commit f8200adb71
12 changed files with 1091 additions and 8 deletions

View File

@@ -20,6 +20,7 @@ import type {
WorkflowStepResult,
PluginInstallation,
PluginSetupCheckResult,
PluginState,
PluginUiSlotDefinition,
PluginUiContributionDefinition,
PluginDashboardViewDefinition,
@@ -9073,11 +9074,46 @@ export function resetAgentBudget(agentId: string, projectId?: string): Promise<v
// ── Plugin Management ────────────────────────────────────────────────────────
export interface RegistryPluginEntry {
id: string;
name: string;
description: string;
version: string;
author: string;
category: "runtime" | "integration";
npmPackage?: string;
path?: string;
homepage?: string;
tags?: string[];
installed: boolean;
state?: PluginState;
installedVersion?: string;
canInstall: boolean;
}
/** Fetch all installed plugins */
export async function fetchPlugins(projectId?: string): Promise<PluginInstallation[]> {
return api<PluginInstallation[]>(withProjectId("/plugins", projectId));
}
/** Fetch curated registry plugins with installed-state metadata */
export async function fetchPluginRegistry(
query?: string,
category?: string,
projectId?: string,
): Promise<RegistryPluginEntry[]> {
const params = new URLSearchParams();
if (query?.trim()) {
params.set("q", query.trim());
}
if (category?.trim()) {
params.set("category", category.trim());
}
const suffix = params.size > 0 ? `?${params.toString()}` : "";
const response = await api<{ plugins: RegistryPluginEntry[] }>(withProjectId(`/plugins/registry${suffix}`, projectId));
return response.plugins;
}
/** Fetch a single plugin by ID */
export async function fetchPluginDetail(id: string, projectId?: string): Promise<PluginInstallation> {
return api<PluginInstallation>(withProjectId(`/plugins/${encodeURIComponent(id)}`, projectId));

View File

@@ -480,6 +480,165 @@
text-transform: uppercase;
}
.plugin-registry-section {
display: flex;
flex-direction: column;
gap: var(--space-sm);
}
.plugin-registry-header {
display: flex;
align-items: flex-end;
justify-content: space-between;
gap: var(--space-md);
}
.plugin-registry-heading-copy {
display: flex;
flex-direction: column;
gap: var(--space-xs);
min-width: 0;
}
.plugin-registry-heading {
margin: 0;
font-size: var(--font-size-md, 0.95rem);
}
.plugin-registry-description {
margin: 0;
font-size: var(--font-size-sm, 0.85rem);
color: var(--text-muted);
}
.plugin-registry-search-label {
display: flex;
min-width: min(100%, 18rem);
}
.plugin-registry-search-input {
width: 100%;
}
.plugin-registry-search-input:focus-visible,
.plugin-registry-action:focus-visible,
.plugin-registry-retry:focus-visible {
outline: none;
box-shadow: var(--focus-ring-strong);
}
.plugin-registry-list {
width: 100%;
display: flex;
flex-direction: column;
gap: var(--space-sm);
margin-top: var(--space-sm);
max-height: min(60vh, 34rem);
overflow-y: auto;
}
.plugin-registry-item {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-sm);
padding: var(--space-sm) var(--space-md);
border: var(--btn-border-width) solid var(--border);
border-radius: var(--radius-md);
background: var(--surface);
}
.plugin-registry-meta {
display: flex;
flex-direction: column;
gap: var(--space-xs);
min-width: 0;
}
.plugin-registry-title-row {
display: flex;
align-items: center;
gap: var(--space-sm);
row-gap: var(--space-xs);
flex-wrap: wrap;
min-width: 0;
}
.plugin-registry-name {
color: var(--text);
font-size: var(--font-size-base, 0.9rem);
font-weight: 500;
}
.plugin-registry-version,
.plugin-registry-author,
.plugin-registry-description-text {
color: var(--text-muted);
font-size: var(--font-size-sm, 0.85rem);
}
.plugin-registry-description-text {
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.plugin-registry-badge,
.plugin-registry-status,
.plugin-registry-coming-soon {
display: inline-flex;
align-items: center;
padding: var(--btn-border-width) var(--space-xs);
border-radius: var(--radius-pill);
font-size: var(--font-size-2xs, 0.75rem);
font-weight: 600;
text-transform: uppercase;
}
.plugin-registry-badge {
background: var(--status-in-review-bg);
color: var(--in-review);
}
.plugin-registry-status--installed {
background: var(--status-done-bg);
color: var(--done);
}
.plugin-registry-coming-soon {
background: var(--status-todo-bg);
color: var(--todo);
}
.plugin-registry-actions {
display: flex;
align-items: center;
justify-content: flex-end;
gap: var(--space-xs);
flex-shrink: 0;
}
.plugin-registry-state {
display: flex;
align-items: center;
justify-content: center;
gap: var(--space-sm);
padding: var(--space-lg);
border: var(--btn-border-width) solid var(--border);
border-radius: var(--radius-md);
background: var(--surface);
color: var(--text-muted);
font-size: var(--font-size-sm, 0.85rem);
text-align: center;
}
.plugin-registry-state--error {
color: var(--color-error);
background: color-mix(in srgb, var(--color-error) 10%, var(--surface));
border-color: color-mix(in srgb, var(--color-error) 45%, var(--border));
}
.plugin-bundled-runtime-list {
width: 100%;
display: flex;
@@ -651,11 +810,35 @@
}
.plugin-builtins-item,
.plugin-bundled-runtime-item {
.plugin-bundled-runtime-item,
.plugin-registry-item {
flex-direction: column;
align-items: stretch;
}
.plugin-registry-header {
flex-direction: column;
align-items: stretch;
}
.plugin-registry-search-label {
min-width: 0;
}
.plugin-registry-list {
max-height: 60vh;
overflow-y: auto;
}
.plugin-registry-actions {
justify-content: stretch;
}
.plugin-registry-action,
.plugin-registry-retry {
min-height: 36px;
}
.plugin-builtins-item .btn,
.plugin-bundled-runtime-item .btn {
min-height: 36px;

View File

@@ -14,10 +14,10 @@ import "./PluginManager.css";
import { useState, useEffect, useCallback, useRef } from "react";
import { useTranslation } from "react-i18next";
import { Package, Settings, Trash2, Plus, X, RefreshCw, RotateCcw, ExternalLink, Shield } from "lucide-react";
import { fetchPlugins, installPlugin, enablePlugin, disablePlugin, uninstallPlugin, fetchPluginSettings, updatePluginSettings, reloadPlugin, fetchPluginSetupStatus, installPluginSetup, updatePlugin, rescanPlugin } from "../api";
import { fetchPlugins, fetchPluginRegistry, installPlugin, enablePlugin, disablePlugin, uninstallPlugin, fetchPluginSettings, updatePluginSettings, reloadPlugin, fetchPluginSetupStatus, installPluginSetup, updatePlugin, rescanPlugin } from "../api";
import { DirectoryPicker } from "./DirectoryPicker";
import type { PluginInstallation, PluginState, PluginSettingSchema } from "@fusion/core";
import type { PluginSetupStatusResponse } from "../api";
import type { PluginSetupStatusResponse, RegistryPluginEntry } from "../api";
import type { ToastType } from "../hooks/useToast";
import { useConfirm } from "../hooks/useConfirm";
import { subscribeSse } from "../sse-bus";
@@ -108,7 +108,7 @@ const AGENT_BROWSER_LABEL_KEYS: Record<string, string> = {
"Skill Exposure": "plugins.agentBrowser.labelSkillExposure",
};
const BUILTIN_PLUGINS: BuiltinPlugin[] = [
export const BUILTIN_PLUGINS: BuiltinPlugin[] = [
{
id: "fusion-plugin-hermes-runtime",
name: "Hermes Runtime",
@@ -259,6 +259,12 @@ export function PluginManager({ addToast, projectId }: PluginManagerProps) {
const [pluginSettings, setPluginSettings] = useState<Record<string, unknown>>({});
const [settingsLoading, setSettingsLoading] = useState(false);
const [installingBuiltinPluginId, setInstallingBuiltinPluginId] = useState<string | null>(null);
const [registryEntries, setRegistryEntries] = useState<RegistryPluginEntry[]>([]);
const [registryLoading, setRegistryLoading] = useState(true);
const [registryError, setRegistryError] = useState<string | null>(null);
const [registrySearchQuery, setRegistrySearchQuery] = useState("");
const [installingRegistryId, setInstallingRegistryId] = useState<string | null>(null);
const registrySearchTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const [builtinSetupStatusById, setBuiltinSetupStatusById] = useState<Record<string, PluginSetupStatusResponse>>({});
const [loadingBuiltinSetupId, setLoadingBuiltinSetupId] = useState<string | null>(null);
const [installingBuiltinSetupId, setInstallingBuiltinSetupId] = useState<string | null>(null);
@@ -276,10 +282,40 @@ export function PluginManager({ addToast, projectId }: PluginManagerProps) {
}
}, [projectId, addToast]);
const loadRegistry = useCallback(async (query = registrySearchQuery) => {
try {
setRegistryLoading(true);
setRegistryError(null);
const entries = await fetchPluginRegistry(query, undefined, projectId);
setRegistryEntries(entries);
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
setRegistryError(message);
} finally {
setRegistryLoading(false);
}
}, [projectId, registrySearchQuery]);
useEffect(() => {
loadPlugins();
}, [loadPlugins]);
useEffect(() => {
if (registrySearchTimerRef.current) {
clearTimeout(registrySearchTimerRef.current);
}
registrySearchTimerRef.current = setTimeout(() => {
void loadRegistry(registrySearchQuery);
}, 300);
return () => {
if (registrySearchTimerRef.current) {
clearTimeout(registrySearchTimerRef.current);
}
};
}, [loadRegistry, registrySearchQuery]);
useEffect(() => {
const installedBuiltinsWithSetup = BUILTIN_PLUGINS.filter((builtinPlugin) => (
builtinPlugin.hasSetup && plugins.some((plugin) => plugin.id === builtinPlugin.id)
@@ -341,6 +377,7 @@ export function PluginManager({ addToast, projectId }: PluginManagerProps) {
case "enabled":
case "disabled":
case "settings-updated":
void loadRegistry(registrySearchQuery);
// Update existing plugin or add if new
setPlugins((prev) => {
const existingIndex = prev.findIndex((p) => p.id === payload.pluginId);
@@ -382,6 +419,7 @@ export function PluginManager({ addToast, projectId }: PluginManagerProps) {
case "uninstalled":
// Remove plugin from list
setPlugins((prev) => prev.filter((p) => p.id !== payload.pluginId));
void loadRegistry(registrySearchQuery);
break;
case "error":
@@ -412,9 +450,10 @@ export function PluginManager({ addToast, projectId }: PluginManagerProps) {
// Re-sync plugin list after a forced reconnect — any events that
// occurred while disconnected would otherwise be missed.
void loadPlugins();
void loadRegistry(registrySearchQuery);
},
});
}, [projectId, loadPlugins]);
}, [projectId, loadPlugins, loadRegistry, registrySearchQuery]);
const handleInstall = async () => {
if (!installPath.trim()) {
@@ -455,6 +494,25 @@ export function PluginManager({ addToast, projectId }: PluginManagerProps) {
}
};
const handleInstallRegistryPlugin = async (entry: RegistryPluginEntry) => {
if (!entry.path) {
addToast(t("plugins.registryNotInstallable", "{{name}} is not available for one-click install yet", { name: entry.name }), "warning");
return;
}
try {
setInstallingRegistryId(entry.id);
await installPlugin({ path: entry.path }, projectId);
addToast(t("plugins.registryInstalled", "{{name}} installed and enabled", { name: entry.name }), "success");
await loadPlugins();
await loadRegistry(registrySearchQuery);
} catch (err) {
addToast(t("plugins.registryInstallFailed", "Failed to install {{name}}: {{error}}", { name: entry.name, error: err instanceof Error ? err.message : String(err) }), "error");
} finally {
setInstallingRegistryId(null);
}
};
const handleInstallBuiltinSetup = async (plugin: BuiltinPlugin) => {
try {
setInstallingBuiltinSetupId(plugin.id);
@@ -870,6 +928,101 @@ export function PluginManager({ addToast, projectId }: PluginManagerProps) {
const installedPluginsById = new Map(plugins.map((plugin) => [plugin.id, plugin]));
const installedPlugins = plugins;
const renderRegistryPluginSection = () => (
<section className="plugin-registry-section" aria-label={t("plugins.browseRegistry", "Browse Registry")}>
<div className="plugin-registry-header">
<div className="plugin-registry-heading-copy">
<h4 className="plugin-registry-heading">{t("plugins.browseRegistry", "Browse Registry")}</h4>
<p className="plugin-registry-description">
{t("plugins.registryDescription", "Discover curated runtimes and integrations that can be added to this Fusion workspace.")}
</p>
</div>
<label className="plugin-registry-search-label">
<span className="sr-only">{t("plugins.searchRegistry", "Search registry")}</span>
<input
className="input plugin-registry-search-input"
type="search"
value={registrySearchQuery}
onChange={(event) => setRegistrySearchQuery(event.target.value)}
placeholder={t("plugins.searchRegistryPlaceholder", "Search registry plugins")}
/>
</label>
</div>
{registryLoading ? (
<div className="plugin-registry-state" role="status">
<RefreshCw size={14} className="spin" />
{t("plugins.registryLoading", "Loading registry...")}
</div>
) : registryError ? (
<div className="plugin-registry-state plugin-registry-state--error" role="alert">
<span>{t("plugins.registryLoadFailed", "Failed to load registry: {{error}}", { error: registryError })}</span>
<button className="btn btn-secondary btn-sm plugin-registry-retry" onClick={() => void loadRegistry(registrySearchQuery)}>
{t("plugins.retry", "Retry")}
</button>
</div>
) : registryEntries.length === 0 ? (
<div className="plugin-registry-state">
{registrySearchQuery.trim()
? t("plugins.registryEmptySearch", "No registry plugins match your search.")
: t("plugins.registryEmpty", "No registry plugins are available.")}
</div>
) : (
<div className="plugin-registry-list" aria-label={t("plugins.registryPluginResults", "Registry plugin results")}>
{registryEntries.map((entry) => {
const installedPlugin = installedPluginsById.get(entry.id);
const isInstalling = installingRegistryId === entry.id;
return (
<div key={entry.id} className="plugin-registry-item">
<div className="plugin-registry-meta">
<div className="plugin-registry-title-row">
<span className="plugin-registry-name">{entry.name}</span>
<span className="plugin-registry-version">v{entry.version}</span>
<span className="plugin-registry-badge">{entry.category}</span>
{entry.installed && (
<span className="plugin-registry-status plugin-registry-status--installed">
{t("plugins.statusInstalled", "Installed")}
{entry.installedVersion && entry.installedVersion !== entry.version ? ` · v${entry.installedVersion}` : ""}
</span>
)}
</div>
<span className="plugin-registry-description-text">{entry.description}</span>
<span className="plugin-registry-author">{t("plugins.registryByAuthor", "By {{author}}", { author: entry.author })}</span>
</div>
<div className="plugin-registry-actions">
{entry.installed ? (
<button
className="btn btn-secondary btn-sm plugin-registry-action"
onClick={() => {
if (installedPlugin) {
void handleSelectPlugin(installedPlugin);
} else {
void loadPlugins();
}
}}
>
{t("plugins.manage", "Manage")}
</button>
) : entry.canInstall ? (
<button
className="btn btn-primary btn-sm plugin-registry-action"
onClick={() => void handleInstallRegistryPlugin(entry)}
disabled={isInstalling}
>
{isInstalling ? t("plugins.installing", "Installing...") : t("plugins.installFromRegistry", "Install")}
</button>
) : (
<span className="plugin-registry-coming-soon">{t("plugins.registryComingSoon", "Coming Soon")}</span>
)}
</div>
</div>
);
})}
</div>
)}
</section>
);
const renderBuiltinPluginSection = () => (
<section className="plugin-builtins-section" aria-label={t("plugins.builtinPlugins", "Built-in Plugins")}>
<div className="plugin-builtins-header">
@@ -1094,6 +1247,7 @@ export function PluginManager({ addToast, projectId }: PluginManagerProps) {
</div>
)}
{renderBuiltinPluginSection()}
{renderRegistryPluginSection()}
</>
)}
</div>

View File

@@ -14,6 +14,7 @@ import type { PluginInstallation } from "@fusion/core";
// ── Mock API ──────────────────────────────────────────────────────
vi.mock("../../api", () => ({
fetchPlugins: vi.fn(() => Promise.resolve([])),
fetchPluginRegistry: vi.fn(() => Promise.resolve([])),
installPlugin: vi.fn(() =>
Promise.resolve({
id: "browsed-plugin",
@@ -69,7 +70,9 @@ beforeEach(() => {
onopen: null,
onmessage: null,
};
const MockES = vi.fn(() => esInstance) as unknown as typeof EventSource;
const MockES = vi.fn(function MockEventSource() {
return esInstance;
}) as unknown as typeof EventSource;
(MockES as unknown as { CONNECTING: number; OPEN: number; CLOSED: number }).CONNECTING = 0;
(MockES as unknown as { CONNECTING: number; OPEN: number; CLOSED: number }).OPEN = 1;
(MockES as unknown as { CONNECTING: number; OPEN: number; CLOSED: number }).CLOSED = 2;

View File

@@ -0,0 +1,260 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { act, cleanup, fireEvent, render, screen, within } from "@testing-library/react";
import type { PluginInstallation } from "@fusion/core";
import type { RegistryPluginEntry } from "../../api";
import { loadAllAppCss, loadAllAppCssBaseOnly } from "../../test/cssFixture";
vi.mock("../../api", () => ({
fetchPlugins: vi.fn(() => Promise.resolve([])),
fetchPluginRegistry: vi.fn(() => Promise.resolve([])),
installPlugin: vi.fn(() => Promise.resolve({ id: "registry-installable", name: "Installable Registry", version: "1.0.0", state: "started", enabled: true, settings: {} })),
enablePlugin: vi.fn(() => Promise.resolve({})),
disablePlugin: vi.fn(() => Promise.resolve({})),
uninstallPlugin: vi.fn(() => Promise.resolve()),
fetchPluginSettings: vi.fn(() => Promise.resolve({})),
updatePluginSettings: vi.fn(() => Promise.resolve({})),
reloadPlugin: vi.fn(() => Promise.resolve({})),
fetchPluginSetupStatus: vi.fn(() => Promise.resolve({ hasSetup: false })),
installPluginSetup: vi.fn(() => Promise.resolve({ success: true })),
updatePlugin: vi.fn(() => Promise.resolve({})),
rescanPlugin: vi.fn(() => Promise.resolve({})),
browseDirectory: vi.fn(() => Promise.resolve({ currentPath: "/home", parentPath: null, entries: [] })),
}));
import { PluginManager } from "../PluginManager";
import { fetchPluginRegistry, fetchPlugins, fetchPluginSettings, installPlugin } from "../../api";
const addToast = vi.fn();
const installedPlugin: PluginInstallation = {
id: "registry-installed",
name: "Installed Registry",
version: "2.0.0",
state: "started",
enabled: true,
description: "Already installed plugin",
author: "Registry Team",
path: "/plugins/registry-installed",
settings: {},
settingsSchema: {},
createdAt: "2026-06-09T00:00:00.000Z",
updatedAt: "2026-06-09T00:00:00.000Z",
};
const registryEntries: RegistryPluginEntry[] = [
{
id: "registry-installable",
name: "Installable Registry",
description: "Adds installable registry capabilities.",
version: "1.0.0",
author: "Fusion Labs",
category: "integration",
path: "./plugins/registry-installable",
tags: ["registry"],
installed: false,
canInstall: true,
},
{
id: "registry-installed",
name: "Installed Registry",
description: "Already available in this workspace.",
version: "2.0.0",
author: "Fusion Core",
category: "runtime",
installed: true,
installedVersion: "2.0.0",
state: "started",
canInstall: true,
},
{
id: "registry-coming-soon",
name: "Coming Soon Registry",
description: "Listed before it is locally installable.",
version: "0.1.0",
author: "Fusion Labs",
category: "integration",
installed: false,
canInstall: false,
},
];
function stubEventSource() {
const esInstance = {
close: vi.fn(),
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
readyState: 1,
onerror: null,
onopen: null,
onmessage: null,
};
const MockEventSource = vi.fn(function MockEventSource() {
return esInstance;
}) as unknown as typeof EventSource;
(MockEventSource as unknown as { CONNECTING: number; OPEN: number; CLOSED: number }).CONNECTING = 0;
(MockEventSource as unknown as { CONNECTING: number; OPEN: number; CLOSED: number }).OPEN = 1;
(MockEventSource as unknown as { CONNECTING: number; OPEN: number; CLOSED: number }).CLOSED = 2;
vi.stubGlobal("EventSource", MockEventSource);
}
async function renderRegistry(entries: RegistryPluginEntry[] = registryEntries) {
vi.mocked(fetchPlugins).mockResolvedValue([installedPlugin]);
vi.mocked(fetchPluginRegistry).mockResolvedValue(entries);
render(<PluginManager addToast={addToast} />);
await act(async () => {
await Promise.resolve();
});
await act(async () => {
await vi.advanceTimersByTimeAsync(350);
});
await act(async () => {
await Promise.resolve();
});
expect(fetchPluginRegistry).toHaveBeenCalled();
}
beforeEach(() => {
vi.useFakeTimers();
vi.clearAllMocks();
stubEventSource();
});
afterEach(() => {
cleanup();
vi.useRealTimers();
vi.unstubAllGlobals();
});
describe("PluginManager registry browsing", () => {
it("renders registry entries with metadata", async () => {
await renderRegistry();
const section = screen.getByRole("region", { name: "Browse Registry" });
expect(within(section).getByText("Installable Registry")).toBeInTheDocument();
expect(within(section).getByText("Adds installable registry capabilities.")).toBeInTheDocument();
expect(within(section).getByText("v1.0.0")).toBeInTheDocument();
expect(within(section).getAllByText("By Fusion Labs").length).toBeGreaterThan(0);
expect(within(section).getAllByText("integration").length).toBeGreaterThan(0);
});
it("shows action states for installable, installed, and unavailable entries", async () => {
await renderRegistry();
const section = screen.getByRole("region", { name: "Browse Registry" });
const installable = within(section).getByText("Installable Registry").closest(".plugin-registry-item") as HTMLElement;
expect(within(installable).getByRole("button", { name: "Install" })).toBeInTheDocument();
const installed = within(section).getByText("Installed Registry").closest(".plugin-registry-item") as HTMLElement;
expect(within(installed).getByRole("button", { name: "Manage" })).toBeInTheDocument();
const comingSoon = screen.getByText("Coming Soon Registry").closest(".plugin-registry-item") as HTMLElement;
expect(within(comingSoon).getByText("Coming Soon")).toBeInTheDocument();
});
it("installs registry plugins with their manifest path and refreshes installed plugins", async () => {
await renderRegistry();
expect(fetchPlugins).toHaveBeenCalledTimes(1);
const installable = screen.getByText("Installable Registry").closest(".plugin-registry-item") as HTMLElement;
fireEvent.click(within(installable).getByRole("button", { name: "Install" }));
await act(async () => {
await Promise.resolve();
});
expect(installPlugin).toHaveBeenCalledWith({ path: "./plugins/registry-installable" }, undefined);
expect(fetchPlugins).toHaveBeenCalledTimes(2);
});
it("opens detail management for installed entries", async () => {
await renderRegistry();
const section = screen.getByRole("region", { name: "Browse Registry" });
const installed = within(section).getByText("Installed Registry").closest(".plugin-registry-item") as HTMLElement;
fireEvent.click(within(installed).getByRole("button", { name: "Manage" }));
await act(async () => {
await Promise.resolve();
});
expect(fetchPluginSettings).toHaveBeenCalledWith("registry-installed", undefined);
});
it("debounces search before fetching registry results", async () => {
await renderRegistry();
vi.mocked(fetchPluginRegistry).mockClear();
fireEvent.change(screen.getByPlaceholderText("Search registry plugins"), { target: { value: "slack" } });
await act(async () => {
await vi.advanceTimersByTimeAsync(299);
});
expect(fetchPluginRegistry).not.toHaveBeenCalled();
await act(async () => {
await vi.advanceTimersByTimeAsync(1);
});
await act(async () => {
await Promise.resolve();
});
expect(fetchPluginRegistry).toHaveBeenCalledWith("slack", undefined, undefined);
});
it("shows loading state while registry fetch is pending", async () => {
vi.mocked(fetchPlugins).mockResolvedValue([]);
vi.mocked(fetchPluginRegistry).mockReturnValue(new Promise(() => undefined));
render(<PluginManager addToast={addToast} />);
await act(async () => {
await Promise.resolve();
});
expect(screen.getByText("Loading registry...")).toBeInTheDocument();
});
it("shows error state with retry action", async () => {
vi.mocked(fetchPlugins).mockResolvedValue([]);
vi.mocked(fetchPluginRegistry)
.mockRejectedValueOnce(new Error("registry unavailable"))
.mockResolvedValueOnce(registryEntries);
render(<PluginManager addToast={addToast} />);
await act(async () => {
await vi.advanceTimersByTimeAsync(350);
});
expect(screen.getByRole("alert")).toHaveTextContent("registry unavailable");
fireEvent.click(screen.getByRole("button", { name: "Retry" }));
await act(async () => {
await Promise.resolve();
});
expect(fetchPluginRegistry).toHaveBeenCalledTimes(2);
});
it("shows empty state when no registry entries match", async () => {
await renderRegistry([]);
expect(screen.getByText("No registry plugins are available.")).toBeInTheDocument();
});
});
describe("PluginManager registry CSS", () => {
it("defines base registry rules with design tokens", () => {
const css = loadAllAppCssBaseOnly();
expect(css).toContain(".plugin-registry-section");
expect(css).toContain(".plugin-registry-item");
expect(css).toContain(".plugin-registry-search-input:focus-visible");
expect(css).toContain("var(--focus-ring-strong)");
const registryCss = Array.from(css.matchAll(/\.plugin-registry[^{}]*\{[^}]*\}/g))
.map((match) => match[0])
.join("\n");
expect(registryCss).not.toMatch(/#[0-9a-fA-F]{3,8}\b/);
expect(registryCss).not.toMatch(/rgba?\(/);
expect(registryCss).not.toMatch(/\b(?!0\b)\d+px\b/);
});
it("defines responsive registry overrides", () => {
const css = loadAllAppCss();
expect(css).toContain("@media (max-width: 768px)");
expect(css).toMatch(/@media \(max-width: 768px\)[\s\S]*\.plugin-registry-item[\s\S]*flex-direction: column/);
expect(css).toMatch(/@media \(max-width: 768px\)[\s\S]*\.plugin-registry-action,[\s\S]*\.plugin-registry-retry[\s\S]*min-height: 36px/);
expect(css).toMatch(/@media \(max-width: 768px\)[\s\S]*\.plugin-registry-list[\s\S]*overflow-y: auto/);
});
});

View File

@@ -40,6 +40,7 @@ const mockPlugins: PluginInstallation[] = [
// Mock API module - must be defined inline in vi.mock
vi.mock("../../api", () => ({
fetchPlugins: vi.fn(() => Promise.resolve([])),
fetchPluginRegistry: vi.fn(() => Promise.resolve([])),
installPlugin: vi.fn(() => Promise.resolve({
id: "plugin-a",
name: "Test Plugin A",
@@ -205,7 +206,7 @@ beforeEach(() => {
onmessage: null,
};
const MockEventSource = vi.fn((url: string) => {
const MockEventSource = vi.fn(function MockEventSource(url: string) {
eventSourceInstance.url = url;
return eventSourceInstance;
}) as unknown as typeof EventSource;

View File

@@ -6,6 +6,7 @@ import { loadAllAppCss } from "../../test/cssFixture";
vi.mock("../../api", () => ({
fetchPlugins: vi.fn(() => Promise.resolve([])),
fetchPluginRegistry: vi.fn(() => Promise.resolve([])),
installPlugin: vi.fn(() => Promise.resolve({})),
enablePlugin: vi.fn(() => Promise.resolve({})),
disablePlugin: vi.fn(() => Promise.resolve({})),
@@ -55,7 +56,9 @@ beforeEach(() => {
onopen: null,
onmessage: null,
};
const MockES = vi.fn(() => esInstance) as unknown as typeof EventSource;
const MockES = vi.fn(function MockEventSource() {
return esInstance;
}) as unknown as typeof EventSource;
(MockES as unknown as { CONNECTING: number; OPEN: number; CLOSED: number }).CONNECTING = 0;
(MockES as unknown as { CONNECTING: number; OPEN: number; CLOSED: number }).OPEN = 1;
(MockES as unknown as { CONNECTING: number; OPEN: number; CLOSED: number }).CLOSED = 2;

View File

@@ -0,0 +1,156 @@
// @vitest-environment node
import { describe, expect, it, vi, beforeEach } from "vitest";
import express from "express";
import type { PluginInstallation, PluginStore } from "@fusion/core";
import registryManifest from "../registry-manifest.json";
import { buildRegistryPluginEntries, createPluginRouter } from "../plugin-routes.js";
import { get as performGet } from "../test-request.js";
import * as projectStoreResolver from "../project-store-resolver.js";
function createInstalledPlugin(overrides: Partial<PluginInstallation> & { id: string }): PluginInstallation {
return {
id: overrides.id,
name: overrides.name ?? overrides.id,
version: overrides.version ?? "9.9.9",
description: overrides.description ?? "Installed plugin",
author: overrides.author ?? "Installed Author",
homepage: overrides.homepage,
path: overrides.path ?? `/plugins/${overrides.id}/dist/index.js`,
enabled: overrides.enabled ?? true,
state: overrides.state ?? "started",
settings: overrides.settings ?? {},
dependencies: overrides.dependencies ?? [],
createdAt: overrides.createdAt ?? "2026-06-09T00:00:00.000Z",
updatedAt: overrides.updatedAt ?? "2026-06-09T00:00:00.000Z",
} as PluginInstallation;
}
function createMockPluginStore(installed: PluginInstallation[] = []): PluginStore {
const installedById = new Map(installed.map((plugin) => [plugin.id, plugin]));
return {
listPlugins: vi.fn(async () => installed),
getPlugin: vi.fn(async (id: string) => {
const plugin = installedById.get(id);
if (!plugin) {
throw Object.assign(new Error(`Plugin "${id}" not found`), { code: "ENOENT" });
}
return plugin;
}),
registerPlugin: vi.fn(),
unregisterPlugin: vi.fn(),
enablePlugin: vi.fn(),
disablePlugin: vi.fn(),
updatePluginState: vi.fn(),
updatePluginSettings: vi.fn(),
updatePlugin: vi.fn(),
} as unknown as PluginStore;
}
function buildApp(pluginStore: PluginStore) {
const app = express();
app.use(express.json());
app.use("/api/plugins", createPluginRouter(pluginStore, { loadPlugin: vi.fn(), stopPlugin: vi.fn() } as any));
app.use((err: unknown, _req: express.Request, res: express.Response, _next: express.NextFunction) => {
res.status(500).json({ error: err instanceof Error ? err.message : String(err) });
});
return app;
}
describe("GET /api/plugins/registry", () => {
beforeEach(() => {
vi.restoreAllMocks();
});
it("returns all manifest entries when no filters are provided", async () => {
const pluginStore = createMockPluginStore();
const res = await performGet(buildApp(pluginStore), "/api/plugins/registry");
expect(res.status).toBe(200);
// Registry currently includes Agent Browser as metadata-only plus 3 discovery-only partner/plugin ideas.
expect(registryManifest.plugins.filter((plugin) => !plugin.path)).toHaveLength(4);
expect((res.body as { plugins: unknown[] }).plugins).toHaveLength(registryManifest.plugins.length);
});
it("filters by q across searchable text", async () => {
const pluginStore = createMockPluginStore();
const res = await performGet(buildApp(pluginStore), "/api/plugins/registry?q=whatsapp");
expect(res.status).toBe(200);
expect((res.body as { plugins: Array<{ id: string }> }).plugins.map((plugin) => plugin.id)).toEqual([
"fusion-plugin-whatsapp-chat",
]);
});
it("filters by category", async () => {
const pluginStore = createMockPluginStore();
const res = await performGet(buildApp(pluginStore), "/api/plugins/registry?category=runtime");
expect(res.status).toBe(200);
const plugins = (res.body as { plugins: Array<{ category: string }> }).plugins;
expect(plugins.length).toBeGreaterThan(0);
expect(plugins.every((plugin) => plugin.category === "runtime")).toBe(true);
});
it("annotates installed state for installed and missing plugins", async () => {
const installed = createInstalledPlugin({
id: "fusion-plugin-hermes-runtime",
version: "2.0.0",
state: "loaded",
});
const pluginStore = createMockPluginStore([installed]);
const res = await performGet(buildApp(pluginStore), "/api/plugins/registry?q=runtime");
expect(res.status).toBe(200);
const plugins = (res.body as { plugins: Array<{ id: string; installed: boolean; state?: string; installedVersion?: string }> }).plugins;
expect(plugins.find((plugin) => plugin.id === "fusion-plugin-hermes-runtime")).toMatchObject({
installed: true,
state: "loaded",
installedVersion: "2.0.0",
});
expect(plugins.find((plugin) => plugin.id === "fusion-plugin-paperclip-runtime")).toMatchObject({
installed: false,
});
});
it("sets canInstall from manifest path presence", async () => {
const pluginStore = createMockPluginStore();
const res = await performGet(buildApp(pluginStore), "/api/plugins/registry");
expect(res.status).toBe(200);
const plugins = (res.body as { plugins: Array<{ id: string; canInstall: boolean }> }).plugins;
expect(plugins.find((plugin) => plugin.id === "fusion-plugin-hermes-runtime")).toMatchObject({ canInstall: true });
expect(plugins.find((plugin) => plugin.id === "fusion-plugin-agent-browser")).toMatchObject({ canInstall: false });
expect(plugins.find((plugin) => plugin.id === "fusion-plugin-slack-bridge")).toMatchObject({ canInstall: false });
});
it("handles missing or empty manifest shapes gracefully", async () => {
const pluginStore = createMockPluginStore();
await expect(buildRegistryPluginEntries({}, pluginStore)).resolves.toEqual([]);
await expect(buildRegistryPluginEntries({ plugins: [] }, pluginStore)).resolves.toEqual([]);
});
it("uses the project-scoped plugin store when projectId is provided", async () => {
const globalStore = createMockPluginStore();
const projectStore = createMockPluginStore([
createInstalledPlugin({ id: "fusion-plugin-reports", state: "started", version: "3.0.0" }),
]);
const getPluginStore = vi.fn(() => projectStore);
const getOrCreateProjectStore = vi.spyOn(projectStoreResolver, "getOrCreateProjectStore").mockResolvedValue({
getPluginStore,
} as any);
const res = await performGet(buildApp(globalStore), "/api/plugins/registry?projectId=project-one&q=side-by-side");
expect(res.status).toBe(200);
expect(getOrCreateProjectStore).toHaveBeenCalledWith("project-one");
expect(getPluginStore).toHaveBeenCalled();
expect(globalStore.getPlugin).not.toHaveBeenCalled();
expect((projectStore.getPlugin as unknown as ReturnType<typeof vi.fn>)).toHaveBeenCalledWith("fusion-plugin-reports");
expect((res.body as { plugins: Array<{ id: string; installed: boolean }> }).plugins).toEqual([
expect.objectContaining({ id: "fusion-plugin-reports", installed: true }),
]);
});
});

View File

@@ -19,10 +19,13 @@ import { Router, type Request, type Response } from "express";
import { access, stat, readFile } from "node:fs/promises";
import { join, isAbsolute, dirname, basename } from "node:path";
import { emitPluginCustomSseEvent } from "./sse.js";
import registryManifest from "./registry-manifest.json";
import type {
PluginInstallation,
PluginLoader,
PluginStore,
PluginContext,
PluginState,
} from "@fusion/core";
import { resolvePluginEntryPath, validatePluginManifest } from "@fusion/core";
import {
@@ -53,6 +56,102 @@ interface PluginRunner {
getPluginRoutes(): Array<{ pluginId: string; route: import("@fusion/core").PluginRouteDefinition }>;
}
export interface RegistryManifestEntry {
id: string;
name: string;
description: string;
version: string;
author: string;
category: "runtime" | "integration";
npmPackage?: string;
path?: string;
homepage?: string;
tags?: string[];
}
export interface RegistryPluginEntry extends RegistryManifestEntry {
installed: boolean;
state?: PluginState;
installedVersion?: string;
canInstall: boolean;
}
interface RegistryManifestShape {
plugins?: unknown;
}
function normalizeRegistryManifestEntries(manifest: RegistryManifestShape): RegistryManifestEntry[] {
if (!manifest || !Array.isArray(manifest.plugins)) {
return [];
}
return manifest.plugins.filter((entry): entry is RegistryManifestEntry => {
if (!entry || typeof entry !== "object" || Array.isArray(entry)) {
return false;
}
const candidate = entry as Partial<RegistryManifestEntry>;
return typeof candidate.id === "string"
&& typeof candidate.name === "string"
&& typeof candidate.description === "string"
&& typeof candidate.version === "string"
&& typeof candidate.author === "string"
&& (candidate.category === "runtime" || candidate.category === "integration");
});
}
function registryEntryMatchesSearch(entry: RegistryManifestEntry, query: string): boolean {
if (!query) {
return true;
}
const haystack = [
entry.name,
entry.description,
entry.author,
...(entry.tags ?? []),
].join(" ").toLowerCase();
return haystack.includes(query);
}
async function annotateRegistryEntry(
entry: RegistryManifestEntry,
store: Pick<PluginStore, "getPlugin">,
): Promise<RegistryPluginEntry> {
let installedPlugin: PluginInstallation | null = null;
try {
installedPlugin = await store.getPlugin(entry.id);
} catch (err: unknown) {
if ((err as NodeJS.ErrnoException).code !== "ENOENT") {
const message = err instanceof Error ? err.message : "";
if (!message.toLowerCase().includes("not found")) {
throw err;
}
}
}
return {
...entry,
installed: Boolean(installedPlugin),
state: installedPlugin?.state,
installedVersion: installedPlugin?.version,
canInstall: typeof entry.path === "string" && entry.path.trim().length > 0,
};
}
export async function buildRegistryPluginEntries(
manifest: RegistryManifestShape,
store: Pick<PluginStore, "getPlugin">,
filters: { q?: string; category?: string } = {},
): Promise<RegistryPluginEntry[]> {
const query = filters.q?.trim().toLowerCase() ?? "";
const category = filters.category?.trim().toLowerCase() ?? "";
const entries = normalizeRegistryManifestEntries(manifest)
.filter((entry) => !category || entry.category === category)
.filter((entry) => registryEntryMatchesSearch(entry, query));
return Promise.all(entries.map((entry) => annotateRegistryEntry(entry, store)));
}
// ── Install-Source Resolution Helpers ──────────────────────────────────
// Exported for reuse in routes.ts and for direct testing.
@@ -219,6 +318,22 @@ export function createPluginRouter(
res.json(plugins);
}));
/**
* GET /plugins/registry
* List curated registry plugin metadata with installed-state annotations.
*/
router.get("/registry", catchHandler(async (req: Request, res: Response) => {
const q = typeof req.query.q === "string" ? req.query.q : undefined;
const category = typeof req.query.category === "string" ? req.query.category : undefined;
const projectId = typeof req.query.projectId === "string" && req.query.projectId.trim()
? req.query.projectId
: undefined;
const scopedStore = projectId ? await getOrCreateProjectStore(projectId) : null;
const store = scopedStore?.getPluginStore?.() ?? pluginStore;
const plugins = await buildRegistryPluginEntries(registryManifest, store, { q, category });
res.json({ plugins });
}));
/**
* GET /plugins/:id
* Get a single plugin by ID.

View File

@@ -0,0 +1,143 @@
{
"plugins": [
{
"id": "fusion-plugin-hermes-runtime",
"name": "Hermes Runtime",
"description": "Runtime provider for Hermes CLI-backed execution.",
"version": "0.1.0",
"author": "Fusion",
"category": "runtime",
"path": "./plugins/fusion-plugin-hermes-runtime",
"tags": ["runtime", "cli", "hermes"]
},
{
"id": "fusion-plugin-paperclip-runtime",
"name": "Paperclip Runtime",
"description": "Runtime provider for Paperclip agent connections.",
"version": "0.1.0",
"author": "Fusion",
"category": "runtime",
"path": "./plugins/fusion-plugin-paperclip-runtime",
"tags": ["runtime", "agent"]
},
{
"id": "fusion-plugin-openclaw-runtime",
"name": "OpenClaw Runtime",
"description": "Runtime provider for OpenClaw execution.",
"version": "0.1.0",
"author": "Fusion",
"category": "runtime",
"path": "./plugins/fusion-plugin-openclaw-runtime",
"tags": ["runtime", "openclaw"]
},
{
"id": "fusion-plugin-droid-runtime",
"name": "Droid Runtime",
"description": "Runtime provider for Droid CLI execution.",
"version": "0.1.0",
"author": "Fusion",
"category": "runtime",
"path": "./plugins/fusion-plugin-droid-runtime",
"tags": ["runtime", "cli", "droid"]
},
{
"id": "fusion-plugin-dependency-graph",
"name": "Dependency Graph",
"description": "Dashboard plugin for task dependency graph visualization.",
"version": "0.1.0",
"author": "Fusion",
"category": "integration",
"path": "./plugins/fusion-plugin-dependency-graph",
"tags": ["dashboard", "visualization", "dependencies"]
},
{
"id": "fusion-plugin-reports",
"name": "Reports",
"description": "View report history, compare runs side-by-side, and export standalone HTML summaries.",
"version": "0.1.0",
"author": "Fusion",
"category": "integration",
"path": "./plugins/fusion-plugin-reports",
"tags": ["dashboard", "reports", "exports"]
},
{
"id": "fusion-plugin-whatsapp-chat",
"name": "WhatsApp Chat",
"description": "Pairs to WhatsApp Web (multi-device) with QR or pairing code, then bridges direct chats to a Fusion agent.",
"version": "0.1.0",
"author": "Fusion",
"category": "integration",
"path": "./plugins/fusion-plugin-whatsapp-chat",
"tags": ["chat", "whatsapp", "bridge"]
},
{
"id": "fusion-plugin-cli-printing-press",
"name": "CLI Printing Press",
"description": "Guided wizard for drafting external service CLI definitions.",
"version": "0.1.0",
"author": "Fusion",
"category": "integration",
"path": "./plugins/fusion-plugin-cli-printing-press",
"tags": ["cli", "wizard", "definitions"]
},
{
"id": "fusion-plugin-compound-engineering",
"name": "Compound Engineering",
"description": "A dedicated dashboard surface for compound-engineering artifacts and interactive ce-* sessions.",
"version": "0.1.0",
"author": "Fusion",
"category": "integration",
"path": "./plugins/fusion-plugin-compound-engineering",
"tags": ["dashboard", "compound-engineering"]
},
{
"id": "fusion-plugin-roadmap",
"name": "Roadmaps",
"description": "Standalone roadmap planning plugin.",
"version": "0.1.0",
"author": "Fusion",
"category": "integration",
"path": "./plugins/fusion-plugin-roadmap",
"tags": ["planning", "roadmap"]
},
{
"id": "fusion-plugin-agent-browser",
"name": "Agent Browser",
"description": "Built-in integration metadata. Package install support lands in FN-3101.",
"version": "0.1.0",
"author": "Fusion",
"category": "integration",
"tags": ["browser", "agents", "setup"]
},
{
"id": "fusion-plugin-slack-bridge",
"name": "Slack Bridge",
"description": "Connect Slack workspaces to Fusion agents for monitored support and triage workflows.",
"version": "0.1.0",
"author": "Fusion Labs",
"category": "integration",
"homepage": "https://runfusion.ai/plugins/slack-bridge",
"tags": ["chat", "slack", "bridge"]
},
{
"id": "fusion-plugin-github-insights",
"name": "GitHub Insights",
"description": "Summarize repository activity, pull request trends, and engineering risk signals inside Fusion.",
"version": "0.1.0",
"author": "Fusion Labs",
"category": "integration",
"homepage": "https://runfusion.ai/plugins/github-insights",
"tags": ["github", "insights", "reports"]
},
{
"id": "fusion-plugin-local-llm-runtime",
"name": "Local LLM Runtime",
"description": "Runtime provider for local model servers with configurable routing and health checks.",
"version": "0.1.0",
"author": "Fusion Labs",
"category": "runtime",
"homepage": "https://runfusion.ai/plugins/local-llm-runtime",
"tags": ["runtime", "local", "llm"]
}
]
}