feat(FN-1111): merge fusion/fn-1111 (auto-resolved)
- docs(FN-1111): complete Step 8 - plugin system documentation - feat(FN-1111): complete Step 7 - testing and build fixes - feat(FN-1111): complete Step 6 - export plugin types from core - feat(FN-1111): complete Step 5 - plugin SDK package - feat(FN-1111): complete Step 4 - plugin loader with lifecycle management - feat(FN-1111): complete Step 3 - plugin store with CRUD - feat(FN-1111): complete Step 2 - add plugins table schema migration - feat(FN-1111): complete Step 1 - plugin type definitions
This commit is contained in:
@@ -51,7 +51,7 @@ describe("TaskStore task documents", () => {
|
||||
|
||||
expect(tableNames.has("task_documents")).toBe(true);
|
||||
expect(tableNames.has("task_document_revisions")).toBe(true);
|
||||
expect(db.getSchemaVersion()).toBe(23);
|
||||
expect(db.getSchemaVersion()).toBe(24);
|
||||
|
||||
const index = db
|
||||
.prepare(
|
||||
|
||||
@@ -106,7 +106,7 @@ describe("Database", () => {
|
||||
});
|
||||
|
||||
it("seeds schema version", () => {
|
||||
expect(db.getSchemaVersion()).toBe(23);
|
||||
expect(db.getSchemaVersion()).toBe(24);
|
||||
});
|
||||
|
||||
it("seeds lastModified", () => {
|
||||
@@ -129,7 +129,7 @@ describe("Database", () => {
|
||||
|
||||
it("is idempotent - calling init() twice does not fail", () => {
|
||||
expect(() => db.init()).not.toThrow();
|
||||
expect(db.getSchemaVersion()).toBe(23);
|
||||
expect(db.getSchemaVersion()).toBe(24);
|
||||
});
|
||||
|
||||
it("does not overwrite existing config on re-init", () => {
|
||||
@@ -736,7 +736,7 @@ describe("schema migrations", () => {
|
||||
db.init();
|
||||
|
||||
// Verify version bumped to 22 (includes v1→v2 through v21→v22)
|
||||
expect(db.getSchemaVersion()).toBe(23);
|
||||
expect(db.getSchemaVersion()).toBe(24);
|
||||
|
||||
// Verify new columns exist and existing data is intact
|
||||
const cols = db.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>;
|
||||
@@ -761,11 +761,11 @@ describe("schema migrations", () => {
|
||||
const db = new Database(kbDir);
|
||||
db.init();
|
||||
|
||||
expect(db.getSchemaVersion()).toBe(23);
|
||||
expect(db.getSchemaVersion()).toBe(24);
|
||||
|
||||
// Re-init should not fail
|
||||
db.init();
|
||||
expect(db.getSchemaVersion()).toBe(23);
|
||||
expect(db.getSchemaVersion()).toBe(24);
|
||||
|
||||
db.close();
|
||||
});
|
||||
@@ -781,7 +781,7 @@ describe("schema migrations", () => {
|
||||
|
||||
db.init();
|
||||
|
||||
expect(db.getSchemaVersion()).toBe(23);
|
||||
expect(db.getSchemaVersion()).toBe(24);
|
||||
|
||||
const tables = db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name = 'agentRatings'").all() as Array<{ name: string }>;
|
||||
expect(tables).toEqual([{ name: "agentRatings" }]);
|
||||
@@ -805,7 +805,7 @@ describe("schema migrations", () => {
|
||||
|
||||
db.init();
|
||||
|
||||
expect(db.getSchemaVersion()).toBe(23);
|
||||
expect(db.getSchemaVersion()).toBe(24);
|
||||
|
||||
const tables = db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name = 'mission_events'").all() as Array<{ name: string }>;
|
||||
expect(tables).toEqual([{ name: "mission_events" }]);
|
||||
@@ -909,7 +909,7 @@ describe("schema migrations", () => {
|
||||
db.init();
|
||||
|
||||
// Verify version bumped to 22
|
||||
expect(db.getSchemaVersion()).toBe(23);
|
||||
expect(db.getSchemaVersion()).toBe(24);
|
||||
|
||||
// Verify new columns exist and existing data is intact
|
||||
const cols = db.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>;
|
||||
@@ -1275,7 +1275,7 @@ describe("createDatabase factory", () => {
|
||||
const db = createDatabase(kbDir);
|
||||
db.init();
|
||||
|
||||
expect(db.getSchemaVersion()).toBe(23);
|
||||
expect(db.getSchemaVersion()).toBe(24);
|
||||
expect(db.getLastModified()).toBeGreaterThan(0);
|
||||
|
||||
db.close();
|
||||
|
||||
@@ -59,7 +59,7 @@ export function fromJson<T>(json: string | null | undefined): T | undefined {
|
||||
|
||||
// ── Schema Definition ────────────────────────────────────────────────
|
||||
|
||||
const SCHEMA_VERSION = 23;
|
||||
const SCHEMA_VERSION = 24;
|
||||
|
||||
function normalizeTaskComments(
|
||||
steeringComments: SteeringComment[] | undefined,
|
||||
@@ -373,6 +373,25 @@ CREATE TABLE IF NOT EXISTS mission_events (
|
||||
CREATE INDEX IF NOT EXISTS idxMissionEventsMissionId ON mission_events(missionId);
|
||||
CREATE INDEX IF NOT EXISTS idxMissionEventsTimestamp ON mission_events(timestamp);
|
||||
CREATE INDEX IF NOT EXISTS idxMissionEventsType ON mission_events(eventType);
|
||||
|
||||
-- Plugins table for plugin system
|
||||
CREATE TABLE IF NOT EXISTS plugins (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
version TEXT NOT NULL,
|
||||
description TEXT,
|
||||
author TEXT,
|
||||
homepage TEXT,
|
||||
path TEXT NOT NULL,
|
||||
enabled INTEGER DEFAULT 1,
|
||||
state TEXT NOT NULL DEFAULT 'installed',
|
||||
settings TEXT DEFAULT '{}',
|
||||
settingsSchema TEXT,
|
||||
error TEXT,
|
||||
dependencies TEXT DEFAULT '[]',
|
||||
createdAt TEXT NOT NULL,
|
||||
updatedAt TEXT NOT NULL
|
||||
);
|
||||
`;
|
||||
|
||||
// ── Database Class ───────────────────────────────────────────────────
|
||||
@@ -862,6 +881,30 @@ export class Database {
|
||||
this.addColumnIfMissing("mission_events", "seq", "INTEGER NOT NULL DEFAULT 0");
|
||||
});
|
||||
}
|
||||
|
||||
if (version < 24) {
|
||||
this.applyMigration(24, () => {
|
||||
this.db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS plugins (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
version TEXT NOT NULL,
|
||||
description TEXT,
|
||||
author TEXT,
|
||||
homepage TEXT,
|
||||
path TEXT NOT NULL,
|
||||
enabled INTEGER DEFAULT 1,
|
||||
state TEXT NOT NULL DEFAULT 'installed',
|
||||
settings TEXT DEFAULT '{}',
|
||||
settingsSchema TEXT,
|
||||
error TEXT,
|
||||
dependencies TEXT DEFAULT '[]',
|
||||
createdAt TEXT NOT NULL,
|
||||
updatedAt TEXT NOT NULL
|
||||
)
|
||||
`);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -43,6 +43,33 @@ export { AUTOMATION_PRESETS, MAX_RUN_HISTORY } from "./automation.js";
|
||||
export type { ScheduleType, ScheduledTask, ScheduledTaskCreateInput, ScheduledTaskUpdateInput, AutomationRunResult, AutomationStepType, AutomationStep, AutomationStepResult } from "./automation.js";
|
||||
export { AutomationStore } from "./automation-store.js";
|
||||
export type { AutomationStoreEvents } from "./automation-store.js";
|
||||
|
||||
// ── Plugin System ─────────────────────────────────────────────────────
|
||||
export type {
|
||||
PluginManifest,
|
||||
PluginSettingSchema,
|
||||
PluginSettingType,
|
||||
PluginOnLoad,
|
||||
PluginOnUnload,
|
||||
PluginOnTaskCreated,
|
||||
PluginOnTaskMoved,
|
||||
PluginOnTaskCompleted,
|
||||
PluginOnError,
|
||||
PluginToolDefinition,
|
||||
PluginToolResult,
|
||||
PluginRouteDefinition,
|
||||
PluginRouteMethod,
|
||||
PluginContext,
|
||||
PluginLogger,
|
||||
FusionPlugin,
|
||||
PluginState,
|
||||
PluginInstallation,
|
||||
} from "./plugin-types.js";
|
||||
export { validatePluginManifest } from "./plugin-types.js";
|
||||
export { PluginStore } from "./plugin-store.js";
|
||||
export type { PluginStoreEvents, PluginRegistrationInput, PluginUpdateInput } from "./plugin-store.js";
|
||||
export { PluginLoader } from "./plugin-loader.js";
|
||||
export type { PluginLoaderOptions } from "./plugin-loader.js";
|
||||
export {
|
||||
BackupManager,
|
||||
createBackupManager,
|
||||
|
||||
865
packages/core/src/plugin-loader.test.ts
Normal file
865
packages/core/src/plugin-loader.test.ts
Normal file
@@ -0,0 +1,865 @@
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
||||
import { writeFile, mkdir } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { mkdtempSync, existsSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { PluginLoader } from "./plugin-loader.js";
|
||||
import { PluginStore } from "./plugin-store.js";
|
||||
import type { FusionPlugin, PluginManifest } from "./plugin-types.js";
|
||||
|
||||
// Test plugin manifest
|
||||
function makeManifest(overrides: Partial<PluginManifest> = {}): PluginManifest {
|
||||
return {
|
||||
id: "test-plugin",
|
||||
name: "Test Plugin",
|
||||
version: "1.0.0",
|
||||
description: "A test plugin",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
// Create a minimal FusionPlugin for testing
|
||||
function makePlugin(manifest: PluginManifest): FusionPlugin {
|
||||
return {
|
||||
manifest,
|
||||
state: "installed",
|
||||
hooks: {},
|
||||
tools: [],
|
||||
routes: [],
|
||||
};
|
||||
}
|
||||
|
||||
// Write a plugin module to disk - creates a simple module without hooks
|
||||
async function writePluginModule(
|
||||
dir: string,
|
||||
filename: string,
|
||||
plugin: FusionPlugin,
|
||||
): Promise<string> {
|
||||
const filepath = join(dir, filename);
|
||||
await mkdir(dir, { recursive: true });
|
||||
|
||||
const manifest = JSON.stringify(plugin.manifest, null, 2);
|
||||
|
||||
// Create a module that exports the plugin
|
||||
const moduleCode = `
|
||||
const manifest = ${manifest};
|
||||
const plugin = {
|
||||
manifest,
|
||||
state: "${plugin.state}",
|
||||
hooks: {},
|
||||
tools: ${JSON.stringify(plugin.tools || [])},
|
||||
routes: ${JSON.stringify(plugin.routes || [])},
|
||||
};
|
||||
|
||||
export default plugin;
|
||||
export { plugin };
|
||||
`;
|
||||
|
||||
await writeFile(filepath, moduleCode);
|
||||
return filepath;
|
||||
}
|
||||
|
||||
// Create a plugin module with hooks
|
||||
async function writePluginWithHooks(
|
||||
dir: string,
|
||||
filename: string,
|
||||
hooks: {
|
||||
onLoad?: string;
|
||||
onUnload?: string;
|
||||
onTaskCreated?: string;
|
||||
onError?: string;
|
||||
},
|
||||
manifest: PluginManifest,
|
||||
): Promise<string> {
|
||||
const filepath = join(dir, filename);
|
||||
await mkdir(dir, { recursive: true });
|
||||
|
||||
const manifestStr = JSON.stringify(manifest, null, 2);
|
||||
|
||||
const hooksCode = Object.entries(hooks)
|
||||
.map(([name, body]) => `${name}: ${body}`)
|
||||
.join(",\n ");
|
||||
|
||||
const moduleCode = `
|
||||
const manifest = ${manifestStr};
|
||||
const plugin = {
|
||||
manifest,
|
||||
state: "installed",
|
||||
hooks: {
|
||||
${hooksCode}
|
||||
},
|
||||
tools: [],
|
||||
routes: [],
|
||||
};
|
||||
|
||||
export default plugin;
|
||||
export { plugin };
|
||||
`;
|
||||
|
||||
await writeFile(filepath, moduleCode);
|
||||
return filepath;
|
||||
}
|
||||
|
||||
function makeTmpDir(): string {
|
||||
return mkdtempSync(join(tmpdir(), "kb-plugin-loader-test-"));
|
||||
}
|
||||
|
||||
// Mock TaskStore for testing
|
||||
const mockTaskStore = {
|
||||
logActivity: vi.fn(),
|
||||
} as any;
|
||||
|
||||
describe("PluginLoader", () => {
|
||||
let rootDir: string;
|
||||
let pluginStore: PluginStore;
|
||||
let loader: PluginLoader;
|
||||
|
||||
beforeEach(() => {
|
||||
rootDir = makeTmpDir();
|
||||
pluginStore = new PluginStore(rootDir);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
const { rm } = await import("node:fs/promises");
|
||||
await rm(rootDir, { recursive: true, force: true });
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
// ── Constructor & init ─────────────────────────────────────────────
|
||||
|
||||
describe("constructor", () => {
|
||||
it("creates loader with options", () => {
|
||||
const loader = new PluginLoader({
|
||||
pluginStore,
|
||||
taskStore: mockTaskStore,
|
||||
});
|
||||
expect(loader).toBeTruthy();
|
||||
});
|
||||
|
||||
it("accepts custom plugin directories", () => {
|
||||
const loader = new PluginLoader({
|
||||
pluginStore,
|
||||
taskStore: mockTaskStore,
|
||||
pluginDirs: ["/custom/plugins"],
|
||||
});
|
||||
expect(loader).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
// ── resolveLoadOrder ──────────────────────────────────────────────
|
||||
|
||||
describe("resolveLoadOrder", () => {
|
||||
it("returns plugins in dependency order", async () => {
|
||||
await pluginStore.init();
|
||||
await pluginStore.registerPlugin({
|
||||
manifest: makeManifest({ id: "plugin-a", dependencies: [] }),
|
||||
path: "/a",
|
||||
});
|
||||
await pluginStore.registerPlugin({
|
||||
manifest: makeManifest({ id: "plugin-b", dependencies: ["plugin-a"] }),
|
||||
path: "/b",
|
||||
});
|
||||
|
||||
const loader = new PluginLoader({
|
||||
pluginStore,
|
||||
taskStore: mockTaskStore,
|
||||
});
|
||||
|
||||
const plugins = await pluginStore.listPlugins();
|
||||
const sorted = loader.resolveLoadOrder(plugins);
|
||||
|
||||
expect(sorted[0].id).toBe("plugin-a");
|
||||
expect(sorted[1].id).toBe("plugin-b");
|
||||
});
|
||||
|
||||
it("handles complex dependency chains", async () => {
|
||||
await pluginStore.init();
|
||||
await pluginStore.registerPlugin({
|
||||
manifest: makeManifest({ id: "base", dependencies: [] }),
|
||||
path: "/base",
|
||||
});
|
||||
await pluginStore.registerPlugin({
|
||||
manifest: makeManifest({ id: "middle", dependencies: ["base"] }),
|
||||
path: "/middle",
|
||||
});
|
||||
await pluginStore.registerPlugin({
|
||||
manifest: makeManifest({ id: "top", dependencies: ["middle", "base"] }),
|
||||
path: "/top",
|
||||
});
|
||||
|
||||
const loader = new PluginLoader({
|
||||
pluginStore,
|
||||
taskStore: mockTaskStore,
|
||||
});
|
||||
|
||||
const plugins = await pluginStore.listPlugins();
|
||||
const sorted = loader.resolveLoadOrder(plugins);
|
||||
|
||||
// base must come before middle and top
|
||||
expect(sorted.findIndex((p) => p.id === "base")).toBeLessThan(
|
||||
sorted.findIndex((p) => p.id === "middle"),
|
||||
);
|
||||
expect(sorted.findIndex((p) => p.id === "base")).toBeLessThan(
|
||||
sorted.findIndex((p) => p.id === "top"),
|
||||
);
|
||||
// middle must come before top
|
||||
expect(sorted.findIndex((p) => p.id === "middle")).toBeLessThan(
|
||||
sorted.findIndex((p) => p.id === "top"),
|
||||
);
|
||||
});
|
||||
|
||||
it("throws on circular dependencies", async () => {
|
||||
await pluginStore.init();
|
||||
await pluginStore.registerPlugin({
|
||||
manifest: makeManifest({ id: "a", dependencies: ["b"] }),
|
||||
path: "/a",
|
||||
});
|
||||
await pluginStore.registerPlugin({
|
||||
manifest: makeManifest({ id: "b", dependencies: ["a"] }),
|
||||
path: "/b",
|
||||
});
|
||||
|
||||
const loader = new PluginLoader({
|
||||
pluginStore,
|
||||
taskStore: mockTaskStore,
|
||||
});
|
||||
|
||||
const plugins = await pluginStore.listPlugins();
|
||||
expect(() => loader.resolveLoadOrder(plugins)).toThrow(
|
||||
"Circular dependency detected",
|
||||
);
|
||||
});
|
||||
|
||||
it("handles plugins with no dependencies", async () => {
|
||||
await pluginStore.init();
|
||||
await pluginStore.registerPlugin({
|
||||
manifest: makeManifest({ id: "solo" }),
|
||||
path: "/solo",
|
||||
});
|
||||
|
||||
const loader = new PluginLoader({
|
||||
pluginStore,
|
||||
taskStore: mockTaskStore,
|
||||
});
|
||||
|
||||
const plugins = await pluginStore.listPlugins();
|
||||
const sorted = loader.resolveLoadOrder(plugins);
|
||||
|
||||
expect(sorted).toHaveLength(1);
|
||||
expect(sorted[0].id).toBe("solo");
|
||||
});
|
||||
});
|
||||
|
||||
// ── loadPlugin ─────────────────────────────────────────────────────
|
||||
|
||||
describe("loadPlugin", () => {
|
||||
it("loads a valid plugin from file path", async () => {
|
||||
await pluginStore.init();
|
||||
|
||||
const pluginDir = join(rootDir, "plugins");
|
||||
const plugin = makePlugin(makeManifest({ id: "load-test" }));
|
||||
const pluginPath = await writePluginModule(pluginDir, "index.js", plugin);
|
||||
|
||||
await pluginStore.registerPlugin({
|
||||
manifest: plugin.manifest,
|
||||
path: pluginPath,
|
||||
});
|
||||
|
||||
const loader = new PluginLoader({
|
||||
pluginStore,
|
||||
taskStore: mockTaskStore,
|
||||
});
|
||||
|
||||
const loaded = await loader.loadPlugin("load-test");
|
||||
|
||||
expect(loaded.manifest.id).toBe("load-test");
|
||||
expect(loaded.state).toBe("started");
|
||||
expect(loader.isPluginLoaded("load-test")).toBe(true);
|
||||
});
|
||||
|
||||
it("updates plugin state to started", async () => {
|
||||
await pluginStore.init();
|
||||
|
||||
const plugin = makePlugin(makeManifest({ id: "state-test" }));
|
||||
const pluginDir = join(rootDir, "plugins");
|
||||
const pluginPath = await writePluginModule(pluginDir, "index.js", plugin);
|
||||
|
||||
await pluginStore.registerPlugin({
|
||||
manifest: plugin.manifest,
|
||||
path: pluginPath,
|
||||
});
|
||||
|
||||
const loader = new PluginLoader({
|
||||
pluginStore,
|
||||
taskStore: mockTaskStore,
|
||||
});
|
||||
|
||||
await loader.loadPlugin("state-test");
|
||||
|
||||
const updated = await pluginStore.getPlugin("state-test");
|
||||
expect(updated.state).toBe("started");
|
||||
});
|
||||
|
||||
it("skips disabled plugins", async () => {
|
||||
await pluginStore.init();
|
||||
|
||||
const plugin = makePlugin(makeManifest({ id: "disabled-test" }));
|
||||
const pluginDir = join(rootDir, "plugins");
|
||||
const pluginPath = await writePluginModule(pluginDir, "index.js", plugin);
|
||||
|
||||
await pluginStore.registerPlugin({
|
||||
manifest: plugin.manifest,
|
||||
path: pluginPath,
|
||||
});
|
||||
await pluginStore.disablePlugin("disabled-test");
|
||||
|
||||
const loader = new PluginLoader({
|
||||
pluginStore,
|
||||
taskStore: mockTaskStore,
|
||||
});
|
||||
|
||||
await expect(loader.loadPlugin("disabled-test")).rejects.toThrow(
|
||||
"disabled",
|
||||
);
|
||||
});
|
||||
|
||||
it("loads dependencies before loading dependent", async () => {
|
||||
await pluginStore.init();
|
||||
|
||||
const depPlugin = makePlugin(makeManifest({ id: "dep-plugin" }));
|
||||
const mainPlugin = makePlugin(
|
||||
makeManifest({ id: "main-plugin", dependencies: ["dep-plugin"] }),
|
||||
);
|
||||
|
||||
const pluginDir = join(rootDir, "plugins");
|
||||
const depPath = await writePluginModule(pluginDir, "dep.js", depPlugin);
|
||||
const mainPath = await writePluginModule(pluginDir, "main.js", mainPlugin);
|
||||
|
||||
await pluginStore.registerPlugin({
|
||||
manifest: depPlugin.manifest,
|
||||
path: depPath,
|
||||
});
|
||||
await pluginStore.registerPlugin({
|
||||
manifest: mainPlugin.manifest,
|
||||
path: mainPath,
|
||||
});
|
||||
|
||||
const loader = new PluginLoader({
|
||||
pluginStore,
|
||||
taskStore: mockTaskStore,
|
||||
});
|
||||
|
||||
// Use loadAllPlugins to test dependency ordering
|
||||
const result = await loader.loadAllPlugins();
|
||||
|
||||
expect(result.loaded).toBe(2);
|
||||
expect(loader.isPluginLoaded("dep-plugin")).toBe(true);
|
||||
expect(loader.isPluginLoaded("main-plugin")).toBe(true);
|
||||
});
|
||||
|
||||
it("fails when dependency is missing", async () => {
|
||||
await pluginStore.init();
|
||||
|
||||
const plugin = makePlugin(
|
||||
makeManifest({ id: "orphan-plugin", dependencies: ["nonexistent"] }),
|
||||
);
|
||||
|
||||
const pluginDir = join(rootDir, "plugins");
|
||||
const pluginPath = await writePluginModule(pluginDir, "index.js", plugin);
|
||||
|
||||
await pluginStore.registerPlugin({
|
||||
manifest: plugin.manifest,
|
||||
path: pluginPath,
|
||||
});
|
||||
|
||||
const loader = new PluginLoader({
|
||||
pluginStore,
|
||||
taskStore: mockTaskStore,
|
||||
});
|
||||
|
||||
await expect(loader.loadPlugin("orphan-plugin")).rejects.toThrow(
|
||||
"depends on nonexistent",
|
||||
);
|
||||
});
|
||||
|
||||
it("error isolation - plugin crash during load doesn't crash loader", async () => {
|
||||
await pluginStore.init();
|
||||
|
||||
const pluginDir = join(rootDir, "plugins");
|
||||
const pluginPath = await writePluginWithHooks(
|
||||
pluginDir,
|
||||
"bad.js",
|
||||
{
|
||||
onLoad: "(async () => { throw new Error('Plugin crashed!'); })",
|
||||
},
|
||||
makeManifest({ id: "bad-plugin" }),
|
||||
);
|
||||
|
||||
await pluginStore.registerPlugin({
|
||||
manifest: makeManifest({ id: "bad-plugin" }),
|
||||
path: pluginPath,
|
||||
});
|
||||
|
||||
const loader = new PluginLoader({
|
||||
pluginStore,
|
||||
taskStore: mockTaskStore,
|
||||
});
|
||||
|
||||
// Should throw but not crash the process
|
||||
await expect(loader.loadPlugin("bad-plugin")).rejects.toThrow(
|
||||
"Plugin crashed!",
|
||||
);
|
||||
|
||||
// Plugin should be in error state
|
||||
const updated = await pluginStore.getPlugin("bad-plugin");
|
||||
expect(updated.state).toBe("error");
|
||||
expect(updated.error).toContain("Plugin crashed!");
|
||||
});
|
||||
});
|
||||
|
||||
// ── loadAllPlugins ─────────────────────────────────────────────────
|
||||
|
||||
describe("loadAllPlugins", () => {
|
||||
it("loads all enabled plugins", async () => {
|
||||
await pluginStore.init();
|
||||
|
||||
const plugins: FusionPlugin[] = [
|
||||
makePlugin(makeManifest({ id: "all-a" })),
|
||||
makePlugin(makeManifest({ id: "all-b", dependencies: ["all-a"] })),
|
||||
];
|
||||
|
||||
const pluginDir = join(rootDir, "plugins");
|
||||
for (const plugin of plugins) {
|
||||
const path = await writePluginModule(
|
||||
pluginDir,
|
||||
`${plugin.manifest.id}.js`,
|
||||
plugin,
|
||||
);
|
||||
await pluginStore.registerPlugin({
|
||||
manifest: plugin.manifest,
|
||||
path,
|
||||
});
|
||||
}
|
||||
|
||||
const loader = new PluginLoader({
|
||||
pluginStore,
|
||||
taskStore: mockTaskStore,
|
||||
});
|
||||
|
||||
const result = await loader.loadAllPlugins();
|
||||
|
||||
expect(result.loaded).toBe(2);
|
||||
expect(result.errors).toBe(0);
|
||||
expect(loader.isPluginLoaded("all-a")).toBe(true);
|
||||
expect(loader.isPluginLoaded("all-b")).toBe(true);
|
||||
});
|
||||
|
||||
it("returns error count for failed plugins", async () => {
|
||||
await pluginStore.init();
|
||||
|
||||
const goodPlugin = makePlugin(makeManifest({ id: "good-plugin" }));
|
||||
const pluginDir = join(rootDir, "plugins");
|
||||
|
||||
const goodPath = await writePluginModule(
|
||||
pluginDir,
|
||||
"good.js",
|
||||
goodPlugin,
|
||||
);
|
||||
const badPath = await writePluginWithHooks(
|
||||
pluginDir,
|
||||
"bad.js",
|
||||
{
|
||||
onLoad: "(async () => { throw new Error('Load failed'); })",
|
||||
},
|
||||
makeManifest({ id: "bad-plugin" }),
|
||||
);
|
||||
|
||||
await pluginStore.registerPlugin({
|
||||
manifest: goodPlugin.manifest,
|
||||
path: goodPath,
|
||||
});
|
||||
await pluginStore.registerPlugin({
|
||||
manifest: makeManifest({ id: "bad-plugin" }),
|
||||
path: badPath,
|
||||
});
|
||||
|
||||
const loader = new PluginLoader({
|
||||
pluginStore,
|
||||
taskStore: mockTaskStore,
|
||||
});
|
||||
|
||||
const result = await loader.loadAllPlugins();
|
||||
|
||||
expect(result.loaded).toBe(1);
|
||||
expect(result.errors).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
// ── stopPlugin ────────────────────────────────────────────────────
|
||||
|
||||
describe("stopPlugin", () => {
|
||||
it("updates plugin state to stopped", async () => {
|
||||
await pluginStore.init();
|
||||
|
||||
const plugin = makePlugin(makeManifest({ id: "stop-state-test" }));
|
||||
const pluginDir = join(rootDir, "plugins");
|
||||
const pluginPath = await writePluginModule(pluginDir, "index.js", plugin);
|
||||
|
||||
await pluginStore.registerPlugin({
|
||||
manifest: plugin.manifest,
|
||||
path: pluginPath,
|
||||
});
|
||||
|
||||
const loader = new PluginLoader({
|
||||
pluginStore,
|
||||
taskStore: mockTaskStore,
|
||||
});
|
||||
|
||||
await loader.loadPlugin("stop-state-test");
|
||||
await loader.stopPlugin("stop-state-test");
|
||||
|
||||
const updated = await pluginStore.getPlugin("stop-state-test");
|
||||
expect(updated.state).toBe("stopped");
|
||||
});
|
||||
|
||||
it("removes plugin from loaded map", async () => {
|
||||
await pluginStore.init();
|
||||
|
||||
const plugin = makePlugin(makeManifest({ id: "remove-test" }));
|
||||
const pluginDir = join(rootDir, "plugins");
|
||||
const pluginPath = await writePluginModule(pluginDir, "index.js", plugin);
|
||||
|
||||
await pluginStore.registerPlugin({
|
||||
manifest: plugin.manifest,
|
||||
path: pluginPath,
|
||||
});
|
||||
|
||||
const loader = new PluginLoader({
|
||||
pluginStore,
|
||||
taskStore: mockTaskStore,
|
||||
});
|
||||
|
||||
await loader.loadPlugin("remove-test");
|
||||
expect(loader.isPluginLoaded("remove-test")).toBe(true);
|
||||
|
||||
await loader.stopPlugin("remove-test");
|
||||
expect(loader.isPluginLoaded("remove-test")).toBe(false);
|
||||
});
|
||||
|
||||
it("no-ops for non-loaded plugin", async () => {
|
||||
await pluginStore.init();
|
||||
|
||||
const loader = new PluginLoader({
|
||||
pluginStore,
|
||||
taskStore: mockTaskStore,
|
||||
});
|
||||
|
||||
// Should not throw
|
||||
await loader.stopPlugin("nonexistent");
|
||||
});
|
||||
});
|
||||
|
||||
// ── stopAllPlugins ─────────────────────────────────────────────────
|
||||
|
||||
describe("stopAllPlugins", () => {
|
||||
it("stops all loaded plugins", async () => {
|
||||
await pluginStore.init();
|
||||
|
||||
const plugins: FusionPlugin[] = [
|
||||
makePlugin(makeManifest({ id: "stop-all-a" })),
|
||||
makePlugin(makeManifest({ id: "stop-all-b" })),
|
||||
];
|
||||
|
||||
const pluginDir = join(rootDir, "plugins");
|
||||
for (const plugin of plugins) {
|
||||
const path = await writePluginModule(
|
||||
pluginDir,
|
||||
`${plugin.manifest.id}.js`,
|
||||
plugin,
|
||||
);
|
||||
await pluginStore.registerPlugin({
|
||||
manifest: plugin.manifest,
|
||||
path,
|
||||
});
|
||||
}
|
||||
|
||||
const loader = new PluginLoader({
|
||||
pluginStore,
|
||||
taskStore: mockTaskStore,
|
||||
});
|
||||
|
||||
await loader.loadAllPlugins();
|
||||
await loader.stopAllPlugins();
|
||||
|
||||
expect(loader.isPluginLoaded("stop-all-a")).toBe(false);
|
||||
expect(loader.isPluginLoaded("stop-all-b")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// ── invokeHook ───────────────────────────────────────────────────
|
||||
|
||||
describe("invokeHook", () => {
|
||||
it("calls hook on all plugins with the hook", async () => {
|
||||
await pluginStore.init();
|
||||
|
||||
const hookA = vi.fn();
|
||||
const hookB = vi.fn();
|
||||
|
||||
const loader = new PluginLoader({
|
||||
pluginStore,
|
||||
taskStore: mockTaskStore,
|
||||
});
|
||||
|
||||
// Manually add plugins with hooks to the loader's internal state
|
||||
(loader as any).plugins.set("hook-a", {
|
||||
manifest: makeManifest({ id: "hook-a" }),
|
||||
state: "started",
|
||||
hooks: { onTaskCreated: hookA },
|
||||
tools: [],
|
||||
routes: [],
|
||||
} as FusionPlugin);
|
||||
(loader as any).plugins.set("hook-b", {
|
||||
manifest: makeManifest({ id: "hook-b" }),
|
||||
state: "started",
|
||||
hooks: { onTaskCreated: hookB },
|
||||
tools: [],
|
||||
routes: [],
|
||||
} as FusionPlugin);
|
||||
|
||||
await loader.invokeHook("onTaskCreated", { id: "FN-001" } as any);
|
||||
|
||||
expect(hookA).toHaveBeenCalledTimes(1);
|
||||
expect(hookB).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("continues when one plugin's hook fails", async () => {
|
||||
await pluginStore.init();
|
||||
|
||||
const hookGood = vi.fn();
|
||||
const hookBad = vi.fn().mockImplementation(() => {
|
||||
throw new Error("Hook failed!");
|
||||
});
|
||||
|
||||
const loader = new PluginLoader({
|
||||
pluginStore,
|
||||
taskStore: mockTaskStore,
|
||||
});
|
||||
|
||||
// Manually add plugins with hooks
|
||||
(loader as any).plugins.set("good-hook", {
|
||||
manifest: makeManifest({ id: "good-hook" }),
|
||||
state: "started",
|
||||
hooks: { onTaskCreated: hookGood },
|
||||
tools: [],
|
||||
routes: [],
|
||||
} as FusionPlugin);
|
||||
(loader as any).plugins.set("bad-hook", {
|
||||
manifest: makeManifest({ id: "bad-hook" }),
|
||||
state: "started",
|
||||
hooks: { onTaskCreated: hookBad },
|
||||
tools: [],
|
||||
routes: [],
|
||||
} as FusionPlugin);
|
||||
|
||||
// Should not throw
|
||||
await loader.invokeHook("onTaskCreated", { id: "FN-001" } as any);
|
||||
|
||||
// Both hooks were attempted
|
||||
expect(hookGood).toHaveBeenCalledTimes(1);
|
||||
expect(hookBad).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("no error when plugin doesn't have the hook", async () => {
|
||||
await pluginStore.init();
|
||||
|
||||
const loader = new PluginLoader({
|
||||
pluginStore,
|
||||
taskStore: mockTaskStore,
|
||||
});
|
||||
|
||||
// Manually add plugin without hooks
|
||||
(loader as any).plugins.set("no-hook", {
|
||||
manifest: makeManifest({ id: "no-hook" }),
|
||||
state: "started",
|
||||
hooks: {},
|
||||
tools: [],
|
||||
routes: [],
|
||||
} as FusionPlugin);
|
||||
|
||||
// Should not throw even though plugin has no hooks
|
||||
await loader.invokeHook("onTaskCreated", { id: "FN-001" } as any);
|
||||
});
|
||||
});
|
||||
|
||||
// ── getPluginTools ─────────────────────────────────────────────────
|
||||
|
||||
describe("getPluginTools", () => {
|
||||
it("aggregates tools from all loaded plugins", async () => {
|
||||
await pluginStore.init();
|
||||
|
||||
const loader = new PluginLoader({
|
||||
pluginStore,
|
||||
taskStore: mockTaskStore,
|
||||
});
|
||||
|
||||
// Manually add plugins with tools
|
||||
(loader as any).plugins.set("tools-a", {
|
||||
manifest: makeManifest({ id: "tools-a" }),
|
||||
state: "started",
|
||||
hooks: {},
|
||||
tools: [
|
||||
{
|
||||
name: "tool_a1",
|
||||
description: "Tool A1",
|
||||
parameters: {},
|
||||
execute: async () => ({ content: [] }),
|
||||
},
|
||||
],
|
||||
routes: [],
|
||||
} as FusionPlugin);
|
||||
(loader as any).plugins.set("tools-b", {
|
||||
manifest: makeManifest({ id: "tools-b" }),
|
||||
state: "started",
|
||||
hooks: {},
|
||||
tools: [
|
||||
{
|
||||
name: "tool_b1",
|
||||
description: "Tool B1",
|
||||
parameters: {},
|
||||
execute: async () => ({ content: [] }),
|
||||
},
|
||||
],
|
||||
routes: [],
|
||||
} as FusionPlugin);
|
||||
|
||||
const tools = loader.getPluginTools();
|
||||
|
||||
expect(tools).toHaveLength(2);
|
||||
expect(tools.map((t) => t.name)).toContain("tool_a1");
|
||||
expect(tools.map((t) => t.name)).toContain("tool_b1");
|
||||
});
|
||||
|
||||
it("returns empty array when no plugins have tools", async () => {
|
||||
await pluginStore.init();
|
||||
|
||||
const loader = new PluginLoader({
|
||||
pluginStore,
|
||||
taskStore: mockTaskStore,
|
||||
});
|
||||
|
||||
// Manually add plugin without tools
|
||||
(loader as any).plugins.set("no-tools", {
|
||||
manifest: makeManifest({ id: "no-tools" }),
|
||||
state: "started",
|
||||
hooks: {},
|
||||
tools: [],
|
||||
routes: [],
|
||||
} as FusionPlugin);
|
||||
|
||||
const tools = loader.getPluginTools();
|
||||
|
||||
expect(tools).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
// ── getPluginRoutes ───────────────────────────────────────────────
|
||||
|
||||
describe("getPluginRoutes", () => {
|
||||
it("aggregates routes from all loaded plugins", async () => {
|
||||
await pluginStore.init();
|
||||
|
||||
const loader = new PluginLoader({
|
||||
pluginStore,
|
||||
taskStore: mockTaskStore,
|
||||
});
|
||||
|
||||
// Manually add plugins with routes
|
||||
(loader as any).plugins.set("routes-a", {
|
||||
manifest: makeManifest({ id: "routes-a" }),
|
||||
state: "started",
|
||||
hooks: {},
|
||||
tools: [],
|
||||
routes: [
|
||||
{
|
||||
method: "GET",
|
||||
path: "/status",
|
||||
handler: async () => ({}),
|
||||
},
|
||||
],
|
||||
} as FusionPlugin);
|
||||
(loader as any).plugins.set("routes-b", {
|
||||
manifest: makeManifest({ id: "routes-b" }),
|
||||
state: "started",
|
||||
hooks: {},
|
||||
tools: [],
|
||||
routes: [
|
||||
{
|
||||
method: "POST",
|
||||
path: "/action",
|
||||
handler: async () => ({}),
|
||||
},
|
||||
],
|
||||
} as FusionPlugin);
|
||||
|
||||
const routes = loader.getPluginRoutes();
|
||||
|
||||
expect(routes).toHaveLength(2);
|
||||
expect(routes.find((r) => r.pluginId === "routes-a")?.route.path).toBe(
|
||||
"/status",
|
||||
);
|
||||
expect(routes.find((r) => r.pluginId === "routes-b")?.route.path).toBe(
|
||||
"/action",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ── getLoadedPlugins ───────────────────────────────────────────────
|
||||
|
||||
describe("getLoadedPlugins", () => {
|
||||
it("returns all loaded plugin instances", async () => {
|
||||
await pluginStore.init();
|
||||
|
||||
const loader = new PluginLoader({
|
||||
pluginStore,
|
||||
taskStore: mockTaskStore,
|
||||
});
|
||||
|
||||
// Manually add plugins
|
||||
(loader as any).plugins.set("loaded-a", {
|
||||
manifest: makeManifest({ id: "loaded-a" }),
|
||||
state: "started",
|
||||
hooks: {},
|
||||
tools: [],
|
||||
routes: [],
|
||||
} as FusionPlugin);
|
||||
(loader as any).plugins.set("loaded-b", {
|
||||
manifest: makeManifest({ id: "loaded-b" }),
|
||||
state: "started",
|
||||
hooks: {},
|
||||
tools: [],
|
||||
routes: [],
|
||||
} as FusionPlugin);
|
||||
|
||||
const loaded = loader.getLoadedPlugins();
|
||||
|
||||
expect(loaded).toHaveLength(2);
|
||||
expect(loaded.map((p) => p.manifest.id).sort()).toEqual([
|
||||
"loaded-a",
|
||||
"loaded-b",
|
||||
]);
|
||||
});
|
||||
|
||||
it("returns empty array when no plugins loaded", async () => {
|
||||
await pluginStore.init();
|
||||
|
||||
const loader = new PluginLoader({
|
||||
pluginStore,
|
||||
taskStore: mockTaskStore,
|
||||
});
|
||||
|
||||
const loaded = loader.getLoadedPlugins();
|
||||
|
||||
expect(loaded).toEqual([]);
|
||||
});
|
||||
});
|
||||
});
|
||||
535
packages/core/src/plugin-loader.ts
Normal file
535
packages/core/src/plugin-loader.ts
Normal file
@@ -0,0 +1,535 @@
|
||||
/**
|
||||
* PluginLoader - Dynamic plugin loading and lifecycle management.
|
||||
*
|
||||
* Handles:
|
||||
* - Dynamic import of plugins from file paths or npm packages
|
||||
* - Plugin lifecycle (load, start, stop)
|
||||
* - Dependency resolution via topological sort
|
||||
* - Hook invocation across all loaded plugins
|
||||
* - Error isolation (plugin crashes don't crash the loader)
|
||||
*/
|
||||
|
||||
import { join, isAbsolute, resolve } from "node:path";
|
||||
import { EventEmitter } from "node:events";
|
||||
import type { TaskStore } from "./store.js";
|
||||
import { PluginStore } from "./plugin-store.js";
|
||||
import type {
|
||||
FusionPlugin,
|
||||
PluginContext,
|
||||
PluginLogger,
|
||||
PluginToolDefinition,
|
||||
PluginRouteDefinition,
|
||||
PluginState,
|
||||
PluginInstallation,
|
||||
} from "./plugin-types.js";
|
||||
import { validatePluginManifest } from "./plugin-types.js";
|
||||
|
||||
// Minimum Fusion version for plugin compatibility checks (can be expanded later)
|
||||
const MINIMUM_FUSION_VERSION = "0.1.0";
|
||||
|
||||
export interface PluginLoaderOptions {
|
||||
/** Plugin store for persistence */
|
||||
pluginStore: PluginStore;
|
||||
/** Task store for plugin context */
|
||||
taskStore: TaskStore;
|
||||
/** Additional directories to scan for plugins */
|
||||
pluginDirs?: string[];
|
||||
/** npm prefix for resolving packages */
|
||||
npmPrefix?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Event emitted when a plugin is loaded and started.
|
||||
*/
|
||||
export interface PluginLoadedEvent {
|
||||
pluginId: string;
|
||||
plugin: FusionPlugin;
|
||||
}
|
||||
|
||||
/**
|
||||
* Event emitted when a plugin encounters an error.
|
||||
*/
|
||||
export interface PluginErrorEvent {
|
||||
pluginId: string;
|
||||
error: Error;
|
||||
}
|
||||
|
||||
export class PluginLoader extends EventEmitter<{
|
||||
"plugin:loaded": [PluginLoadedEvent];
|
||||
"plugin:error": [PluginErrorEvent];
|
||||
"plugin:stopped": [string];
|
||||
}> {
|
||||
/** Loaded plugin instances keyed by plugin id */
|
||||
private plugins: Map<string, FusionPlugin> = new Map();
|
||||
|
||||
/** Cache of dynamically imported modules */
|
||||
private loadedModules: Map<string, unknown> = new Map();
|
||||
|
||||
constructor(private options: PluginLoaderOptions) {
|
||||
super();
|
||||
}
|
||||
|
||||
// ── Context Creation ───────────────────────────────────────────────
|
||||
|
||||
private async createContext(plugin: FusionPlugin): Promise<PluginContext> {
|
||||
return {
|
||||
pluginId: plugin.manifest.id,
|
||||
taskStore: this.options.taskStore,
|
||||
settings: await this.getPluginSettings(plugin.manifest.id),
|
||||
logger: this.createLogger(plugin.manifest.id),
|
||||
emitEvent: (event: string, data: unknown) => {
|
||||
this.emit("plugin:error", { pluginId: plugin.manifest.id, error: new Error(`Custom event: ${event}`) });
|
||||
// Custom events are logged but not surfaced as errors
|
||||
console.log(`[plugin:${plugin.manifest.id}] Custom event: ${event}`, data);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
private createLogger(pluginId: string): PluginLogger {
|
||||
const prefix = `[plugin:${pluginId}]`;
|
||||
return {
|
||||
info: (...args: unknown[]) => console.log(prefix, ...args),
|
||||
warn: (...args: unknown[]) => console.warn(prefix, ...args),
|
||||
error: (...args: unknown[]) => console.error(prefix, ...args),
|
||||
debug: (...args: unknown[]) => {
|
||||
if (process.env.DEBUG?.includes("plugins")) {
|
||||
console.log(prefix, ...args);
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
private async getPluginSettings(pluginId: string): Promise<Record<string, unknown>> {
|
||||
try {
|
||||
const plugin = await this.options.pluginStore.getPlugin(pluginId);
|
||||
return plugin.settings;
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
// ── Plugin Loading ─────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Load and start a single plugin.
|
||||
*/
|
||||
async loadPlugin(pluginId: string): Promise<FusionPlugin> {
|
||||
// Get plugin installation record
|
||||
let installation: PluginInstallation;
|
||||
try {
|
||||
installation = await this.options.pluginStore.getPlugin(pluginId);
|
||||
} catch (err) {
|
||||
throw new Error(`Plugin "${pluginId}" not found in store: ${(err as Error).message}`);
|
||||
}
|
||||
|
||||
// Skip disabled plugins
|
||||
if (!installation.enabled) {
|
||||
console.log(`[plugin-loader] Skipping disabled plugin: ${pluginId}`);
|
||||
throw Object.assign(new Error(`Plugin "${pluginId}" is disabled`), {
|
||||
code: "PLUGIN_DISABLED",
|
||||
});
|
||||
}
|
||||
|
||||
// Skip already loaded plugins
|
||||
if (this.plugins.has(pluginId)) {
|
||||
console.log(`[plugin-loader] Plugin already loaded: ${pluginId}`);
|
||||
return this.plugins.get(pluginId)!;
|
||||
}
|
||||
|
||||
// Resolve plugin path
|
||||
const pluginPath = this.resolvePluginPath(installation.path);
|
||||
|
||||
try {
|
||||
// Dynamic import the plugin
|
||||
const mod = await this.importPluginModule(pluginPath);
|
||||
const plugin = this.extractPluginFromModule(mod);
|
||||
|
||||
// Validate manifest
|
||||
const manifestValidation = validatePluginManifest(plugin.manifest);
|
||||
if (!manifestValidation.valid) {
|
||||
throw new Error(
|
||||
`Invalid plugin manifest: ${manifestValidation.errors.join(", ")}`,
|
||||
);
|
||||
}
|
||||
|
||||
// Check version compatibility
|
||||
if (plugin.manifest.fusionVersion) {
|
||||
const compatible = this.checkVersionCompatibility(
|
||||
plugin.manifest.fusionVersion,
|
||||
);
|
||||
if (!compatible) {
|
||||
console.warn(
|
||||
`[plugin-loader] Plugin ${pluginId} requires Fusion ${plugin.manifest.fusionVersion}, minimum is ${MINIMUM_FUSION_VERSION}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Resolve dependencies
|
||||
await this.resolveDependencies(plugin);
|
||||
|
||||
// Update state to started
|
||||
await this.options.pluginStore.updatePluginState(pluginId, "started");
|
||||
|
||||
// Update plugin state locally and store
|
||||
plugin.state = "started";
|
||||
this.plugins.set(pluginId, plugin);
|
||||
|
||||
// Call onLoad hook
|
||||
const ctx = await this.createContext(plugin);
|
||||
await this.safeCallHook(plugin, "onLoad", [ctx]);
|
||||
|
||||
this.emit("plugin:loaded", { pluginId, plugin });
|
||||
return plugin;
|
||||
} catch (err) {
|
||||
// Error isolation: set error state but don't crash
|
||||
const errorMsg = err instanceof Error ? err.message : String(err);
|
||||
await this.options.pluginStore.updatePluginState(
|
||||
pluginId,
|
||||
"error",
|
||||
errorMsg,
|
||||
);
|
||||
|
||||
this.emit("plugin:error", {
|
||||
pluginId,
|
||||
error: err instanceof Error ? err : new Error(errorMsg),
|
||||
});
|
||||
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
private resolvePluginPath(path: string): string {
|
||||
// If already absolute, use as-is
|
||||
if (isAbsolute(path)) {
|
||||
return path;
|
||||
}
|
||||
|
||||
// Check if it's an npm package (contains / or starts with @)
|
||||
if (path.startsWith("@") || path.includes("/")) {
|
||||
// For npm packages, we'd use require.resolve in a real implementation
|
||||
// For now, assume it's a local path relative to project root
|
||||
return resolve(process.cwd(), path);
|
||||
}
|
||||
|
||||
// Default: resolve relative to project root
|
||||
return resolve(process.cwd(), path);
|
||||
}
|
||||
|
||||
private async importPluginModule(path: string): Promise<unknown> {
|
||||
// Check cache first
|
||||
if (this.loadedModules.has(path)) {
|
||||
return this.loadedModules.get(path)!;
|
||||
}
|
||||
|
||||
// Dynamic import
|
||||
const mod = await import(path);
|
||||
this.loadedModules.set(path, mod);
|
||||
return mod;
|
||||
}
|
||||
|
||||
private extractPluginFromModule(mod: unknown): FusionPlugin {
|
||||
if (!mod || typeof mod !== "object") {
|
||||
throw new Error("Plugin module must export an object");
|
||||
}
|
||||
|
||||
const obj = mod as Record<string, unknown>;
|
||||
|
||||
// Look for default export first, then named export
|
||||
const pluginExport = obj.default ?? obj.plugin;
|
||||
|
||||
if (!pluginExport || typeof pluginExport !== "object") {
|
||||
throw new Error(
|
||||
"Plugin module must export a default 'FusionPlugin' or have a 'plugin' export",
|
||||
);
|
||||
}
|
||||
|
||||
const plugin = pluginExport as FusionPlugin;
|
||||
|
||||
// Basic validation
|
||||
if (!plugin.manifest?.id) {
|
||||
throw new Error("Plugin must have a manifest with id");
|
||||
}
|
||||
|
||||
return plugin;
|
||||
}
|
||||
|
||||
private checkVersionCompatibility(requiredVersion: string): boolean {
|
||||
// Simple version comparison for now
|
||||
// In a real implementation, use a proper semver library
|
||||
const required = this.parseVersion(requiredVersion);
|
||||
const minimum = this.parseVersion(MINIMUM_FUSION_VERSION);
|
||||
|
||||
if (required.major > minimum.major) return false;
|
||||
if (required.major < minimum.major) return true;
|
||||
if (required.minor > minimum.minor) return false;
|
||||
if (required.minor < minimum.minor) return true;
|
||||
return required.patch <= minimum.patch;
|
||||
}
|
||||
|
||||
private parseVersion(version: string): { major: number; minor: number; patch: number } {
|
||||
const parts = version.split(".").map(Number);
|
||||
return {
|
||||
major: parts[0] || 0,
|
||||
minor: parts[1] || 0,
|
||||
patch: parts[2] || 0,
|
||||
};
|
||||
}
|
||||
|
||||
private async resolveDependencies(plugin: FusionPlugin): Promise<void> {
|
||||
if (!plugin.manifest.dependencies?.length) return;
|
||||
|
||||
for (const depId of plugin.manifest.dependencies) {
|
||||
if (!this.plugins.has(depId)) {
|
||||
throw new Error(
|
||||
`Plugin ${plugin.manifest.id} depends on ${depId}, which is not loaded`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Load All ──────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Load all enabled plugins in dependency order.
|
||||
*/
|
||||
async loadAllPlugins(): Promise<{ loaded: number; errors: number }> {
|
||||
const enabled = await this.options.pluginStore.listPlugins({ enabled: true });
|
||||
const sorted = this.resolveLoadOrder(enabled);
|
||||
|
||||
let loaded = 0;
|
||||
let errors = 0;
|
||||
|
||||
for (const installation of sorted) {
|
||||
try {
|
||||
await this.loadPlugin(installation.id);
|
||||
loaded++;
|
||||
} catch (err) {
|
||||
if ((err as any).code !== "PLUGIN_DISABLED") {
|
||||
errors++;
|
||||
console.error(
|
||||
`[plugin-loader] Failed to load plugin ${installation.id}:`,
|
||||
err,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { loaded, errors };
|
||||
}
|
||||
|
||||
/**
|
||||
* Topological sort for load order.
|
||||
*/
|
||||
resolveLoadOrder(plugins: PluginInstallation[]): PluginInstallation[] {
|
||||
const pluginMap = new Map(plugins.map((p) => [p.id, p]));
|
||||
const visited = new Set<string>();
|
||||
const result: PluginInstallation[] = [];
|
||||
const visiting = new Set<string>();
|
||||
|
||||
const visit = (id: string) => {
|
||||
if (visited.has(id)) return;
|
||||
if (visiting.has(id)) {
|
||||
throw new Error(`Circular dependency detected: ${id}`);
|
||||
}
|
||||
|
||||
const plugin = pluginMap.get(id);
|
||||
if (!plugin) return; // Skip plugins not in our list
|
||||
|
||||
visiting.add(id);
|
||||
|
||||
// Visit dependencies first
|
||||
for (const depId of plugin.dependencies || []) {
|
||||
visit(depId);
|
||||
}
|
||||
|
||||
visiting.delete(id);
|
||||
visited.add(id);
|
||||
result.push(plugin);
|
||||
};
|
||||
|
||||
for (const plugin of plugins) {
|
||||
visit(plugin.id);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
// ── Plugin Stopping ────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Stop and unload a single plugin.
|
||||
*/
|
||||
async stopPlugin(pluginId: string): Promise<void> {
|
||||
const plugin = this.plugins.get(pluginId);
|
||||
if (!plugin) {
|
||||
console.log(`[plugin-loader] Plugin not loaded: ${pluginId}`);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Call onUnload hook
|
||||
await this.safeCallHook(plugin, "onUnload", []);
|
||||
} catch (err) {
|
||||
console.error(`[plugin-loader] Error in onUnload for ${pluginId}:`, err);
|
||||
}
|
||||
|
||||
// Update state
|
||||
await this.options.pluginStore.updatePluginState(pluginId, "stopped");
|
||||
|
||||
// Remove from loaded plugins
|
||||
this.plugins.delete(pluginId);
|
||||
|
||||
this.emit("plugin:stopped", pluginId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop all loaded plugins in reverse dependency order.
|
||||
*/
|
||||
async stopAllPlugins(): Promise<void> {
|
||||
// Get plugins in reverse topological order
|
||||
const loadedPlugins = Array.from(this.plugins.values());
|
||||
const sorted = this.resolveLoadOrder(
|
||||
loadedPlugins.map((p) => ({
|
||||
id: p.manifest.id,
|
||||
name: p.manifest.name,
|
||||
version: p.manifest.version,
|
||||
description: p.manifest.description,
|
||||
author: p.manifest.author,
|
||||
homepage: p.manifest.homepage,
|
||||
path: "",
|
||||
enabled: true,
|
||||
state: p.state,
|
||||
settings: {},
|
||||
dependencies: p.manifest.dependencies,
|
||||
createdAt: "",
|
||||
updatedAt: "",
|
||||
})),
|
||||
);
|
||||
|
||||
// Stop in reverse order
|
||||
for (const plugin of sorted.reverse()) {
|
||||
try {
|
||||
await this.stopPlugin(plugin.id);
|
||||
} catch (err) {
|
||||
console.error(`[plugin-loader] Error stopping plugin ${plugin.id}:`, err);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Hook Invocation ────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Invoke a hook on all loaded plugins.
|
||||
* Errors are isolated - one plugin's failure doesn't affect others.
|
||||
*/
|
||||
async invokeHook(
|
||||
hookName: keyof FusionPlugin["hooks"],
|
||||
...args: unknown[]
|
||||
): Promise<void> {
|
||||
for (const [pluginId, plugin] of this.plugins) {
|
||||
const hook = plugin.hooks[hookName];
|
||||
if (!hook) continue;
|
||||
|
||||
try {
|
||||
await this.safeCallHook(plugin, hookName, args);
|
||||
} catch (err) {
|
||||
console.error(
|
||||
`[plugin-loader] Error in ${hookName} hook for ${pluginId}:`,
|
||||
err,
|
||||
);
|
||||
|
||||
// Update plugin state to error
|
||||
try {
|
||||
await this.options.pluginStore.updatePluginState(
|
||||
pluginId,
|
||||
"error",
|
||||
err instanceof Error ? err.message : String(err),
|
||||
);
|
||||
plugin.state = "error";
|
||||
} catch {
|
||||
// Non-fatal
|
||||
}
|
||||
|
||||
// Call onError hook if available
|
||||
if (hookName !== "onError" && plugin.hooks.onError) {
|
||||
try {
|
||||
const ctx = await this.createContext(plugin);
|
||||
await plugin.hooks.onError(
|
||||
err instanceof Error ? err : new Error(String(err)),
|
||||
ctx,
|
||||
);
|
||||
} catch {
|
||||
// Non-fatal
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async safeCallHook(
|
||||
plugin: FusionPlugin,
|
||||
hookName: keyof FusionPlugin["hooks"],
|
||||
args: unknown[],
|
||||
): Promise<void> {
|
||||
const hook = plugin.hooks[hookName];
|
||||
if (!hook) return;
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const fn = hook as (...args: unknown[]) => unknown;
|
||||
const result = fn(...args);
|
||||
if (result instanceof Promise) {
|
||||
await result;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Accessors ─────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Get all tools from loaded plugins.
|
||||
*/
|
||||
getPluginTools(): PluginToolDefinition[] {
|
||||
const tools: PluginToolDefinition[] = [];
|
||||
for (const plugin of this.plugins.values()) {
|
||||
if (plugin.tools) {
|
||||
tools.push(...plugin.tools);
|
||||
}
|
||||
}
|
||||
return tools;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all routes from loaded plugins.
|
||||
*/
|
||||
getPluginRoutes(): Array<{ pluginId: string; route: PluginRouteDefinition }> {
|
||||
const routes: Array<{ pluginId: string; route: PluginRouteDefinition }> = [];
|
||||
for (const [pluginId, plugin] of this.plugins) {
|
||||
if (plugin.routes) {
|
||||
for (const route of plugin.routes) {
|
||||
routes.push({ pluginId, route });
|
||||
}
|
||||
}
|
||||
}
|
||||
return routes;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all loaded plugin instances.
|
||||
*/
|
||||
getLoadedPlugins(): FusionPlugin[] {
|
||||
return Array.from(this.plugins.values());
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a loaded plugin by id.
|
||||
*/
|
||||
getPlugin(pluginId: string): FusionPlugin | undefined {
|
||||
return this.plugins.get(pluginId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a plugin is loaded.
|
||||
*/
|
||||
isPluginLoaded(pluginId: string): boolean {
|
||||
return this.plugins.has(pluginId);
|
||||
}
|
||||
}
|
||||
584
packages/core/src/plugin-store.test.ts
Normal file
584
packages/core/src/plugin-store.test.ts
Normal file
@@ -0,0 +1,584 @@
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
||||
import { PluginStore } from "./plugin-store.js";
|
||||
import { rm } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { mkdtempSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import type { PluginManifest, PluginState } from "./plugin-types.js";
|
||||
|
||||
function makeTmpDir(): string {
|
||||
return mkdtempSync(join(tmpdir(), "kb-plugin-test-"));
|
||||
}
|
||||
|
||||
function makeManifest(overrides: Partial<PluginManifest> = {}): PluginManifest {
|
||||
return {
|
||||
id: "test-plugin",
|
||||
name: "Test Plugin",
|
||||
version: "1.0.0",
|
||||
description: "A test plugin",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("PluginStore", () => {
|
||||
let rootDir: string;
|
||||
let store: PluginStore;
|
||||
|
||||
beforeEach(async () => {
|
||||
rootDir = makeTmpDir();
|
||||
store = new PluginStore(rootDir);
|
||||
await store.init();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await rm(rootDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
// ── init ──────────────────────────────────────────────────────────
|
||||
|
||||
describe("init", () => {
|
||||
it("creates the database file", async () => {
|
||||
const dbPath = join(rootDir, ".fusion", "fusion.db");
|
||||
const { existsSync } = await import("node:fs");
|
||||
expect(existsSync(dbPath)).toBe(true);
|
||||
});
|
||||
|
||||
it("is idempotent", async () => {
|
||||
await store.init();
|
||||
await store.init();
|
||||
// Should not throw
|
||||
const plugins = await store.listPlugins();
|
||||
expect(plugins).toEqual([]);
|
||||
});
|
||||
|
||||
it("creates the plugins table", async () => {
|
||||
// If the table doesn't exist, listPlugins would fail
|
||||
const plugins = await store.listPlugins();
|
||||
expect(Array.isArray(plugins)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// ── registerPlugin ─────────────────────────────────────────────────
|
||||
|
||||
describe("registerPlugin", () => {
|
||||
it("registers a valid plugin and returns full record", async () => {
|
||||
const manifest = makeManifest();
|
||||
const plugin = await store.registerPlugin({ manifest, path: "/path/to/plugin" });
|
||||
|
||||
expect(plugin.id).toBe("test-plugin");
|
||||
expect(plugin.name).toBe("Test Plugin");
|
||||
expect(plugin.version).toBe("1.0.0");
|
||||
expect(plugin.description).toBe("A test plugin");
|
||||
expect(plugin.path).toBe("/path/to/plugin");
|
||||
expect(plugin.enabled).toBe(true);
|
||||
expect(plugin.state).toBe("installed");
|
||||
expect(plugin.settings).toEqual({});
|
||||
expect(plugin.dependencies).toEqual([]);
|
||||
expect(plugin.createdAt).toBeTruthy();
|
||||
expect(plugin.updatedAt).toBeTruthy();
|
||||
});
|
||||
|
||||
it("registers plugin with custom settings", async () => {
|
||||
const manifest = makeManifest();
|
||||
const plugin = await store.registerPlugin({
|
||||
manifest,
|
||||
path: "/path/to/plugin",
|
||||
settings: { apiKey: "secret123", maxItems: 10 },
|
||||
});
|
||||
|
||||
expect(plugin.settings).toEqual({ apiKey: "secret123", maxItems: 10 });
|
||||
});
|
||||
|
||||
it("registers plugin with dependencies", async () => {
|
||||
const manifest = makeManifest({ dependencies: ["other-plugin"] });
|
||||
const plugin = await store.registerPlugin({ manifest, path: "/path/to/plugin" });
|
||||
|
||||
expect(plugin.dependencies).toEqual(["other-plugin"]);
|
||||
});
|
||||
|
||||
it("registers plugin with settings schema", async () => {
|
||||
const manifest = makeManifest({
|
||||
settingsSchema: {
|
||||
apiKey: { type: "string", required: true },
|
||||
count: { type: "number", defaultValue: 5 },
|
||||
},
|
||||
});
|
||||
const plugin = await store.registerPlugin({ manifest, path: "/path/to/plugin" });
|
||||
|
||||
expect(plugin.settingsSchema).toBeTruthy();
|
||||
expect(plugin.settingsSchema!.apiKey.type).toBe("string");
|
||||
expect(plugin.settingsSchema!.count.defaultValue).toBe(5);
|
||||
});
|
||||
|
||||
it("rejects missing manifest id", async () => {
|
||||
const manifest = makeManifest({ id: "" });
|
||||
await expect(
|
||||
store.registerPlugin({ manifest, path: "/path/to/plugin" }),
|
||||
).rejects.toThrow("Invalid plugin manifest");
|
||||
});
|
||||
|
||||
it("rejects missing manifest name", async () => {
|
||||
const manifest = makeManifest({ name: "" });
|
||||
await expect(
|
||||
store.registerPlugin({ manifest, path: "/path/to/plugin" }),
|
||||
).rejects.toThrow("Invalid plugin manifest");
|
||||
});
|
||||
|
||||
it("rejects missing manifest version", async () => {
|
||||
const manifest = makeManifest({ version: "" });
|
||||
await expect(
|
||||
store.registerPlugin({ manifest, path: "/path/to/plugin" }),
|
||||
).rejects.toThrow("Invalid plugin manifest");
|
||||
});
|
||||
|
||||
it("rejects invalid id format (uppercase)", async () => {
|
||||
const manifest = makeManifest({ id: "Test-Plugin" });
|
||||
await expect(
|
||||
store.registerPlugin({ manifest, path: "/path/to/plugin" }),
|
||||
).rejects.toThrow("Invalid plugin manifest");
|
||||
});
|
||||
|
||||
it("rejects invalid id format (underscores)", async () => {
|
||||
const manifest = makeManifest({ id: "test_plugin" });
|
||||
await expect(
|
||||
store.registerPlugin({ manifest, path: "/path/to/plugin" }),
|
||||
).rejects.toThrow("Invalid plugin manifest");
|
||||
});
|
||||
|
||||
it("rejects invalid id format (starts with hyphen)", async () => {
|
||||
const manifest = makeManifest({ id: "-test-plugin" });
|
||||
await expect(
|
||||
store.registerPlugin({ manifest, path: "/path/to/plugin" }),
|
||||
).rejects.toThrow("Invalid plugin manifest");
|
||||
});
|
||||
|
||||
it("rejects empty path", async () => {
|
||||
const manifest = makeManifest({ id: "valid-plugin" });
|
||||
await expect(
|
||||
store.registerPlugin({ manifest, path: "" }),
|
||||
).rejects.toThrow("Plugin path is required");
|
||||
});
|
||||
|
||||
it("rejects duplicate plugin id", async () => {
|
||||
const manifest = makeManifest();
|
||||
await store.registerPlugin({ manifest, path: "/path/to/plugin1" });
|
||||
|
||||
await expect(
|
||||
store.registerPlugin({ manifest, path: "/path/to/plugin2" }),
|
||||
).rejects.toThrow("already registered");
|
||||
});
|
||||
|
||||
it("emits plugin:registered event", async () => {
|
||||
const listener = vi.fn();
|
||||
store.on("plugin:registered", listener);
|
||||
|
||||
const manifest = makeManifest({ id: "event-plugin" });
|
||||
const plugin = await store.registerPlugin({ manifest, path: "/path/to/plugin" });
|
||||
|
||||
expect(listener).toHaveBeenCalledWith(plugin);
|
||||
});
|
||||
});
|
||||
|
||||
// ── unregisterPlugin ─────────────────────────────────────────────
|
||||
|
||||
describe("unregisterPlugin", () => {
|
||||
it("removes a registered plugin", async () => {
|
||||
const manifest = makeManifest();
|
||||
await store.registerPlugin({ manifest, path: "/path/to/plugin" });
|
||||
|
||||
const removed = await store.unregisterPlugin("test-plugin");
|
||||
expect(removed.id).toBe("test-plugin");
|
||||
|
||||
await expect(store.getPlugin("test-plugin")).rejects.toThrow("not found");
|
||||
});
|
||||
|
||||
it("throws on non-existent plugin", async () => {
|
||||
await expect(store.unregisterPlugin("nonexistent")).rejects.toThrow(
|
||||
"not found",
|
||||
);
|
||||
});
|
||||
|
||||
it("emits plugin:unregistered event", async () => {
|
||||
const listener = vi.fn();
|
||||
store.on("plugin:unregistered", listener);
|
||||
|
||||
const manifest = makeManifest();
|
||||
await store.registerPlugin({ manifest, path: "/path/to/plugin" });
|
||||
await store.unregisterPlugin("test-plugin");
|
||||
|
||||
expect(listener).toHaveBeenCalledTimes(1);
|
||||
expect(listener.mock.calls[0][0].id).toBe("test-plugin");
|
||||
});
|
||||
});
|
||||
|
||||
// ── getPlugin ────────────────────────────────────────────────────
|
||||
|
||||
describe("getPlugin", () => {
|
||||
it("returns registered plugin", async () => {
|
||||
const manifest = makeManifest();
|
||||
await store.registerPlugin({ manifest, path: "/path/to/plugin" });
|
||||
|
||||
const plugin = await store.getPlugin("test-plugin");
|
||||
expect(plugin.id).toBe("test-plugin");
|
||||
expect(plugin.name).toBe("Test Plugin");
|
||||
});
|
||||
|
||||
it("throws ENOENT on non-existent plugin", async () => {
|
||||
await expect(store.getPlugin("nonexistent")).rejects.toThrow("not found");
|
||||
});
|
||||
});
|
||||
|
||||
// ── listPlugins ──────────────────────────────────────────────────
|
||||
|
||||
describe("listPlugins", () => {
|
||||
it("returns all registered plugins", async () => {
|
||||
await store.registerPlugin({
|
||||
manifest: makeManifest({ id: "plugin-a" }),
|
||||
path: "/path/a",
|
||||
});
|
||||
await store.registerPlugin({
|
||||
manifest: makeManifest({ id: "plugin-b" }),
|
||||
path: "/path/b",
|
||||
});
|
||||
|
||||
const plugins = await store.listPlugins();
|
||||
expect(plugins).toHaveLength(2);
|
||||
expect(plugins.map((p) => p.id).sort()).toEqual(["plugin-a", "plugin-b"]);
|
||||
});
|
||||
|
||||
it("filters by enabled status", async () => {
|
||||
await store.registerPlugin({
|
||||
manifest: makeManifest({ id: "plugin-a" }),
|
||||
path: "/path/a",
|
||||
});
|
||||
const b = await store.registerPlugin({
|
||||
manifest: makeManifest({ id: "plugin-b" }),
|
||||
path: "/path/b",
|
||||
});
|
||||
await store.disablePlugin("plugin-a");
|
||||
|
||||
const enabled = await store.listPlugins({ enabled: true });
|
||||
expect(enabled).toHaveLength(1);
|
||||
expect(enabled[0].id).toBe("plugin-b");
|
||||
|
||||
const disabled = await store.listPlugins({ enabled: false });
|
||||
expect(disabled).toHaveLength(1);
|
||||
expect(disabled[0].id).toBe("plugin-a");
|
||||
});
|
||||
|
||||
it("filters by state", async () => {
|
||||
await store.registerPlugin({
|
||||
manifest: makeManifest({ id: "plugin-a" }),
|
||||
path: "/path/a",
|
||||
});
|
||||
await store.registerPlugin({
|
||||
manifest: makeManifest({ id: "plugin-b" }),
|
||||
path: "/path/b",
|
||||
});
|
||||
|
||||
// Start plugin-a
|
||||
await store.updatePluginState("plugin-a", "started");
|
||||
|
||||
const installed = await store.listPlugins({ state: "installed" });
|
||||
expect(installed).toHaveLength(1);
|
||||
expect(installed[0].id).toBe("plugin-b");
|
||||
|
||||
const started = await store.listPlugins({ state: "started" });
|
||||
expect(started).toHaveLength(1);
|
||||
expect(started[0].id).toBe("plugin-a");
|
||||
});
|
||||
|
||||
it("returns empty array when no plugins", async () => {
|
||||
const plugins = await store.listPlugins();
|
||||
expect(plugins).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
// ── enablePlugin ─────────────────────────────────────────────────
|
||||
|
||||
describe("enablePlugin", () => {
|
||||
it("sets enabled to true", async () => {
|
||||
const manifest = makeManifest();
|
||||
await store.registerPlugin({ manifest, path: "/path/to/plugin" });
|
||||
await store.disablePlugin("test-plugin");
|
||||
|
||||
const plugin = await store.enablePlugin("test-plugin");
|
||||
expect(plugin.enabled).toBe(true);
|
||||
});
|
||||
|
||||
it("emits plugin:enabled event", async () => {
|
||||
const listener = vi.fn();
|
||||
store.on("plugin:enabled", listener);
|
||||
|
||||
const manifest = makeManifest();
|
||||
await store.registerPlugin({ manifest, path: "/path/to/plugin" });
|
||||
await store.enablePlugin("test-plugin");
|
||||
|
||||
expect(listener).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("emits plugin:updated event", async () => {
|
||||
const listener = vi.fn();
|
||||
store.on("plugin:updated", listener);
|
||||
|
||||
const manifest = makeManifest();
|
||||
await store.registerPlugin({ manifest, path: "/path/to/plugin" });
|
||||
await store.enablePlugin("test-plugin");
|
||||
|
||||
expect(listener).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
// ── disablePlugin ────────────────────────────────────────────────
|
||||
|
||||
describe("disablePlugin", () => {
|
||||
it("sets enabled to false", async () => {
|
||||
const manifest = makeManifest();
|
||||
await store.registerPlugin({ manifest, path: "/path/to/plugin" });
|
||||
|
||||
const plugin = await store.disablePlugin("test-plugin");
|
||||
expect(plugin.enabled).toBe(false);
|
||||
});
|
||||
|
||||
it("emits plugin:disabled event", async () => {
|
||||
const listener = vi.fn();
|
||||
store.on("plugin:disabled", listener);
|
||||
|
||||
const manifest = makeManifest();
|
||||
await store.registerPlugin({ manifest, path: "/path/to/plugin" });
|
||||
await store.disablePlugin("test-plugin");
|
||||
|
||||
expect(listener).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
// ── updatePluginState ────────────────────────────────────────────
|
||||
|
||||
describe("updatePluginState", () => {
|
||||
it("updates state to started", async () => {
|
||||
const manifest = makeManifest();
|
||||
await store.registerPlugin({ manifest, path: "/path/to/plugin" });
|
||||
|
||||
const plugin = await store.updatePluginState("test-plugin", "started");
|
||||
expect(plugin.state).toBe("started");
|
||||
});
|
||||
|
||||
it("updates state to stopped", async () => {
|
||||
const manifest = makeManifest();
|
||||
await store.registerPlugin({ manifest, path: "/path/to/plugin" });
|
||||
await store.updatePluginState("test-plugin", "started");
|
||||
|
||||
const plugin = await store.updatePluginState("test-plugin", "stopped");
|
||||
expect(plugin.state).toBe("stopped");
|
||||
});
|
||||
|
||||
it("updates state to error with message", async () => {
|
||||
const manifest = makeManifest();
|
||||
await store.registerPlugin({ manifest, path: "/path/to/plugin" });
|
||||
|
||||
const plugin = await store.updatePluginState(
|
||||
"test-plugin",
|
||||
"error",
|
||||
"Failed to load",
|
||||
);
|
||||
expect(plugin.state).toBe("error");
|
||||
expect(plugin.error).toBe("Failed to load");
|
||||
});
|
||||
|
||||
it("allows any state to transition to error", async () => {
|
||||
const manifest = makeManifest();
|
||||
await store.registerPlugin({ manifest, path: "/path/to/plugin" });
|
||||
await store.updatePluginState("test-plugin", "started");
|
||||
|
||||
// installed -> error is valid
|
||||
const plugin1 = await store.updatePluginState(
|
||||
"test-plugin",
|
||||
"error",
|
||||
"test",
|
||||
);
|
||||
expect(plugin1.state).toBe("error");
|
||||
});
|
||||
|
||||
it("rejects invalid state transitions", async () => {
|
||||
const manifest = makeManifest();
|
||||
await store.registerPlugin({ manifest, path: "/path/to/plugin" });
|
||||
|
||||
// Cannot go from stopped directly back to installed
|
||||
await store.updatePluginState("test-plugin", "stopped");
|
||||
await expect(
|
||||
store.updatePluginState("test-plugin", "installed"),
|
||||
).rejects.toThrow("Invalid state transition");
|
||||
});
|
||||
|
||||
it("allows restarting from stopped", async () => {
|
||||
const manifest = makeManifest();
|
||||
await store.registerPlugin({ manifest, path: "/path/to/plugin" });
|
||||
await store.updatePluginState("test-plugin", "started");
|
||||
await store.updatePluginState("test-plugin", "stopped");
|
||||
|
||||
const plugin = await store.updatePluginState("test-plugin", "started");
|
||||
expect(plugin.state).toBe("started");
|
||||
});
|
||||
|
||||
it("emits plugin:stateChanged event", async () => {
|
||||
const listener = vi.fn();
|
||||
store.on("plugin:stateChanged", listener);
|
||||
|
||||
const manifest = makeManifest();
|
||||
await store.registerPlugin({ manifest, path: "/path/to/plugin" });
|
||||
await store.updatePluginState("test-plugin", "started");
|
||||
|
||||
expect(listener).toHaveBeenCalledTimes(1);
|
||||
expect(listener.mock.calls[0][0].id).toBe("test-plugin");
|
||||
expect(listener.mock.calls[0][1]).toBe("installed");
|
||||
expect(listener.mock.calls[0][2]).toBe("started");
|
||||
});
|
||||
});
|
||||
|
||||
// ── updatePluginSettings ─────────────────────────────────────────
|
||||
|
||||
describe("updatePluginSettings", () => {
|
||||
it("merges settings", async () => {
|
||||
const manifest = makeManifest({
|
||||
settingsSchema: {
|
||||
apiKey: { type: "string" },
|
||||
count: { type: "number", defaultValue: 5 },
|
||||
},
|
||||
});
|
||||
await store.registerPlugin({
|
||||
manifest,
|
||||
path: "/path/to/plugin",
|
||||
settings: { apiKey: "secret123" },
|
||||
});
|
||||
|
||||
const plugin = await store.updatePluginSettings("test-plugin", {
|
||||
count: 10,
|
||||
});
|
||||
|
||||
expect(plugin.settings).toEqual({ apiKey: "secret123", count: 10 });
|
||||
});
|
||||
|
||||
it("validates required settings", async () => {
|
||||
const manifest = makeManifest({
|
||||
settingsSchema: {
|
||||
apiKey: { type: "string", required: true },
|
||||
},
|
||||
});
|
||||
await store.registerPlugin({
|
||||
manifest,
|
||||
path: "/path/to/plugin",
|
||||
settings: {},
|
||||
});
|
||||
|
||||
await expect(
|
||||
store.updatePluginSettings("test-plugin", {}),
|
||||
).rejects.toThrow('Setting "apiKey" is required');
|
||||
});
|
||||
|
||||
it("validates setting types", async () => {
|
||||
const manifest = makeManifest({
|
||||
settingsSchema: {
|
||||
count: { type: "number" },
|
||||
},
|
||||
});
|
||||
await store.registerPlugin({
|
||||
manifest,
|
||||
path: "/path/to/plugin",
|
||||
settings: {},
|
||||
});
|
||||
|
||||
await expect(
|
||||
store.updatePluginSettings("test-plugin", { count: "not a number" }),
|
||||
).rejects.toThrow('Setting "count" must be a number');
|
||||
});
|
||||
|
||||
it("validates enum values", async () => {
|
||||
const manifest = makeManifest({
|
||||
settingsSchema: {
|
||||
color: { type: "enum", enumValues: ["red", "green", "blue"] },
|
||||
},
|
||||
});
|
||||
await store.registerPlugin({
|
||||
manifest,
|
||||
path: "/path/to/plugin",
|
||||
settings: {},
|
||||
});
|
||||
|
||||
await expect(
|
||||
store.updatePluginSettings("test-plugin", { color: "yellow" }),
|
||||
).rejects.toThrow('Setting "color" must be one of');
|
||||
});
|
||||
|
||||
it("emits plugin:updated event", async () => {
|
||||
const listener = vi.fn();
|
||||
store.on("plugin:updated", listener);
|
||||
|
||||
const manifest = makeManifest();
|
||||
await store.registerPlugin({ manifest, path: "/path/to/plugin" });
|
||||
await store.updatePluginSettings("test-plugin", { key: "value" });
|
||||
|
||||
expect(listener).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
// ── updatePlugin ─────────────────────────────────────────────────
|
||||
|
||||
describe("updatePlugin", () => {
|
||||
it("updates name", async () => {
|
||||
const manifest = makeManifest();
|
||||
await store.registerPlugin({ manifest, path: "/path/to/plugin" });
|
||||
|
||||
const plugin = await store.updatePlugin("test-plugin", { name: "New Name" });
|
||||
expect(plugin.name).toBe("New Name");
|
||||
});
|
||||
|
||||
it("updates version", async () => {
|
||||
const manifest = makeManifest();
|
||||
await store.registerPlugin({ manifest, path: "/path/to/plugin" });
|
||||
|
||||
const plugin = await store.updatePlugin("test-plugin", { version: "2.0.0" });
|
||||
expect(plugin.version).toBe("2.0.0");
|
||||
});
|
||||
|
||||
it("updates description", async () => {
|
||||
const manifest = makeManifest();
|
||||
await store.registerPlugin({ manifest, path: "/path/to/plugin" });
|
||||
|
||||
const plugin = await store.updatePlugin("test-plugin", {
|
||||
description: "New description",
|
||||
});
|
||||
expect(plugin.description).toBe("New description");
|
||||
});
|
||||
|
||||
it("updates path", async () => {
|
||||
const manifest = makeManifest();
|
||||
await store.registerPlugin({ manifest, path: "/path/to/plugin" });
|
||||
|
||||
const plugin = await store.updatePlugin("test-plugin", {
|
||||
path: "/new/path/to/plugin",
|
||||
});
|
||||
expect(plugin.path).toBe("/new/path/to/plugin");
|
||||
});
|
||||
|
||||
it("updates dependencies", async () => {
|
||||
const manifest = makeManifest();
|
||||
await store.registerPlugin({ manifest, path: "/path/to/plugin" });
|
||||
|
||||
const plugin = await store.updatePlugin("test-plugin", {
|
||||
dependencies: ["dep-a", "dep-b"],
|
||||
});
|
||||
expect(plugin.dependencies).toEqual(["dep-a", "dep-b"]);
|
||||
});
|
||||
|
||||
it("emits plugin:updated event", async () => {
|
||||
const listener = vi.fn();
|
||||
store.on("plugin:updated", listener);
|
||||
|
||||
const manifest = makeManifest();
|
||||
await store.registerPlugin({ manifest, path: "/path/to/plugin" });
|
||||
await store.updatePlugin("test-plugin", { name: "Updated" });
|
||||
|
||||
expect(listener).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
437
packages/core/src/plugin-store.ts
Normal file
437
packages/core/src/plugin-store.ts
Normal file
@@ -0,0 +1,437 @@
|
||||
/**
|
||||
* SQLite-backed PluginStore for managing plugin installations.
|
||||
*
|
||||
* Provides CRUD operations for plugins with event emission for state changes.
|
||||
*/
|
||||
|
||||
import { EventEmitter } from "node:events";
|
||||
import { join } from "node:path";
|
||||
import { Database, toJson, fromJson } from "./db.js";
|
||||
import type {
|
||||
PluginInstallation,
|
||||
PluginManifest,
|
||||
PluginSettingSchema,
|
||||
PluginState,
|
||||
} from "./plugin-types.js";
|
||||
import { validatePluginManifest } from "./plugin-types.js";
|
||||
|
||||
export interface PluginStoreEvents {
|
||||
"plugin:registered": [plugin: PluginInstallation];
|
||||
"plugin:unregistered": [plugin: PluginInstallation];
|
||||
"plugin:enabled": [plugin: PluginInstallation];
|
||||
"plugin:disabled": [plugin: PluginInstallation];
|
||||
"plugin:updated": [plugin: PluginInstallation];
|
||||
"plugin:stateChanged": [plugin: PluginInstallation, oldState: PluginState, newState: PluginState];
|
||||
}
|
||||
|
||||
/** Input for registering a new plugin */
|
||||
export interface PluginRegistrationInput {
|
||||
manifest: PluginManifest;
|
||||
path: string;
|
||||
settings?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/** Partial update input for a plugin */
|
||||
export interface PluginUpdateInput {
|
||||
name?: string;
|
||||
version?: string;
|
||||
description?: string;
|
||||
author?: string;
|
||||
homepage?: string;
|
||||
path?: string;
|
||||
dependencies?: string[];
|
||||
}
|
||||
|
||||
export class PluginStore extends EventEmitter<PluginStoreEvents> {
|
||||
/** SQLite database instance */
|
||||
private _db: Database | null = null;
|
||||
|
||||
constructor(private rootDir: string) {
|
||||
super();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the SQLite database, initializing it on first access.
|
||||
*/
|
||||
private get db(): Database {
|
||||
if (!this._db) {
|
||||
const kbDir = join(this.rootDir, ".fusion");
|
||||
this._db = new Database(kbDir);
|
||||
this._db.init();
|
||||
}
|
||||
return this._db;
|
||||
}
|
||||
|
||||
/** Initialize the store. */
|
||||
async init(): Promise<void> {
|
||||
// Ensure DB is initialized (triggers table creation)
|
||||
const _ = this.db;
|
||||
}
|
||||
|
||||
// ── Row Conversion ─────────────────────────────────────────────────
|
||||
|
||||
private rowToPlugin(row: any): PluginInstallation {
|
||||
return {
|
||||
id: row.id,
|
||||
name: row.name,
|
||||
version: row.version,
|
||||
description: row.description || undefined,
|
||||
author: row.author || undefined,
|
||||
homepage: row.homepage || undefined,
|
||||
path: row.path,
|
||||
enabled: row.enabled === 1,
|
||||
state: row.state as PluginState,
|
||||
settings: fromJson<Record<string, unknown>>(row.settings) || {},
|
||||
settingsSchema: fromJson<Record<string, PluginSettingSchema>>(row.settingsSchema),
|
||||
error: row.error || undefined,
|
||||
dependencies: fromJson<string[]>(row.dependencies) || [],
|
||||
createdAt: row.createdAt,
|
||||
updatedAt: row.updatedAt,
|
||||
};
|
||||
}
|
||||
|
||||
// ── Validation Helpers ───────────────────────────────────────────────
|
||||
|
||||
private validateIdFormat(id: string): boolean {
|
||||
// Valid slug: lowercase alphanumeric, hyphens, cannot start/end with hyphen
|
||||
return /^[a-z0-9]([a-z0-9-]*[a-z0-9])?$/.test(id);
|
||||
}
|
||||
|
||||
private validateSettingsAgainstSchema(
|
||||
settings: Record<string, unknown>,
|
||||
schema?: Record<string, PluginSettingSchema>,
|
||||
): string[] {
|
||||
if (!schema) return [];
|
||||
|
||||
const errors: string[] = [];
|
||||
for (const [key, settingSchema] of Object.entries(schema)) {
|
||||
const value = settings[key];
|
||||
|
||||
// Check required
|
||||
if (settingSchema.required && !(key in settings)) {
|
||||
errors.push(`Setting "${key}" is required`);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Skip validation if not provided and not required
|
||||
if (!(key in settings)) continue;
|
||||
|
||||
// Check type
|
||||
const expectedType = settingSchema.type;
|
||||
if (expectedType === "string" && typeof value !== "string") {
|
||||
errors.push(`Setting "${key}" must be a string`);
|
||||
} else if (expectedType === "number" && typeof value !== "number") {
|
||||
errors.push(`Setting "${key}" must be a number`);
|
||||
} else if (expectedType === "boolean" && typeof value !== "boolean") {
|
||||
errors.push(`Setting "${key}" must be a boolean`);
|
||||
} else if (expectedType === "enum") {
|
||||
if (typeof value !== "string" || !settingSchema.enumValues?.includes(value)) {
|
||||
errors.push(
|
||||
`Setting "${key}" must be one of: ${settingSchema.enumValues?.join(", ")}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return errors;
|
||||
}
|
||||
|
||||
// ── CRUD Operations ────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Register a new plugin.
|
||||
*/
|
||||
async registerPlugin(input: PluginRegistrationInput): Promise<PluginInstallation> {
|
||||
const { manifest, path, settings = {} } = input;
|
||||
|
||||
// Validate manifest
|
||||
const manifestValidation = validatePluginManifest(manifest);
|
||||
if (!manifestValidation.valid) {
|
||||
throw new Error(`Invalid plugin manifest: ${manifestValidation.errors.join(", ")}`);
|
||||
}
|
||||
|
||||
// Validate required fields
|
||||
if (!path?.trim()) {
|
||||
throw new Error("Plugin path is required and cannot be empty");
|
||||
}
|
||||
|
||||
// Validate id format
|
||||
if (!this.validateIdFormat(manifest.id)) {
|
||||
throw new Error(
|
||||
"Plugin id must be a valid slug (lowercase, alphanumeric, hyphens only, cannot start or end with hyphen)",
|
||||
);
|
||||
}
|
||||
|
||||
// Check for duplicate
|
||||
const existing = this.db
|
||||
.prepare("SELECT id FROM plugins WHERE id = ?")
|
||||
.get(manifest.id);
|
||||
if (existing) {
|
||||
throw Object.assign(new Error(`Plugin "${manifest.id}" is already registered`), {
|
||||
code: "EEXISTS",
|
||||
});
|
||||
}
|
||||
|
||||
const now = new Date().toISOString();
|
||||
const plugin: PluginInstallation = {
|
||||
id: manifest.id,
|
||||
name: manifest.name,
|
||||
version: manifest.version,
|
||||
description: manifest.description,
|
||||
author: manifest.author,
|
||||
homepage: manifest.homepage,
|
||||
path: path.trim(),
|
||||
enabled: true,
|
||||
state: "installed",
|
||||
settings,
|
||||
settingsSchema: manifest.settingsSchema,
|
||||
dependencies: manifest.dependencies || [],
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
};
|
||||
|
||||
// Insert into database
|
||||
this.db.prepare(`
|
||||
INSERT INTO plugins (
|
||||
id, name, version, description, author, homepage, path,
|
||||
enabled, state, settings, settingsSchema, dependencies, createdAt, updatedAt
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`).run(
|
||||
plugin.id,
|
||||
plugin.name,
|
||||
plugin.version,
|
||||
plugin.description ?? null,
|
||||
plugin.author ?? null,
|
||||
plugin.homepage ?? null,
|
||||
plugin.path,
|
||||
plugin.enabled ? 1 : 0,
|
||||
plugin.state,
|
||||
toJson(plugin.settings),
|
||||
plugin.settingsSchema ? toJson(plugin.settingsSchema) : null,
|
||||
toJson(plugin.dependencies),
|
||||
plugin.createdAt,
|
||||
plugin.updatedAt,
|
||||
);
|
||||
|
||||
this.db.bumpLastModified();
|
||||
this.emit("plugin:registered", plugin);
|
||||
return plugin;
|
||||
}
|
||||
|
||||
/**
|
||||
* Unregister (delete) a plugin.
|
||||
*/
|
||||
async unregisterPlugin(id: string): Promise<PluginInstallation> {
|
||||
const plugin = await this.getPlugin(id);
|
||||
|
||||
this.db.prepare("DELETE FROM plugins WHERE id = ?").run(id);
|
||||
this.db.bumpLastModified();
|
||||
this.emit("plugin:unregistered", plugin);
|
||||
return plugin;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a plugin by id.
|
||||
*/
|
||||
async getPlugin(id: string): Promise<PluginInstallation> {
|
||||
const row = this.db.prepare("SELECT * FROM plugins WHERE id = ?").get(id) as any;
|
||||
if (!row) {
|
||||
throw Object.assign(new Error(`Plugin "${id}" not found`), { code: "ENOENT" });
|
||||
}
|
||||
return this.rowToPlugin(row);
|
||||
}
|
||||
|
||||
/**
|
||||
* List all plugins, optionally filtered.
|
||||
*/
|
||||
async listPlugins(
|
||||
filter?: { enabled?: boolean; state?: PluginState },
|
||||
): Promise<PluginInstallation[]> {
|
||||
let sql = "SELECT * FROM plugins";
|
||||
const conditions: string[] = [];
|
||||
const params: any[] = [];
|
||||
|
||||
if (filter?.enabled !== undefined) {
|
||||
conditions.push("enabled = ?");
|
||||
params.push(filter.enabled ? 1 : 0);
|
||||
}
|
||||
if (filter?.state) {
|
||||
conditions.push("state = ?");
|
||||
params.push(filter.state);
|
||||
}
|
||||
|
||||
if (conditions.length > 0) {
|
||||
sql += " WHERE " + conditions.join(" AND ");
|
||||
}
|
||||
sql += " ORDER BY createdAt ASC";
|
||||
|
||||
const rows = this.db.prepare(sql).all(...params) as any[];
|
||||
return rows.map((row) => this.rowToPlugin(row));
|
||||
}
|
||||
|
||||
/**
|
||||
* Enable a plugin.
|
||||
*/
|
||||
async enablePlugin(id: string): Promise<PluginInstallation> {
|
||||
const plugin = await this.getPlugin(id);
|
||||
|
||||
this.db.prepare("UPDATE plugins SET enabled = 1, updatedAt = ? WHERE id = ?").run(
|
||||
new Date().toISOString(),
|
||||
id,
|
||||
);
|
||||
this.db.bumpLastModified();
|
||||
|
||||
const updated = { ...plugin, enabled: true };
|
||||
this.emit("plugin:enabled", updated);
|
||||
this.emit("plugin:updated", updated);
|
||||
return updated;
|
||||
}
|
||||
|
||||
/**
|
||||
* Disable a plugin.
|
||||
*/
|
||||
async disablePlugin(id: string): Promise<PluginInstallation> {
|
||||
const plugin = await this.getPlugin(id);
|
||||
|
||||
this.db.prepare("UPDATE plugins SET enabled = 0, updatedAt = ? WHERE id = ?").run(
|
||||
new Date().toISOString(),
|
||||
id,
|
||||
);
|
||||
this.db.bumpLastModified();
|
||||
|
||||
const updated = { ...plugin, enabled: false };
|
||||
this.emit("plugin:disabled", updated);
|
||||
this.emit("plugin:updated", updated);
|
||||
return updated;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update plugin state.
|
||||
*/
|
||||
async updatePluginState(
|
||||
id: string,
|
||||
state: PluginState,
|
||||
error?: string,
|
||||
): Promise<PluginInstallation> {
|
||||
const plugin = await this.getPlugin(id);
|
||||
const oldState = plugin.state;
|
||||
|
||||
// Validate state transitions
|
||||
const validStates: PluginState[] = ["installed", "started", "stopped", "error"];
|
||||
if (!validStates.includes(state)) {
|
||||
throw new Error(`Invalid state: ${state}`);
|
||||
}
|
||||
|
||||
// Validate transitions (any state can go to error)
|
||||
if (state !== "error") {
|
||||
const validTransitions: Record<PluginState, PluginState[]> = {
|
||||
installed: ["started", "stopped", "error"],
|
||||
started: ["stopped", "error"],
|
||||
stopped: ["started", "error"],
|
||||
error: ["installed", "started", "stopped"],
|
||||
};
|
||||
if (!validTransitions[oldState]?.includes(state)) {
|
||||
throw new Error(
|
||||
`Invalid state transition from "${oldState}" to "${state}"`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
this.db.prepare("UPDATE plugins SET state = ?, error = ?, updatedAt = ? WHERE id = ?").run(
|
||||
state,
|
||||
error ?? null,
|
||||
new Date().toISOString(),
|
||||
id,
|
||||
);
|
||||
this.db.bumpLastModified();
|
||||
|
||||
const updated = { ...plugin, state, error };
|
||||
this.emit("plugin:stateChanged", updated, oldState, state);
|
||||
this.emit("plugin:updated", updated);
|
||||
return updated;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update plugin settings.
|
||||
*/
|
||||
async updatePluginSettings(
|
||||
id: string,
|
||||
settings: Record<string, unknown>,
|
||||
): Promise<PluginInstallation> {
|
||||
const plugin = await this.getPlugin(id);
|
||||
|
||||
// Validate settings against schema
|
||||
const validationErrors = this.validateSettingsAgainstSchema(
|
||||
settings,
|
||||
plugin.settingsSchema,
|
||||
);
|
||||
if (validationErrors.length > 0) {
|
||||
throw new Error(`Settings validation failed: ${validationErrors.join(", ")}`);
|
||||
}
|
||||
|
||||
// Merge settings
|
||||
const mergedSettings = { ...plugin.settings, ...settings };
|
||||
|
||||
this.db.prepare("UPDATE plugins SET settings = ?, updatedAt = ? WHERE id = ?").run(
|
||||
toJson(mergedSettings),
|
||||
new Date().toISOString(),
|
||||
id,
|
||||
);
|
||||
this.db.bumpLastModified();
|
||||
|
||||
const updated = { ...plugin, settings: mergedSettings };
|
||||
this.emit("plugin:updated", updated);
|
||||
return updated;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generic update for plugin metadata.
|
||||
*/
|
||||
async updatePlugin(
|
||||
id: string,
|
||||
updates: PluginUpdateInput,
|
||||
): Promise<PluginInstallation> {
|
||||
const plugin = await this.getPlugin(id);
|
||||
const now = new Date().toISOString();
|
||||
|
||||
const setClauses: string[] = ["updatedAt = ?"];
|
||||
const params: any[] = [now];
|
||||
|
||||
if (updates.name !== undefined) {
|
||||
setClauses.push("name = ?");
|
||||
params.push(updates.name);
|
||||
}
|
||||
if (updates.version !== undefined) {
|
||||
setClauses.push("version = ?");
|
||||
params.push(updates.version);
|
||||
}
|
||||
if (updates.description !== undefined) {
|
||||
setClauses.push("description = ?");
|
||||
params.push(updates.description ?? null);
|
||||
}
|
||||
if (updates.author !== undefined) {
|
||||
setClauses.push("author = ?");
|
||||
params.push(updates.author ?? null);
|
||||
}
|
||||
if (updates.homepage !== undefined) {
|
||||
setClauses.push("homepage = ?");
|
||||
params.push(updates.homepage ?? null);
|
||||
}
|
||||
if (updates.path !== undefined) {
|
||||
setClauses.push("path = ?");
|
||||
params.push(updates.path);
|
||||
}
|
||||
if (updates.dependencies !== undefined) {
|
||||
setClauses.push("dependencies = ?");
|
||||
params.push(toJson(updates.dependencies));
|
||||
}
|
||||
|
||||
params.push(id);
|
||||
this.db.prepare(`UPDATE plugins SET ${setClauses.join(", ")} WHERE id = ?`).run(...params);
|
||||
this.db.bumpLastModified();
|
||||
|
||||
const updated = await this.getPlugin(id);
|
||||
this.emit("plugin:updated", updated);
|
||||
return updated;
|
||||
}
|
||||
}
|
||||
407
packages/core/src/plugin-types.test.ts
Normal file
407
packages/core/src/plugin-types.test.ts
Normal file
@@ -0,0 +1,407 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { validatePluginManifest } from "./plugin-types.js";
|
||||
|
||||
describe("validatePluginManifest", () => {
|
||||
// ── Valid Manifests ─────────────────────────────────────────────────
|
||||
|
||||
describe("valid manifests", () => {
|
||||
it("accepts a minimal valid manifest", () => {
|
||||
const manifest = { id: "my-plugin", name: "My Plugin", version: "1.0.0" };
|
||||
const result = validatePluginManifest(manifest);
|
||||
expect(result.valid).toBe(true);
|
||||
expect(result.errors).toEqual([]);
|
||||
});
|
||||
|
||||
it("accepts a full valid manifest with all optional fields", () => {
|
||||
const manifest = {
|
||||
id: "my-plugin",
|
||||
name: "My Plugin",
|
||||
version: "1.2.3",
|
||||
description: "A test plugin",
|
||||
author: "Test Author",
|
||||
homepage: "https://example.com",
|
||||
fusionVersion: "1.0.0",
|
||||
dependencies: ["other-plugin"],
|
||||
settingsSchema: {
|
||||
apiKey: {
|
||||
type: "string",
|
||||
label: "API Key",
|
||||
description: "Your API key",
|
||||
required: true,
|
||||
},
|
||||
maxItems: {
|
||||
type: "number",
|
||||
label: "Max Items",
|
||||
defaultValue: 10,
|
||||
},
|
||||
enabled: {
|
||||
type: "boolean",
|
||||
label: "Enable Feature",
|
||||
defaultValue: true,
|
||||
},
|
||||
color: {
|
||||
type: "enum",
|
||||
label: "Color",
|
||||
enumValues: ["red", "green", "blue"],
|
||||
defaultValue: "blue",
|
||||
},
|
||||
},
|
||||
};
|
||||
const result = validatePluginManifest(manifest);
|
||||
expect(result.valid).toBe(true);
|
||||
expect(result.errors).toEqual([]);
|
||||
});
|
||||
|
||||
it("accepts manifest with version 0.0.1", () => {
|
||||
const manifest = { id: "test", name: "Test", version: "0.0.1" };
|
||||
const result = validatePluginManifest(manifest);
|
||||
expect(result.valid).toBe(true);
|
||||
});
|
||||
|
||||
it("accepts manifest with large version numbers", () => {
|
||||
const manifest = { id: "test", name: "Test", version: "100.200.300" };
|
||||
const result = validatePluginManifest(manifest);
|
||||
expect(result.valid).toBe(true);
|
||||
});
|
||||
|
||||
it("accepts manifest with empty dependencies array", () => {
|
||||
const manifest = { id: "test", name: "Test", version: "1.0.0", dependencies: [] };
|
||||
const result = validatePluginManifest(manifest);
|
||||
expect(result.valid).toBe(true);
|
||||
});
|
||||
|
||||
it("accepts manifest with multiple valid dependencies", () => {
|
||||
const manifest = {
|
||||
id: "test",
|
||||
name: "Test",
|
||||
version: "1.0.0",
|
||||
dependencies: ["plugin-a", "plugin-b", "plugin-c"],
|
||||
};
|
||||
const result = validatePluginManifest(manifest);
|
||||
expect(result.valid).toBe(true);
|
||||
});
|
||||
|
||||
it("accepts manifest with valid settingsSchema", () => {
|
||||
const manifest = {
|
||||
id: "test",
|
||||
name: "Test",
|
||||
version: "1.0.0",
|
||||
settingsSchema: {
|
||||
setting1: { type: "string" },
|
||||
setting2: { type: "number" },
|
||||
setting3: { type: "boolean" },
|
||||
setting4: { type: "enum", enumValues: ["a", "b"] },
|
||||
},
|
||||
};
|
||||
const result = validatePluginManifest(manifest);
|
||||
expect(result.valid).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// ── Missing Required Fields ─────────────────────────────────────────
|
||||
|
||||
describe("missing required fields", () => {
|
||||
it("rejects manifest with missing id", () => {
|
||||
const manifest = { name: "My Plugin", version: "1.0.0" };
|
||||
const result = validatePluginManifest(manifest);
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.errors).toContain("id is required and must be a non-empty string");
|
||||
});
|
||||
|
||||
it("rejects manifest with missing name", () => {
|
||||
const manifest = { id: "my-plugin", version: "1.0.0" };
|
||||
const result = validatePluginManifest(manifest);
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.errors).toContain("name is required and must be a non-empty string");
|
||||
});
|
||||
|
||||
it("rejects manifest with missing version", () => {
|
||||
const manifest = { id: "my-plugin", name: "My Plugin" };
|
||||
const result = validatePluginManifest(manifest);
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.errors).toContain("version is required and must be a non-empty string");
|
||||
});
|
||||
|
||||
it("rejects manifest with all required fields missing", () => {
|
||||
const manifest = { description: "Only a description" };
|
||||
const result = validatePluginManifest(manifest);
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.errors).toContain("id is required and must be a non-empty string");
|
||||
expect(result.errors).toContain("name is required and must be a non-empty string");
|
||||
expect(result.errors).toContain("version is required and must be a non-empty string");
|
||||
});
|
||||
});
|
||||
|
||||
// ── Empty Strings ───────────────────────────────────────────────────
|
||||
|
||||
describe("empty strings for required fields", () => {
|
||||
it("rejects manifest with empty id", () => {
|
||||
const manifest = { id: "", name: "My Plugin", version: "1.0.0" };
|
||||
const result = validatePluginManifest(manifest);
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.errors).toContain("id is required and must be a non-empty string");
|
||||
});
|
||||
|
||||
it("rejects manifest with whitespace-only id", () => {
|
||||
const manifest = { id: " ", name: "My Plugin", version: "1.0.0" };
|
||||
const result = validatePluginManifest(manifest);
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.errors).toContain("id is required and must be a non-empty string");
|
||||
});
|
||||
|
||||
it("rejects manifest with empty name", () => {
|
||||
const manifest = { id: "my-plugin", name: "", version: "1.0.0" };
|
||||
const result = validatePluginManifest(manifest);
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.errors).toContain("name is required and must be a non-empty string");
|
||||
});
|
||||
|
||||
it("rejects manifest with empty version", () => {
|
||||
const manifest = { id: "my-plugin", name: "My Plugin", version: "" };
|
||||
const result = validatePluginManifest(manifest);
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.errors).toContain("version is required and must be a non-empty string");
|
||||
});
|
||||
});
|
||||
|
||||
// ── Invalid ID Format ───────────────────────────────────────────────
|
||||
|
||||
describe("invalid id format", () => {
|
||||
it("rejects id with uppercase letters", () => {
|
||||
const manifest = { id: "My-Plugin", name: "My Plugin", version: "1.0.0" };
|
||||
const result = validatePluginManifest(manifest);
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.errors.some(e => e.includes("id must be a valid slug"))).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects id with underscores", () => {
|
||||
const manifest = { id: "my_plugin", name: "My Plugin", version: "1.0.0" };
|
||||
const result = validatePluginManifest(manifest);
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.errors.some(e => e.includes("id must be a valid slug"))).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects id with spaces", () => {
|
||||
const manifest = { id: "my plugin", name: "My Plugin", version: "1.0.0" };
|
||||
const result = validatePluginManifest(manifest);
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.errors.some(e => e.includes("id must be a valid slug"))).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects id starting with a hyphen", () => {
|
||||
const manifest = { id: "-my-plugin", name: "My Plugin", version: "1.0.0" };
|
||||
const result = validatePluginManifest(manifest);
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.errors.some(e => e.includes("id must be a valid slug"))).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// ── Invalid Version Format ──────────────────────────────────────────
|
||||
|
||||
describe("invalid version format", () => {
|
||||
it("rejects version without semver format", () => {
|
||||
const manifest = { id: "my-plugin", name: "My Plugin", version: "latest" };
|
||||
const result = validatePluginManifest(manifest);
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.errors).toContain("version must be a valid semver string (e.g., 1.0.0)");
|
||||
});
|
||||
|
||||
it("rejects version with only major number", () => {
|
||||
const manifest = { id: "my-plugin", name: "My Plugin", version: "1" };
|
||||
const result = validatePluginManifest(manifest);
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.errors).toContain("version must be a valid semver string (e.g., 1.0.0)");
|
||||
});
|
||||
|
||||
it("rejects version with only two parts", () => {
|
||||
const manifest = { id: "my-plugin", name: "My Plugin", version: "1.0" };
|
||||
const result = validatePluginManifest(manifest);
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.errors).toContain("version must be a valid semver string (e.g., 1.0.0)");
|
||||
});
|
||||
|
||||
it("rejects version with four parts", () => {
|
||||
const manifest = { id: "my-plugin", name: "My Plugin", version: "1.0.0.0" };
|
||||
const result = validatePluginManifest(manifest);
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.errors).toContain("version must be a valid semver string (e.g., 1.0.0)");
|
||||
});
|
||||
|
||||
it("rejects version with letters", () => {
|
||||
const manifest = { id: "my-plugin", name: "My Plugin", version: "1.0.0-beta" };
|
||||
const result = validatePluginManifest(manifest);
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.errors).toContain("version must be a valid semver string (e.g., 1.0.0)");
|
||||
});
|
||||
|
||||
it("accepts version with leading zero (1.02.03)", () => {
|
||||
// This is technically valid semver syntax (though unusual)
|
||||
const manifest = { id: "my-plugin", name: "My Plugin", version: "1.02.03" };
|
||||
const result = validatePluginManifest(manifest);
|
||||
expect(result.valid).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// ── Invalid Dependencies ────────────────────────────────────────────
|
||||
|
||||
describe("invalid dependencies", () => {
|
||||
it("rejects non-array dependencies", () => {
|
||||
const manifest = { id: "test", name: "Test", version: "1.0.0", dependencies: "not-an-array" };
|
||||
const result = validatePluginManifest(manifest);
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.errors).toContain("dependencies must be an array");
|
||||
});
|
||||
|
||||
it("rejects dependencies with non-string items", () => {
|
||||
const manifest = { id: "test", name: "Test", version: "1.0.0", dependencies: ["valid", 123, "also-valid"] };
|
||||
const result = validatePluginManifest(manifest);
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.errors).toContain("All dependencies must be non-empty strings");
|
||||
});
|
||||
|
||||
it("rejects dependencies with empty string items", () => {
|
||||
const manifest = { id: "test", name: "Test", version: "1.0.0", dependencies: ["valid", "", "also-valid"] };
|
||||
const result = validatePluginManifest(manifest);
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.errors).toContain("All dependencies must be non-empty strings");
|
||||
});
|
||||
|
||||
it("rejects dependencies with whitespace-only string items", () => {
|
||||
const manifest = { id: "test", name: "Test", version: "1.0.0", dependencies: [" "] };
|
||||
const result = validatePluginManifest(manifest);
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.errors).toContain("All dependencies must be non-empty strings");
|
||||
});
|
||||
});
|
||||
|
||||
// ── Invalid settingsSchema ────────────────────────────────────────
|
||||
|
||||
describe("invalid settingsSchema", () => {
|
||||
it("rejects non-object settingsSchema", () => {
|
||||
const manifest = { id: "test", name: "Test", version: "1.0.0", settingsSchema: "not-an-object" };
|
||||
const result = validatePluginManifest(manifest);
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.errors).toContain("settingsSchema must be an object");
|
||||
});
|
||||
|
||||
it("rejects null settingsSchema", () => {
|
||||
const manifest = { id: "test", name: "Test", version: "1.0.0", settingsSchema: null };
|
||||
const result = validatePluginManifest(manifest);
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.errors).toContain("settingsSchema must be an object");
|
||||
});
|
||||
|
||||
it("rejects setting with invalid type", () => {
|
||||
const manifest = {
|
||||
id: "test",
|
||||
name: "Test",
|
||||
version: "1.0.0",
|
||||
settingsSchema: { setting1: { type: "invalid-type" } },
|
||||
};
|
||||
const result = validatePluginManifest(manifest);
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.errors).toContain(
|
||||
"settingsSchema.setting1.type must be one of: string, number, boolean, enum",
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects enum setting without enumValues", () => {
|
||||
const manifest = {
|
||||
id: "test",
|
||||
name: "Test",
|
||||
version: "1.0.0",
|
||||
settingsSchema: { setting1: { type: "enum" } },
|
||||
};
|
||||
const result = validatePluginManifest(manifest);
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.errors).toContain(
|
||||
"settingsSchema.setting1.enumValues is required and must be a non-empty array when type is enum",
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects enum setting with empty enumValues", () => {
|
||||
const manifest = {
|
||||
id: "test",
|
||||
name: "Test",
|
||||
version: "1.0.0",
|
||||
settingsSchema: { setting1: { type: "enum", enumValues: [] } },
|
||||
};
|
||||
const result = validatePluginManifest(manifest);
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.errors).toContain(
|
||||
"settingsSchema.setting1.enumValues is required and must be a non-empty array when type is enum",
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects multiple invalid settings", () => {
|
||||
const manifest = {
|
||||
id: "test",
|
||||
name: "Test",
|
||||
version: "1.0.0",
|
||||
settingsSchema: {
|
||||
setting1: { type: "invalid" },
|
||||
setting2: { type: "enum" },
|
||||
setting3: { type: "string" },
|
||||
},
|
||||
};
|
||||
const result = validatePluginManifest(manifest);
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.errors.length).toBeGreaterThanOrEqual(2);
|
||||
});
|
||||
});
|
||||
|
||||
// ── Null/Undefined Input ────────────────────────────────────────────
|
||||
|
||||
describe("null/undefined input", () => {
|
||||
it("rejects null manifest", () => {
|
||||
const result = validatePluginManifest(null);
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.errors).toContain("Manifest is required");
|
||||
});
|
||||
|
||||
it("rejects undefined manifest", () => {
|
||||
const result = validatePluginManifest(undefined);
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.errors).toContain("Manifest is required");
|
||||
});
|
||||
|
||||
it("rejects non-object manifest", () => {
|
||||
const result = validatePluginManifest("string");
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.errors).toContain("Manifest must be an object");
|
||||
});
|
||||
|
||||
it("rejects number manifest", () => {
|
||||
const result = validatePluginManifest(123);
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.errors).toContain("Manifest must be an object");
|
||||
});
|
||||
|
||||
it("rejects array manifest", () => {
|
||||
const result = validatePluginManifest([]);
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.errors).toContain("Manifest must be an object");
|
||||
});
|
||||
});
|
||||
|
||||
// ── Error Message Quality ───────────────────────────────────────────
|
||||
|
||||
describe("error message quality", () => {
|
||||
it("returns all errors, not just the first one", () => {
|
||||
const manifest = { id: "", name: "", version: "" };
|
||||
const result = validatePluginManifest(manifest);
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.errors.length).toBe(3);
|
||||
});
|
||||
|
||||
it("errors are descriptive enough to fix the issue", () => {
|
||||
const manifest = { id: "Invalid-ID", name: "", version: "bad" };
|
||||
const result = validatePluginManifest(manifest);
|
||||
expect(result.valid).toBe(false);
|
||||
// Each error should give clear guidance
|
||||
expect(result.errors.some((e) => e.includes("id"))).toBe(true);
|
||||
expect(result.errors.some((e) => e.includes("name"))).toBe(true);
|
||||
expect(result.errors.some((e) => e.includes("version"))).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
271
packages/core/src/plugin-types.ts
Normal file
271
packages/core/src/plugin-types.ts
Normal file
@@ -0,0 +1,271 @@
|
||||
/**
|
||||
* Plugin System Type Definitions for Fusion
|
||||
*
|
||||
* This module defines all types for the Fusion plugin system, including:
|
||||
* - PluginManifest: metadata and capability declaration
|
||||
* - Plugin hooks: lifecycle callbacks
|
||||
* - Plugin tools: AI agent tool definitions
|
||||
* - Plugin routes: custom dashboard API routes
|
||||
* - PluginContext: API surface available to plugins at runtime
|
||||
* - FusionPlugin: loaded plugin instance
|
||||
* - PluginInstallation: persisted plugin record
|
||||
*/
|
||||
|
||||
import type { TaskStore } from "./store.js";
|
||||
|
||||
// ── Plugin Manifest ───────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Metadata and capability declaration for a plugin.
|
||||
*/
|
||||
export interface PluginManifest {
|
||||
/** Unique identifier (e.g., "fusion-plugin-slack") */
|
||||
id: string;
|
||||
/** Human-readable name */
|
||||
name: string;
|
||||
/** Semver version string */
|
||||
version: string;
|
||||
/** Short description */
|
||||
description?: string;
|
||||
/** Author name or org */
|
||||
author?: string;
|
||||
/** URL to plugin docs/repo */
|
||||
homepage?: string;
|
||||
/** Minimum Fusion version required */
|
||||
fusionVersion?: string;
|
||||
/** IDs of other plugins this depends on */
|
||||
dependencies?: string[];
|
||||
/** Settings schema for validation */
|
||||
settingsSchema?: Record<string, PluginSettingSchema>;
|
||||
}
|
||||
|
||||
// ── Plugin Setting Schema ──────────────────────────────────────────────
|
||||
|
||||
export type PluginSettingType = "string" | "number" | "boolean" | "enum";
|
||||
|
||||
/**
|
||||
* Schema for a single plugin setting.
|
||||
*/
|
||||
export interface PluginSettingSchema {
|
||||
type: PluginSettingType;
|
||||
/** Human-readable label for UI */
|
||||
label?: string;
|
||||
description?: string;
|
||||
defaultValue?: unknown;
|
||||
required?: boolean;
|
||||
/** Only when type is "enum" */
|
||||
enumValues?: string[];
|
||||
}
|
||||
|
||||
// ── Plugin Hooks ─────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Context object passed to plugins at runtime.
|
||||
* Contains task store access, settings, logging, and event emission.
|
||||
*/
|
||||
export interface PluginContext {
|
||||
pluginId: string;
|
||||
/** Read-only access to task data */
|
||||
taskStore: TaskStore;
|
||||
/** Plugin's own settings */
|
||||
settings: Record<string, unknown>;
|
||||
/** Structured logger */
|
||||
logger: PluginLogger;
|
||||
/** Emit custom events */
|
||||
emitEvent: (event: string, data: unknown) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Structured logger interface for plugins.
|
||||
*/
|
||||
export interface PluginLogger {
|
||||
info(message: string, ...args: unknown[]): void;
|
||||
warn(message: string, ...args: unknown[]): void;
|
||||
error(message: string, ...args: unknown[]): void;
|
||||
debug(message: string, ...args: unknown[]): void;
|
||||
}
|
||||
|
||||
/** Lifecycle hook: called when plugin is loaded */
|
||||
export type PluginOnLoad = (ctx: PluginContext) => Promise<void> | void;
|
||||
/** Lifecycle hook: called when plugin is unloaded */
|
||||
export type PluginOnUnload = () => Promise<void> | void;
|
||||
/** Lifecycle hook: called when a task is created */
|
||||
export type PluginOnTaskCreated = (task: Task, ctx: PluginContext) => Promise<void> | void;
|
||||
/** Lifecycle hook: called when a task moves between columns */
|
||||
export type PluginOnTaskMoved = (task: Task, fromColumn: string, toColumn: string, ctx: PluginContext) => Promise<void> | void;
|
||||
/** Lifecycle hook: called when a task is completed */
|
||||
export type PluginOnTaskCompleted = (task: Task, ctx: PluginContext) => Promise<void> | void;
|
||||
/** Lifecycle hook: called when an error occurs */
|
||||
export type PluginOnError = (error: Error, ctx: PluginContext) => Promise<void> | void;
|
||||
|
||||
// ── Plugin Tools ─────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Tool registration for AI agents.
|
||||
* Tools are prefixed with "plugin_" at runtime.
|
||||
*/
|
||||
export interface PluginToolDefinition {
|
||||
/** Tool name (prefixed with "plugin_" at runtime) */
|
||||
name: string;
|
||||
/** Description for the AI agent */
|
||||
description: string;
|
||||
/** TypeBox-style parameter schema */
|
||||
parameters: Record<string, unknown>;
|
||||
execute: (params: Record<string, unknown>, ctx: PluginContext) => Promise<PluginToolResult>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Result returned by a plugin tool execution.
|
||||
*/
|
||||
export interface PluginToolResult {
|
||||
content: Array<{ type: "text"; text: string }>;
|
||||
isError?: boolean;
|
||||
details?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
// ── Plugin Routes ────────────────────────────────────────────────────
|
||||
|
||||
export type PluginRouteMethod = "GET" | "POST" | "PUT" | "DELETE";
|
||||
|
||||
/**
|
||||
* Custom dashboard API route definition.
|
||||
*/
|
||||
export interface PluginRouteDefinition {
|
||||
method: PluginRouteMethod;
|
||||
/** Relative path under /api/plugins/:pluginId/ */
|
||||
path: string;
|
||||
handler: (req: unknown, ctx: PluginContext) => Promise<unknown>;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
// ── Fusion Plugin ────────────────────────────────────────────────────
|
||||
|
||||
export type PluginState = "installed" | "started" | "stopped" | "error";
|
||||
|
||||
/**
|
||||
* Loaded plugin instance with all hooks, tools, and routes.
|
||||
*/
|
||||
export interface FusionPlugin {
|
||||
manifest: PluginManifest;
|
||||
state: PluginState;
|
||||
hooks: {
|
||||
onLoad?: PluginOnLoad;
|
||||
onUnload?: PluginOnUnload;
|
||||
onTaskCreated?: PluginOnTaskCreated;
|
||||
onTaskMoved?: PluginOnTaskMoved;
|
||||
onTaskCompleted?: PluginOnTaskCompleted;
|
||||
onError?: PluginOnError;
|
||||
};
|
||||
tools?: PluginToolDefinition[];
|
||||
routes?: PluginRouteDefinition[];
|
||||
}
|
||||
|
||||
// ── Plugin Installation ───────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Persisted plugin record in the store.
|
||||
*/
|
||||
export interface PluginInstallation {
|
||||
/** Same as manifest.id */
|
||||
id: string;
|
||||
name: string;
|
||||
version: string;
|
||||
description?: string;
|
||||
author?: string;
|
||||
homepage?: string;
|
||||
/** Absolute path to plugin directory or npm package */
|
||||
path: string;
|
||||
enabled: boolean;
|
||||
state: PluginState;
|
||||
settings: Record<string, unknown>;
|
||||
settingsSchema?: Record<string, PluginSettingSchema>;
|
||||
/** Last error message (if state is "error") */
|
||||
error?: string;
|
||||
dependencies?: string[];
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
// ── Manifest Validation ──────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Validate a plugin manifest.
|
||||
*
|
||||
* @returns Object with valid=true and empty errors array on success,
|
||||
* or valid=false with descriptive error messages on failure.
|
||||
*/
|
||||
export function validatePluginManifest(manifest: unknown): { valid: boolean; errors: string[] } {
|
||||
const errors: string[] = [];
|
||||
|
||||
if (manifest === null || manifest === undefined) {
|
||||
return { valid: false, errors: ["Manifest is required"] };
|
||||
}
|
||||
|
||||
if (typeof manifest !== "object" || Array.isArray(manifest)) {
|
||||
return { valid: false, errors: ["Manifest must be an object"] };
|
||||
}
|
||||
|
||||
const m = manifest as Record<string, unknown>;
|
||||
|
||||
// Required fields
|
||||
if (!m.id || typeof m.id !== "string" || m.id.trim() === "") {
|
||||
errors.push("id is required and must be a non-empty string");
|
||||
} else if (!/^[a-z0-9]([a-z0-9-]*[a-z0-9])?$/.test(m.id)) {
|
||||
errors.push("id must be a valid slug (lowercase, alphanumeric, hyphens only, cannot start or end with hyphen)");
|
||||
}
|
||||
|
||||
if (!m.name || typeof m.name !== "string" || m.name.trim() === "") {
|
||||
errors.push("name is required and must be a non-empty string");
|
||||
}
|
||||
|
||||
if (!m.version || typeof m.version !== "string" || m.version.trim() === "") {
|
||||
errors.push("version is required and must be a non-empty string");
|
||||
} else if (!/^\d+\.\d+\.\d+$/.test(m.version)) {
|
||||
errors.push("version must be a valid semver string (e.g., 1.0.0)");
|
||||
}
|
||||
|
||||
// Optional: dependencies
|
||||
if (m.dependencies !== undefined) {
|
||||
if (!Array.isArray(m.dependencies)) {
|
||||
errors.push("dependencies must be an array");
|
||||
} else {
|
||||
const invalidDeps = m.dependencies.filter(
|
||||
(d) => typeof d !== "string" || d.trim() === "",
|
||||
);
|
||||
if (invalidDeps.length > 0) {
|
||||
errors.push("All dependencies must be non-empty strings");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Optional: settingsSchema
|
||||
if (m.settingsSchema !== undefined) {
|
||||
if (typeof m.settingsSchema !== "object" || m.settingsSchema === null) {
|
||||
errors.push("settingsSchema must be an object");
|
||||
} else {
|
||||
const settingsSchema = m.settingsSchema as Record<string, unknown>;
|
||||
for (const [key, schema] of Object.entries(settingsSchema)) {
|
||||
if (!schema || typeof schema !== "object") {
|
||||
errors.push(`settingsSchema.${key} must be an object`);
|
||||
continue;
|
||||
}
|
||||
const setting = schema as Record<string, unknown>;
|
||||
if (!setting.type || !["string", "number", "boolean", "enum"].includes(setting.type as string)) {
|
||||
errors.push(`settingsSchema.${key}.type must be one of: string, number, boolean, enum`);
|
||||
}
|
||||
if (setting.type === "enum" && (!Array.isArray(setting.enumValues) || setting.enumValues.length === 0)) {
|
||||
errors.push(`settingsSchema.${key}.enumValues is required and must be a non-empty array when type is enum`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
valid: errors.length === 0,
|
||||
errors,
|
||||
};
|
||||
}
|
||||
|
||||
// ── Re-export Task type for hook signatures ───────────────────────────
|
||||
// The Task type is used in hook signatures; we import it via types.js
|
||||
import type { Task } from "./types.js";
|
||||
Reference in New Issue
Block a user