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 |
|
| [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
|
||||||
|
|||||||
@@ -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
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(),
|
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",
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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", () => {
|
||||||
|
|||||||
@@ -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 ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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 }>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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()", () => {
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user