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

@@ -0,0 +1,7 @@
---
"@runfusion/fusion": minor
---
Add plugin-managed binary installation/setup lifecycle. Plugins can now declare
setup hooks (check, install, uninstall) for required binaries/runtimes. Dashboard
API and CLI commands support checking setup status and triggering install/uninstall.

View File

@@ -54,10 +54,11 @@ For a full walkthrough (installation, onboarding, first task, and daily workflow
| [Code Signing](./CODE_SIGNING.md) | macOS and Windows code signing configuration for release binaries | | [Code Signing](./CODE_SIGNING.md) | macOS and Windows code signing configuration for release binaries |
| [Mobile](../MOBILE.md) | Capacitor/PWA mobile development setup and workflow | | [Mobile](../MOBILE.md) | Capacitor/PWA mobile development setup and workflow |
### Plugin Development ### Plugins
| Guide | Description | | Guide | Description |
|---|---| |---|---|
| [Plugin Authoring](./PLUGIN_AUTHORING.md) | Creating Fusion plugins with the plugin system | | [Plugin Management](./plugin-management.md) | End-user guide for discovering, installing, enabling, configuring, updating, uninstalling, and troubleshooting Fusion plugins |
| [Plugin Authoring](./PLUGIN_AUTHORING.md) | Developer guide for building Fusion plugins (manifest, SDK hooks, routes, UI/runtime contributions) |
| [Memory Plugin Contract](./memory-plugin-contract.md) | Pluggable memory backend architecture, interface contract, and migration strategy | | [Memory Plugin Contract](./memory-plugin-contract.md) | Pluggable memory backend architecture, interface contract, and migration strategy |
### Audit Reports ### Audit Reports

View File

@@ -259,7 +259,7 @@ Features:
- Inspect plugin runtime state and transition feedback - Inspect plugin runtime state and transition feedback
- Edit and save plugin-defined settings schemas from the same panel - Edit and save plugin-defined settings schemas from the same panel
For plugin-related settings and experimental toggles, see [Settings reference](./settings-reference.md). For full plugin lifecycle workflows (discovery, install, enable/disable, configure, update, uninstall, troubleshooting), see [Plugin Management](./plugin-management.md). For plugin-related settings and experimental toggles, see [Settings reference](./settings-reference.md).
## Pi Extensions Manager ## Pi Extensions Manager

201
docs/plugin-management.md Normal file
View File

@@ -0,0 +1,201 @@
# Plugin Management Guide
[← Docs index](./README.md)
This guide is the canonical end-user workflow for managing Fusion plugins across the full lifecycle: discover, install, enable/disable, configure, use, update, uninstall, and troubleshoot.
> Plugin author/developer details (manifest, SDK APIs, hooks, routes, and runtime implementation) live in [Plugin Authoring](./PLUGIN_AUTHORING.md).
## 1) Plugin basics
Fusion uses two plugin surfaces in Settings:
- **Fusion Plugins** (`Settings → Plugins → Fusion Plugins`): extend Fusion behavior (tools, routes, UI slots/views, runtimes)
- **Pi Extensions** (`Settings → Plugins → Pi Extensions`): manage pi extension packages/sources
These are related but different systems; do not treat Pi Extensions as Fusion Plugins.
### Lifecycle states
| State | Meaning |
|---|---|
| `installed` | Registered but not started yet |
| `started` | Loaded and active |
| `stopped` | Disabled/stopped |
| `error` | Failed to load or failed at runtime |
### Common locations
| Location | Purpose |
|---|---|
| `~/.fusion/plugins/` | Default local plugin install location |
| Bundled plugin manifests (shipped with Fusion) | Discoverable/installable from Plugin Manager |
| Custom local path (absolute path) | Install plugin from a local directory |
## 2) Discover available plugins
### Dashboard
1. Open **Settings → Plugins → Fusion Plugins**.
2. Review bundled entries in **Bundled Plugins** and currently installed entries.
3. Check each plugins status/state in the manager.
Expected outcome: You can see what is already installed, what is bundled and available, and each plugins current lifecycle state.
### CLI
1. Run:
```bash
fn plugin list
```
2. Review installed plugin IDs and status.
Expected outcome: You have a terminal view of installed plugins for scripting/remote workflows.
## 3) Install plugins
### Install bundled plugin (dashboard)
1. Go to **Settings → Plugins → Fusion Plugins**.
2. In **Bundled Plugins**, click **Install** for the plugin.
Expected outcome: Plugin is registered and appears with an initial state (typically `installed` then `started` when enabled/loaded).
### Install from local path (dashboard)
1. Go to **Settings → Plugins → Fusion Plugins**.
2. Use **Install** and provide an absolute plugin path.
3. Confirm installation.
Expected outcome: Plugin is added to your local plugin set and appears in the manager.
### Install from local path (CLI)
1. Run:
```bash
fn plugin install <path>
```
2. Confirm the plugin appears in:
```bash
fn plugin list
```
Expected outcome: Plugin is installed from the specified path and visible in plugin listings.
## 4) Enable, disable, and reload plugins
### Dashboard
1. Open **Settings → Plugins → Fusion Plugins**.
2. Toggle plugin enable/disable controls.
3. Use reload controls when available.
Expected outcome: Plugin transitions between runtime states (`started` / `stopped`) and reflects transitions in the manager.
### CLI
```bash
fn plugin enable <id>
fn plugin disable <id>
```
Expected outcome: Plugin is enabled or disabled by ID.
## 5) Configure plugin settings
1. Go to **Settings → Plugins → Fusion Plugins**.
2. Open the plugin settings editor (gear/settings action).
3. Update fields and save.
Expected outcome: Plugin-defined settings are persisted and used by that plugin at runtime.
## 6) Verify plugin is working
After installing/enabling, verify success signals relevant to that plugin:
- New agent tools become available in runtime/tooling surfaces
- Plugin routes are reachable through plugin API paths
- Plugin UI slots/views appear in dashboard surfaces (tabs, sections, cards, nav entries)
- Runtime-providing plugins become available for runtime hint selection/usage
- Plugin state remains `started` (not `error`)
If you need capability-level details for a specific plugin, check its README and [Plugin Authoring](./PLUGIN_AUTHORING.md).
## 7) Update plugins
Fusion does not use a dedicated `fn plugin update` command. Update by reinstalling the desired plugin version/source.
### Dashboard
1. Reinstall from the bundled entry or updated local path.
2. Re-check state and behavior in the plugin manager.
### CLI
1. Re-run install against the updated source path:
```bash
fn plugin install <path>
```
2. Confirm with:
```bash
fn plugin list
```
Expected outcome: Updated plugin build/version is installed and operational.
## 8) Uninstall plugins
### Dashboard
1. Open **Settings → Plugins → Fusion Plugins**.
2. Uninstall the target plugin.
Expected outcome: Plugin is removed from the installed list and no longer active.
### CLI
1. Run:
```bash
fn plugin uninstall <id> --force
```
2. Verify removal:
```bash
fn plugin list
```
Expected outcome: Plugin is removed by ID.
## 9) Dashboard vs CLI mapping
| Workflow | Dashboard path | CLI command |
|---|---|---|
| List installed plugins | Settings → Plugins → Fusion Plugins | `fn plugin list` |
| Install plugin | Settings → Plugins → Fusion Plugins → Install | `fn plugin install <path>` |
| Enable plugin | Settings → Plugins → Fusion Plugins → Enable toggle | `fn plugin enable <id>` |
| Disable plugin | Settings → Plugins → Fusion Plugins → Disable toggle | `fn plugin disable <id>` |
| Uninstall plugin | Settings → Plugins → Fusion Plugins → Uninstall | `fn plugin uninstall <id> --force` |
| Scaffold new plugin (authoring) | n/a (developer workflow) | `fn plugin create <name>` |
## 10) Troubleshooting
### Plugin is in `error` state
1. Open **Settings → Plugins → Fusion Plugins** and inspect state/transition feedback.
2. Disable then re-enable the plugin.
3. Confirm plugin source path and dependencies are valid.
4. If needed, uninstall and reinstall the plugin.
### Plugin installed but features are not visible
1. Confirm plugin state is `started`.
2. Verify what that plugin actually contributes (tools/routes/UI/runtime) in plugin docs.
3. Confirm you are checking the correct dashboard surface (for example nav view vs settings section vs task detail slot).
### Confusion between Fusion Plugins and Pi Extensions
1. Use **Fusion Plugins** for Fusion plugin lifecycle management.
2. Use **Pi Extensions** only for pi extension sources/extensions/skills/prompts/themes.
### Need implementation/API details
Use [Plugin Authoring](./PLUGIN_AUTHORING.md) for manifest fields, lifecycle hook signatures, UI/runtime contribution contracts, and SDK examples.

View File

@@ -91,6 +91,8 @@ const commandMocks = vi.hoisted(() => ({
runPluginUninstall: vi.fn(), runPluginUninstall: vi.fn(),
runPluginEnable: vi.fn(), runPluginEnable: vi.fn(),
runPluginDisable: vi.fn(), runPluginDisable: vi.fn(),
runPluginSetupStatus: vi.fn(),
runPluginSetup: vi.fn(),
runPluginCreate: vi.fn(), runPluginCreate: vi.fn(),
runResearchCreate: vi.fn(), runResearchCreate: vi.fn(),
@@ -211,6 +213,8 @@ vi.mock("../commands/plugin.js", () => ({
runPluginUninstall: commandMocks.runPluginUninstall, runPluginUninstall: commandMocks.runPluginUninstall,
runPluginEnable: commandMocks.runPluginEnable, runPluginEnable: commandMocks.runPluginEnable,
runPluginDisable: commandMocks.runPluginDisable, runPluginDisable: commandMocks.runPluginDisable,
runPluginSetupStatus: commandMocks.runPluginSetupStatus,
runPluginSetup: commandMocks.runPluginSetup,
})); }));
vi.mock("../commands/plugin-scaffold.js", () => ({ 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"); await expect(runBin(["plugin", "oops"])).rejects.toThrow("process.exit:1");
expect(errorSpy).toHaveBeenCalledWith("Unknown subcommand: plugin oops"); expect(errorSpy).toHaveBeenCalledWith("Unknown subcommand: plugin oops");
expect(logSpy).toHaveBeenCalledWith( 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 { runAgentImport } = await import("./commands/agent-import.js");
const { runAgentExport } = await import("./commands/agent-export.js"); const { runAgentExport } = await import("./commands/agent-export.js");
const { runMessageInbox, runMessageOutbox, runMessageSend, runMessageRead, runMessageDelete, runAgentMailbox } = await import("./commands/message.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 { runPluginCreate } = await import("./commands/plugin-scaffold.js");
const { runSkillsSearch, runSkillsInstall } = await import("./commands/skills.js"); const { runSkillsSearch, runSkillsInstall } = await import("./commands/skills.js");
const { runResearchCreate, runResearchList, runResearchShow, runResearchExport, runResearchCancel, runResearchRetry } = await import("./commands/research.js"); const { runResearchCreate, runResearchList, runResearchShow, runResearchExport, runResearchCancel, runResearchRetry } = await import("./commands/research.js");
@@ -214,6 +214,8 @@ async function loadCommandHandlers() {
runPluginUninstall, runPluginUninstall,
runPluginEnable, runPluginEnable,
runPluginDisable, runPluginDisable,
runPluginSetupStatus,
runPluginSetup,
runPluginCreate, runPluginCreate,
runSkillsSearch, runSkillsSearch,
runSkillsInstall, runSkillsInstall,
@@ -340,6 +342,9 @@ Usage:
fn plugin uninstall <id> [--force] Uninstall a plugin fn plugin uninstall <id> [--force] Uninstall a plugin
fn plugin enable <id> Enable a plugin fn plugin enable <id> Enable a plugin
fn plugin disable <id> Disable 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 plugin create <name> Scaffold a new plugin project
fn skills search <query> Search skills.sh for agent skills fn skills search <query> Search skills.sh for agent skills
fn skills search <query> --limit 5 Limit results fn skills search <query> --limit 5 Limit results
@@ -553,6 +558,8 @@ async function main() {
runPluginUninstall, runPluginUninstall,
runPluginEnable, runPluginEnable,
runPluginDisable, runPluginDisable,
runPluginSetupStatus,
runPluginSetup,
runPluginCreate, runPluginCreate,
runSkillsSearch, runSkillsSearch,
runSkillsInstall, runSkillsInstall,
@@ -1443,6 +1450,24 @@ async function main() {
await runPluginDisable(id, { projectName }); await runPluginDisable(id, { projectName });
break; 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": { case "create": {
const pluginName = args[2]; const pluginName = args[2];
if (!pluginName) { console.error("Usage: fn plugin create <name>"); process.exit(1); } if (!pluginName) { console.error("Usage: fn plugin create <name>"); process.exit(1); }
@@ -1451,7 +1476,7 @@ async function main() {
} }
default: default:
console.error(`Unknown subcommand: plugin ${sub || ""}`); 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); process.exit(1);
} }
break; break;

View File

@@ -327,3 +327,82 @@ export async function runPluginDisable(
console.log(`${plugin.name} disabled and stopped`); console.log(`${plugin.name} disabled and stopped`);
console.log(); 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 ─────────────────────────────────────────────── // ── getLoadedPlugins ───────────────────────────────────────────────
describe("getLoadedPlugins", () => { describe("getLoadedPlugins", () => {

View File

@@ -34,6 +34,7 @@ import type {
PluginPromptContributions, PluginPromptContributions,
PluginSetupManifest, PluginSetupManifest,
PluginSetupHooks, PluginSetupHooks,
PluginSetupCheckResult,
} from "./plugin-types.js"; } from "./plugin-types.js";
import { normalizePluginUiContributionDefinition, validatePluginManifest } from "./plugin-types.js"; import { normalizePluginUiContributionDefinition, validatePluginManifest } from "./plugin-types.js";
import { createLogger } from "./logger.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 ───────────────────────────────────────────────────── // ── Accessors ─────────────────────────────────────────────────────
/** /**

View File

@@ -675,13 +675,24 @@ describe("POST /plugins/:id/disable", () => {
describe("POST /plugins/:id/reload", () => { describe("POST /plugins/:id/reload", () => {
let store: TaskStore; let store: TaskStore;
let pluginStore: PluginStore; 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(() => { beforeEach(() => {
pluginStore = createMockPluginStore(); pluginStore = createMockPluginStore();
pluginRunner = { pluginRunner = {
getPluginRoutes: vi.fn().mockReturnValue([]), getPluginRoutes: vi.fn().mockReturnValue([]),
reloadPlugin: vi.fn().mockResolvedValue(undefined), 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({ store = createMockTaskStore({
getPluginStore: vi.fn().mockReturnValue(pluginStore), 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", () => { describe("PUT /plugins/:id/settings", () => {
let store: TaskStore; let store: TaskStore;
let pluginStore: PluginStore; let pluginStore: PluginStore;

View File

@@ -35,6 +35,10 @@ import {
// PluginRunner interface for optional plugin runner // PluginRunner interface for optional plugin runner
interface PluginRunner { interface PluginRunner {
reloadPlugin?(pluginId: string): Promise<void>; 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 }>; 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); 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 * PUT /api/plugins/:id/settings
* Update plugin settings. * Update plugin settings.

View File

@@ -218,6 +218,14 @@ export interface ServerOptions {
getRuntimeById?(runtimeId: string): unknown; getRuntimeById?(runtimeId: string): unknown;
createRuntimeContext?(pluginId: string): Promise<unknown>; createRuntimeContext?(pluginId: string): Promise<unknown>;
reloadPlugin?(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 */ /** Optional ChatStore for chat session management */
chatStore?: import("@fusion/core").ChatStore; chatStore?: import("@fusion/core").ChatStore;

View File

@@ -41,6 +41,9 @@ describe("PluginRunner", () => {
getPluginWorkflowStepTemplates: ReturnType<typeof vi.fn>; getPluginWorkflowStepTemplates: ReturnType<typeof vi.fn>;
getPluginPromptContributions: ReturnType<typeof vi.fn>; getPluginPromptContributions: ReturnType<typeof vi.fn>;
getPluginSetupInfo: 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>; getLoadedPlugins: ReturnType<typeof vi.fn>;
getPlugin: ReturnType<typeof vi.fn>; getPlugin: ReturnType<typeof vi.fn>;
loadPlugin: ReturnType<typeof vi.fn>; loadPlugin: ReturnType<typeof vi.fn>;
@@ -102,6 +105,9 @@ describe("PluginRunner", () => {
getPluginWorkflowStepTemplates: vi.fn().mockReturnValue([]), getPluginWorkflowStepTemplates: vi.fn().mockReturnValue([]),
getPluginPromptContributions: vi.fn().mockReturnValue([]), getPluginPromptContributions: vi.fn().mockReturnValue([]),
getPluginSetupInfo: 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([]), getLoadedPlugins: vi.fn().mockReturnValue([]),
getPlugin: vi.fn(), getPlugin: vi.fn(),
loadPlugin: vi.fn().mockResolvedValue({}), loadPlugin: vi.fn().mockResolvedValue({}),
@@ -909,6 +915,64 @@ describe("PluginRunner", () => {
expect(mockPluginLoader.getPluginPromptContributions).toHaveBeenCalledTimes(3); expect(mockPluginLoader.getPluginPromptContributions).toHaveBeenCalledTimes(3);
expect(mockPluginLoader.getPluginSetupInfo).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()", () => { describe("getRuntimeById()", () => {

View File

@@ -25,6 +25,7 @@ import type {
PluginPromptSurface, PluginPromptSurface,
PluginSetupManifest, PluginSetupManifest,
PluginSetupHooks, PluginSetupHooks,
PluginSetupCheckResult,
} from "@fusion/core"; } from "@fusion/core";
import type { ToolDefinition } from "@mariozechner/pi-coding-agent"; import type { ToolDefinition } from "@mariozechner/pi-coding-agent";
import { Type } from "@mariozechner/pi-ai"; import { Type } from "@mariozechner/pi-ai";
@@ -371,6 +372,55 @@ export class PluginRunner {
return this.cachedSetupInfo.setups; 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<{ getPromptContributionsForSurface(surface: PluginPromptSurface): Array<{
pluginId: string; pluginId: string;
contribution: PluginPromptContribution; contribution: PluginPromptContribution;