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 27973a8345
commit 8f812e2f89
15 changed files with 994 additions and 7 deletions

View File

@@ -91,6 +91,8 @@ const commandMocks = vi.hoisted(() => ({
runPluginUninstall: vi.fn(),
runPluginEnable: vi.fn(),
runPluginDisable: vi.fn(),
runPluginSetupStatus: vi.fn(),
runPluginSetup: vi.fn(),
runPluginCreate: vi.fn(),
runResearchCreate: vi.fn(),
@@ -211,6 +213,8 @@ vi.mock("../commands/plugin.js", () => ({
runPluginUninstall: commandMocks.runPluginUninstall,
runPluginEnable: commandMocks.runPluginEnable,
runPluginDisable: commandMocks.runPluginDisable,
runPluginSetupStatus: commandMocks.runPluginSetupStatus,
runPluginSetup: commandMocks.runPluginSetup,
}));
vi.mock("../commands/plugin-scaffold.js", () => ({
@@ -432,7 +436,7 @@ describe("bin command routing and fallbacks", () => {
await expect(runBin(["plugin", "oops"])).rejects.toThrow("process.exit:1");
expect(errorSpy).toHaveBeenCalledWith("Unknown subcommand: plugin oops");
expect(logSpy).toHaveBeenCalledWith(
"Try: fn plugin list | install | add (alias for install) | uninstall | enable | disable | create",
"Try: fn plugin list | install | add (alias for install) | uninstall | enable | disable | setup-status | setup | create",
);
});

View File

@@ -132,7 +132,7 @@ async function loadCommandHandlers() {
const { runAgentImport } = await import("./commands/agent-import.js");
const { runAgentExport } = await import("./commands/agent-export.js");
const { runMessageInbox, runMessageOutbox, runMessageSend, runMessageRead, runMessageDelete, runAgentMailbox } = await import("./commands/message.js");
const { runPluginList, runPluginInstall, runPluginUninstall, runPluginEnable, runPluginDisable } = await import("./commands/plugin.js");
const { runPluginList, runPluginInstall, runPluginUninstall, runPluginEnable, runPluginDisable, runPluginSetupStatus, runPluginSetup } = await import("./commands/plugin.js");
const { runPluginCreate } = await import("./commands/plugin-scaffold.js");
const { runSkillsSearch, runSkillsInstall } = await import("./commands/skills.js");
const { runResearchCreate, runResearchList, runResearchShow, runResearchExport, runResearchCancel, runResearchRetry } = await import("./commands/research.js");
@@ -214,6 +214,8 @@ async function loadCommandHandlers() {
runPluginUninstall,
runPluginEnable,
runPluginDisable,
runPluginSetupStatus,
runPluginSetup,
runPluginCreate,
runSkillsSearch,
runSkillsInstall,
@@ -340,6 +342,9 @@ Usage:
fn plugin uninstall <id> [--force] Uninstall a plugin
fn plugin enable <id> Enable a plugin
fn plugin disable <id> Disable a plugin
fn plugin setup-status <id> Check plugin setup binary/runtime status
fn plugin setup <id> [--action install|uninstall]
Install or uninstall plugin setup binaries/runtimes
fn plugin create <name> Scaffold a new plugin project
fn skills search <query> Search skills.sh for agent skills
fn skills search <query> --limit 5 Limit results
@@ -553,6 +558,8 @@ async function main() {
runPluginUninstall,
runPluginEnable,
runPluginDisable,
runPluginSetupStatus,
runPluginSetup,
runPluginCreate,
runSkillsSearch,
runSkillsInstall,
@@ -1443,6 +1450,24 @@ async function main() {
await runPluginDisable(id, { projectName });
break;
}
case "setup-status": {
const id = args[2];
if (!id) { console.error("Usage: fn plugin setup-status <id>"); process.exit(1); }
await runPluginSetupStatus(id, { projectName });
break;
}
case "setup": {
const id = args[2];
if (!id) { console.error("Usage: fn plugin setup <id> [--action install|uninstall]"); process.exit(1); }
const actionIndex = args.indexOf("--action");
const action = actionIndex >= 0 ? args[actionIndex + 1] : "install";
if (action !== "install" && action !== "uninstall") {
console.error("--action must be install or uninstall");
process.exit(1);
}
await runPluginSetup(id, { action, projectName });
break;
}
case "create": {
const pluginName = args[2];
if (!pluginName) { console.error("Usage: fn plugin create <name>"); process.exit(1); }
@@ -1451,7 +1476,7 @@ async function main() {
}
default:
console.error(`Unknown subcommand: plugin ${sub || ""}`);
console.log("Try: fn plugin list | install | add (alias for install) | uninstall | enable | disable | create");
console.log("Try: fn plugin list | install | add (alias for install) | uninstall | enable | disable | setup-status | setup | create");
process.exit(1);
}
break;

View File

@@ -327,3 +327,82 @@ export async function runPluginDisable(
console.log(`${plugin.name} disabled and stopped`);
console.log();
}
export async function runPluginSetupStatus(
id: string,
options?: { projectName?: string },
): Promise<void> {
const projectName = options?.projectName;
const { store, loader } = await createPluginLoader(await createPluginStore(projectName), projectName);
try {
await store.getPlugin(id);
} catch {
console.error(`Plugin "${id}" not found`);
process.exit(1);
}
if (!loader.isPluginLoaded(id)) {
console.error(`Plugin "${id}" is not loaded. Enable the plugin first.`);
process.exit(1);
}
const loadedPlugin = loader.getPlugin(id);
if (!loadedPlugin?.setup) {
console.log("Plugin has no setup requirements");
return;
}
const result = await loader.checkPluginSetup(id);
console.log(`status: ${result.status}`);
if (result.version) console.log(`version: ${result.version}`);
if (result.binaryPath) console.log(`binaryPath: ${result.binaryPath}`);
if (result.error) console.log(`error: ${result.error}`);
}
export async function runPluginSetup(
id: string,
options?: { action?: "install" | "uninstall"; projectName?: string },
): Promise<void> {
const projectName = options?.projectName;
const action = options?.action ?? "install";
const { store, loader } = await createPluginLoader(await createPluginStore(projectName), projectName);
let plugin;
try {
plugin = await store.getPlugin(id);
} catch {
console.error(`Plugin "${id}" not found`);
process.exit(1);
}
if (!loader.isPluginLoaded(id)) {
console.error(`Plugin "${id}" is not loaded. Enable the plugin first.`);
process.exit(1);
}
const loadedPlugin = loader.getPlugin(id);
if (!loadedPlugin?.setup) {
console.log("Plugin has no setup requirements");
return;
}
try {
if (action === "uninstall") {
await loader.uninstallPluginSetup(id);
console.log(`${plugin.name} setup uninstalled`);
return;
}
if (!loadedPlugin.setup.hooks.install) {
console.error("Plugin has no install hook");
process.exit(1);
}
await loader.installPluginSetup(id);
console.log(`${plugin.name} setup installed`);
} catch (error) {
console.error(`Failed to ${action} setup for "${id}": ${error instanceof Error ? error.message : String(error)}`);
process.exit(1);
}
}

View File

@@ -2096,6 +2096,224 @@ export default plugin;
});
});
describe("plugin setup lifecycle", () => {
it("checkPluginSetup returns installed for plugins without setup", async () => {
await pluginStore.init();
loader = new PluginLoader({ pluginStore, taskStore: mockTaskStore });
(loader as any).plugins.set("plain-plugin", {
manifest: makeManifest({ id: "plain-plugin" }),
state: "started",
hooks: {},
} as FusionPlugin);
await expect(loader.checkPluginSetup("plain-plugin")).resolves.toEqual({ status: "installed" });
});
it("checkPluginSetup throws when plugin is not loaded", async () => {
await pluginStore.init();
loader = new PluginLoader({ pluginStore, taskStore: mockTaskStore });
await expect(loader.checkPluginSetup("missing-plugin")).rejects.toThrow('Plugin "missing-plugin" is not loaded');
});
it("checkPluginSetup calls hook and returns result", async () => {
await pluginStore.init();
loader = new PluginLoader({ pluginStore, taskStore: mockTaskStore });
const checkSetup = vi.fn().mockResolvedValue({ status: "installed", version: "1.2.3", binaryPath: "/bin/agent-browser" });
(loader as any).plugins.set("setup-plugin", {
manifest: makeManifest({ id: "setup-plugin" }),
state: "started",
hooks: {},
setup: {
manifest: { binaryName: "agent-browser", description: "Binary" },
hooks: { checkSetup },
},
} as FusionPlugin);
await expect(loader.checkPluginSetup("setup-plugin")).resolves.toEqual({
status: "installed",
version: "1.2.3",
binaryPath: "/bin/agent-browser",
});
expect(checkSetup).toHaveBeenCalledTimes(1);
});
it("checkPluginSetup returns error status when hook throws", async () => {
await pluginStore.init();
loader = new PluginLoader({ pluginStore, taskStore: mockTaskStore });
const checkSetup = vi.fn().mockRejectedValue(new Error("probe failed"));
(loader as any).plugins.set("error-setup-plugin", {
manifest: makeManifest({ id: "error-setup-plugin" }),
state: "started",
hooks: {},
setup: { manifest: { binaryName: "agent-browser", description: "Binary" }, hooks: { checkSetup } },
} as FusionPlugin);
await expect(loader.checkPluginSetup("error-setup-plugin")).resolves.toEqual({ status: "error", error: "probe failed" });
});
it("checkPluginSetup returns error status when hook times out", async () => {
await pluginStore.init();
loader = new PluginLoader({ pluginStore, taskStore: mockTaskStore });
vi.useFakeTimers();
const checkSetup = vi.fn().mockImplementation(() => new Promise(() => undefined));
(loader as any).plugins.set("timeout-setup-plugin", {
manifest: makeManifest({ id: "timeout-setup-plugin" }),
state: "started",
hooks: {},
setup: {
manifest: { binaryName: "agent-browser", description: "Binary", defaultTimeoutMs: 5 },
hooks: { checkSetup },
},
} as FusionPlugin);
const resultPromise = loader.checkPluginSetup("timeout-setup-plugin");
await vi.advanceTimersByTimeAsync(6);
await expect(resultPromise).resolves.toEqual({
status: "error",
error: 'Setup check for "timeout-setup-plugin" timed out after 5ms',
});
vi.useRealTimers();
});
it("checkPluginSetup respects manifest defaultTimeoutMs", async () => {
await pluginStore.init();
loader = new PluginLoader({ pluginStore, taskStore: mockTaskStore });
vi.useFakeTimers();
const checkSetup = vi.fn().mockImplementation(() => new Promise(() => undefined));
(loader as any).plugins.set("custom-timeout-setup-plugin", {
manifest: makeManifest({ id: "custom-timeout-setup-plugin" }),
state: "started",
hooks: {},
setup: {
manifest: { binaryName: "agent-browser", description: "Binary", defaultTimeoutMs: 12 },
hooks: { checkSetup },
},
} as FusionPlugin);
const resultPromise = loader.checkPluginSetup("custom-timeout-setup-plugin");
await vi.advanceTimersByTimeAsync(11);
expect(checkSetup).toHaveBeenCalledTimes(1);
await vi.advanceTimersByTimeAsync(1);
await expect(resultPromise).resolves.toEqual({
status: "error",
error: 'Setup check for "custom-timeout-setup-plugin" timed out after 12ms',
});
vi.useRealTimers();
});
it("installPluginSetup calls install hook", async () => {
await pluginStore.init();
loader = new PluginLoader({ pluginStore, taskStore: mockTaskStore });
const install = vi.fn().mockResolvedValue(undefined);
(loader as any).plugins.set("install-plugin", {
manifest: makeManifest({ id: "install-plugin" }),
state: "started",
hooks: {},
setup: {
manifest: { binaryName: "agent-browser", description: "Binary" },
hooks: { checkSetup: vi.fn().mockResolvedValue({ status: "installed" }), install },
},
} as FusionPlugin);
await expect(loader.installPluginSetup("install-plugin")).resolves.toBeUndefined();
expect(install).toHaveBeenCalledTimes(1);
});
it("installPluginSetup throws when plugin has no install hook", async () => {
await pluginStore.init();
loader = new PluginLoader({ pluginStore, taskStore: mockTaskStore });
(loader as any).plugins.set("no-install-plugin", {
manifest: makeManifest({ id: "no-install-plugin" }),
state: "started",
hooks: {},
setup: { manifest: { binaryName: "agent-browser", description: "Binary" }, hooks: { checkSetup: vi.fn() } },
} as FusionPlugin);
await expect(loader.installPluginSetup("no-install-plugin")).rejects.toThrow('Plugin "no-install-plugin" has no install hook');
});
it("installPluginSetup throws when plugin is not loaded", async () => {
await pluginStore.init();
loader = new PluginLoader({ pluginStore, taskStore: mockTaskStore });
await expect(loader.installPluginSetup("missing-install-plugin")).rejects.toThrow('Plugin "missing-install-plugin" is not loaded');
});
it("installPluginSetup throws on timeout", async () => {
await pluginStore.init();
loader = new PluginLoader({ pluginStore, taskStore: mockTaskStore });
vi.useFakeTimers();
const install = vi.fn().mockImplementation(() => new Promise(() => undefined));
(loader as any).plugins.set("timeout-install-plugin", {
manifest: makeManifest({ id: "timeout-install-plugin" }),
state: "started",
hooks: {},
setup: {
manifest: { binaryName: "agent-browser", description: "Binary", defaultTimeoutMs: 5 },
hooks: { checkSetup: vi.fn(), install },
},
} as FusionPlugin);
const installPromise = loader.installPluginSetup("timeout-install-plugin");
const installAssertion = expect(installPromise).rejects.toThrow('Install command for "timeout-install-plugin" timed out after 5ms');
await vi.advanceTimersByTimeAsync(6);
await installAssertion;
vi.useRealTimers();
});
it("uninstallPluginSetup calls uninstall hook", async () => {
await pluginStore.init();
loader = new PluginLoader({ pluginStore, taskStore: mockTaskStore });
const uninstall = vi.fn().mockResolvedValue(undefined);
(loader as any).plugins.set("uninstall-plugin", {
manifest: makeManifest({ id: "uninstall-plugin" }),
state: "started",
hooks: {},
setup: {
manifest: { binaryName: "agent-browser", description: "Binary" },
hooks: { checkSetup: vi.fn().mockResolvedValue({ status: "installed" }), uninstall },
},
} as FusionPlugin);
await expect(loader.uninstallPluginSetup("uninstall-plugin")).resolves.toBeUndefined();
expect(uninstall).toHaveBeenCalledTimes(1);
});
it("uninstallPluginSetup returns silently when no uninstall hook", async () => {
await pluginStore.init();
loader = new PluginLoader({ pluginStore, taskStore: mockTaskStore });
(loader as any).plugins.set("no-uninstall-plugin", {
manifest: makeManifest({ id: "no-uninstall-plugin" }),
state: "started",
hooks: {},
setup: { manifest: { binaryName: "agent-browser", description: "Binary" }, hooks: { checkSetup: vi.fn() } },
} as FusionPlugin);
await expect(loader.uninstallPluginSetup("no-uninstall-plugin")).resolves.toBeUndefined();
});
it("uninstallPluginSetup respects timeout", async () => {
await pluginStore.init();
loader = new PluginLoader({ pluginStore, taskStore: mockTaskStore });
vi.useFakeTimers();
const uninstall = vi.fn().mockImplementation(() => new Promise(() => undefined));
(loader as any).plugins.set("timeout-uninstall-plugin", {
manifest: makeManifest({ id: "timeout-uninstall-plugin" }),
state: "started",
hooks: {},
setup: {
manifest: { binaryName: "agent-browser", description: "Binary", defaultTimeoutMs: 5 },
hooks: { checkSetup: vi.fn(), uninstall },
},
} as FusionPlugin);
const uninstallPromise = loader.uninstallPluginSetup("timeout-uninstall-plugin");
const uninstallAssertion = expect(uninstallPromise).rejects.toThrow('Uninstall command for "timeout-uninstall-plugin" timed out after 5ms');
await vi.advanceTimersByTimeAsync(6);
await uninstallAssertion;
vi.useRealTimers();
});
});
// ── getLoadedPlugins ───────────────────────────────────────────────
describe("getLoadedPlugins", () => {

View File

@@ -34,6 +34,7 @@ import type {
PluginPromptContributions,
PluginSetupManifest,
PluginSetupHooks,
PluginSetupCheckResult,
} from "./plugin-types.js";
import { normalizePluginUiContributionDefinition, validatePluginManifest } from "./plugin-types.js";
import { createLogger } from "./logger.js";
@@ -745,6 +746,89 @@ export class PluginLoader extends EventEmitter<{
}
}
async checkPluginSetup(pluginId: string): Promise<PluginSetupCheckResult> {
const plugin = this.plugins.get(pluginId);
if (!plugin) {
throw new Error(`Plugin "${pluginId}" is not loaded`);
}
if (!plugin.setup) {
return { status: "installed" };
}
const timeout = plugin.setup.manifest.defaultTimeoutMs ?? 30_000;
try {
const ctx = await this.createContext(plugin);
return await this.withTimeout(
plugin.setup.hooks.checkSetup(ctx),
timeout,
`Setup check for "${pluginId}" timed out after ${timeout}ms`,
);
} catch (error) {
return {
status: "error",
error: error instanceof Error ? error.message : String(error),
};
}
}
async installPluginSetup(pluginId: string): Promise<void> {
const plugin = this.plugins.get(pluginId);
if (!plugin) {
throw new Error(`Plugin "${pluginId}" is not loaded`);
}
if (!plugin.setup?.hooks.install) {
throw new Error(`Plugin "${pluginId}" has no install hook`);
}
const timeout = plugin.setup.manifest.defaultTimeoutMs ?? 120_000;
const ctx = await this.createContext(plugin);
try {
await this.withTimeout(
plugin.setup.hooks.install(ctx),
timeout,
`Install command for "${pluginId}" timed out after ${timeout}ms`,
);
} catch (error) {
if (error instanceof Error && error.message.includes(`timed out after ${timeout}ms`)) {
throw error;
}
const message = error instanceof Error ? error.message : String(error);
throw new Error(`Install hook failed for "${pluginId}": ${message}`);
}
}
async uninstallPluginSetup(pluginId: string): Promise<void> {
const plugin = this.plugins.get(pluginId);
if (!plugin) {
throw new Error(`Plugin "${pluginId}" is not loaded`);
}
if (!plugin.setup?.hooks.uninstall) {
return;
}
const timeout = plugin.setup.manifest.defaultTimeoutMs ?? 60_000;
const ctx = await this.createContext(plugin);
try {
await this.withTimeout(
plugin.setup.hooks.uninstall(ctx),
timeout,
`Uninstall command for "${pluginId}" timed out after ${timeout}ms`,
);
} catch (error) {
if (error instanceof Error && error.message.includes(`timed out after ${timeout}ms`)) {
throw error;
}
const message = error instanceof Error ? error.message : String(error);
throw new Error(`Uninstall hook failed for "${pluginId}": ${message}`);
}
}
// ── Accessors ─────────────────────────────────────────────────────
/**

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;

View File

@@ -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()", () => {

View File

@@ -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;