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:
7
.changeset/plugin-setup-lifecycle.md
Normal file
7
.changeset/plugin-setup-lifecycle.md
Normal 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.
|
||||
@@ -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 |
|
||||
| [Mobile](../MOBILE.md) | Capacitor/PWA mobile development setup and workflow |
|
||||
|
||||
### Plugin Development
|
||||
### Plugins
|
||||
| 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 |
|
||||
|
||||
### Audit Reports
|
||||
|
||||
@@ -259,7 +259,7 @@ Features:
|
||||
- Inspect plugin runtime state and transition feedback
|
||||
- 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
|
||||
|
||||
|
||||
201
docs/plugin-management.md
Normal file
201
docs/plugin-management.md
Normal 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 plugin’s status/state in the manager.
|
||||
|
||||
Expected outcome: You can see what is already installed, what is bundled and available, and each plugin’s 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.
|
||||
@@ -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",
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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 ─────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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