feat(FN-3565): add plugin management CLI, loader, runner, and dashboard rou

This merge adds a complete plugin management system to Fusion: a new `fn plugin` CLI command for installing/removing plugins, a plugin loader in core, a plugin runner in engine, and dashboard routes for plugin management UI, along with a plugin management guide in docs. It also documents task evalua

Fusion-Task-Id: FN-3565
This commit is contained in:
Fusion
2026-05-06 04:15:13 -07:00
committed by gsxdsm
parent ce323742a3
commit 408a46e1c2
15 changed files with 994 additions and 7 deletions

View File

@@ -675,13 +675,24 @@ describe("POST /plugins/:id/disable", () => {
describe("POST /plugins/:id/reload", () => {
let store: TaskStore;
let pluginStore: PluginStore;
let pluginRunner: { getPluginRoutes: ReturnType<typeof vi.fn>; reloadPlugin: ReturnType<typeof vi.fn> };
let pluginRunner: {
getPluginRoutes: ReturnType<typeof vi.fn>;
reloadPlugin: ReturnType<typeof vi.fn>;
checkPluginSetup: ReturnType<typeof vi.fn>;
installPluginSetup: ReturnType<typeof vi.fn>;
uninstallPluginSetup: ReturnType<typeof vi.fn>;
getPluginSetupInfo: ReturnType<typeof vi.fn>;
};
beforeEach(() => {
pluginStore = createMockPluginStore();
pluginRunner = {
getPluginRoutes: vi.fn().mockReturnValue([]),
reloadPlugin: vi.fn().mockResolvedValue(undefined),
checkPluginSetup: vi.fn().mockResolvedValue({ status: "installed" }),
installPluginSetup: vi.fn().mockResolvedValue({ success: true }),
uninstallPluginSetup: vi.fn().mockResolvedValue({ success: true }),
getPluginSetupInfo: vi.fn().mockReturnValue([]),
};
store = createMockTaskStore({
getPluginStore: vi.fn().mockReturnValue(pluginStore),
@@ -759,6 +770,140 @@ describe("POST /plugins/:id/reload", () => {
});
});
describe("plugin setup routes", () => {
let store: TaskStore;
let pluginStore: PluginStore;
let pluginRunner: {
getPluginRoutes: ReturnType<typeof vi.fn>;
checkPluginSetup: ReturnType<typeof vi.fn>;
installPluginSetup: ReturnType<typeof vi.fn>;
uninstallPluginSetup: ReturnType<typeof vi.fn>;
getPluginSetupInfo: ReturnType<typeof vi.fn>;
};
beforeEach(() => {
pluginStore = createMockPluginStore();
pluginRunner = {
getPluginRoutes: vi.fn().mockReturnValue([]),
checkPluginSetup: vi.fn().mockResolvedValue({ status: "installed", version: "1.0.0" }),
installPluginSetup: vi.fn().mockResolvedValue({ success: true }),
uninstallPluginSetup: vi.fn().mockResolvedValue({ success: true }),
getPluginSetupInfo: vi.fn().mockReturnValue([]),
};
store = createMockTaskStore({
getPluginStore: vi.fn().mockReturnValue(pluginStore),
});
});
function buildApp() {
const app = express();
app.use(express.json());
app.use("/api", createApiRoutes(store, {
pluginStore,
pluginLoader: createMockPluginLoader(),
pluginRunner,
}));
return app;
}
it("GET /plugins/:id/setup-status returns hasSetup true result", async () => {
(pluginStore.getPlugin as ReturnType<typeof vi.fn>).mockResolvedValueOnce({ ...FAKE_PLUGIN, state: "started" });
pluginRunner.getPluginSetupInfo.mockReturnValueOnce([
{
pluginId: "test-plugin",
manifest: { binaryName: "agent-browser", description: "Binary" },
hooks: { checkSetup: vi.fn() },
},
]);
const res = await REQUEST(buildApp(), "GET", "/api/plugins/test-plugin/setup-status");
expect(res.status).toBe(200);
expect(res.body).toEqual({ hasSetup: true, status: "installed", version: "1.0.0" });
});
it("GET /plugins/:id/setup-status returns hasSetup false when no setup", async () => {
(pluginStore.getPlugin as ReturnType<typeof vi.fn>).mockResolvedValueOnce({ ...FAKE_PLUGIN, state: "started" });
pluginRunner.getPluginSetupInfo.mockReturnValueOnce([]);
const res = await REQUEST(buildApp(), "GET", "/api/plugins/test-plugin/setup-status");
expect(res.status).toBe(200);
expect(res.body).toEqual({ hasSetup: false });
});
it("GET /plugins/:id/setup-status returns 404 for nonexistent plugin", async () => {
(pluginStore.getPlugin as ReturnType<typeof vi.fn>).mockRejectedValueOnce(new Error('Plugin "missing" not found'));
const res = await REQUEST(buildApp(), "GET", "/api/plugins/missing/setup-status");
expect(res.status).toBe(404);
});
it("POST /plugins/:id/setup/install returns success true", async () => {
(pluginStore.getPlugin as ReturnType<typeof vi.fn>).mockResolvedValueOnce({ ...FAKE_PLUGIN, enabled: true });
pluginRunner.getPluginSetupInfo.mockReturnValueOnce([
{
pluginId: "test-plugin",
manifest: { binaryName: "agent-browser", description: "Binary" },
hooks: { checkSetup: vi.fn(), install: vi.fn() },
},
]);
const res = await REQUEST(buildApp(), "POST", "/api/plugins/test-plugin/setup/install", {});
expect(res.status).toBe(200);
expect(res.body).toEqual({ success: true });
});
it("POST /plugins/:id/setup/install returns setup failure result", async () => {
(pluginStore.getPlugin as ReturnType<typeof vi.fn>).mockResolvedValueOnce({ ...FAKE_PLUGIN, enabled: true });
pluginRunner.getPluginSetupInfo.mockReturnValueOnce([
{
pluginId: "test-plugin",
manifest: { binaryName: "agent-browser", description: "Binary" },
hooks: { checkSetup: vi.fn(), install: vi.fn() },
},
]);
pluginRunner.installPluginSetup.mockResolvedValueOnce({ success: false, error: "install failed" });
const res = await REQUEST(buildApp(), "POST", "/api/plugins/test-plugin/setup/install", {});
expect(res.status).toBe(200);
expect(res.body).toEqual({ success: false, error: "install failed" });
});
it("POST /plugins/:id/setup/install returns 400 when no install hook", async () => {
(pluginStore.getPlugin as ReturnType<typeof vi.fn>).mockResolvedValueOnce({ ...FAKE_PLUGIN, enabled: true });
pluginRunner.getPluginSetupInfo.mockReturnValueOnce([
{
pluginId: "test-plugin",
manifest: { binaryName: "agent-browser", description: "Binary" },
hooks: { checkSetup: vi.fn() },
},
]);
const res = await REQUEST(buildApp(), "POST", "/api/plugins/test-plugin/setup/install", {});
expect(res.status).toBe(400);
expect(res.body.error).toContain("no install hook");
});
it("POST /plugins/:id/setup/uninstall returns success and failure results", async () => {
(pluginStore.getPlugin as ReturnType<typeof vi.fn>).mockResolvedValue({ ...FAKE_PLUGIN, enabled: true });
pluginRunner.getPluginSetupInfo.mockReturnValue([
{
pluginId: "test-plugin",
manifest: { binaryName: "agent-browser", description: "Binary" },
hooks: { checkSetup: vi.fn(), uninstall: vi.fn() },
},
]);
const successRes = await REQUEST(buildApp(), "POST", "/api/plugins/test-plugin/setup/uninstall", {});
expect(successRes.status).toBe(200);
expect(successRes.body).toEqual({ success: true });
pluginRunner.uninstallPluginSetup.mockResolvedValueOnce({ success: false, error: "uninstall failed" });
const failureRes = await REQUEST(buildApp(), "POST", "/api/plugins/test-plugin/setup/uninstall", {});
expect(failureRes.status).toBe(200);
expect(failureRes.body).toEqual({ success: false, error: "uninstall failed" });
});
});
describe("PUT /plugins/:id/settings", () => {
let store: TaskStore;
let pluginStore: PluginStore;

View File

@@ -35,6 +35,10 @@ import {
// PluginRunner interface for optional plugin runner
interface PluginRunner {
reloadPlugin?(pluginId: string): Promise<void>;
checkPluginSetup?(pluginId: string): Promise<import("@fusion/core").PluginSetupCheckResult>;
installPluginSetup?(pluginId: string): Promise<{ success: boolean; error?: string }>;
uninstallPluginSetup?(pluginId: string): Promise<{ success: boolean; error?: string }>;
getPluginSetupInfo?(): Array<{ pluginId: string; manifest: import("@fusion/core").PluginSetupManifest; hooks: import("@fusion/core").PluginSetupHooks }>;
getPluginRoutes(): Array<{ pluginId: string; route: import("@fusion/core").PluginRouteDefinition }>;
}

View File

@@ -3473,6 +3473,103 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
res.json(updatedPlugin);
});
/**
* GET /api/plugins/:id/setup-status
* Check plugin setup status.
*/
router.get("/plugins/:id/setup-status", async (req: Request, res: Response) => {
const { store: scopedStore } = await getProjectContext(req);
const pluginStore = scopedStore.getPluginStore();
const id = req.params.id as string;
let plugin: import("@fusion/core").PluginInstallation;
try {
plugin = await pluginStore.getPlugin(id);
} catch (err: unknown) {
if (err instanceof Error && err.message.includes("not found")) {
throw notFound(`Plugin "${id}" not found`);
}
throw internalError(err instanceof Error ? err.message : "Unknown error");
}
if (!options?.pluginRunner?.checkPluginSetup || !options?.pluginRunner?.getPluginSetupInfo) {
throw internalError("Plugin runner not available");
}
const setupInfo = options.pluginRunner.getPluginSetupInfo();
const hasSetup = setupInfo.some((entry) => entry.pluginId === id);
if (!hasSetup) {
res.json({ hasSetup: false });
return;
}
if (plugin.state !== "started") {
res.json({
hasSetup: false,
status: { status: "error", error: "Plugin not loaded" },
});
return;
}
const status = await options.pluginRunner.checkPluginSetup(id);
res.json({ hasSetup: true, ...status });
});
/**
* POST /api/plugins/:id/setup/install
* Trigger plugin setup install hook.
*/
router.post("/plugins/:id/setup/install", async (req: Request, res: Response) => {
const { store: scopedStore } = await getProjectContext(req);
const pluginStore = scopedStore.getPluginStore();
const id = req.params.id as string;
const plugin = await pluginStore.getPlugin(id);
if (!plugin.enabled) {
throw badRequest("Plugin must be enabled before setup install");
}
if (!options?.pluginRunner?.installPluginSetup || !options?.pluginRunner?.getPluginSetupInfo) {
throw internalError("Plugin runner not available");
}
const setupInfo = options.pluginRunner.getPluginSetupInfo();
const setup = setupInfo.find((entry) => entry.pluginId === id);
if (!setup?.hooks.install) {
throw badRequest("Plugin has no install hook");
}
const result = await options.pluginRunner.installPluginSetup(id);
res.json(result ?? { success: true });
});
/**
* POST /api/plugins/:id/setup/uninstall
* Trigger plugin setup uninstall hook.
*/
router.post("/plugins/:id/setup/uninstall", async (req: Request, res: Response) => {
const { store: scopedStore } = await getProjectContext(req);
const pluginStore = scopedStore.getPluginStore();
const id = req.params.id as string;
await pluginStore.getPlugin(id);
if (!options?.pluginRunner?.uninstallPluginSetup || !options?.pluginRunner?.getPluginSetupInfo) {
throw internalError("Plugin runner not available");
}
const setupInfo = options.pluginRunner.getPluginSetupInfo();
const setup = setupInfo.find((entry) => entry.pluginId === id);
if (!setup) {
res.json({ success: true });
return;
}
const result = await options.pluginRunner.uninstallPluginSetup(id);
res.json(result ?? { success: true });
});
/**
* PUT /api/plugins/:id/settings
* Update plugin settings.

View File

@@ -218,6 +218,14 @@ export interface ServerOptions {
getRuntimeById?(runtimeId: string): unknown;
createRuntimeContext?(pluginId: string): Promise<unknown>;
reloadPlugin?(pluginId: string): Promise<unknown>;
checkPluginSetup?(pluginId: string): Promise<import("@fusion/core").PluginSetupCheckResult>;
installPluginSetup?(pluginId: string): Promise<void | { success: boolean; error?: string }>;
uninstallPluginSetup?(pluginId: string): Promise<void | { success: boolean; error?: string }>;
getPluginSetupInfo?(): Array<{
pluginId: string;
manifest: import("@fusion/core").PluginSetupManifest;
hooks: import("@fusion/core").PluginSetupHooks;
}>;
};
/** Optional ChatStore for chat session management */
chatStore?: import("@fusion/core").ChatStore;