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

@@ -186,6 +186,12 @@ A Bundled Plugin must be registered in several independently maintained surfaces
### Plugin Entry
The single loadable file persisted as a plugin's path and dynamically imported by the loader. The contract is strict: a package directory is never a valid entry (ESM cannot import directories), so every install surface must resolve a concrete file before persisting, preferring the shipped bundle, then a prebuilt output, then raw workspace source. Legacy registrations that stored a directory are healed in place — re-pointed at a resolved entry — the next time the plugin is enabled or auto-installed.
### Plugin Registry Entry
A dashboard discovery record served from `GET /api/plugins/registry` and shown in Settings → Plugins → Browse Registry. It is metadata-first (`id`, `name`, `description`, `category`, optional version/author/homepage) and may include a concrete Plugin Entry `path`.
### Registry Installability
The server-derived `canInstall` flag for a Plugin Registry Entry. `canInstall: true` means the manifest entry has a concrete `path` and the dashboard can call the normal plugin install flow; `canInstall: false` means the entry is discovery-only and should be presented as Coming Soon instead of attempting installation.
### Workflow Extension
A plugin-contributed workflow capability registered through the engine rather than hardcoded into core workflow logic: column metadata, movement policies, column work engines, workflow node handlers, task verdict providers, or merge-routing facts. A Workflow Extension is opt-in by workflow metadata and must degrade or park by an explicit fallback policy when its plugin is disabled or missing, preserving the Default workflow baseline when no extension is active.

View File

@@ -1239,6 +1239,29 @@ Or by copying to the plugins directory:
cp -r fusion-plugin-my-plugin ~/.fusion/plugins/
```
### Dashboard registry manifest
The dashboard **Browse Registry** surface is backed by the static manifest at `packages/dashboard/src/registry-manifest.json`. Each entry describes a plugin that may appear in Settings → Plugins before it is installed.
Manifest entries use this shape:
```json
{
"id": "fusion-plugin-my-plugin",
"name": "My Plugin",
"description": "What the plugin adds to Fusion.",
"category": "Runtime",
"version": "0.1.0",
"author": "Fusion Team",
"homepage": "https://example.com/plugin",
"path": "plugins/fusion-plugin-my-plugin/dist/index.js"
}
```
Required fields are `id`, `name`, `description`, and `category`. Optional metadata (`version`, `author`, `homepage`) is displayed when present. `path` is optional: entries with a `path` are installable from the registry; entries without one are discovery-only and render as Coming Soon (`canInstall: false`) until a loadable plugin entry path is available.
When adding a bundled plugin, mirror the existing bundled-plugin registration surfaces and then add a registry manifest entry whose `path` points at a concrete loadable file, not a package directory. Use stable ids because installed-state annotation matches registry entries to installed plugins by id.
---
## 14. Example Plugins

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"]
}
]
}