feat(FN-3182): scope plugin installs globally with project enablement

- Store plugin installation metadata in central DB while preserving per-project enable/disable state
- Update plugin CLI and dashboard Plugin Manager copy/behavior to distinguish global install from project enablement
- Expand plugin store and loader test coverage, including legacy migration and scoped install assertions
- Update runtime plugin e2e tests to use isolated central DB directories and document the new semantics
- Add a changeset for @runfusion/fusion describing the plugin scope behavior change

Fusion-Task-Id: FN-3182
This commit is contained in:
Fusion
2026-05-07 22:11:53 -07:00
committed by gsxdsm
parent 979cf115d9
commit b7f68d7dc0
20 changed files with 617 additions and 279 deletions

View File

@@ -0,0 +1,5 @@
---
"@runfusion/fusion": patch
---
Plugin management now separates global installation from project activation: installs/uninstalls are global, while enable/disable and runtime state remain project-scoped. Updated dashboard plugin lifecycle SSE payloads and Plugin Manager/CLI copy to make global vs project scope explicit.

View File

@@ -301,8 +301,11 @@ Hybrid evaluator pipeline (FN-3389/FN-3391):
### Plugin System ### Plugin System
- `PluginStore` (`plugin-store.ts`) stores plugin installation state and settings (`plugins` table) - `PluginStore` (`plugin-store.ts`) is a facade over two persistence scopes:
- `PluginLoader` (`plugin-loader.ts`) loads/unloads plugin modules and emits lifecycle events - **Global install metadata** in central DB table `plugin_installs` (`~/.fusion/fusion-central.db`) including manifest/path/settings/schema/dependencies
- **Per-project runtime state** in central DB table `project_plugin_states` keyed by normalized project path (`enabled`, `state`, `error`)
- Legacy project-local `plugins` rows in `.fusion/fusion.db` are migrated lazily on plugin-store init/read; migration is idempotent and keeps newest `updatedAt` install metadata as global canonical data while preserving per-project enablement rows
- `PluginLoader` (`plugin-loader.ts`) loads/unloads plugin modules using the effective per-project plugin state
- Plugin contributions now include both embedded `uiSlots` and top-level `dashboardViews` - Plugin contributions now include both embedded `uiSlots` and top-level `dashboardViews`
- Discovery endpoints: - Discovery endpoints:
- `GET /api/plugins/ui-slots` - `GET /api/plugins/ui-slots`

View File

@@ -828,6 +828,11 @@ fn plugin create <name>
Subcommands: `list|ls`, `install`, `rescan`, `uninstall`, `enable`, `disable`, `create`. Subcommands: `list|ls`, `install`, `rescan`, `uninstall`, `enable`, `disable`, `create`.
Scope semantics:
- `fn plugin install` / `fn plugin uninstall` are **global** operations
- `fn plugin enable` / `fn plugin disable` are **project-scoped** operations (`--project` selects the project context)
- `fn plugin list` shows globally installed plugins plus enabled/disabled state for the current project context
`fn plugin install --ai-scan` enables AI security scanning on plugin load. `fn plugin rescan <id>` runs a fresh scan/reload cycle and prints plugin name, verdict, summary, and finding count. It exits non-zero for `blocked`, `error`, or `unavailable` verdicts. `fn plugin install --ai-scan` enables AI security scanning on plugin load. `fn plugin rescan <id>` runs a fresh scan/reload cycle and prints plugin name, verdict, summary, and finding count. It exits non-zero for `blocked`, `error`, or `unavailable` verdicts.
--- ---

View File

@@ -80,6 +80,18 @@ Central health tracking keeps mutable project metrics, including:
A singleton central record enforces system-wide limits so one project cannot monopolize all execution slots. A singleton central record enforces system-wide limits so one project cannot monopolize all execution slots.
## Plugin Scope in Multi-Project Mode
Plugin persistence is split across global and project scopes:
- Global installation metadata is shared across projects in `~/.fusion/fusion-central.db` (`plugin_installs`)
- Per-project activation/runtime state is tracked separately per normalized project path (`project_plugin_states`)
Operationally:
- `install` / `uninstall` are global actions
- `enable` / `disable` and runtime state/error are project-scoped
- A single global plugin install can be enabled in one project and disabled in another
## Isolation Modes ## Isolation Modes
Projects can run with: Projects can run with:

View File

@@ -573,6 +573,8 @@ The fallback ensures tasks continue executing even if the configured runtime plu
To use plugin-provided runtimes like Paperclip, Hermes, or OpenClaw: To use plugin-provided runtimes like Paperclip, Hermes, or OpenClaw:
> Scope model: plugin installation + plugin settings are global (shared across projects), while plugin enabled/disabled state and runtime status are project-scoped.
1. Install one or more runtime plugins: 1. Install one or more runtime plugins:
```bash ```bash

View File

@@ -178,7 +178,7 @@ export async function runPluginList(projectName?: string): Promise<void> {
} }
console.log(); console.log();
console.log(" ID Name Version State Enabled"); console.log(" ID Name Version State Project Enabled");
console.log(" ─────────────────────────────────────────────────────────────────────"); console.log(" ─────────────────────────────────────────────────────────────────────");
for (const plugin of plugins) { for (const plugin of plugins) {
@@ -221,7 +221,7 @@ export async function runPluginInstall(
const { manifest, path } = await loadManifestFromPath(source); const { manifest, path } = await loadManifestFromPath(source);
console.log(); console.log();
console.log(` Installing ${manifest.name} v${manifest.version}...`); console.log(` Installing ${manifest.name} v${manifest.version} globally...`);
// Register the plugin // Register the plugin
const plugin = await store.registerPlugin({ const plugin = await store.registerPlugin({
@@ -234,12 +234,12 @@ export async function runPluginInstall(
if (plugin.enabled) { if (plugin.enabled) {
try { try {
await loader.loadPlugin(plugin.id); await loader.loadPlugin(plugin.id);
console.log(`${manifest.name} installed and loaded`); console.log(`${manifest.name} installed globally and enabled for this project`);
} catch (loadErr) { } catch (loadErr) {
console.log(`${manifest.name} installed but failed to load: ${loadErr instanceof Error ? loadErr.message : String(loadErr)}`); console.log(`${manifest.name} installed but failed to load: ${loadErr instanceof Error ? loadErr.message : String(loadErr)}`);
} }
} else { } else {
console.log(`${manifest.name} installed (disabled)`); console.log(`${manifest.name} installed globally (disabled for this project)`);
} }
console.log(); console.log();
} catch (err) { } catch (err) {
@@ -272,8 +272,8 @@ export async function runPluginUninstall(
// Confirm unless force // Confirm unless force
if (!options?.force) { if (!options?.force) {
console.log(); console.log();
console.log(` Uninstall "${plugin.name}"?`); console.log(` Uninstall "${plugin.name}" globally?`);
console.log(` This will stop and remove the plugin.`); console.log(" This removes it for all projects.");
console.log(); console.log();
const response = await new Promise<string>((resolve) => { const response = await new Promise<string>((resolve) => {
@@ -304,7 +304,7 @@ export async function runPluginUninstall(
await store.unregisterPlugin(id); await store.unregisterPlugin(id);
console.log(); console.log();
console.log(`${plugin.name} uninstalled`); console.log(`${plugin.name} uninstalled globally`);
console.log(); console.log();
} }
@@ -346,7 +346,7 @@ export async function runPluginEnable(
} }
console.log(); console.log();
console.log(`${plugin.name} enabled and started`); console.log(`${plugin.name} enabled for this project and started`);
console.log(); console.log();
} }
@@ -381,7 +381,7 @@ export async function runPluginDisable(
await store.disablePlugin(id); await store.disablePlugin(id);
console.log(); console.log();
console.log(`${plugin.name} disabled and stopped`); console.log(`${plugin.name} disabled for this project and stopped`);
console.log(); console.log();
} }

View File

@@ -39,7 +39,7 @@ describe("CentralDatabase", () => {
it("should initialize schema version", () => { it("should initialize schema version", () => {
db.init(); db.init();
expect(db.getSchemaVersion()).toBe(8); expect(db.getSchemaVersion()).toBe(9);
}); });
it("should seed lastModified on init", () => { it("should seed lastModified on init", () => {
@@ -217,7 +217,7 @@ describe("CentralDatabase", () => {
db.init(); db.init();
expect(db.getSchemaVersion()).toBe(8); expect(db.getSchemaVersion()).toBe(9);
const nodeColumns = db.prepare("PRAGMA table_info(nodes)").all() as Array<{ name: string }>; const nodeColumns = db.prepare("PRAGMA table_info(nodes)").all() as Array<{ name: string }>;
const nodeColumnNames = nodeColumns.map((column) => column.name); const nodeColumnNames = nodeColumns.map((column) => column.name);
@@ -282,7 +282,7 @@ describe("CentralDatabase", () => {
db.init(); db.init();
expect(db.getSchemaVersion()).toBe(8); expect(db.getSchemaVersion()).toBe(9);
const nodeColumns = db.prepare("PRAGMA table_info(nodes)").all() as Array<{ name: string }>; const nodeColumns = db.prepare("PRAGMA table_info(nodes)").all() as Array<{ name: string }>;
const nodeColumnNames = nodeColumns.map((column) => column.name); const nodeColumnNames = nodeColumns.map((column) => column.name);
@@ -370,7 +370,7 @@ describe("CentralDatabase", () => {
db.init(); db.init();
expect(db.getSchemaVersion()).toBe(8); expect(db.getSchemaVersion()).toBe(9);
const nodeColumns = db.prepare("PRAGMA table_info(nodes)").all() as Array<{ name: string }>; const nodeColumns = db.prepare("PRAGMA table_info(nodes)").all() as Array<{ name: string }>;
expect(nodeColumns.map((column) => column.name)).toContain("dockerConfig"); expect(nodeColumns.map((column) => column.name)).toContain("dockerConfig");
@@ -520,7 +520,7 @@ describe("CentralDatabase", () => {
db.init(); db.init();
expect(db.getSchemaVersion()).toBe(8); expect(db.getSchemaVersion()).toBe(9);
const mappings = db const mappings = db
.prepare("SELECT projectId, nodeId, path FROM projectNodePathMappings ORDER BY projectId") .prepare("SELECT projectId, nodeId, path FROM projectNodePathMappings ORDER BY projectId")

View File

@@ -38,7 +38,7 @@ describe.skipIf(!hasContributionApis)("PluginLoader contribution loading", () =>
beforeEach(() => { beforeEach(() => {
rootDir = mkdtempSync(join(tmpdir(), "kb-plugin-loader-contrib-")); rootDir = mkdtempSync(join(tmpdir(), "kb-plugin-loader-contrib-"));
pluginStore = new PluginStore(rootDir, { inMemoryDb: true }); pluginStore = new PluginStore(rootDir, { inMemoryDb: true, centralGlobalDir: rootDir });
loader = new PluginLoader({ pluginStore, taskStore: { logActivity: vi.fn() } as any }); loader = new PluginLoader({ pluginStore, taskStore: { logActivity: vi.fn() } as any });
}); });

View File

@@ -165,7 +165,7 @@ describe("PluginLoader", () => {
beforeEach(() => { beforeEach(() => {
rootDir = makeTmpDir(); rootDir = makeTmpDir();
pluginStore = new PluginStore(rootDir, { inMemoryDb: true }); pluginStore = new PluginStore(rootDir, { inMemoryDb: true, centralGlobalDir: rootDir });
setCreateAiSessionFactory(undefined); setCreateAiSessionFactory(undefined);
}); });

View File

@@ -1,5 +1,6 @@
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
import { PluginStore } from "../plugin-store.js"; import { PluginStore } from "../plugin-store.js";
import { Database, toJson } from "../db.js";
import { rm } from "node:fs/promises"; import { rm } from "node:fs/promises";
import { join } from "node:path"; import { join } from "node:path";
import { mkdtempSync } from "node:fs"; import { mkdtempSync } from "node:fs";
@@ -20,19 +21,66 @@ function makeManifest(overrides: Partial<PluginManifest> = {}): PluginManifest {
}; };
} }
function seedLegacyPluginRow(
projectRoot: string,
row: {
id: string;
name: string;
version: string;
path: string;
enabled?: number;
state?: PluginState;
error?: string | null;
settings?: Record<string, unknown>;
updatedAt?: string;
},
): void {
const db = new Database(join(projectRoot, ".fusion"));
db.init();
const now = row.updatedAt ?? new Date().toISOString();
db.prepare(`
INSERT INTO plugins (
id, name, version, description, author, homepage, path,
enabled, state, settings, settingsSchema, error, dependencies,
aiScanOnLoad, lastSecurityScan, createdAt, updatedAt
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`).run(
row.id,
row.name,
row.version,
null,
null,
null,
row.path,
row.enabled ?? 1,
row.state ?? "installed",
toJson(row.settings ?? {}),
null,
row.error ?? null,
toJson([]),
0,
null,
now,
now,
);
}
describe("PluginStore", () => { describe("PluginStore", () => {
let rootDir: string; let rootDir: string;
let store: PluginStore; let store: PluginStore;
let centralDir: string;
beforeEach(async () => { beforeEach(async () => {
rootDir = makeTmpDir(); rootDir = makeTmpDir();
// In-memory SQLite for test speed; see store.test.ts beforeEach. centralDir = makeTmpDir();
store = new PluginStore(rootDir, { inMemoryDb: true }); // In-memory project DB + isolated central DB directory.
store = new PluginStore(rootDir, { inMemoryDb: true, centralGlobalDir: centralDir });
await store.init(); await store.init();
}); });
afterEach(async () => { afterEach(async () => {
await rm(rootDir, { recursive: true, force: true }); await rm(rootDir, { recursive: true, force: true });
await rm(centralDir, { recursive: true, force: true });
}); });
// ── init ────────────────────────────────────────────────────────── // ── init ──────────────────────────────────────────────────────────
@@ -41,7 +89,7 @@ describe("PluginStore", () => {
it("creates the database file", async () => { it("creates the database file", async () => {
// Asserts a real file on disk exists, which the in-memory // Asserts a real file on disk exists, which the in-memory
// beforeEach store can't satisfy — open a disk-backed store. // beforeEach store can't satisfy — open a disk-backed store.
const diskStore = new PluginStore(rootDir); const diskStore = new PluginStore(rootDir, { centralGlobalDir: centralDir });
await diskStore.init(); await diskStore.init();
const dbPath = join(rootDir, ".fusion", "fusion.db"); const dbPath = join(rootDir, ".fusion", "fusion.db");
const { existsSync } = await import("node:fs"); const { existsSync } = await import("node:fs");
@@ -63,6 +111,103 @@ describe("PluginStore", () => {
}); });
}); });
describe("migration", () => {
it("migrates legacy project plugin rows into central install and project state", async () => {
const migrationProject = makeTmpDir();
const migrationCentral = makeTmpDir();
try {
seedLegacyPluginRow(migrationProject, {
id: "legacy-plugin",
name: "Legacy Plugin",
version: "1.2.3",
path: "/legacy/path",
enabled: 0,
state: "error",
error: "boom",
settings: { token: "abc" },
});
const migrationStore = new PluginStore(migrationProject, { centralGlobalDir: migrationCentral });
await migrationStore.init();
const plugin = await migrationStore.getPlugin("legacy-plugin");
expect(plugin.path).toBe("/legacy/path");
expect(plugin.enabled).toBe(false);
expect(plugin.state).toBe("error");
expect(plugin.error).toBe("boom");
expect(plugin.settings).toEqual({ token: "abc" });
} finally {
await rm(migrationProject, { recursive: true, force: true });
await rm(migrationCentral, { recursive: true, force: true });
}
});
it("is idempotent when init runs multiple times", async () => {
const migrationProject = makeTmpDir();
const migrationCentral = makeTmpDir();
try {
seedLegacyPluginRow(migrationProject, {
id: "legacy-idempotent",
name: "Legacy Idempotent",
version: "1.0.0",
path: "/legacy/idempotent",
});
const migrationStore = new PluginStore(migrationProject, { centralGlobalDir: migrationCentral });
await migrationStore.init();
await migrationStore.init();
const plugins = await migrationStore.listPlugins();
expect(plugins.filter((plugin) => plugin.id === "legacy-idempotent")).toHaveLength(1);
} finally {
await rm(migrationProject, { recursive: true, force: true });
await rm(migrationCentral, { recursive: true, force: true });
}
});
it("keeps latest updatedAt install metadata across projects while preserving per-project enablement", async () => {
const projectA = makeTmpDir();
const projectB = makeTmpDir();
const sharedCentral = makeTmpDir();
try {
seedLegacyPluginRow(projectA, {
id: "shared-legacy",
name: "Shared Legacy Old",
version: "1.0.0",
path: "/old/path",
enabled: 1,
updatedAt: "2026-01-01T00:00:00.000Z",
});
seedLegacyPluginRow(projectB, {
id: "shared-legacy",
name: "Shared Legacy New",
version: "2.0.0",
path: "/new/path",
enabled: 0,
updatedAt: "2026-02-01T00:00:00.000Z",
});
const storeA = new PluginStore(projectA, { centralGlobalDir: sharedCentral });
const storeB = new PluginStore(projectB, { centralGlobalDir: sharedCentral });
await storeA.init();
await storeB.init();
const pluginFromA = await storeA.getPlugin("shared-legacy");
const pluginFromB = await storeB.getPlugin("shared-legacy");
expect(pluginFromA.name).toBe("Shared Legacy New");
expect(pluginFromA.version).toBe("2.0.0");
expect(pluginFromA.path).toBe("/new/path");
expect(pluginFromA.enabled).toBe(true);
expect(pluginFromB.enabled).toBe(false);
} finally {
await rm(projectA, { recursive: true, force: true });
await rm(projectB, { recursive: true, force: true });
await rm(sharedCentral, { recursive: true, force: true });
}
});
});
// ── registerPlugin ───────────────────────────────────────────────── // ── registerPlugin ─────────────────────────────────────────────────
describe("registerPlugin", () => { describe("registerPlugin", () => {

View File

@@ -23,7 +23,7 @@ export { toJson, toJsonNullable, fromJson };
// ── Schema Definition ─────────────────────────────────────────────────── // ── Schema Definition ───────────────────────────────────────────────────
const CENTRAL_SCHEMA_VERSION = 8; const CENTRAL_SCHEMA_VERSION = 9;
const CENTRAL_SCHEMA_SQL = ` const CENTRAL_SCHEMA_SQL = `
-- Projects table (project registry) -- Projects table (project registry)
@@ -177,6 +177,39 @@ CREATE TABLE IF NOT EXISTS managedDockerNodes (
CREATE INDEX IF NOT EXISTS idxManagedDockerNodesStatus ON managedDockerNodes(status); CREATE INDEX IF NOT EXISTS idxManagedDockerNodesStatus ON managedDockerNodes(status);
CREATE INDEX IF NOT EXISTS idxManagedDockerNodesNodeId ON managedDockerNodes(nodeId); CREATE INDEX IF NOT EXISTS idxManagedDockerNodesNodeId ON managedDockerNodes(nodeId);
-- Global plugin install registry
CREATE TABLE IF NOT EXISTS plugin_installs (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
version TEXT NOT NULL,
description TEXT,
author TEXT,
homepage TEXT,
path TEXT NOT NULL,
settings TEXT DEFAULT '{}',
settingsSchema TEXT,
dependencies TEXT DEFAULT '[]',
aiScanOnLoad INTEGER NOT NULL DEFAULT 0,
lastSecurityScan TEXT,
createdAt TEXT NOT NULL,
updatedAt TEXT NOT NULL
);
-- Per-project plugin state
CREATE TABLE IF NOT EXISTS project_plugin_states (
projectPath TEXT NOT NULL,
pluginId TEXT NOT NULL,
enabled INTEGER NOT NULL DEFAULT 0,
state TEXT NOT NULL DEFAULT 'installed',
error TEXT,
createdAt TEXT NOT NULL,
updatedAt TEXT NOT NULL,
PRIMARY KEY (projectPath, pluginId),
FOREIGN KEY (pluginId) REFERENCES plugin_installs(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idxProjectPluginStatesProjectPath ON project_plugin_states(projectPath);
CREATE INDEX IF NOT EXISTS idxProjectPluginStatesPluginId ON project_plugin_states(pluginId);
-- Schema version tracking -- Schema version tracking
CREATE TABLE IF NOT EXISTS __meta ( CREATE TABLE IF NOT EXISTS __meta (
key TEXT PRIMARY KEY, key TEXT PRIMARY KEY,
@@ -285,6 +318,39 @@ CREATE INDEX IF NOT EXISTS idxProjectNodePathMappingsProjectId ON projectNodePat
CREATE INDEX IF NOT EXISTS idxProjectNodePathMappingsNodeId ON projectNodePathMappings(nodeId); CREATE INDEX IF NOT EXISTS idxProjectNodePathMappingsNodeId ON projectNodePathMappings(nodeId);
`; `;
const CENTRAL_SCHEMA_V9_MIGRATION_SQL = `
CREATE TABLE IF NOT EXISTS plugin_installs (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
version TEXT NOT NULL,
description TEXT,
author TEXT,
homepage TEXT,
path TEXT NOT NULL,
settings TEXT DEFAULT '{}',
settingsSchema TEXT,
dependencies TEXT DEFAULT '[]',
aiScanOnLoad INTEGER NOT NULL DEFAULT 0,
lastSecurityScan TEXT,
createdAt TEXT NOT NULL,
updatedAt TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS project_plugin_states (
projectPath TEXT NOT NULL,
pluginId TEXT NOT NULL,
enabled INTEGER NOT NULL DEFAULT 0,
state TEXT NOT NULL DEFAULT 'installed',
error TEXT,
createdAt TEXT NOT NULL,
updatedAt TEXT NOT NULL,
PRIMARY KEY (projectPath, pluginId),
FOREIGN KEY (pluginId) REFERENCES plugin_installs(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idxProjectPluginStatesProjectPath ON project_plugin_states(projectPath);
CREATE INDEX IF NOT EXISTS idxProjectPluginStatesPluginId ON project_plugin_states(pluginId);
`;
// ── Central Database Class ──────────────────────────────────────────────── // ── Central Database Class ────────────────────────────────────────────────
export class CentralDatabase { export class CentralDatabase {
@@ -407,6 +473,11 @@ export class CentralDatabase {
migrated = true; migrated = true;
} }
if (currentVersion < 9) {
this.db.exec(CENTRAL_SCHEMA_V9_MIGRATION_SQL);
migrated = true;
}
if (migrated) { if (migrated) {
this.db this.db
.prepare("INSERT INTO __meta (key, value) VALUES ('schemaVersion', ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value") .prepare("INSERT INTO __meta (key, value) VALUES ('schemaVersion', ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value")

View File

@@ -1,12 +1,14 @@
/** /**
* SQLite-backed PluginStore for managing plugin installations. * SQLite-backed PluginStore for managing plugin installations.
* *
* Provides CRUD operations for plugins with event emission for state changes. * Global install metadata is persisted in central DB, while per-project
* enablement/runtime state is persisted per project path.
*/ */
import { EventEmitter } from "node:events"; import { EventEmitter } from "node:events";
import { join } from "node:path"; import { join, resolve } from "node:path";
import { Database, toJson, fromJson } from "./db.js"; import { Database, fromJson, toJson } from "./db.js";
import { CentralDatabase } from "./central-db.js";
import type { import type {
PluginInstallation, PluginInstallation,
PluginManifest, PluginManifest,
@@ -26,7 +28,6 @@ export interface PluginStoreEvents {
"plugin:stateChanged": [plugin: PluginInstallation, oldState: PluginState, newState: PluginState]; "plugin:stateChanged": [plugin: PluginInstallation, oldState: PluginState, newState: PluginState];
} }
/** Input for registering a new plugin */
export interface PluginRegistrationInput { export interface PluginRegistrationInput {
manifest: PluginManifest; manifest: PluginManifest;
path: string; path: string;
@@ -34,7 +35,6 @@ export interface PluginRegistrationInput {
aiScanOnLoad?: boolean; aiScanOnLoad?: boolean;
} }
/** Partial update input for a plugin */
export interface PluginUpdateInput { export interface PluginUpdateInput {
name?: string; name?: string;
version?: string; version?: string;
@@ -47,8 +47,7 @@ export interface PluginUpdateInput {
lastSecurityScan?: PluginSecurityScanResult; lastSecurityScan?: PluginSecurityScanResult;
} }
/** Database row shape for the plugins table. */ interface LegacyPluginRow {
interface PluginRow {
id: string; id: string;
name: string; name: string;
version: string; version: string;
@@ -68,64 +67,75 @@ interface PluginRow {
updatedAt: string; updatedAt: string;
} }
interface InstallRow {
id: string;
name: string;
version: string;
description: string | null;
author: string | null;
homepage: string | null;
path: string;
settings: string | null;
settingsSchema: string | null;
dependencies: string | null;
aiScanOnLoad: number;
lastSecurityScan: string | null;
createdAt: string;
updatedAt: string;
}
interface ProjectStateRow {
projectPath: string;
pluginId: string;
enabled: number;
state: string;
error: string | null;
createdAt: string;
updatedAt: string;
}
export class PluginStore extends EventEmitter<PluginStoreEvents> { export class PluginStore extends EventEmitter<PluginStoreEvents> {
/** SQLite database instance */ private _localDb: Database | null = null;
private _db: Database | null = null; private _centralDb: CentralDatabase | null = null;
private readonly inMemoryDb: boolean; private readonly inMemoryDb: boolean;
private readonly normalizedProjectPath: string;
private readonly centralGlobalDir?: string;
constructor(private rootDir: string, options?: { inMemoryDb?: boolean }) { constructor(
private rootDir: string,
options?: { inMemoryDb?: boolean; centralGlobalDir?: string },
) {
super(); super();
assertProjectRootDir(rootDir, "PluginStore"); assertProjectRootDir(rootDir, "PluginStore");
this.inMemoryDb = options?.inMemoryDb === true; this.inMemoryDb = options?.inMemoryDb === true;
this.normalizedProjectPath = resolve(rootDir);
this.centralGlobalDir = options?.centralGlobalDir;
} }
/** private get localDb(): Database {
* Get the SQLite database, initializing it on first access. if (!this._localDb) {
*/
private get db(): Database {
if (!this._db) {
const fusionDir = join(this.rootDir, ".fusion"); const fusionDir = join(this.rootDir, ".fusion");
this._db = new Database(fusionDir, { inMemory: this.inMemoryDb }); this._localDb = new Database(fusionDir, { inMemory: this.inMemoryDb });
this._db.init(); this._localDb.init();
} }
return this._db; return this._localDb;
}
private get centralDb(): CentralDatabase {
if (!this._centralDb) {
this._centralDb = new CentralDatabase(this.centralGlobalDir);
this._centralDb.init();
}
return this._centralDb;
} }
/** Initialize the store. */
async init(): Promise<void> { async init(): Promise<void> {
// Ensure DB is initialized (triggers table creation) const _ = this.localDb;
const _ = this.db; const __ = this.centralDb;
this.migrateLegacyProjectRows();
} }
// ── Row Conversion ─────────────────────────────────────────────────
private rowToPlugin(row: PluginRow): 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) || [],
aiScanOnLoad: row.aiScanOnLoad === 1,
lastSecurityScan: fromJson<PluginSecurityScanResult>(row.lastSecurityScan ?? null) ?? undefined,
createdAt: row.createdAt,
updatedAt: row.updatedAt,
};
}
// ── Validation Helpers ───────────────────────────────────────────────
private validateIdFormat(id: string): boolean { 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); return /^[a-z0-9]([a-z0-9-]*[a-z0-9])?$/.test(id);
} }
@@ -138,17 +148,12 @@ export class PluginStore extends EventEmitter<PluginStoreEvents> {
const errors: string[] = []; const errors: string[] = [];
for (const [key, settingSchema] of Object.entries(schema)) { for (const [key, settingSchema] of Object.entries(schema)) {
const value = settings[key]; const value = settings[key];
// Check required
if (settingSchema.required && !(key in settings)) { if (settingSchema.required && !(key in settings)) {
errors.push(`Setting "${key}" is required`); errors.push(`Setting "${key}" is required`);
continue; continue;
} }
// Skip validation if not provided and not required
if (!(key in settings)) continue; if (!(key in settings)) continue;
// Check type
const expectedType = settingSchema.type; const expectedType = settingSchema.type;
if (expectedType === "string" && typeof value !== "string") { if (expectedType === "string" && typeof value !== "string") {
errors.push(`Setting "${key}" must be a string`); errors.push(`Setting "${key}" must be a string`);
@@ -160,15 +165,12 @@ export class PluginStore extends EventEmitter<PluginStoreEvents> {
errors.push(`Setting "${key}" must be a boolean`); errors.push(`Setting "${key}" must be a boolean`);
} else if (expectedType === "enum") { } else if (expectedType === "enum") {
if (typeof value !== "string" || !settingSchema.enumValues?.includes(value)) { if (typeof value !== "string" || !settingSchema.enumValues?.includes(value)) {
errors.push( errors.push(`Setting "${key}" must be one of: ${settingSchema.enumValues?.join(", ")}`);
`Setting "${key}" must be one of: ${settingSchema.enumValues?.join(", ")}`,
);
} }
} else if (expectedType === "array") { } else if (expectedType === "array") {
if (!Array.isArray(value)) { if (!Array.isArray(value)) {
errors.push(`Setting "${key}" must be an array`); errors.push(`Setting "${key}" must be an array`);
} else { } else {
// Validate item types
const itemType = settingSchema.itemType; const itemType = settingSchema.itemType;
for (const item of value) { for (const item of value) {
if (itemType === "string" && typeof item !== "string") { if (itemType === "string" && typeof item !== "string") {
@@ -186,35 +188,187 @@ export class PluginStore extends EventEmitter<PluginStoreEvents> {
return errors; return errors;
} }
// ── CRUD Operations ──────────────────────────────────────────────── private rowToPlugin(install: InstallRow, state?: ProjectStateRow): PluginInstallation {
return {
id: install.id,
name: install.name,
version: install.version,
description: install.description || undefined,
author: install.author || undefined,
homepage: install.homepage || undefined,
path: install.path,
enabled: state?.enabled === 1,
state: (state?.state ?? "installed") as PluginState,
settings: fromJson<Record<string, unknown>>(install.settings) || {},
settingsSchema: fromJson<Record<string, PluginSettingSchema>>(install.settingsSchema),
error: state?.error || undefined,
dependencies: fromJson<string[]>(install.dependencies) || [],
aiScanOnLoad: install.aiScanOnLoad === 1,
lastSecurityScan: fromJson<PluginSecurityScanResult>(install.lastSecurityScan ?? null) ?? undefined,
createdAt: install.createdAt,
updatedAt: state?.updatedAt ?? install.updatedAt,
};
}
private getProjectState(pluginId: string): ProjectStateRow | undefined {
return this.centralDb
.prepare("SELECT * FROM project_plugin_states WHERE projectPath = ? AND pluginId = ?")
.get(this.normalizedProjectPath, pluginId) as ProjectStateRow | undefined;
}
private upsertProjectState(
pluginId: string,
updates: { enabled?: boolean; state?: PluginState; error?: string | null },
): ProjectStateRow {
const existing = this.getProjectState(pluginId);
const now = new Date().toISOString();
const row: ProjectStateRow = {
projectPath: this.normalizedProjectPath,
pluginId,
enabled: updates.enabled === undefined ? (existing?.enabled ?? 0) : updates.enabled ? 1 : 0,
state: updates.state ?? existing?.state ?? "installed",
error: updates.error === undefined ? (existing?.error ?? null) : updates.error,
createdAt: existing?.createdAt ?? now,
updatedAt: now,
};
this.centralDb
.prepare(`
INSERT INTO project_plugin_states (projectPath, pluginId, enabled, state, error, createdAt, updatedAt)
VALUES (?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(projectPath, pluginId) DO UPDATE SET
enabled = excluded.enabled,
state = excluded.state,
error = excluded.error,
updatedAt = excluded.updatedAt
`)
.run(
row.projectPath,
row.pluginId,
row.enabled,
row.state,
row.error,
row.createdAt,
row.updatedAt,
);
return row;
}
private migrateLegacyProjectRows(): void {
const marker = this.localDb
.prepare("SELECT value FROM __meta WHERE key = 'pluginCentralMigrationV1'")
.get() as { value: string } | undefined;
if (marker?.value === "done") return;
const hasPluginsTable = this.localDb
.prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'plugins'")
.get() as { name?: string } | undefined;
if (!hasPluginsTable?.name) {
this.localDb
.prepare("INSERT INTO __meta (key, value) VALUES ('pluginCentralMigrationV1', 'done') ON CONFLICT(key) DO UPDATE SET value = excluded.value")
.run();
return;
}
const rows = this.localDb
.prepare("SELECT * FROM plugins ORDER BY updatedAt ASC")
.all() as LegacyPluginRow[];
this.centralDb.transaction(() => {
for (const row of rows) {
const existingInstall = this.centralDb
.prepare("SELECT * FROM plugin_installs WHERE id = ?")
.get(row.id) as InstallRow | undefined;
const takeLegacy = !existingInstall || new Date(row.updatedAt).getTime() >= new Date(existingInstall.updatedAt).getTime();
if (takeLegacy) {
this.centralDb
.prepare(`
INSERT INTO plugin_installs (
id, name, version, description, author, homepage, path,
settings, settingsSchema, dependencies, aiScanOnLoad, lastSecurityScan, createdAt, updatedAt
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(id) DO UPDATE SET
name = excluded.name,
version = excluded.version,
description = excluded.description,
author = excluded.author,
homepage = excluded.homepage,
path = excluded.path,
settings = excluded.settings,
settingsSchema = excluded.settingsSchema,
dependencies = excluded.dependencies,
aiScanOnLoad = excluded.aiScanOnLoad,
lastSecurityScan = excluded.lastSecurityScan,
updatedAt = excluded.updatedAt
`)
.run(
row.id,
row.name,
row.version,
row.description,
row.author,
row.homepage,
row.path,
row.settings ?? "{}",
row.settingsSchema,
row.dependencies ?? "[]",
row.aiScanOnLoad === 1 ? 1 : 0,
row.lastSecurityScan ?? null,
existingInstall?.createdAt ?? row.createdAt,
row.updatedAt,
);
}
this.centralDb
.prepare(`
INSERT INTO project_plugin_states (projectPath, pluginId, enabled, state, error, createdAt, updatedAt)
VALUES (?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(projectPath, pluginId) DO UPDATE SET
enabled = excluded.enabled,
state = excluded.state,
error = excluded.error,
updatedAt = excluded.updatedAt
`)
.run(
this.normalizedProjectPath,
row.id,
row.enabled === 1 ? 1 : 0,
row.state,
row.error,
row.createdAt,
row.updatedAt,
);
}
this.localDb
.prepare("INSERT INTO __meta (key, value) VALUES ('pluginCentralMigrationV1', 'done') ON CONFLICT(key) DO UPDATE SET value = excluded.value")
.run();
});
}
/**
* Register a new plugin.
*/
async registerPlugin(input: PluginRegistrationInput): Promise<PluginInstallation> { async registerPlugin(input: PluginRegistrationInput): Promise<PluginInstallation> {
const { manifest, path, settings = {}, aiScanOnLoad = false } = input; const { manifest, path, settings = {}, aiScanOnLoad = false } = input;
// Validate manifest
const manifestValidation = validatePluginManifest(manifest); const manifestValidation = validatePluginManifest(manifest);
if (!manifestValidation.valid) { if (!manifestValidation.valid) {
throw new Error(`Invalid plugin manifest: ${manifestValidation.errors.join(", ")}`); throw new Error(`Invalid plugin manifest: ${manifestValidation.errors.join(", ")}`);
} }
// Validate required fields
if (!path?.trim()) { if (!path?.trim()) {
throw new Error("Plugin path is required and cannot be empty"); throw new Error("Plugin path is required and cannot be empty");
} }
// Validate id format
if (!this.validateIdFormat(manifest.id)) { if (!this.validateIdFormat(manifest.id)) {
throw new Error( throw new Error(
"Plugin id must be a valid slug (lowercase, alphanumeric, hyphens only, cannot start or end with hyphen)", "Plugin id must be a valid slug (lowercase, alphanumeric, hyphens only, cannot start or end with hyphen)",
); );
} }
// Check for duplicate const existing = this.centralDb
const existing = this.db .prepare("SELECT id FROM plugin_installs WHERE id = ?")
.prepare("SELECT id FROM plugins WHERE id = ?")
.get(manifest.id); .get(manifest.id);
if (existing) { if (existing) {
throw Object.assign(new Error(`Plugin "${manifest.id}" is already registered`), { throw Object.assign(new Error(`Plugin "${manifest.id}" is already registered`), {
@@ -222,7 +376,6 @@ export class PluginStore extends EventEmitter<PluginStoreEvents> {
}); });
} }
// Compute defaults from settingsSchema and merge with provided settings
const defaultSettings: Record<string, unknown> = {}; const defaultSettings: Record<string, unknown> = {};
if (manifest.settingsSchema) { if (manifest.settingsSchema) {
for (const [key, schema] of Object.entries(manifest.settingsSchema)) { for (const [key, schema] of Object.entries(manifest.settingsSchema)) {
@@ -234,159 +387,106 @@ export class PluginStore extends EventEmitter<PluginStoreEvents> {
const mergedSettings = { ...defaultSettings, ...settings }; const mergedSettings = { ...defaultSettings, ...settings };
const now = new Date().toISOString(); 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: mergedSettings,
settingsSchema: manifest.settingsSchema,
dependencies: manifest.dependencies || [],
aiScanOnLoad,
createdAt: now,
updatedAt: now,
};
// Insert into database this.centralDb
this.db.prepare(` .prepare(`
INSERT INTO plugins ( INSERT INTO plugin_installs (
id, name, version, description, author, homepage, path, id, name, version, description, author, homepage, path,
enabled, state, settings, settingsSchema, dependencies, aiScanOnLoad, lastSecurityScan, createdAt, updatedAt settings, settingsSchema, dependencies, aiScanOnLoad, lastSecurityScan, createdAt, updatedAt
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`).run( `)
plugin.id, .run(
plugin.name, manifest.id,
plugin.version, manifest.name,
plugin.description ?? null, manifest.version,
plugin.author ?? null, manifest.description ?? null,
plugin.homepage ?? null, manifest.author ?? null,
plugin.path, manifest.homepage ?? null,
plugin.enabled ? 1 : 0, path.trim(),
plugin.state, toJson(mergedSettings),
toJson(plugin.settings), manifest.settingsSchema ? toJson(manifest.settingsSchema) : null,
plugin.settingsSchema ? toJson(plugin.settingsSchema) : null, toJson(manifest.dependencies || []),
toJson(plugin.dependencies), aiScanOnLoad ? 1 : 0,
plugin.aiScanOnLoad ? 1 : 0, null,
null, now,
plugin.createdAt, now,
plugin.updatedAt, );
);
this.db.bumpLastModified(); this.upsertProjectState(manifest.id, { enabled: true, state: "installed", error: null });
this.centralDb.bumpLastModified();
const plugin = await this.getPlugin(manifest.id);
this.emit("plugin:registered", plugin); this.emit("plugin:registered", plugin);
return plugin; return plugin;
} }
/**
* Unregister (delete) a plugin.
*/
async unregisterPlugin(id: string): Promise<PluginInstallation> { async unregisterPlugin(id: string): Promise<PluginInstallation> {
const plugin = await this.getPlugin(id); const plugin = await this.getPlugin(id);
this.centralDb.prepare("DELETE FROM plugin_installs WHERE id = ?").run(id);
this.db.prepare("DELETE FROM plugins WHERE id = ?").run(id); this.centralDb.bumpLastModified();
this.db.bumpLastModified();
this.emit("plugin:unregistered", plugin); this.emit("plugin:unregistered", plugin);
return plugin; return plugin;
} }
/**
* Get a plugin by id.
*/
async getPlugin(id: string): Promise<PluginInstallation> { async getPlugin(id: string): Promise<PluginInstallation> {
const row = this.db.prepare("SELECT * FROM plugins WHERE id = ?").get(id) as unknown as PluginRow | undefined; const install = this.centralDb
if (!row) { .prepare("SELECT * FROM plugin_installs WHERE id = ?")
.get(id) as InstallRow | undefined;
if (!install) {
throw Object.assign(new Error(`Plugin "${id}" not found`), { code: "ENOENT" }); throw Object.assign(new Error(`Plugin "${id}" not found`), { code: "ENOENT" });
} }
return this.rowToPlugin(row); return this.rowToPlugin(install, this.getProjectState(id));
} }
/** async listPlugins(filter?: { enabled?: boolean; state?: PluginState }): Promise<PluginInstallation[]> {
* List all plugins, optionally filtered. const installs = this.centralDb
*/ .prepare("SELECT * FROM plugin_installs ORDER BY createdAt ASC")
async listPlugins( .all() as InstallRow[];
filter?: { enabled?: boolean; state?: PluginState },
): Promise<PluginInstallation[]> {
let sql = "SELECT * FROM plugins";
const conditions: string[] = [];
const params: (string | number)[] = [];
if (filter?.enabled !== undefined) { const results = installs.map((install) => this.rowToPlugin(install, this.getProjectState(install.id)));
conditions.push("enabled = ?");
params.push(filter.enabled ? 1 : 0);
}
if (filter?.state) {
conditions.push("state = ?");
params.push(filter.state);
}
if (conditions.length > 0) { return results.filter((plugin) => {
sql += " WHERE " + conditions.join(" AND "); if (filter?.enabled !== undefined && plugin.enabled !== filter.enabled) {
} return false;
sql += " ORDER BY createdAt ASC"; }
if (filter?.state && plugin.state !== filter.state) {
const rows = this.db.prepare(sql).all(...params) as unknown as PluginRow[]; return false;
return rows.map((row) => this.rowToPlugin(row)); }
return true;
});
} }
/**
* Enable a plugin.
*/
async enablePlugin(id: string): Promise<PluginInstallation> { async enablePlugin(id: string): Promise<PluginInstallation> {
const plugin = await this.getPlugin(id); await this.getPlugin(id);
this.upsertProjectState(id, { enabled: true });
this.centralDb.bumpLastModified();
this.db.prepare("UPDATE plugins SET enabled = 1, updatedAt = ? WHERE id = ?").run( const updated = await this.getPlugin(id);
new Date().toISOString(),
id,
);
this.db.bumpLastModified();
const updated = { ...plugin, enabled: true };
this.emit("plugin:enabled", updated); this.emit("plugin:enabled", updated);
this.emit("plugin:updated", updated); this.emit("plugin:updated", updated);
return updated; return updated;
} }
/**
* Disable a plugin.
*/
async disablePlugin(id: string): Promise<PluginInstallation> { async disablePlugin(id: string): Promise<PluginInstallation> {
const plugin = await this.getPlugin(id); await this.getPlugin(id);
this.upsertProjectState(id, { enabled: false });
this.centralDb.bumpLastModified();
this.db.prepare("UPDATE plugins SET enabled = 0, updatedAt = ? WHERE id = ?").run( const updated = await this.getPlugin(id);
new Date().toISOString(),
id,
);
this.db.bumpLastModified();
const updated = { ...plugin, enabled: false };
this.emit("plugin:disabled", updated); this.emit("plugin:disabled", updated);
this.emit("plugin:updated", updated); this.emit("plugin:updated", updated);
return updated; return updated;
} }
/** async updatePluginState(id: string, state: PluginState, error?: string): Promise<PluginInstallation> {
* Update plugin state.
*/
async updatePluginState(
id: string,
state: PluginState,
error?: string,
): Promise<PluginInstallation> {
const plugin = await this.getPlugin(id); const plugin = await this.getPlugin(id);
const oldState = plugin.state; const oldState = plugin.state;
// Validate state transitions
const validStates: PluginState[] = ["installed", "started", "stopped", "error"]; const validStates: PluginState[] = ["installed", "started", "stopped", "error"];
if (!validStates.includes(state)) { if (!validStates.includes(state)) {
throw new Error(`Invalid state: ${state}`); throw new Error(`Invalid state: ${state}`);
} }
// Validate transitions (any state can go to error)
if (state !== "error") { if (state !== "error") {
const validTransitions: Record<PluginState, PluginState[]> = { const validTransitions: Record<PluginState, PluginState[]> = {
installed: ["started", "stopped", "error"], installed: ["started", "stopped", "error"],
@@ -395,71 +495,45 @@ export class PluginStore extends EventEmitter<PluginStoreEvents> {
error: ["installed", "started", "stopped"], error: ["installed", "started", "stopped"],
}; };
if (!validTransitions[oldState]?.includes(state)) { if (!validTransitions[oldState]?.includes(state)) {
throw new Error( throw new Error(`Invalid state transition from "${oldState}" to "${state}"`);
`Invalid state transition from "${oldState}" to "${state}"`,
);
} }
} }
this.db.prepare("UPDATE plugins SET state = ?, error = ?, updatedAt = ? WHERE id = ?").run( this.upsertProjectState(id, { state, error: error ?? null });
state, this.centralDb.bumpLastModified();
error ?? null,
new Date().toISOString(),
id,
);
this.db.bumpLastModified();
const updated = { ...plugin, state, error }; const updated = await this.getPlugin(id);
this.emit("plugin:stateChanged", updated, oldState, state); this.emit("plugin:stateChanged", updated, oldState, state);
this.emit("plugin:updated", updated); this.emit("plugin:updated", updated);
return updated; return updated;
} }
/** async updatePluginSettings(id: string, settings: Record<string, unknown>): Promise<PluginInstallation> {
* Update plugin settings.
*/
async updatePluginSettings(
id: string,
settings: Record<string, unknown>,
): Promise<PluginInstallation> {
const plugin = await this.getPlugin(id); const plugin = await this.getPlugin(id);
// Validate settings against schema const validationErrors = this.validateSettingsAgainstSchema(settings, plugin.settingsSchema);
const validationErrors = this.validateSettingsAgainstSchema(
settings,
plugin.settingsSchema,
);
if (validationErrors.length > 0) { if (validationErrors.length > 0) {
throw new Error(`Settings validation failed: ${validationErrors.join(", ")}`); throw new Error(`Settings validation failed: ${validationErrors.join(", ")}`);
} }
// Merge settings
const mergedSettings = { ...plugin.settings, ...settings }; const mergedSettings = { ...plugin.settings, ...settings };
this.db.prepare("UPDATE plugins SET settings = ?, updatedAt = ? WHERE id = ?").run( this.centralDb
toJson(mergedSettings), .prepare("UPDATE plugin_installs SET settings = ?, updatedAt = ? WHERE id = ?")
new Date().toISOString(), .run(toJson(mergedSettings), new Date().toISOString(), id);
id, this.centralDb.bumpLastModified();
);
this.db.bumpLastModified();
const updated = { ...plugin, settings: mergedSettings }; const updated = await this.getPlugin(id);
this.emit("plugin:updated", updated); this.emit("plugin:updated", updated);
return updated; return updated;
} }
/** async updatePlugin(id: string, updates: PluginUpdateInput): Promise<PluginInstallation> {
* Generic update for plugin metadata.
*/
async updatePlugin(
id: string,
updates: PluginUpdateInput,
): Promise<PluginInstallation> {
await this.getPlugin(id); await this.getPlugin(id);
const now = new Date().toISOString(); const now = new Date().toISOString();
const setClauses: string[] = ["updatedAt = ?"]; const setClauses: string[] = ["updatedAt = ?"];
const params: (string | null)[] = [now]; const params: (string | null | number)[] = [now];
if (updates.name !== undefined) { if (updates.name !== undefined) {
setClauses.push("name = ?"); setClauses.push("name = ?");
@@ -491,7 +565,7 @@ export class PluginStore extends EventEmitter<PluginStoreEvents> {
} }
if (updates.aiScanOnLoad !== undefined) { if (updates.aiScanOnLoad !== undefined) {
setClauses.push("aiScanOnLoad = ?"); setClauses.push("aiScanOnLoad = ?");
params.push(updates.aiScanOnLoad ? "1" : "0"); params.push(updates.aiScanOnLoad ? 1 : 0);
} }
if (updates.lastSecurityScan !== undefined) { if (updates.lastSecurityScan !== undefined) {
setClauses.push("lastSecurityScan = ?"); setClauses.push("lastSecurityScan = ?");
@@ -499,8 +573,8 @@ export class PluginStore extends EventEmitter<PluginStoreEvents> {
} }
params.push(id); params.push(id);
this.db.prepare(`UPDATE plugins SET ${setClauses.join(", ")} WHERE id = ?`).run(...params); this.centralDb.prepare(`UPDATE plugin_installs SET ${setClauses.join(", ")} WHERE id = ?`).run(...params);
this.db.bumpLastModified(); this.centralDb.bumpLastModified();
const updated = await this.getPlugin(id); const updated = await this.getPlugin(id);
this.emit("plugin:updated", updated); this.emit("plugin:updated", updated);

View File

@@ -23,8 +23,9 @@ import { subscribeSse } from "../sse-bus";
/** Normalized plugin lifecycle payload from SSE plugin:lifecycle events */ /** Normalized plugin lifecycle payload from SSE plugin:lifecycle events */
interface PluginLifecyclePayload { interface PluginLifecyclePayload {
scope: "global" | "project";
pluginId: string; pluginId: string;
transition: "installing" | "enabled" | "disabled" | "error" | "uninstalled" | "settings-updated"; transition: "installing" | "enabled" | "disabled" | "error" | "state-changed" | "uninstalled" | "settings-updated";
sourceEvent: string; sourceEvent: string;
timestamp: string; timestamp: string;
projectId?: string; projectId?: string;
@@ -266,9 +267,10 @@ export function PluginManager({ addToast, projectId }: PluginManagerProps) {
try { try {
const payload: PluginLifecyclePayload = JSON.parse(e.data); const payload: PluginLifecyclePayload = JSON.parse(e.data);
// Filter by projectId if in project-scoped mode if (payload.scope === "project") {
if (projectId && payload.projectId && payload.projectId !== projectId) { if ((payload.projectId ?? projectId) !== projectId) {
return; return;
}
} }
switch (payload.transition) { switch (payload.transition) {
@@ -298,6 +300,22 @@ export function PluginManager({ addToast, projectId }: PluginManagerProps) {
}); });
break; break;
case "state-changed":
setPlugins((prev) => {
const existingIndex = prev.findIndex((p) => p.id === payload.pluginId);
if (existingIndex >= 0) {
const updated = [...prev];
updated[existingIndex] = {
...updated[existingIndex],
state: payload.state,
error: payload.error,
};
return updated;
}
return prev;
});
break;
case "uninstalled": case "uninstalled":
// Remove plugin from list // Remove plugin from list
setPlugins((prev) => prev.filter((p) => p.id !== payload.pluginId)); setPlugins((prev) => prev.filter((p) => p.id !== payload.pluginId));
@@ -344,7 +362,7 @@ export function PluginManager({ addToast, projectId }: PluginManagerProps) {
try { try {
setInstalling(true); setInstalling(true);
await installPlugin({ path: installPath, ...(installAiScanOnLoad ? { aiScanOnLoad: true } : {}) }, projectId); await installPlugin({ path: installPath, ...(installAiScanOnLoad ? { aiScanOnLoad: true } : {}) }, projectId);
addToast("Plugin installed successfully", "success"); addToast("Plugin installed globally", "success");
setShowInstall(false); setShowInstall(false);
setInstallPath(""); setInstallPath("");
setInstallAiScanOnLoad(false); setInstallAiScanOnLoad(false);
@@ -365,7 +383,7 @@ export function PluginManager({ addToast, projectId }: PluginManagerProps) {
try { try {
setInstallingBuiltinPluginId(plugin.id); setInstallingBuiltinPluginId(plugin.id);
await installPlugin({ path: plugin.path }, projectId); await installPlugin({ path: plugin.path }, projectId);
addToast(`${plugin.name} installed successfully`, "success"); addToast(`${plugin.name} installed globally`, "success");
await loadPlugins(); await loadPlugins();
} catch (err) { } catch (err) {
addToast(`Failed to install ${plugin.name}: ${err instanceof Error ? err.message : String(err)}`, "error"); addToast(`Failed to install ${plugin.name}: ${err instanceof Error ? err.message : String(err)}`, "error");
@@ -397,7 +415,7 @@ export function PluginManager({ addToast, projectId }: PluginManagerProps) {
const handleEnable = async (plugin: PluginInstallation) => { const handleEnable = async (plugin: PluginInstallation) => {
try { try {
await enablePlugin(plugin.id, projectId); await enablePlugin(plugin.id, projectId);
addToast(`${plugin.name} enabled`, "success"); addToast(`${plugin.name} enabled for this project`, "success");
await loadPlugins(); await loadPlugins();
} catch (err) { } catch (err) {
addToast(`Failed to enable plugin: ${err instanceof Error ? err.message : String(err)}`, "error"); addToast(`Failed to enable plugin: ${err instanceof Error ? err.message : String(err)}`, "error");
@@ -407,7 +425,7 @@ export function PluginManager({ addToast, projectId }: PluginManagerProps) {
const handleDisable = async (plugin: PluginInstallation) => { const handleDisable = async (plugin: PluginInstallation) => {
try { try {
await disablePlugin(plugin.id, projectId); await disablePlugin(plugin.id, projectId);
addToast(`${plugin.name} disabled`, "success"); addToast(`${plugin.name} disabled for this project`, "success");
await loadPlugins(); await loadPlugins();
} catch (err) { } catch (err) {
addToast(`Failed to disable plugin: ${err instanceof Error ? err.message : String(err)}`, "error"); addToast(`Failed to disable plugin: ${err instanceof Error ? err.message : String(err)}`, "error");
@@ -429,8 +447,8 @@ export function PluginManager({ addToast, projectId }: PluginManagerProps) {
const handleUninstall = async (plugin: PluginInstallation) => { const handleUninstall = async (plugin: PluginInstallation) => {
const shouldUninstall = await confirm({ const shouldUninstall = await confirm({
title: "Uninstall Plugin", title: "Uninstall Plugin Globally",
message: `Are you sure you want to uninstall "${plugin.name}"?`, message: `Are you sure you want to uninstall "${plugin.name}" globally (all projects)?`,
danger: true, danger: true,
}); });
if (!shouldUninstall) { if (!shouldUninstall) {
@@ -439,7 +457,7 @@ export function PluginManager({ addToast, projectId }: PluginManagerProps) {
try { try {
await uninstallPlugin(plugin.id, projectId); await uninstallPlugin(plugin.id, projectId);
addToast(`${plugin.name} uninstalled`, "success"); addToast(`${plugin.name} uninstalled globally`, "success");
await loadPlugins(); await loadPlugins();
setSelectedPlugin(null); setSelectedPlugin(null);
} catch (err) { } catch (err) {
@@ -752,15 +770,15 @@ export function PluginManager({ addToast, projectId }: PluginManagerProps) {
)} )}
{selectedPlugin.enabled ? ( {selectedPlugin.enabled ? (
<button className="btn btn-secondary" onClick={() => handleDisable(selectedPlugin)}> <button className="btn btn-secondary" onClick={() => handleDisable(selectedPlugin)}>
Disable Disable in Project
</button> </button>
) : ( ) : (
<button className="btn btn-primary" onClick={() => handleEnable(selectedPlugin)}> <button className="btn btn-primary" onClick={() => handleEnable(selectedPlugin)}>
Enable Enable in Project
</button> </button>
)} )}
<button className="btn btn-danger" onClick={() => handleUninstall(selectedPlugin)}> <button className="btn btn-danger" onClick={() => handleUninstall(selectedPlugin)}>
<Trash2 size={14} /> Uninstall <Trash2 size={14} /> Uninstall Globally
</button> </button>
</div> </div>
</div> </div>
@@ -920,7 +938,7 @@ export function PluginManager({ addToast, projectId }: PluginManagerProps) {
</label> </label>
<div className="plugin-install-actions"> <div className="plugin-install-actions">
<button className="btn btn-primary" onClick={handleInstall} disabled={installing || !installPath.trim()}> <button className="btn btn-primary" onClick={handleInstall} disabled={installing || !installPath.trim()}>
{installing ? "Installing..." : "Install Plugin"} {installing ? "Installing..." : "Install Plugin Globally"}
</button> </button>
<button className="btn btn-secondary" onClick={() => { setShowInstall(false); setInstallPath(""); }}> <button className="btn btn-secondary" onClick={() => { setShowInstall(false); setInstallPath(""); }}>
Cancel Cancel
@@ -979,7 +997,7 @@ export function PluginManager({ addToast, projectId }: PluginManagerProps) {
<button <button
className="btn-icon" className="btn-icon"
onClick={() => handleUninstall(plugin)} onClick={() => handleUninstall(plugin)}
title="Uninstall" title="Uninstall globally"
> >
<Trash2 size={14} /> <Trash2 size={14} />
</button> </button>

View File

@@ -284,7 +284,7 @@ describe("PluginManager browse-driven install workflow", () => {
await waitFor(() => { await waitFor(() => {
expect(addToast).toHaveBeenCalledWith( expect(addToast).toHaveBeenCalledWith(
"Plugin installed successfully", "Plugin installed globally",
"success", "success",
); );
}); });

View File

@@ -401,7 +401,7 @@ describe("PluginManager", () => {
await waitFor(() => { await waitFor(() => {
expect(installPlugin).toHaveBeenCalledWith({ path: "./plugins/fusion-plugin-hermes-runtime" }, undefined); expect(installPlugin).toHaveBeenCalledWith({ path: "./plugins/fusion-plugin-hermes-runtime" }, undefined);
expect(addToast).toHaveBeenCalledWith("Hermes Runtime installed successfully", "success"); expect(addToast).toHaveBeenCalledWith("Hermes Runtime installed globally", "success");
}); });
}); });
@@ -421,7 +421,7 @@ describe("PluginManager", () => {
await waitFor(() => { await waitFor(() => {
expect(installPlugin).toHaveBeenCalledWith({ path: "./plugins/fusion-plugin-dependency-graph" }, undefined); expect(installPlugin).toHaveBeenCalledWith({ path: "./plugins/fusion-plugin-dependency-graph" }, undefined);
expect(addToast).toHaveBeenCalledWith("Dependency Graph installed successfully", "success"); expect(addToast).toHaveBeenCalledWith("Dependency Graph installed globally", "success");
}); });
}); });
@@ -571,8 +571,8 @@ describe("PluginManager", () => {
await userEvent.click(uninstallButtons[0]); await userEvent.click(uninstallButtons[0]);
expect(mockConfirm).toHaveBeenCalledWith({ expect(mockConfirm).toHaveBeenCalledWith({
title: "Uninstall Plugin", title: "Uninstall Plugin Globally",
message: 'Are you sure you want to uninstall "Test Plugin A"?', message: 'Are you sure you want to uninstall "Test Plugin A" globally (all projects)?',
danger: true, danger: true,
}); });
expect(uninstallPlugin).not.toHaveBeenCalled(); expect(uninstallPlugin).not.toHaveBeenCalled();

View File

@@ -204,6 +204,7 @@ export type PluginLifecycleTransition =
| "enabled" | "enabled"
| "disabled" | "disabled"
| "error" | "error"
| "state-changed"
| "uninstalled" | "uninstalled"
| "settings-updated"; | "settings-updated";
@@ -219,6 +220,8 @@ export type MessageSseEventType =
* This is the stable contract the UI can reconcile. * This is the stable contract the UI can reconcile.
*/ */
export interface PluginLifecyclePayload { export interface PluginLifecyclePayload {
/** Global install metadata event vs project runtime-state event */
scope: "global" | "project";
/** Plugin identifier */ /** Plugin identifier */
pluginId: string; pluginId: string;
/** Normalized transition type */ /** Normalized transition type */
@@ -261,13 +264,10 @@ function mapSourceEventToTransition(
return "disabled"; return "disabled";
case "plugin:stateChanged": case "plugin:stateChanged":
// If the new state is "error", emit the "error" transition
if (plugin.state === "error") { if (plugin.state === "error") {
return "error"; return "error";
} }
// For other state changes (started, stopped), we don't emit a dedicated transition return "state-changed";
// but still emit the lifecycle event for observability
return "error"; // Map to "error" as a fallback for non-standard state transitions
case "plugin:unregistered": case "plugin:unregistered":
return "uninstalled"; return "uninstalled";
@@ -291,12 +291,15 @@ function createPluginLifecyclePayload(
plugin: PluginInstallation, plugin: PluginInstallation,
projectId?: string, projectId?: string,
): PluginLifecyclePayload { ): PluginLifecyclePayload {
const transition = mapSourceEventToTransition(sourceEvent, plugin);
const scope = transition === "installing" || transition === "uninstalled" ? "global" : "project";
return { return {
scope,
pluginId: plugin.id, pluginId: plugin.id,
transition: mapSourceEventToTransition(sourceEvent, plugin), transition,
sourceEvent, sourceEvent,
timestamp: new Date().toISOString(), timestamp: new Date().toISOString(),
projectId, projectId: scope === "project" ? projectId : undefined,
enabled: plugin.enabled, enabled: plugin.enabled,
state: plugin.state, state: plugin.state,
version: plugin.version, version: plugin.version,

View File

@@ -74,7 +74,7 @@ describe("Droid runtime E2E pipeline", () => {
}); });
it("loads Droid plugin and creates sessions through Droid runtime without createFnAgent", async () => { it("loads Droid plugin and creates sessions through Droid runtime without createFnAgent", async () => {
const pluginStore = new PluginStore(testRoot, { inMemoryDb: true }); const pluginStore = new PluginStore(testRoot, { inMemoryDb: true, centralGlobalDir: testRoot });
await pluginStore.init(); await pluginStore.init();
await pluginStore.registerPlugin({ await pluginStore.registerPlugin({
@@ -133,7 +133,7 @@ describe("Droid runtime E2E pipeline", () => {
}); });
it("falls back to default pi runtime when Droid plugin is not installed", async () => { it("falls back to default pi runtime when Droid plugin is not installed", async () => {
const pluginStore = new PluginStore(testRoot, { inMemoryDb: true }); const pluginStore = new PluginStore(testRoot, { inMemoryDb: true, centralGlobalDir: testRoot });
await pluginStore.init(); await pluginStore.init();
const taskStore = createTaskStoreMock(testRoot); const taskStore = createTaskStoreMock(testRoot);

View File

@@ -105,7 +105,7 @@ describe("Hermes runtime E2E pipeline", () => {
}); });
it("loads Hermes plugin and executes through Hermes runtime without createFnAgent", async () => { it("loads Hermes plugin and executes through Hermes runtime without createFnAgent", async () => {
const pluginStore = new PluginStore(testRoot, { inMemoryDb: true }); const pluginStore = new PluginStore(testRoot, { inMemoryDb: true, centralGlobalDir: testRoot });
await pluginStore.init(); await pluginStore.init();
await pluginStore.registerPlugin({ await pluginStore.registerPlugin({
@@ -170,7 +170,7 @@ describe("Hermes runtime E2E pipeline", () => {
}); });
it("reuses Hermes adapter instance without compatibility wrapping when runtime is AgentRuntime-shaped", async () => { it("reuses Hermes adapter instance without compatibility wrapping when runtime is AgentRuntime-shaped", async () => {
const pluginStore = new PluginStore(testRoot, { inMemoryDb: true }); const pluginStore = new PluginStore(testRoot, { inMemoryDb: true, centralGlobalDir: testRoot });
await pluginStore.init(); await pluginStore.init();
await pluginStore.registerPlugin({ await pluginStore.registerPlugin({
@@ -218,7 +218,7 @@ describe("Hermes runtime E2E pipeline", () => {
}); });
it("falls back to default pi runtime when Hermes plugin is not installed", async () => { it("falls back to default pi runtime when Hermes plugin is not installed", async () => {
const pluginStore = new PluginStore(testRoot, { inMemoryDb: true }); const pluginStore = new PluginStore(testRoot, { inMemoryDb: true, centralGlobalDir: testRoot });
await pluginStore.init(); await pluginStore.init();
const taskStore = createTaskStoreMock(testRoot); const taskStore = createTaskStoreMock(testRoot);
@@ -253,7 +253,7 @@ describe("Hermes runtime E2E pipeline", () => {
// attach the resolved runtime's promptWithFallback onto the session object. // attach the resolved runtime's promptWithFallback onto the session object.
// Without the fix, pi.promptWithFallback (pi.ts:175) would fall through to // Without the fix, pi.promptWithFallback (pi.ts:175) would fall through to
// pi's own session.prompt() instead of dispatching to HermesRuntimeAdapter. // pi's own session.prompt() instead of dispatching to HermesRuntimeAdapter.
const pluginStore = new PluginStore(testRoot, { inMemoryDb: true }); const pluginStore = new PluginStore(testRoot, { inMemoryDb: true, centralGlobalDir: testRoot });
await pluginStore.init(); await pluginStore.init();
await pluginStore.registerPlugin({ await pluginStore.registerPlugin({

View File

@@ -153,7 +153,7 @@ describe("OpenClaw runtime E2E pipeline", () => {
}); });
it("loads OpenClaw plugin and executes through OpenClaw runtime", async () => { it("loads OpenClaw plugin and executes through OpenClaw runtime", async () => {
const pluginStore = new PluginStore(testRoot, { inMemoryDb: true }); const pluginStore = new PluginStore(testRoot, { inMemoryDb: true, centralGlobalDir: testRoot });
await pluginStore.init(); await pluginStore.init();
await pluginStore.registerPlugin({ await pluginStore.registerPlugin({
@@ -240,7 +240,7 @@ describe("OpenClaw runtime E2E pipeline", () => {
}); });
it("falls back to default pi runtime when OpenClaw plugin is not installed", async () => { it("falls back to default pi runtime when OpenClaw plugin is not installed", async () => {
const pluginStore = new PluginStore(testRoot, { inMemoryDb: true }); const pluginStore = new PluginStore(testRoot, { inMemoryDb: true, centralGlobalDir: testRoot });
await pluginStore.init(); await pluginStore.init();
const taskStore = createTaskStoreMock(testRoot); const taskStore = createTaskStoreMock(testRoot);

View File

@@ -125,7 +125,7 @@ describe("Paperclip runtime E2E pipeline", () => {
}); });
it("loads Paperclip plugin and executes through Paperclip runtime", async () => { it("loads Paperclip plugin and executes through Paperclip runtime", async () => {
const pluginStore = new PluginStore(testRoot, { inMemoryDb: true }); const pluginStore = new PluginStore(testRoot, { inMemoryDb: true, centralGlobalDir: testRoot });
await pluginStore.init(); await pluginStore.init();
await pluginStore.registerPlugin({ await pluginStore.registerPlugin({
@@ -187,7 +187,7 @@ describe("Paperclip runtime E2E pipeline", () => {
}); });
it("falls back to default pi runtime when Paperclip plugin is not installed", async () => { it("falls back to default pi runtime when Paperclip plugin is not installed", async () => {
const pluginStore = new PluginStore(testRoot, { inMemoryDb: true }); const pluginStore = new PluginStore(testRoot, { inMemoryDb: true, centralGlobalDir: testRoot });
await pluginStore.init(); await pluginStore.init();
const taskStore = createTaskStoreMock(testRoot); const taskStore = createTaskStoreMock(testRoot);