/** * SQLite-backed PluginStore for managing plugin installations. * * 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, resolve } from "node:path"; import { Database, fromJson } from "./db.js"; import { CentralDatabase } from "./central-db.js"; import type { PluginInstallation, PluginManifest, PluginSecurityScanResult, PluginSettingSchema, PluginState, } from "./plugin-types.js"; import { validatePluginManifest } from "./plugin-types.js"; import { assertProjectRootDir } from "./project-root-guard.js"; import type { AsyncDataLayer } from "./postgres/data-layer.js"; /* * FNXC:SqliteFinalRemoval 2026-06-26-10:00: * Async Drizzle helpers for backend-mode (PostgreSQL) PluginStore operations. * These helpers target the central-schema tables via Drizzle and are the async * equivalent of the sync centralDb/localDb.prepare() call sites below. */ import { registerPlugin as registerPluginAsync, unregisterPlugin as unregisterPluginAsync, getPlugin as getPluginAsync, listPlugins as listPluginsAsync, enablePlugin as enablePluginAsync, disablePlugin as disablePluginAsync, updatePluginState as updatePluginStateAsync, updatePluginSettings as updatePluginSettingsAsync, updatePluginInstall as updatePluginInstallAsync, } from "./async-plugin-store.js"; export interface PluginStoreEvents { "plugin:registered": [plugin: PluginInstallation]; "plugin:unregistered": [plugin: PluginInstallation]; "plugin:enabled": [plugin: PluginInstallation]; "plugin:disabled": [plugin: PluginInstallation]; "plugin:updated": [plugin: PluginInstallation]; "plugin:stateChanged": [plugin: PluginInstallation, oldState: PluginState, newState: PluginState]; } export interface PluginRegistrationInput { manifest: PluginManifest; path: string; settings?: Record; aiScanOnLoad?: boolean; } export interface PluginUpdateInput { name?: string; version?: string; description?: string; author?: string; homepage?: string; path?: string; dependencies?: string[]; settingsSchema?: Record | null; aiScanOnLoad?: boolean; lastSecurityScan?: PluginSecurityScanResult; } interface LegacyPluginRow { id: string; name: string; version: string; description: string | null; author: string | null; homepage: string | null; path: string; enabled: number; state: string; settings: string | null; settingsSchema: string | null; error: string | null; dependencies: string | null; aiScanOnLoad?: number; lastSecurityScan?: string | null; createdAt: 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 interface PluginStoreOptions { centralGlobalDir?: string; /** * FNXC:SqliteFinalRemoval 2026-06-26-10:05: * When an AsyncDataLayer is injected, PluginStore operates in "backend mode": * all data access delegates to PostgreSQL via Drizzle and no SQLite * Database is constructed. When absent, the legacy SQLite path is * byte-identical to pre-migration. This mirrors the TaskStore/AgentStore * dual-path pattern. */ asyncLayer?: AsyncDataLayer; } export class PluginStore extends EventEmitter { private _localDb: Database | null = null; private _centralDb: CentralDatabase | null = null; private readonly normalizedProjectPath: string; private readonly centralGlobalDir?: string; /** * FNXC:SqliteFinalRemoval 2026-06-26-10:05: * When set, PluginStore operates in backend mode (PostgreSQL via Drizzle). * All data access delegates to async helpers. No SQLite Database is * constructed. This mirrors the TaskStore/AgentStore dual-path pattern. */ public readonly asyncLayer: AsyncDataLayer | null = null; /** True when AsyncDataLayer was injected. Gates all SQLite construction. */ public get backendMode(): boolean { return this.asyncLayer !== null; } constructor( private rootDir: string, options?: PluginStoreOptions, ) { super(); assertProjectRootDir(rootDir, "PluginStore"); this.normalizedProjectPath = resolve(rootDir); this.centralGlobalDir = options?.centralGlobalDir; this.asyncLayer = options?.asyncLayer ?? null; } private get localDb(): Database { if (this.backendMode) { throw new Error("SQLite Database is not available in backend mode (asyncLayer injected)"); } if (!this._localDb) { const fusionDir = join(this.rootDir, ".fusion"); this._localDb = new Database(fusionDir); this._localDb.init(); } return this._localDb; } private get centralDb(): CentralDatabase { if (this.backendMode) { throw new Error("CentralDatabase is not available in backend mode (asyncLayer injected)"); } if (!this._centralDb) { this._centralDb = new CentralDatabase(this.centralGlobalDir); this._centralDb.init(); } return this._centralDb; } /** * FNXC:Plugins 2026-06-25-03:31: * Shared test harnesses clear the file-backed global settings directory between tests while reusing one TaskStore. * Dispose both plugin database handles first so future plugin access reopens a fresh central DB instead of writing through a connection whose backing file was removed. */ close(): void { this._localDb?.close(); this._localDb = null; this._centralDb?.close(); this._centralDb = null; } /** * FNXC:SqliteFinalRemoval 2026-06-26-10:10: * In backend mode (asyncLayer injected), never construct operational SQLite * stores. A narrowly scoped, read-only bridge may inspect retained plugin * rows once behind a durable PostgreSQL marker; all subsequent install and * project-state authority remains in PostgreSQL. */ async init(): Promise { /* FNXC:SqliteDualPathCleanup 2026-07-26-14:06: PluginStore.init is a no-op under PostgreSQL. The startup factory completes the retained-SQLite bridge with its privileged migration connection before constructing the runtime layer; do not open SQLite local/central DBs here. FNXC:PluginLegacyMigration 2026-07-15-02:09: PostgreSQL plugin reads use central.plugin_installs plus path-scoped project_plugin_states. */ } private validateIdFormat(id: string): boolean { return /^[a-z0-9]([a-z0-9-]*[a-z0-9])?$/.test(id); } private validateSettingsAgainstSchema( settings: Record, schema?: Record, ): string[] { if (!schema) return []; const errors: string[] = []; for (const [key, settingSchema] of Object.entries(schema)) { const value = settings[key]; if (settingSchema.required && !(key in settings)) { errors.push(`Setting "${key}" is required`); continue; } if (!(key in settings)) continue; const expectedType = settingSchema.type; if (expectedType === "string" && typeof value !== "string") { errors.push(`Setting "${key}" must be a string`); } else if (expectedType === "password" && typeof value !== "string") { errors.push(`Setting "${key}" must be a string`); } else if (expectedType === "number" && typeof value !== "number") { errors.push(`Setting "${key}" must be a number`); } else if (expectedType === "boolean" && typeof value !== "boolean") { errors.push(`Setting "${key}" must be a boolean`); } else if (expectedType === "enum") { if (typeof value !== "string" || !settingSchema.enumValues?.includes(value)) { errors.push(`Setting "${key}" must be one of: ${settingSchema.enumValues?.join(", ")}`); } } else if (expectedType === "array") { if (!Array.isArray(value)) { errors.push(`Setting "${key}" must be an array`); } else { const itemType = settingSchema.itemType; for (const item of value) { if (itemType === "string" && typeof item !== "string") { errors.push(`Setting "${key}" must be an array of string`); break; } else if (itemType === "number" && typeof item !== "number") { errors.push(`Setting "${key}" must be an array of number`); break; } } } } } return errors; } 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>(install.settings) || {}, settingsSchema: fromJson>(install.settingsSchema), error: state?.error || undefined, dependencies: fromJson(install.dependencies) || [], aiScanOnLoad: install.aiScanOnLoad === 1, lastSecurityScan: fromJson(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(); } async registerPlugin(input: PluginRegistrationInput): Promise { const { manifest, path, settings = {}, aiScanOnLoad = false } = input; const manifestValidation = validatePluginManifest(manifest); if (!manifestValidation.valid) { throw new Error(`Invalid plugin manifest: ${manifestValidation.errors.join(", ")}`); } if (!path?.trim()) { throw new Error("Plugin path is required and cannot be empty"); } 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)", ); } /* * FNXC:SqliteFinalRemoval 2026-06-26-10:15: * Backend-mode: delegate to the async Drizzle registerPlugin helper which * inserts the install row + per-project state atomically via a transaction. */ const plugin = await registerPluginAsync(this.asyncLayer!, { manifest, path, settings, aiScanOnLoad, projectPath: this.normalizedProjectPath, }); this.emit("plugin:registered", plugin); return plugin; } async unregisterPlugin(id: string): Promise { /* * FNXC:SqliteFinalRemoval 2026-06-26-10:15: * Backend-mode: delegate to the async Drizzle unregisterPlugin helper. */ const plugin = await unregisterPluginAsync(this.asyncLayer!.db, id, this.normalizedProjectPath); this.emit("plugin:unregistered", plugin); return plugin; } async getPlugin(id: string): Promise { /* * FNXC:SqliteFinalRemoval 2026-06-26-10:15: * Backend-mode: delegate to the async Drizzle getPlugin helper. */ return getPluginAsync(this.asyncLayer!.db, id, this.normalizedProjectPath); } async listPlugins(filter?: { enabled?: boolean; state?: PluginState }): Promise { /* * FNXC:SqliteFinalRemoval 2026-06-26-10:15: * Backend-mode: delegate to the async Drizzle listPlugins helper. */ return listPluginsAsync(this.asyncLayer!.db, this.normalizedProjectPath, filter); } async enablePlugin(id: string): Promise { /* FNXC:SqliteDualPathCleanup 2026-07-26-14:06: Plugin enable is PostgreSQL-only; SQLite upsertProjectState arm deleted. */ const updated = await enablePluginAsync(this.asyncLayer!.db, id, this.normalizedProjectPath); this.emit("plugin:enabled", updated); this.emit("plugin:updated", updated); return updated; } async disablePlugin(id: string): Promise { /* FNXC:SqliteDualPathCleanup 2026-07-26-14:06: Plugin disable is PostgreSQL-only; SQLite upsertProjectState arm deleted. */ const updated = await disablePluginAsync(this.asyncLayer!.db, id, this.normalizedProjectPath); this.emit("plugin:disabled", updated); this.emit("plugin:updated", updated); return updated; } async updatePluginState(id: string, state: PluginState, error?: string): Promise { const plugin = await this.getPlugin(id); const oldState = plugin.state; const validStates: PluginState[] = ["installed", "started", "stopped", "error"]; if (!validStates.includes(state)) { throw new Error(`Invalid state: ${state}`); } if (state === oldState) { // Same-state transitions are idempotent by design. Only emit plugin:updated // when a provided error payload actually changes persisted plugin fields. if (error === undefined || plugin.error === error) { return plugin; } /* FNXC:SqliteDualPathCleanup 2026-07-26-14:06: Plugin state updates are PostgreSQL-only. */ const updated = await updatePluginStateAsync( this.asyncLayer!.db, id, this.normalizedProjectPath, state, error, ); this.emit("plugin:updated", updated); return updated; } if (state !== "error") { const validTransitions: Record = { installed: ["started", "stopped", "error"], started: ["stopped", "error"], stopped: ["started", "error"], error: ["installed", "started", "stopped"], }; if (!validTransitions[oldState]?.includes(state)) { throw new Error(`Invalid state transition from "${oldState}" to "${state}"`); } } /* FNXC:SqliteDualPathCleanup 2026-07-26-14:06: Plugin state transitions are PostgreSQL-only; SQLite upsertProjectState arm deleted. */ const updated = await updatePluginStateAsync( this.asyncLayer!.db, id, this.normalizedProjectPath, state, error ?? null, ); this.emit("plugin:stateChanged", updated, oldState, state); this.emit("plugin:updated", updated); return updated; } async updatePluginSettings(id: string, settings: Record): Promise { const plugin = await this.getPlugin(id); const validationErrors = this.validateSettingsAgainstSchema(settings, plugin.settingsSchema); if (validationErrors.length > 0) { throw new Error(`Settings validation failed: ${validationErrors.join(", ")}`); } const mergedSettings = { ...plugin.settings, ...settings }; /* * FNXC:SqliteFinalRemoval 2026-06-26-10:25: * Backend-mode: delegate settings persistence to the async helper. */ await updatePluginSettingsAsync(this.asyncLayer!.db, id, mergedSettings); const updated = await this.getPlugin(id); this.emit("plugin:updated", updated); return updated; } async updatePlugin(id: string, updates: PluginUpdateInput): Promise { await this.getPlugin(id); /* * FNXC:SqliteFinalRemoval 2026-06-26-10:25: * Backend-mode: delegate install-field persistence to the async helper. */ await updatePluginInstallAsync(this.asyncLayer!.db, id, { name: updates.name, version: updates.version, description: updates.description, author: updates.author, homepage: updates.homepage, path: updates.path, dependencies: updates.dependencies, aiScanOnLoad: updates.aiScanOnLoad, lastSecurityScan: updates.lastSecurityScan, }); const updated = await this.getPlugin(id); this.emit("plugin:updated", updated); return updated; } }