mirror of
https://github.com/alkimake/paperclip.git
synced 2026-06-14 01:50:39 +09:00
- Plugin loader: install/reload/remove/reinstall external adapters from npm packages or local directories - Plugin store persisted at ~/.paperclip/adapter-plugins.json - Self-healing UI parser resolution with version caching - UI: Adapter Manager page, dynamic loader, display registry with humanized names for unknown adapter types - Dev watch: exclude adapter-plugins dir from tsx watcher to prevent mid-request server restarts during reinstall - All consumer fallbacks use getAdapterLabel() for consistent display - AdapterTypeDropdown uses controlled open state for proper close behavior - Remove hermes-local from built-in UI (externalized to plugin) - Add docs for external adapters and UI parser contract
177 lines
5.7 KiB
TypeScript
177 lines
5.7 KiB
TypeScript
/**
|
|
* 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;
|
|
}
|