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:
@@ -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;
|
||||
|
||||
@@ -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 }>;
|
||||
}
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user