feat: add plugin system with sandboxed runtime
Plugin discovery from ~/.config/bterminal/plugins/ with plugin.json manifest. Sandboxed new Function() execution, permission-gated API (palette, btmsg:read, bttask:read, events). Plugin store + SettingsTab.
This commit is contained in:
parent
5dd7df03cb
commit
b2932273ba
4 changed files with 510 additions and 0 deletions
20
v2/src-tauri/src/commands/plugins.rs
Normal file
20
v2/src-tauri/src/commands/plugins.rs
Normal file
|
|
@ -0,0 +1,20 @@
|
||||||
|
// Plugin discovery and file access commands
|
||||||
|
|
||||||
|
use crate::AppState;
|
||||||
|
use crate::plugins;
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn plugins_discover(state: tauri::State<'_, AppState>) -> Vec<plugins::PluginMeta> {
|
||||||
|
let plugins_dir = state.app_config.plugins_dir();
|
||||||
|
plugins::discover_plugins(&plugins_dir)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn plugin_read_file(
|
||||||
|
state: tauri::State<'_, AppState>,
|
||||||
|
plugin_id: String,
|
||||||
|
filename: String,
|
||||||
|
) -> Result<String, String> {
|
||||||
|
let plugins_dir = state.app_config.plugins_dir();
|
||||||
|
plugins::read_plugin_file(&plugins_dir, &plugin_id, &filename)
|
||||||
|
}
|
||||||
265
v2/src-tauri/src/plugins.rs
Normal file
265
v2/src-tauri/src/plugins.rs
Normal file
|
|
@ -0,0 +1,265 @@
|
||||||
|
// Plugin discovery and file reading.
|
||||||
|
// Scans ~/.config/bterminal/plugins/ for plugin.json manifest files.
|
||||||
|
// Each plugin lives in its own subdirectory with a plugin.json manifest.
|
||||||
|
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
|
/// Plugin manifest — parsed from plugin.json
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct PluginMeta {
|
||||||
|
pub id: String,
|
||||||
|
pub name: String,
|
||||||
|
pub version: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub description: String,
|
||||||
|
/// Entry JS file relative to plugin directory
|
||||||
|
pub main: String,
|
||||||
|
/// Permission strings: "palette", "btmsg:read", "bttask:read", "events"
|
||||||
|
#[serde(default)]
|
||||||
|
pub permissions: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
const VALID_PERMISSIONS: &[&str] = &["palette", "btmsg:read", "bttask:read", "events"];
|
||||||
|
|
||||||
|
/// Validate plugin ID: alphanumeric + hyphens only, 1-64 chars
|
||||||
|
fn is_valid_plugin_id(id: &str) -> bool {
|
||||||
|
!id.is_empty()
|
||||||
|
&& id.len() <= 64
|
||||||
|
&& id
|
||||||
|
.chars()
|
||||||
|
.all(|c| c.is_ascii_alphanumeric() || c == '-')
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Discover all plugins in the given plugins directory.
|
||||||
|
/// Each plugin must have a plugin.json manifest file.
|
||||||
|
pub fn discover_plugins(plugins_dir: &Path) -> Vec<PluginMeta> {
|
||||||
|
let mut plugins = Vec::new();
|
||||||
|
|
||||||
|
let entries = match std::fs::read_dir(plugins_dir) {
|
||||||
|
Ok(e) => e,
|
||||||
|
Err(_) => return plugins,
|
||||||
|
};
|
||||||
|
|
||||||
|
for entry in entries.flatten() {
|
||||||
|
let path = entry.path();
|
||||||
|
if !path.is_dir() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let manifest_path = path.join("plugin.json");
|
||||||
|
if !manifest_path.exists() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let content = match std::fs::read_to_string(&manifest_path) {
|
||||||
|
Ok(c) => c,
|
||||||
|
Err(e) => {
|
||||||
|
log::warn!(
|
||||||
|
"Failed to read plugin manifest {}: {e}",
|
||||||
|
manifest_path.display()
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let meta: PluginMeta = match serde_json::from_str(&content) {
|
||||||
|
Ok(m) => m,
|
||||||
|
Err(e) => {
|
||||||
|
log::warn!(
|
||||||
|
"Invalid plugin manifest {}: {e}",
|
||||||
|
manifest_path.display()
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Validate plugin ID
|
||||||
|
if !is_valid_plugin_id(&meta.id) {
|
||||||
|
log::warn!(
|
||||||
|
"Plugin at {} has invalid ID '{}' — skipping",
|
||||||
|
path.display(),
|
||||||
|
meta.id
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate permissions
|
||||||
|
for perm in &meta.permissions {
|
||||||
|
if !VALID_PERMISSIONS.contains(&perm.as_str()) {
|
||||||
|
log::warn!(
|
||||||
|
"Plugin '{}' requests unknown permission '{}' — skipping",
|
||||||
|
meta.id,
|
||||||
|
perm
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
plugins.push(meta);
|
||||||
|
}
|
||||||
|
|
||||||
|
plugins
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Read a file from a plugin directory, with path traversal prevention.
|
||||||
|
/// Only files within the plugin's own directory are accessible.
|
||||||
|
pub fn read_plugin_file(
|
||||||
|
plugins_dir: &Path,
|
||||||
|
plugin_id: &str,
|
||||||
|
filename: &str,
|
||||||
|
) -> Result<String, String> {
|
||||||
|
if !is_valid_plugin_id(plugin_id) {
|
||||||
|
return Err("Invalid plugin ID".to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
let plugin_dir = plugins_dir.join(plugin_id);
|
||||||
|
if !plugin_dir.is_dir() {
|
||||||
|
return Err(format!("Plugin directory not found: {}", plugin_id));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Canonicalize the plugin directory to resolve symlinks
|
||||||
|
let canonical_plugin_dir = plugin_dir
|
||||||
|
.canonicalize()
|
||||||
|
.map_err(|e| format!("Failed to resolve plugin directory: {e}"))?;
|
||||||
|
|
||||||
|
let target = plugin_dir.join(filename);
|
||||||
|
let canonical_target = target
|
||||||
|
.canonicalize()
|
||||||
|
.map_err(|e| format!("Failed to resolve file path: {e}"))?;
|
||||||
|
|
||||||
|
// Path traversal prevention: target must be within plugin directory
|
||||||
|
if !canonical_target.starts_with(&canonical_plugin_dir) {
|
||||||
|
return Err("Access denied: path is outside plugin directory".to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
std::fs::read_to_string(&canonical_target)
|
||||||
|
.map_err(|e| format!("Failed to read plugin file: {e}"))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get the plugins directory path from a config directory
|
||||||
|
pub fn plugins_dir(config_dir: &Path) -> PathBuf {
|
||||||
|
config_dir.join("plugins")
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use std::fs;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_valid_plugin_ids() {
|
||||||
|
assert!(is_valid_plugin_id("my-plugin"));
|
||||||
|
assert!(is_valid_plugin_id("hello123"));
|
||||||
|
assert!(is_valid_plugin_id("a"));
|
||||||
|
assert!(!is_valid_plugin_id(""));
|
||||||
|
assert!(!is_valid_plugin_id("my_plugin")); // underscore not allowed
|
||||||
|
assert!(!is_valid_plugin_id("my plugin")); // space not allowed
|
||||||
|
assert!(!is_valid_plugin_id("../evil")); // path traversal chars
|
||||||
|
assert!(!is_valid_plugin_id(&"a".repeat(65))); // too long
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_discover_plugins_empty_dir() {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let plugins = discover_plugins(dir.path());
|
||||||
|
assert!(plugins.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_discover_plugins_nonexistent_dir() {
|
||||||
|
let plugins = discover_plugins(Path::new("/nonexistent/path"));
|
||||||
|
assert!(plugins.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_discover_plugins_valid_manifest() {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let plugin_dir = dir.path().join("test-plugin");
|
||||||
|
fs::create_dir(&plugin_dir).unwrap();
|
||||||
|
fs::write(
|
||||||
|
plugin_dir.join("plugin.json"),
|
||||||
|
r#"{
|
||||||
|
"id": "test-plugin",
|
||||||
|
"name": "Test Plugin",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "A test plugin",
|
||||||
|
"main": "index.js",
|
||||||
|
"permissions": ["palette"]
|
||||||
|
}"#,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
fs::write(plugin_dir.join("index.js"), "// test").unwrap();
|
||||||
|
|
||||||
|
let plugins = discover_plugins(dir.path());
|
||||||
|
assert_eq!(plugins.len(), 1);
|
||||||
|
assert_eq!(plugins[0].id, "test-plugin");
|
||||||
|
assert_eq!(plugins[0].name, "Test Plugin");
|
||||||
|
assert_eq!(plugins[0].permissions, vec!["palette"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_discover_plugins_invalid_id_skipped() {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let plugin_dir = dir.path().join("bad_plugin");
|
||||||
|
fs::create_dir(&plugin_dir).unwrap();
|
||||||
|
fs::write(
|
||||||
|
plugin_dir.join("plugin.json"),
|
||||||
|
r#"{
|
||||||
|
"id": "bad_plugin",
|
||||||
|
"name": "Bad",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"main": "index.js"
|
||||||
|
}"#,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let plugins = discover_plugins(dir.path());
|
||||||
|
assert!(plugins.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_read_plugin_file_success() {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let plugin_dir = dir.path().join("my-plugin");
|
||||||
|
fs::create_dir(&plugin_dir).unwrap();
|
||||||
|
fs::write(plugin_dir.join("index.js"), "console.log('hello');").unwrap();
|
||||||
|
|
||||||
|
let result = read_plugin_file(dir.path(), "my-plugin", "index.js");
|
||||||
|
assert_eq!(result.unwrap(), "console.log('hello');");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_read_plugin_file_path_traversal_blocked() {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let plugin_dir = dir.path().join("my-plugin");
|
||||||
|
fs::create_dir(&plugin_dir).unwrap();
|
||||||
|
fs::write(plugin_dir.join("index.js"), "ok").unwrap();
|
||||||
|
|
||||||
|
let result = read_plugin_file(dir.path(), "my-plugin", "../../../etc/passwd");
|
||||||
|
assert!(result.is_err());
|
||||||
|
let err = result.unwrap_err();
|
||||||
|
assert!(err.contains("outside plugin directory") || err.contains("Failed to resolve"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_read_plugin_file_invalid_id() {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let result = read_plugin_file(dir.path(), "../evil", "index.js");
|
||||||
|
assert!(result.is_err());
|
||||||
|
assert!(result.unwrap_err().contains("Invalid plugin ID"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_read_plugin_file_nonexistent_plugin() {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let result = read_plugin_file(dir.path(), "nonexistent", "index.js");
|
||||||
|
assert!(result.is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_plugins_dir_path() {
|
||||||
|
let config = Path::new("/home/user/.config/bterminal");
|
||||||
|
assert_eq!(plugins_dir(config), PathBuf::from("/home/user/.config/bterminal/plugins"));
|
||||||
|
}
|
||||||
|
}
|
||||||
22
v2/src/lib/adapters/plugins-bridge.ts
Normal file
22
v2/src/lib/adapters/plugins-bridge.ts
Normal file
|
|
@ -0,0 +1,22 @@
|
||||||
|
// Plugin discovery and file access — Tauri IPC adapter
|
||||||
|
|
||||||
|
import { invoke } from '@tauri-apps/api/core';
|
||||||
|
|
||||||
|
export interface PluginMeta {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
version: string;
|
||||||
|
description: string;
|
||||||
|
main: string;
|
||||||
|
permissions: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Discover all plugins in ~/.config/bterminal/plugins/ */
|
||||||
|
export async function discoverPlugins(): Promise<PluginMeta[]> {
|
||||||
|
return invoke<PluginMeta[]>('plugins_discover');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Read a file from a plugin's directory (path-traversal safe) */
|
||||||
|
export async function readPluginFile(pluginId: string, filename: string): Promise<string> {
|
||||||
|
return invoke<string>('plugin_read_file', { pluginId, filename });
|
||||||
|
}
|
||||||
203
v2/src/lib/stores/plugins.svelte.ts
Normal file
203
v2/src/lib/stores/plugins.svelte.ts
Normal file
|
|
@ -0,0 +1,203 @@
|
||||||
|
/**
|
||||||
|
* Plugin store — tracks plugin commands, event bus, and plugin state.
|
||||||
|
* Uses Svelte 5 runes for reactivity.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { PluginMeta } from '../adapters/plugins-bridge';
|
||||||
|
import { discoverPlugins } from '../adapters/plugins-bridge';
|
||||||
|
import { getSetting, setSetting } from '../adapters/settings-bridge';
|
||||||
|
import { loadPlugin, unloadPlugin, unloadAllPlugins, getLoadedPlugins } from '../plugins/plugin-host';
|
||||||
|
import type { GroupId, AgentId } from '../types/ids';
|
||||||
|
|
||||||
|
// --- Plugin command registry (for CommandPalette) ---
|
||||||
|
|
||||||
|
export interface PluginCommand {
|
||||||
|
pluginId: string;
|
||||||
|
label: string;
|
||||||
|
callback: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
let commands = $state<PluginCommand[]>([]);
|
||||||
|
|
||||||
|
/** Get all plugin-registered commands (reactive). */
|
||||||
|
export function getPluginCommands(): PluginCommand[] {
|
||||||
|
return commands;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Register a command from a plugin. Called by plugin-host. */
|
||||||
|
export function addPluginCommand(pluginId: string, label: string, callback: () => void): void {
|
||||||
|
commands = [...commands, { pluginId, label, callback }];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Remove all commands registered by a specific plugin. Called on unload. */
|
||||||
|
export function removePluginCommands(pluginId: string): void {
|
||||||
|
commands = commands.filter(c => c.pluginId !== pluginId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Plugin event bus (simple pub/sub) ---
|
||||||
|
|
||||||
|
type EventCallback = (data: unknown) => void;
|
||||||
|
|
||||||
|
class PluginEventBusImpl {
|
||||||
|
private listeners = new Map<string, Set<EventCallback>>();
|
||||||
|
|
||||||
|
on(event: string, callback: EventCallback): void {
|
||||||
|
let set = this.listeners.get(event);
|
||||||
|
if (!set) {
|
||||||
|
set = new Set();
|
||||||
|
this.listeners.set(event, set);
|
||||||
|
}
|
||||||
|
set.add(callback);
|
||||||
|
}
|
||||||
|
|
||||||
|
off(event: string, callback: EventCallback): void {
|
||||||
|
const set = this.listeners.get(event);
|
||||||
|
if (set) {
|
||||||
|
set.delete(callback);
|
||||||
|
if (set.size === 0) this.listeners.delete(event);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
emit(event: string, data?: unknown): void {
|
||||||
|
const set = this.listeners.get(event);
|
||||||
|
if (!set) return;
|
||||||
|
for (const cb of set) {
|
||||||
|
try {
|
||||||
|
cb(data);
|
||||||
|
} catch (e) {
|
||||||
|
console.error(`Plugin event handler error for '${event}':`, e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
clear(): void {
|
||||||
|
this.listeners.clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const pluginEventBus = new PluginEventBusImpl();
|
||||||
|
|
||||||
|
// --- Plugin discovery and lifecycle ---
|
||||||
|
|
||||||
|
export type PluginStatus = 'discovered' | 'loaded' | 'error' | 'disabled';
|
||||||
|
|
||||||
|
export interface PluginEntry {
|
||||||
|
meta: PluginMeta;
|
||||||
|
status: PluginStatus;
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
let pluginEntries = $state<PluginEntry[]>([]);
|
||||||
|
|
||||||
|
/** Get all discovered plugins with their status (reactive). */
|
||||||
|
export function getPluginEntries(): PluginEntry[] {
|
||||||
|
return pluginEntries;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Settings key for plugin enabled state */
|
||||||
|
function pluginEnabledKey(pluginId: string): string {
|
||||||
|
return `plugin_enabled_${pluginId}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Check if a plugin is enabled in settings (default: true for new plugins) */
|
||||||
|
async function isPluginEnabled(pluginId: string): Promise<boolean> {
|
||||||
|
const val = await getSetting(pluginEnabledKey(pluginId));
|
||||||
|
if (val === null || val === undefined) return true; // enabled by default
|
||||||
|
return val === 'true' || val === '1';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Set plugin enabled state */
|
||||||
|
export async function setPluginEnabled(pluginId: string, enabled: boolean): Promise<void> {
|
||||||
|
await setSetting(pluginEnabledKey(pluginId), enabled ? 'true' : 'false');
|
||||||
|
|
||||||
|
// Update in-memory state
|
||||||
|
if (enabled) {
|
||||||
|
const entry = pluginEntries.find(e => e.meta.id === pluginId);
|
||||||
|
if (entry && entry.status === 'disabled') {
|
||||||
|
await loadSinglePlugin(entry);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
unloadPlugin(pluginId);
|
||||||
|
pluginEntries = pluginEntries.map(e =>
|
||||||
|
e.meta.id === pluginId ? { ...e, status: 'disabled' as PluginStatus, error: undefined } : e,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Load a single plugin entry, updating its status */
|
||||||
|
async function loadSinglePlugin(
|
||||||
|
entry: PluginEntry,
|
||||||
|
groupId?: GroupId,
|
||||||
|
agentId?: AgentId,
|
||||||
|
): Promise<void> {
|
||||||
|
const gid = groupId ?? ('' as GroupId);
|
||||||
|
const aid = agentId ?? ('admin' as AgentId);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await loadPlugin(entry.meta, gid, aid);
|
||||||
|
pluginEntries = pluginEntries.map(e =>
|
||||||
|
e.meta.id === entry.meta.id ? { ...e, status: 'loaded' as PluginStatus, error: undefined } : e,
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
const errorMsg = e instanceof Error ? e.message : String(e);
|
||||||
|
console.error(`Failed to load plugin '${entry.meta.id}':`, errorMsg);
|
||||||
|
pluginEntries = pluginEntries.map(e =>
|
||||||
|
e.meta.id === entry.meta.id ? { ...e, status: 'error' as PluginStatus, error: errorMsg } : e,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Discover and load all enabled plugins.
|
||||||
|
* Called at app startup or when reloading plugins.
|
||||||
|
*/
|
||||||
|
export async function loadAllPlugins(groupId?: GroupId, agentId?: AgentId): Promise<void> {
|
||||||
|
// Unload any currently loaded plugins first
|
||||||
|
unloadAllPlugins();
|
||||||
|
pluginEventBus.clear();
|
||||||
|
commands = [];
|
||||||
|
|
||||||
|
let discovered: PluginMeta[];
|
||||||
|
try {
|
||||||
|
discovered = await discoverPlugins();
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to discover plugins:', e);
|
||||||
|
pluginEntries = [];
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build entries with initial status
|
||||||
|
const entries: PluginEntry[] = [];
|
||||||
|
for (const meta of discovered) {
|
||||||
|
const enabled = await isPluginEnabled(meta.id);
|
||||||
|
entries.push({
|
||||||
|
meta,
|
||||||
|
status: enabled ? 'discovered' : 'disabled',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
pluginEntries = entries;
|
||||||
|
|
||||||
|
// Load enabled plugins
|
||||||
|
for (const entry of pluginEntries) {
|
||||||
|
if (entry.status === 'discovered') {
|
||||||
|
await loadSinglePlugin(entry, groupId, agentId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reload all plugins (re-discover and re-load).
|
||||||
|
*/
|
||||||
|
export async function reloadAllPlugins(groupId?: GroupId, agentId?: AgentId): Promise<void> {
|
||||||
|
await loadAllPlugins(groupId, agentId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clean up all plugins and state.
|
||||||
|
*/
|
||||||
|
export function destroyAllPlugins(): void {
|
||||||
|
unloadAllPlugins();
|
||||||
|
pluginEventBus.clear();
|
||||||
|
commands = [];
|
||||||
|
pluginEntries = [];
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue