/** * JSON-file-backed store for external adapter registrations. * * Stores metadata about externally installed adapter packages at * ~/.paperclip/adapter-plugins.json. This is the source of truth for which * external adapters should be loaded at startup. * * Both the plugin store and the settings store are cached in memory after * the first read. Writes invalidate the cache so the next read picks up * the new state without a redundant disk round-trip. * * @module server/services/adapter-plugin-store */ import fs from "node:fs"; import path from "node:path"; import os from "node:os"; // --------------------------------------------------------------------------- // Types // --------------------------------------------------------------------------- export interface AdapterPluginRecord { /** npm package name (e.g., "droid-paperclip-adapter") */ packageName: string; /** Absolute local filesystem path (for locally linked adapters) */ localPath?: string; /** Installed version string (for npm packages) */ version?: string; /** Adapter type identifier (matches ServerAdapterModule.type) */ type: string; /** ISO 8601 timestamp of when the adapter was installed */ installedAt: string; /** Whether this adapter is disabled (hidden from menus but still functional) */ disabled?: boolean; } interface AdapterSettings { disabledTypes: string[]; } // --------------------------------------------------------------------------- // Paths // --------------------------------------------------------------------------- const PAPERCLIP_DIR = path.join(os.homedir(), ".paperclip"); const ADAPTER_PLUGINS_DIR = path.join(PAPERCLIP_DIR, "adapter-plugins"); const ADAPTER_PLUGINS_STORE_PATH = path.join(PAPERCLIP_DIR, "adapter-plugins.json"); const ADAPTER_SETTINGS_PATH = path.join(PAPERCLIP_DIR, "adapter-settings.json"); // --------------------------------------------------------------------------- // In-memory caches (invalidated on write) // --------------------------------------------------------------------------- let storeCache: AdapterPluginRecord[] | null = null; let settingsCache: AdapterSettings | null = null; // --------------------------------------------------------------------------- // Store functions // --------------------------------------------------------------------------- function ensureDirs(): void { fs.mkdirSync(ADAPTER_PLUGINS_DIR, { recursive: true }); const pkgJsonPath = path.join(ADAPTER_PLUGINS_DIR, "package.json"); if (!fs.existsSync(pkgJsonPath)) { fs.writeFileSync(pkgJsonPath, JSON.stringify({ name: "paperclip-adapter-plugins", version: "0.0.0", private: true, description: "Managed directory for Paperclip external adapter plugins. Do not edit manually.", }, null, 2) + "\n"); } } function readStore(): AdapterPluginRecord[] { if (storeCache) return storeCache; try { const raw = fs.readFileSync(ADAPTER_PLUGINS_STORE_PATH, "utf-8"); const parsed = JSON.parse(raw); storeCache = Array.isArray(parsed) ? (parsed as AdapterPluginRecord[]) : []; } catch { storeCache = []; } return storeCache; } function writeStore(records: AdapterPluginRecord[]): void { ensureDirs(); fs.writeFileSync(ADAPTER_PLUGINS_STORE_PATH, JSON.stringify(records, null, 2), "utf-8"); storeCache = records; } function readSettings(): AdapterSettings { if (settingsCache) return settingsCache; try { const raw = fs.readFileSync(ADAPTER_SETTINGS_PATH, "utf-8"); const parsed = JSON.parse(raw); settingsCache = parsed && Array.isArray(parsed.disabledTypes) ? (parsed as AdapterSettings) : { disabledTypes: [] }; } catch { settingsCache = { disabledTypes: [] }; } return settingsCache; } function writeSettings(settings: AdapterSettings): void { ensureDirs(); fs.writeFileSync(ADAPTER_SETTINGS_PATH, JSON.stringify(settings, null, 2), "utf-8"); settingsCache = settings; } // --------------------------------------------------------------------------- // Public API // --------------------------------------------------------------------------- export function listAdapterPlugins(): AdapterPluginRecord[] { return readStore(); } export function addAdapterPlugin(record: AdapterPluginRecord): void { const store = [...readStore()]; const idx = store.findIndex((r) => r.type === record.type); if (idx >= 0) { store[idx] = record; } else { store.push(record); } writeStore(store); } export function removeAdapterPlugin(type: string): boolean { const store = [...readStore()]; const idx = store.findIndex((r) => r.type === type); if (idx < 0) return false; store.splice(idx, 1); writeStore(store); return true; } export function getAdapterPluginByType(type: string): AdapterPluginRecord | undefined { return readStore().find((r) => r.type === type); } export function getAdapterPluginsDir(): string { ensureDirs(); return ADAPTER_PLUGINS_DIR; } // --------------------------------------------------------------------------- // Adapter enable/disable (settings) // --------------------------------------------------------------------------- export function getDisabledAdapterTypes(): string[] { return readSettings().disabledTypes; } export function isAdapterDisabled(type: string): boolean { return readSettings().disabledTypes.includes(type); } export function setAdapterDisabled(type: string, disabled: boolean): boolean { const settings = { ...readSettings(), disabledTypes: [...readSettings().disabledTypes] }; const idx = settings.disabledTypes.indexOf(type); if (disabled && idx < 0) { settings.disabledTypes.push(type); writeSettings(settings); return true; } if (!disabled && idx >= 0) { settings.disabledTypes.splice(idx, 1); writeSettings(settings); return true; } return false; }