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:
@@ -41,6 +41,9 @@ describe("PluginRunner", () => {
|
||||
getPluginWorkflowStepTemplates: ReturnType<typeof vi.fn>;
|
||||
getPluginPromptContributions: ReturnType<typeof vi.fn>;
|
||||
getPluginSetupInfo: ReturnType<typeof vi.fn>;
|
||||
checkPluginSetup: ReturnType<typeof vi.fn>;
|
||||
installPluginSetup: ReturnType<typeof vi.fn>;
|
||||
uninstallPluginSetup: ReturnType<typeof vi.fn>;
|
||||
getLoadedPlugins: ReturnType<typeof vi.fn>;
|
||||
getPlugin: ReturnType<typeof vi.fn>;
|
||||
loadPlugin: ReturnType<typeof vi.fn>;
|
||||
@@ -102,6 +105,9 @@ describe("PluginRunner", () => {
|
||||
getPluginWorkflowStepTemplates: vi.fn().mockReturnValue([]),
|
||||
getPluginPromptContributions: vi.fn().mockReturnValue([]),
|
||||
getPluginSetupInfo: vi.fn().mockReturnValue([]),
|
||||
checkPluginSetup: vi.fn().mockResolvedValue({ status: "installed" }),
|
||||
installPluginSetup: vi.fn().mockResolvedValue(undefined),
|
||||
uninstallPluginSetup: vi.fn().mockResolvedValue(undefined),
|
||||
getLoadedPlugins: vi.fn().mockReturnValue([]),
|
||||
getPlugin: vi.fn(),
|
||||
loadPlugin: vi.fn().mockResolvedValue({}),
|
||||
@@ -909,6 +915,64 @@ describe("PluginRunner", () => {
|
||||
expect(mockPluginLoader.getPluginPromptContributions).toHaveBeenCalledTimes(3);
|
||||
expect(mockPluginLoader.getPluginSetupInfo).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
it("checkPluginSetup delegates to loader and returns result", async () => {
|
||||
const result = { status: "installed" as const, version: "1.2.3" };
|
||||
mockPluginLoader.checkPluginSetup.mockResolvedValue(result);
|
||||
await expect(pluginRunner.checkPluginSetup("test-plugin")).resolves.toEqual(result);
|
||||
expect(mockPluginLoader.checkPluginSetup).toHaveBeenCalledWith("test-plugin");
|
||||
});
|
||||
|
||||
it("checkPluginSetup returns error status when loader throws", async () => {
|
||||
mockPluginLoader.checkPluginSetup.mockRejectedValue(new Error("check failed"));
|
||||
await expect(pluginRunner.checkPluginSetup("test-plugin")).resolves.toEqual({ status: "error", error: "check failed" });
|
||||
});
|
||||
|
||||
it("installPluginSetup returns success true on success", async () => {
|
||||
await expect(pluginRunner.installPluginSetup("test-plugin")).resolves.toEqual({ success: true });
|
||||
expect(mockPluginLoader.installPluginSetup).toHaveBeenCalledWith("test-plugin");
|
||||
});
|
||||
|
||||
it("installPluginSetup returns success false on failure", async () => {
|
||||
mockPluginLoader.installPluginSetup.mockRejectedValue(new Error("install failed"));
|
||||
await expect(pluginRunner.installPluginSetup("test-plugin")).resolves.toEqual({ success: false, error: "install failed" });
|
||||
});
|
||||
|
||||
it("uninstallPluginSetup returns success and failure results", async () => {
|
||||
await expect(pluginRunner.uninstallPluginSetup("test-plugin")).resolves.toEqual({ success: true });
|
||||
mockPluginLoader.uninstallPluginSetup.mockRejectedValueOnce(new Error("uninstall failed"));
|
||||
await expect(pluginRunner.uninstallPluginSetup("test-plugin")).resolves.toEqual({ success: false, error: "uninstall failed" });
|
||||
});
|
||||
|
||||
it("getSetupStatuses returns statuses for all plugins with setup", async () => {
|
||||
mockPluginLoader.getPluginSetupInfo.mockReturnValue([
|
||||
{ pluginId: "a", manifest: { binaryName: "a-bin", description: "A" }, hooks: { checkSetup: vi.fn() } },
|
||||
{ pluginId: "b", manifest: { binaryName: "b-bin", description: "B" }, hooks: { checkSetup: vi.fn() } },
|
||||
]);
|
||||
mockPluginLoader.checkPluginSetup
|
||||
.mockResolvedValueOnce({ status: "installed", version: "1.0.0" })
|
||||
.mockResolvedValueOnce({ status: "not-installed" });
|
||||
|
||||
await expect(pluginRunner.getSetupStatuses()).resolves.toEqual([
|
||||
{ pluginId: "a", manifest: { binaryName: "a-bin", description: "A" }, status: { status: "installed", version: "1.0.0" } },
|
||||
{ pluginId: "b", manifest: { binaryName: "b-bin", description: "B" }, status: { status: "not-installed" } },
|
||||
]);
|
||||
});
|
||||
|
||||
it("getSetupStatuses handles setup check failures gracefully", async () => {
|
||||
mockPluginLoader.getPluginSetupInfo.mockReturnValue([
|
||||
{ pluginId: "offline", manifest: { binaryName: "off-bin", description: "Offline" }, hooks: { checkSetup: vi.fn() } },
|
||||
]);
|
||||
mockPluginLoader.checkPluginSetup.mockRejectedValue(new Error("Plugin \"offline\" is not loaded"));
|
||||
|
||||
await expect(pluginRunner.getSetupStatuses()).resolves.toEqual([
|
||||
{
|
||||
pluginId: "offline",
|
||||
manifest: { binaryName: "off-bin", description: "Offline" },
|
||||
status: { status: "error", error: 'Plugin "offline" is not loaded' },
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getRuntimeById()", () => {
|
||||
|
||||
@@ -25,6 +25,7 @@ import type {
|
||||
PluginPromptSurface,
|
||||
PluginSetupManifest,
|
||||
PluginSetupHooks,
|
||||
PluginSetupCheckResult,
|
||||
} from "@fusion/core";
|
||||
import type { ToolDefinition } from "@mariozechner/pi-coding-agent";
|
||||
import { Type } from "@mariozechner/pi-ai";
|
||||
@@ -371,6 +372,55 @@ export class PluginRunner {
|
||||
return this.cachedSetupInfo.setups;
|
||||
}
|
||||
|
||||
async checkPluginSetup(pluginId: string): Promise<PluginSetupCheckResult> {
|
||||
try {
|
||||
return await this.withTimeout(
|
||||
this.options.pluginLoader.checkPluginSetup(pluginId),
|
||||
this.hookTimeoutMs,
|
||||
`Setup check for plugin ${pluginId} timed out`,
|
||||
);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
this.log.warn(`Setup check failed for plugin ${pluginId}: ${message}`);
|
||||
return { status: "error", error: message };
|
||||
}
|
||||
}
|
||||
|
||||
async installPluginSetup(pluginId: string): Promise<{ success: boolean; error?: string }> {
|
||||
try {
|
||||
await this.options.pluginLoader.installPluginSetup(pluginId);
|
||||
this.invalidateSetupCache();
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
this.log.warn(`Setup install failed for plugin ${pluginId}: ${message}`);
|
||||
return { success: false, error: message };
|
||||
}
|
||||
}
|
||||
|
||||
async uninstallPluginSetup(pluginId: string): Promise<{ success: boolean; error?: string }> {
|
||||
try {
|
||||
await this.options.pluginLoader.uninstallPluginSetup(pluginId);
|
||||
this.invalidateSetupCache();
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
this.log.warn(`Setup uninstall failed for plugin ${pluginId}: ${message}`);
|
||||
return { success: false, error: message };
|
||||
}
|
||||
}
|
||||
|
||||
async getSetupStatuses(): Promise<Array<{ pluginId: string; manifest: PluginSetupManifest; status?: PluginSetupCheckResult }>> {
|
||||
const setupInfo = this.getPluginSetupInfo();
|
||||
return Promise.all(
|
||||
setupInfo.map(async ({ pluginId, manifest }) => ({
|
||||
pluginId,
|
||||
manifest,
|
||||
status: await this.checkPluginSetup(pluginId),
|
||||
})),
|
||||
);
|
||||
}
|
||||
|
||||
getPromptContributionsForSurface(surface: PluginPromptSurface): Array<{
|
||||
pluginId: string;
|
||||
contribution: PluginPromptContribution;
|
||||
|
||||
Reference in New Issue
Block a user