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:
@@ -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.
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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/);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
156
packages/dashboard/src/__tests__/routes-plugin-registry.test.ts
Normal file
156
packages/dashboard/src/__tests__/routes-plugin-registry.test.ts
Normal 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 }),
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -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.
|
||||
|
||||
143
packages/dashboard/src/registry-manifest.json
Normal file
143
packages/dashboard/src/registry-manifest.json
Normal 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"]
|
||||
}
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user