diff --git a/CONCEPTS.md b/CONCEPTS.md index 41e69af8a9..57e397486b 100644 --- a/CONCEPTS.md +++ b/CONCEPTS.md @@ -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. diff --git a/docs/PLUGIN_AUTHORING.md b/docs/PLUGIN_AUTHORING.md index edef869f43..15293151c3 100644 --- a/docs/PLUGIN_AUTHORING.md +++ b/docs/PLUGIN_AUTHORING.md @@ -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 diff --git a/packages/dashboard/app/api/legacy.ts b/packages/dashboard/app/api/legacy.ts index 75845cb7a1..797739a352 100644 --- a/packages/dashboard/app/api/legacy.ts +++ b/packages/dashboard/app/api/legacy.ts @@ -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 { return api(withProjectId("/plugins", projectId)); } +/** Fetch curated registry plugins with installed-state metadata */ +export async function fetchPluginRegistry( + query?: string, + category?: string, + projectId?: string, +): Promise { + 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 { return api(withProjectId(`/plugins/${encodeURIComponent(id)}`, projectId)); diff --git a/packages/dashboard/app/components/PluginManager.css b/packages/dashboard/app/components/PluginManager.css index 2410578b5e..e2383941e8 100644 --- a/packages/dashboard/app/components/PluginManager.css +++ b/packages/dashboard/app/components/PluginManager.css @@ -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; diff --git a/packages/dashboard/app/components/PluginManager.tsx b/packages/dashboard/app/components/PluginManager.tsx index 7c62639030..ceb75ca74c 100644 --- a/packages/dashboard/app/components/PluginManager.tsx +++ b/packages/dashboard/app/components/PluginManager.tsx @@ -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 = { "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>({}); const [settingsLoading, setSettingsLoading] = useState(false); const [installingBuiltinPluginId, setInstallingBuiltinPluginId] = useState(null); + const [registryEntries, setRegistryEntries] = useState([]); + const [registryLoading, setRegistryLoading] = useState(true); + const [registryError, setRegistryError] = useState(null); + const [registrySearchQuery, setRegistrySearchQuery] = useState(""); + const [installingRegistryId, setInstallingRegistryId] = useState(null); + const registrySearchTimerRef = useRef | null>(null); const [builtinSetupStatusById, setBuiltinSetupStatusById] = useState>({}); const [loadingBuiltinSetupId, setLoadingBuiltinSetupId] = useState(null); const [installingBuiltinSetupId, setInstallingBuiltinSetupId] = useState(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 = () => ( +
+
+
+

{t("plugins.browseRegistry", "Browse Registry")}

+

+ {t("plugins.registryDescription", "Discover curated runtimes and integrations that can be added to this Fusion workspace.")} +

+
+ +
+ + {registryLoading ? ( +
+ + {t("plugins.registryLoading", "Loading registry...")} +
+ ) : registryError ? ( +
+ {t("plugins.registryLoadFailed", "Failed to load registry: {{error}}", { error: registryError })} + +
+ ) : registryEntries.length === 0 ? ( +
+ {registrySearchQuery.trim() + ? t("plugins.registryEmptySearch", "No registry plugins match your search.") + : t("plugins.registryEmpty", "No registry plugins are available.")} +
+ ) : ( +
+ {registryEntries.map((entry) => { + const installedPlugin = installedPluginsById.get(entry.id); + const isInstalling = installingRegistryId === entry.id; + return ( +
+
+
+ {entry.name} + v{entry.version} + {entry.category} + {entry.installed && ( + + {t("plugins.statusInstalled", "Installed")} + {entry.installedVersion && entry.installedVersion !== entry.version ? ` · v${entry.installedVersion}` : ""} + + )} +
+ {entry.description} + {t("plugins.registryByAuthor", "By {{author}}", { author: entry.author })} +
+
+ {entry.installed ? ( + + ) : entry.canInstall ? ( + + ) : ( + {t("plugins.registryComingSoon", "Coming Soon")} + )} +
+
+ ); + })} +
+ )} +
+ ); + const renderBuiltinPluginSection = () => (
@@ -1094,6 +1247,7 @@ export function PluginManager({ addToast, projectId }: PluginManagerProps) {
)} {renderBuiltinPluginSection()} + {renderRegistryPluginSection()} )} diff --git a/packages/dashboard/app/components/__tests__/PluginManager.install-browse.test.tsx b/packages/dashboard/app/components/__tests__/PluginManager.install-browse.test.tsx index a3dca40408..ca4be970ec 100644 --- a/packages/dashboard/app/components/__tests__/PluginManager.install-browse.test.tsx +++ b/packages/dashboard/app/components/__tests__/PluginManager.install-browse.test.tsx @@ -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; diff --git a/packages/dashboard/app/components/__tests__/PluginManager.registry.test.tsx b/packages/dashboard/app/components/__tests__/PluginManager.registry.test.tsx new file mode 100644 index 0000000000..f95b4bb9ae --- /dev/null +++ b/packages/dashboard/app/components/__tests__/PluginManager.registry.test.tsx @@ -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(); + 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(); + 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(); + 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/); + }); +}); diff --git a/packages/dashboard/app/components/__tests__/PluginManager.test.tsx b/packages/dashboard/app/components/__tests__/PluginManager.test.tsx index ad42e4ae04..b7d8d6bd5d 100644 --- a/packages/dashboard/app/components/__tests__/PluginManager.test.tsx +++ b/packages/dashboard/app/components/__tests__/PluginManager.test.tsx @@ -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; diff --git a/packages/dashboard/app/components/__tests__/PluginManager.toggle.test.tsx b/packages/dashboard/app/components/__tests__/PluginManager.toggle.test.tsx index 68bbbb439f..7888f3dc1f 100644 --- a/packages/dashboard/app/components/__tests__/PluginManager.toggle.test.tsx +++ b/packages/dashboard/app/components/__tests__/PluginManager.toggle.test.tsx @@ -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; diff --git a/packages/dashboard/src/__tests__/routes-plugin-registry.test.ts b/packages/dashboard/src/__tests__/routes-plugin-registry.test.ts new file mode 100644 index 0000000000..c63ceede73 --- /dev/null +++ b/packages/dashboard/src/__tests__/routes-plugin-registry.test.ts @@ -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 & { 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)).toHaveBeenCalledWith("fusion-plugin-reports"); + expect((res.body as { plugins: Array<{ id: string; installed: boolean }> }).plugins).toEqual([ + expect.objectContaining({ id: "fusion-plugin-reports", installed: true }), + ]); + }); +}); diff --git a/packages/dashboard/src/plugin-routes.ts b/packages/dashboard/src/plugin-routes.ts index 9720c9bf84..efcb79e001 100644 --- a/packages/dashboard/src/plugin-routes.ts +++ b/packages/dashboard/src/plugin-routes.ts @@ -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; + 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, +): Promise { + 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, + filters: { q?: string; category?: string } = {}, +): Promise { + 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. diff --git a/packages/dashboard/src/registry-manifest.json b/packages/dashboard/src/registry-manifest.json new file mode 100644 index 0000000000..3079a4fe64 --- /dev/null +++ b/packages/dashboard/src/registry-manifest.json @@ -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"] + } + ] +}