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:
gsxdsm
2026-04-09 14:45:32 -07:00
parent cdc430598d
commit 8c5327a660
16 changed files with 3577 additions and 11 deletions

View File

@@ -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(

View File

@@ -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();

View File

@@ -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
)
`);
});
}
}
/**

View File

@@ -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,

View 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([]);
});
});
});

View 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);
}
}

View 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);
});
});
});

View 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;
}
}

View 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);
});
});
});

View 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";