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:
5
.changeset/fn-3182-global-plugin-scope.md
Normal file
5
.changeset/fn-3182-global-plugin-scope.md
Normal 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.
|
||||
@@ -301,8 +301,11 @@ Hybrid evaluator pipeline (FN-3389/FN-3391):
|
||||
|
||||
### Plugin System
|
||||
|
||||
- `PluginStore` (`plugin-store.ts`) stores plugin installation state and settings (`plugins` table)
|
||||
- `PluginLoader` (`plugin-loader.ts`) loads/unloads plugin modules and emits lifecycle events
|
||||
- `PluginStore` (`plugin-store.ts`) is a facade over two persistence scopes:
|
||||
- **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`
|
||||
- Discovery endpoints:
|
||||
- `GET /api/plugins/ui-slots`
|
||||
|
||||
@@ -828,6 +828,11 @@ fn plugin create <name>
|
||||
|
||||
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.
|
||||
|
||||
---
|
||||
|
||||
@@ -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.
|
||||
|
||||
## 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
|
||||
|
||||
Projects can run with:
|
||||
|
||||
@@ -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:
|
||||
|
||||
> 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:
|
||||
|
||||
```bash
|
||||
|
||||
@@ -178,7 +178,7 @@ export async function runPluginList(projectName?: string): Promise<void> {
|
||||
}
|
||||
|
||||
console.log();
|
||||
console.log(" ID Name Version State Enabled");
|
||||
console.log(" ID Name Version State Project Enabled");
|
||||
console.log(" ─────────────────────────────────────────────────────────────────────");
|
||||
|
||||
for (const plugin of plugins) {
|
||||
@@ -221,7 +221,7 @@ export async function runPluginInstall(
|
||||
const { manifest, path } = await loadManifestFromPath(source);
|
||||
|
||||
console.log();
|
||||
console.log(` Installing ${manifest.name} v${manifest.version}...`);
|
||||
console.log(` Installing ${manifest.name} v${manifest.version} globally...`);
|
||||
|
||||
// Register the plugin
|
||||
const plugin = await store.registerPlugin({
|
||||
@@ -234,12 +234,12 @@ export async function runPluginInstall(
|
||||
if (plugin.enabled) {
|
||||
try {
|
||||
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) {
|
||||
console.log(` ⚠ ${manifest.name} installed but failed to load: ${loadErr instanceof Error ? loadErr.message : String(loadErr)}`);
|
||||
}
|
||||
} else {
|
||||
console.log(` ✓ ${manifest.name} installed (disabled)`);
|
||||
console.log(` ✓ ${manifest.name} installed globally (disabled for this project)`);
|
||||
}
|
||||
console.log();
|
||||
} catch (err) {
|
||||
@@ -272,8 +272,8 @@ export async function runPluginUninstall(
|
||||
// Confirm unless force
|
||||
if (!options?.force) {
|
||||
console.log();
|
||||
console.log(` Uninstall "${plugin.name}"?`);
|
||||
console.log(` This will stop and remove the plugin.`);
|
||||
console.log(` Uninstall "${plugin.name}" globally?`);
|
||||
console.log(" This removes it for all projects.");
|
||||
console.log();
|
||||
|
||||
const response = await new Promise<string>((resolve) => {
|
||||
@@ -304,7 +304,7 @@ export async function runPluginUninstall(
|
||||
await store.unregisterPlugin(id);
|
||||
|
||||
console.log();
|
||||
console.log(` ✓ ${plugin.name} uninstalled`);
|
||||
console.log(` ✓ ${plugin.name} uninstalled globally`);
|
||||
console.log();
|
||||
}
|
||||
|
||||
@@ -346,7 +346,7 @@ export async function runPluginEnable(
|
||||
}
|
||||
|
||||
console.log();
|
||||
console.log(` ✓ ${plugin.name} enabled and started`);
|
||||
console.log(` ✓ ${plugin.name} enabled for this project and started`);
|
||||
console.log();
|
||||
}
|
||||
|
||||
@@ -381,7 +381,7 @@ export async function runPluginDisable(
|
||||
await store.disablePlugin(id);
|
||||
|
||||
console.log();
|
||||
console.log(` ✓ ${plugin.name} disabled and stopped`);
|
||||
console.log(` ✓ ${plugin.name} disabled for this project and stopped`);
|
||||
console.log();
|
||||
}
|
||||
|
||||
|
||||
@@ -39,7 +39,7 @@ describe("CentralDatabase", () => {
|
||||
|
||||
it("should initialize schema version", () => {
|
||||
db.init();
|
||||
expect(db.getSchemaVersion()).toBe(8);
|
||||
expect(db.getSchemaVersion()).toBe(9);
|
||||
});
|
||||
|
||||
it("should seed lastModified on init", () => {
|
||||
@@ -217,7 +217,7 @@ describe("CentralDatabase", () => {
|
||||
|
||||
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 nodeColumnNames = nodeColumns.map((column) => column.name);
|
||||
@@ -282,7 +282,7 @@ describe("CentralDatabase", () => {
|
||||
|
||||
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 nodeColumnNames = nodeColumns.map((column) => column.name);
|
||||
@@ -370,7 +370,7 @@ describe("CentralDatabase", () => {
|
||||
|
||||
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 }>;
|
||||
expect(nodeColumns.map((column) => column.name)).toContain("dockerConfig");
|
||||
@@ -520,7 +520,7 @@ describe("CentralDatabase", () => {
|
||||
|
||||
db.init();
|
||||
|
||||
expect(db.getSchemaVersion()).toBe(8);
|
||||
expect(db.getSchemaVersion()).toBe(9);
|
||||
|
||||
const mappings = db
|
||||
.prepare("SELECT projectId, nodeId, path FROM projectNodePathMappings ORDER BY projectId")
|
||||
|
||||
@@ -38,7 +38,7 @@ describe.skipIf(!hasContributionApis)("PluginLoader contribution loading", () =>
|
||||
|
||||
beforeEach(() => {
|
||||
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 });
|
||||
});
|
||||
|
||||
|
||||
@@ -165,7 +165,7 @@ describe("PluginLoader", () => {
|
||||
|
||||
beforeEach(() => {
|
||||
rootDir = makeTmpDir();
|
||||
pluginStore = new PluginStore(rootDir, { inMemoryDb: true });
|
||||
pluginStore = new PluginStore(rootDir, { inMemoryDb: true, centralGlobalDir: rootDir });
|
||||
setCreateAiSessionFactory(undefined);
|
||||
});
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
||||
import { PluginStore } from "../plugin-store.js";
|
||||
import { Database, toJson } from "../db.js";
|
||||
import { rm } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
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", () => {
|
||||
let rootDir: string;
|
||||
let store: PluginStore;
|
||||
let centralDir: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
rootDir = makeTmpDir();
|
||||
// In-memory SQLite for test speed; see store.test.ts beforeEach.
|
||||
store = new PluginStore(rootDir, { inMemoryDb: true });
|
||||
centralDir = makeTmpDir();
|
||||
// In-memory project DB + isolated central DB directory.
|
||||
store = new PluginStore(rootDir, { inMemoryDb: true, centralGlobalDir: centralDir });
|
||||
await store.init();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await rm(rootDir, { recursive: true, force: true });
|
||||
await rm(centralDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
// ── init ──────────────────────────────────────────────────────────
|
||||
@@ -41,7 +89,7 @@ describe("PluginStore", () => {
|
||||
it("creates the database file", async () => {
|
||||
// Asserts a real file on disk exists, which the in-memory
|
||||
// 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();
|
||||
const dbPath = join(rootDir, ".fusion", "fusion.db");
|
||||
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 ─────────────────────────────────────────────────
|
||||
|
||||
describe("registerPlugin", () => {
|
||||
|
||||
@@ -23,7 +23,7 @@ export { toJson, toJsonNullable, fromJson };
|
||||
|
||||
// ── Schema Definition ───────────────────────────────────────────────────
|
||||
|
||||
const CENTRAL_SCHEMA_VERSION = 8;
|
||||
const CENTRAL_SCHEMA_VERSION = 9;
|
||||
|
||||
const CENTRAL_SCHEMA_SQL = `
|
||||
-- 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 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
|
||||
CREATE TABLE IF NOT EXISTS __meta (
|
||||
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);
|
||||
`;
|
||||
|
||||
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 ────────────────────────────────────────────────
|
||||
|
||||
export class CentralDatabase {
|
||||
@@ -407,6 +473,11 @@ export class CentralDatabase {
|
||||
migrated = true;
|
||||
}
|
||||
|
||||
if (currentVersion < 9) {
|
||||
this.db.exec(CENTRAL_SCHEMA_V9_MIGRATION_SQL);
|
||||
migrated = true;
|
||||
}
|
||||
|
||||
if (migrated) {
|
||||
this.db
|
||||
.prepare("INSERT INTO __meta (key, value) VALUES ('schemaVersion', ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value")
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
/**
|
||||
* 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 { join } from "node:path";
|
||||
import { Database, toJson, fromJson } from "./db.js";
|
||||
import { join, resolve } from "node:path";
|
||||
import { Database, fromJson, toJson } from "./db.js";
|
||||
import { CentralDatabase } from "./central-db.js";
|
||||
import type {
|
||||
PluginInstallation,
|
||||
PluginManifest,
|
||||
@@ -26,7 +28,6 @@ export interface PluginStoreEvents {
|
||||
"plugin:stateChanged": [plugin: PluginInstallation, oldState: PluginState, newState: PluginState];
|
||||
}
|
||||
|
||||
/** Input for registering a new plugin */
|
||||
export interface PluginRegistrationInput {
|
||||
manifest: PluginManifest;
|
||||
path: string;
|
||||
@@ -34,7 +35,6 @@ export interface PluginRegistrationInput {
|
||||
aiScanOnLoad?: boolean;
|
||||
}
|
||||
|
||||
/** Partial update input for a plugin */
|
||||
export interface PluginUpdateInput {
|
||||
name?: string;
|
||||
version?: string;
|
||||
@@ -47,8 +47,7 @@ export interface PluginUpdateInput {
|
||||
lastSecurityScan?: PluginSecurityScanResult;
|
||||
}
|
||||
|
||||
/** Database row shape for the plugins table. */
|
||||
interface PluginRow {
|
||||
interface LegacyPluginRow {
|
||||
id: string;
|
||||
name: string;
|
||||
version: string;
|
||||
@@ -68,64 +67,75 @@ interface PluginRow {
|
||||
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> {
|
||||
/** SQLite database instance */
|
||||
private _db: Database | null = null;
|
||||
|
||||
private _localDb: Database | null = null;
|
||||
private _centralDb: CentralDatabase | null = null;
|
||||
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();
|
||||
assertProjectRootDir(rootDir, "PluginStore");
|
||||
this.inMemoryDb = options?.inMemoryDb === true;
|
||||
this.normalizedProjectPath = resolve(rootDir);
|
||||
this.centralGlobalDir = options?.centralGlobalDir;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the SQLite database, initializing it on first access.
|
||||
*/
|
||||
private get db(): Database {
|
||||
if (!this._db) {
|
||||
private get localDb(): Database {
|
||||
if (!this._localDb) {
|
||||
const fusionDir = join(this.rootDir, ".fusion");
|
||||
this._db = new Database(fusionDir, { inMemory: this.inMemoryDb });
|
||||
this._db.init();
|
||||
this._localDb = new Database(fusionDir, { inMemory: this.inMemoryDb });
|
||||
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> {
|
||||
// Ensure DB is initialized (triggers table creation)
|
||||
const _ = this.db;
|
||||
const _ = this.localDb;
|
||||
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 {
|
||||
// Valid slug: lowercase alphanumeric, hyphens, cannot start/end with hyphen
|
||||
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[] = [];
|
||||
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`);
|
||||
@@ -160,15 +165,12 @@ export class PluginStore extends EventEmitter<PluginStoreEvents> {
|
||||
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(", ")}`,
|
||||
);
|
||||
errors.push(`Setting "${key}" must be one of: ${settingSchema.enumValues?.join(", ")}`);
|
||||
}
|
||||
} else if (expectedType === "array") {
|
||||
if (!Array.isArray(value)) {
|
||||
errors.push(`Setting "${key}" must be an array`);
|
||||
} else {
|
||||
// Validate item types
|
||||
const itemType = settingSchema.itemType;
|
||||
for (const item of value) {
|
||||
if (itemType === "string" && typeof item !== "string") {
|
||||
@@ -186,35 +188,187 @@ export class PluginStore extends EventEmitter<PluginStoreEvents> {
|
||||
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> {
|
||||
const { manifest, path, settings = {}, aiScanOnLoad = false } = 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 = ?")
|
||||
const existing = this.centralDb
|
||||
.prepare("SELECT id FROM plugin_installs WHERE id = ?")
|
||||
.get(manifest.id);
|
||||
if (existing) {
|
||||
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> = {};
|
||||
if (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 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.db.prepare(`
|
||||
INSERT INTO plugins (
|
||||
this.centralDb
|
||||
.prepare(`
|
||||
INSERT INTO plugin_installs (
|
||||
id, name, version, description, author, homepage, path,
|
||||
enabled, state, settings, settingsSchema, dependencies, aiScanOnLoad, lastSecurityScan, 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.aiScanOnLoad ? 1 : 0,
|
||||
null,
|
||||
plugin.createdAt,
|
||||
plugin.updatedAt,
|
||||
);
|
||||
settings, settingsSchema, dependencies, aiScanOnLoad, lastSecurityScan, createdAt, updatedAt
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`)
|
||||
.run(
|
||||
manifest.id,
|
||||
manifest.name,
|
||||
manifest.version,
|
||||
manifest.description ?? null,
|
||||
manifest.author ?? null,
|
||||
manifest.homepage ?? null,
|
||||
path.trim(),
|
||||
toJson(mergedSettings),
|
||||
manifest.settingsSchema ? toJson(manifest.settingsSchema) : null,
|
||||
toJson(manifest.dependencies || []),
|
||||
aiScanOnLoad ? 1 : 0,
|
||||
null,
|
||||
now,
|
||||
now,
|
||||
);
|
||||
|
||||
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);
|
||||
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.centralDb.prepare("DELETE FROM plugin_installs WHERE id = ?").run(id);
|
||||
this.centralDb.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 unknown as PluginRow | undefined;
|
||||
if (!row) {
|
||||
const install = this.centralDb
|
||||
.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" });
|
||||
}
|
||||
return this.rowToPlugin(row);
|
||||
return this.rowToPlugin(install, this.getProjectState(id));
|
||||
}
|
||||
|
||||
/**
|
||||
* List all plugins, optionally filtered.
|
||||
*/
|
||||
async listPlugins(
|
||||
filter?: { enabled?: boolean; state?: PluginState },
|
||||
): Promise<PluginInstallation[]> {
|
||||
let sql = "SELECT * FROM plugins";
|
||||
const conditions: string[] = [];
|
||||
const params: (string | number)[] = [];
|
||||
async listPlugins(filter?: { enabled?: boolean; state?: PluginState }): Promise<PluginInstallation[]> {
|
||||
const installs = this.centralDb
|
||||
.prepare("SELECT * FROM plugin_installs ORDER BY createdAt ASC")
|
||||
.all() as InstallRow[];
|
||||
|
||||
if (filter?.enabled !== undefined) {
|
||||
conditions.push("enabled = ?");
|
||||
params.push(filter.enabled ? 1 : 0);
|
||||
}
|
||||
if (filter?.state) {
|
||||
conditions.push("state = ?");
|
||||
params.push(filter.state);
|
||||
}
|
||||
const results = installs.map((install) => this.rowToPlugin(install, this.getProjectState(install.id)));
|
||||
|
||||
if (conditions.length > 0) {
|
||||
sql += " WHERE " + conditions.join(" AND ");
|
||||
}
|
||||
sql += " ORDER BY createdAt ASC";
|
||||
|
||||
const rows = this.db.prepare(sql).all(...params) as unknown as PluginRow[];
|
||||
return rows.map((row) => this.rowToPlugin(row));
|
||||
return results.filter((plugin) => {
|
||||
if (filter?.enabled !== undefined && plugin.enabled !== filter.enabled) {
|
||||
return false;
|
||||
}
|
||||
if (filter?.state && plugin.state !== filter.state) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Enable a plugin.
|
||||
*/
|
||||
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(
|
||||
new Date().toISOString(),
|
||||
id,
|
||||
);
|
||||
this.db.bumpLastModified();
|
||||
|
||||
const updated = { ...plugin, enabled: true };
|
||||
const updated = await this.getPlugin(id);
|
||||
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);
|
||||
await this.getPlugin(id);
|
||||
this.upsertProjectState(id, { enabled: false });
|
||||
this.centralDb.bumpLastModified();
|
||||
|
||||
this.db.prepare("UPDATE plugins SET enabled = 0, updatedAt = ? WHERE id = ?").run(
|
||||
new Date().toISOString(),
|
||||
id,
|
||||
);
|
||||
this.db.bumpLastModified();
|
||||
|
||||
const updated = { ...plugin, enabled: false };
|
||||
const updated = await this.getPlugin(id);
|
||||
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> {
|
||||
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"],
|
||||
@@ -395,71 +495,45 @@ export class PluginStore extends EventEmitter<PluginStoreEvents> {
|
||||
error: ["installed", "started", "stopped"],
|
||||
};
|
||||
if (!validTransitions[oldState]?.includes(state)) {
|
||||
throw new Error(
|
||||
`Invalid state transition from "${oldState}" to "${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();
|
||||
this.upsertProjectState(id, { state, error: error ?? null });
|
||||
this.centralDb.bumpLastModified();
|
||||
|
||||
const updated = { ...plugin, state, error };
|
||||
const updated = await this.getPlugin(id);
|
||||
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> {
|
||||
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,
|
||||
);
|
||||
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();
|
||||
this.centralDb
|
||||
.prepare("UPDATE plugin_installs SET settings = ?, updatedAt = ? WHERE id = ?")
|
||||
.run(toJson(mergedSettings), new Date().toISOString(), id);
|
||||
this.centralDb.bumpLastModified();
|
||||
|
||||
const updated = { ...plugin, settings: mergedSettings };
|
||||
const updated = await this.getPlugin(id);
|
||||
this.emit("plugin:updated", updated);
|
||||
return updated;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generic update for plugin metadata.
|
||||
*/
|
||||
async updatePlugin(
|
||||
id: string,
|
||||
updates: PluginUpdateInput,
|
||||
): Promise<PluginInstallation> {
|
||||
async updatePlugin(id: string, updates: PluginUpdateInput): Promise<PluginInstallation> {
|
||||
await this.getPlugin(id);
|
||||
const now = new Date().toISOString();
|
||||
|
||||
const setClauses: string[] = ["updatedAt = ?"];
|
||||
const params: (string | null)[] = [now];
|
||||
const params: (string | null | number)[] = [now];
|
||||
|
||||
if (updates.name !== undefined) {
|
||||
setClauses.push("name = ?");
|
||||
@@ -491,7 +565,7 @@ export class PluginStore extends EventEmitter<PluginStoreEvents> {
|
||||
}
|
||||
if (updates.aiScanOnLoad !== undefined) {
|
||||
setClauses.push("aiScanOnLoad = ?");
|
||||
params.push(updates.aiScanOnLoad ? "1" : "0");
|
||||
params.push(updates.aiScanOnLoad ? 1 : 0);
|
||||
}
|
||||
if (updates.lastSecurityScan !== undefined) {
|
||||
setClauses.push("lastSecurityScan = ?");
|
||||
@@ -499,8 +573,8 @@ export class PluginStore extends EventEmitter<PluginStoreEvents> {
|
||||
}
|
||||
|
||||
params.push(id);
|
||||
this.db.prepare(`UPDATE plugins SET ${setClauses.join(", ")} WHERE id = ?`).run(...params);
|
||||
this.db.bumpLastModified();
|
||||
this.centralDb.prepare(`UPDATE plugin_installs SET ${setClauses.join(", ")} WHERE id = ?`).run(...params);
|
||||
this.centralDb.bumpLastModified();
|
||||
|
||||
const updated = await this.getPlugin(id);
|
||||
this.emit("plugin:updated", updated);
|
||||
|
||||
@@ -23,8 +23,9 @@ import { subscribeSse } from "../sse-bus";
|
||||
|
||||
/** Normalized plugin lifecycle payload from SSE plugin:lifecycle events */
|
||||
interface PluginLifecyclePayload {
|
||||
scope: "global" | "project";
|
||||
pluginId: string;
|
||||
transition: "installing" | "enabled" | "disabled" | "error" | "uninstalled" | "settings-updated";
|
||||
transition: "installing" | "enabled" | "disabled" | "error" | "state-changed" | "uninstalled" | "settings-updated";
|
||||
sourceEvent: string;
|
||||
timestamp: string;
|
||||
projectId?: string;
|
||||
@@ -266,9 +267,10 @@ export function PluginManager({ addToast, projectId }: PluginManagerProps) {
|
||||
try {
|
||||
const payload: PluginLifecyclePayload = JSON.parse(e.data);
|
||||
|
||||
// Filter by projectId if in project-scoped mode
|
||||
if (projectId && payload.projectId && payload.projectId !== projectId) {
|
||||
return;
|
||||
if (payload.scope === "project") {
|
||||
if ((payload.projectId ?? projectId) !== projectId) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
switch (payload.transition) {
|
||||
@@ -298,6 +300,22 @@ export function PluginManager({ addToast, projectId }: PluginManagerProps) {
|
||||
});
|
||||
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":
|
||||
// Remove plugin from list
|
||||
setPlugins((prev) => prev.filter((p) => p.id !== payload.pluginId));
|
||||
@@ -344,7 +362,7 @@ export function PluginManager({ addToast, projectId }: PluginManagerProps) {
|
||||
try {
|
||||
setInstalling(true);
|
||||
await installPlugin({ path: installPath, ...(installAiScanOnLoad ? { aiScanOnLoad: true } : {}) }, projectId);
|
||||
addToast("Plugin installed successfully", "success");
|
||||
addToast("Plugin installed globally", "success");
|
||||
setShowInstall(false);
|
||||
setInstallPath("");
|
||||
setInstallAiScanOnLoad(false);
|
||||
@@ -365,7 +383,7 @@ export function PluginManager({ addToast, projectId }: PluginManagerProps) {
|
||||
try {
|
||||
setInstallingBuiltinPluginId(plugin.id);
|
||||
await installPlugin({ path: plugin.path }, projectId);
|
||||
addToast(`${plugin.name} installed successfully`, "success");
|
||||
addToast(`${plugin.name} installed globally`, "success");
|
||||
await loadPlugins();
|
||||
} catch (err) {
|
||||
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) => {
|
||||
try {
|
||||
await enablePlugin(plugin.id, projectId);
|
||||
addToast(`${plugin.name} enabled`, "success");
|
||||
addToast(`${plugin.name} enabled for this project`, "success");
|
||||
await loadPlugins();
|
||||
} catch (err) {
|
||||
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) => {
|
||||
try {
|
||||
await disablePlugin(plugin.id, projectId);
|
||||
addToast(`${plugin.name} disabled`, "success");
|
||||
addToast(`${plugin.name} disabled for this project`, "success");
|
||||
await loadPlugins();
|
||||
} catch (err) {
|
||||
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 shouldUninstall = await confirm({
|
||||
title: "Uninstall Plugin",
|
||||
message: `Are you sure you want to uninstall "${plugin.name}"?`,
|
||||
title: "Uninstall Plugin Globally",
|
||||
message: `Are you sure you want to uninstall "${plugin.name}" globally (all projects)?`,
|
||||
danger: true,
|
||||
});
|
||||
if (!shouldUninstall) {
|
||||
@@ -439,7 +457,7 @@ export function PluginManager({ addToast, projectId }: PluginManagerProps) {
|
||||
|
||||
try {
|
||||
await uninstallPlugin(plugin.id, projectId);
|
||||
addToast(`${plugin.name} uninstalled`, "success");
|
||||
addToast(`${plugin.name} uninstalled globally`, "success");
|
||||
await loadPlugins();
|
||||
setSelectedPlugin(null);
|
||||
} catch (err) {
|
||||
@@ -752,15 +770,15 @@ export function PluginManager({ addToast, projectId }: PluginManagerProps) {
|
||||
)}
|
||||
{selectedPlugin.enabled ? (
|
||||
<button className="btn btn-secondary" onClick={() => handleDisable(selectedPlugin)}>
|
||||
Disable
|
||||
Disable in Project
|
||||
</button>
|
||||
) : (
|
||||
<button className="btn btn-primary" onClick={() => handleEnable(selectedPlugin)}>
|
||||
Enable
|
||||
Enable in Project
|
||||
</button>
|
||||
)}
|
||||
<button className="btn btn-danger" onClick={() => handleUninstall(selectedPlugin)}>
|
||||
<Trash2 size={14} /> Uninstall
|
||||
<Trash2 size={14} /> Uninstall Globally
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -920,7 +938,7 @@ export function PluginManager({ addToast, projectId }: PluginManagerProps) {
|
||||
</label>
|
||||
<div className="plugin-install-actions">
|
||||
<button className="btn btn-primary" onClick={handleInstall} disabled={installing || !installPath.trim()}>
|
||||
{installing ? "Installing..." : "Install Plugin"}
|
||||
{installing ? "Installing..." : "Install Plugin Globally"}
|
||||
</button>
|
||||
<button className="btn btn-secondary" onClick={() => { setShowInstall(false); setInstallPath(""); }}>
|
||||
Cancel
|
||||
@@ -979,7 +997,7 @@ export function PluginManager({ addToast, projectId }: PluginManagerProps) {
|
||||
<button
|
||||
className="btn-icon"
|
||||
onClick={() => handleUninstall(plugin)}
|
||||
title="Uninstall"
|
||||
title="Uninstall globally"
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
</button>
|
||||
|
||||
@@ -284,7 +284,7 @@ describe("PluginManager – browse-driven install workflow", () => {
|
||||
|
||||
await waitFor(() => {
|
||||
expect(addToast).toHaveBeenCalledWith(
|
||||
"Plugin installed successfully",
|
||||
"Plugin installed globally",
|
||||
"success",
|
||||
);
|
||||
});
|
||||
|
||||
@@ -401,7 +401,7 @@ describe("PluginManager", () => {
|
||||
|
||||
await waitFor(() => {
|
||||
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(() => {
|
||||
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]);
|
||||
|
||||
expect(mockConfirm).toHaveBeenCalledWith({
|
||||
title: "Uninstall Plugin",
|
||||
message: 'Are you sure you want to uninstall "Test Plugin A"?',
|
||||
title: "Uninstall Plugin Globally",
|
||||
message: 'Are you sure you want to uninstall "Test Plugin A" globally (all projects)?',
|
||||
danger: true,
|
||||
});
|
||||
expect(uninstallPlugin).not.toHaveBeenCalled();
|
||||
|
||||
@@ -204,6 +204,7 @@ export type PluginLifecycleTransition =
|
||||
| "enabled"
|
||||
| "disabled"
|
||||
| "error"
|
||||
| "state-changed"
|
||||
| "uninstalled"
|
||||
| "settings-updated";
|
||||
|
||||
@@ -219,6 +220,8 @@ export type MessageSseEventType =
|
||||
* This is the stable contract the UI can reconcile.
|
||||
*/
|
||||
export interface PluginLifecyclePayload {
|
||||
/** Global install metadata event vs project runtime-state event */
|
||||
scope: "global" | "project";
|
||||
/** Plugin identifier */
|
||||
pluginId: string;
|
||||
/** Normalized transition type */
|
||||
@@ -261,13 +264,10 @@ function mapSourceEventToTransition(
|
||||
return "disabled";
|
||||
|
||||
case "plugin:stateChanged":
|
||||
// If the new state is "error", emit the "error" transition
|
||||
if (plugin.state === "error") {
|
||||
return "error";
|
||||
}
|
||||
// For other state changes (started, stopped), we don't emit a dedicated transition
|
||||
// but still emit the lifecycle event for observability
|
||||
return "error"; // Map to "error" as a fallback for non-standard state transitions
|
||||
return "state-changed";
|
||||
|
||||
case "plugin:unregistered":
|
||||
return "uninstalled";
|
||||
@@ -291,12 +291,15 @@ function createPluginLifecyclePayload(
|
||||
plugin: PluginInstallation,
|
||||
projectId?: string,
|
||||
): PluginLifecyclePayload {
|
||||
const transition = mapSourceEventToTransition(sourceEvent, plugin);
|
||||
const scope = transition === "installing" || transition === "uninstalled" ? "global" : "project";
|
||||
return {
|
||||
scope,
|
||||
pluginId: plugin.id,
|
||||
transition: mapSourceEventToTransition(sourceEvent, plugin),
|
||||
transition,
|
||||
sourceEvent,
|
||||
timestamp: new Date().toISOString(),
|
||||
projectId,
|
||||
projectId: scope === "project" ? projectId : undefined,
|
||||
enabled: plugin.enabled,
|
||||
state: plugin.state,
|
||||
version: plugin.version,
|
||||
|
||||
@@ -74,7 +74,7 @@ describe("Droid runtime E2E pipeline", () => {
|
||||
});
|
||||
|
||||
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.registerPlugin({
|
||||
@@ -133,7 +133,7 @@ describe("Droid runtime E2E pipeline", () => {
|
||||
});
|
||||
|
||||
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();
|
||||
|
||||
const taskStore = createTaskStoreMock(testRoot);
|
||||
|
||||
@@ -105,7 +105,7 @@ describe("Hermes runtime E2E pipeline", () => {
|
||||
});
|
||||
|
||||
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.registerPlugin({
|
||||
@@ -170,7 +170,7 @@ describe("Hermes runtime E2E pipeline", () => {
|
||||
});
|
||||
|
||||
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.registerPlugin({
|
||||
@@ -218,7 +218,7 @@ describe("Hermes runtime E2E pipeline", () => {
|
||||
});
|
||||
|
||||
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();
|
||||
|
||||
const taskStore = createTaskStoreMock(testRoot);
|
||||
@@ -253,7 +253,7 @@ describe("Hermes runtime E2E pipeline", () => {
|
||||
// attach the resolved runtime's promptWithFallback onto the session object.
|
||||
// Without the fix, pi.promptWithFallback (pi.ts:175) would fall through to
|
||||
// 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.registerPlugin({
|
||||
|
||||
@@ -153,7 +153,7 @@ describe("OpenClaw runtime E2E pipeline", () => {
|
||||
});
|
||||
|
||||
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.registerPlugin({
|
||||
@@ -240,7 +240,7 @@ describe("OpenClaw runtime E2E pipeline", () => {
|
||||
});
|
||||
|
||||
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();
|
||||
|
||||
const taskStore = createTaskStoreMock(testRoot);
|
||||
|
||||
@@ -125,7 +125,7 @@ describe("Paperclip runtime E2E pipeline", () => {
|
||||
});
|
||||
|
||||
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.registerPlugin({
|
||||
@@ -187,7 +187,7 @@ describe("Paperclip runtime E2E pipeline", () => {
|
||||
});
|
||||
|
||||
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();
|
||||
|
||||
const taskStore = createTaskStoreMock(testRoot);
|
||||
|
||||
Reference in New Issue
Block a user