| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| import { existsSync } from "node:fs"; |
| import { readdir, readFile, rm, stat } from "node:fs/promises"; |
| import { execFile } from "node:child_process"; |
| import os from "node:os"; |
| import path from "node:path"; |
| import { fileURLToPath } from "node:url"; |
| import { promisify } from "node:util"; |
| import type { Db } from "@paperclipai/db"; |
| import type { |
| PaperclipPluginManifestV1, |
| PluginLauncherDeclaration, |
| PluginRecord, |
| PluginUiSlotDeclaration, |
| } from "@paperclipai/shared"; |
| import { logger } from "../middleware/logger.js"; |
| import { pluginManifestValidator } from "./plugin-manifest-validator.js"; |
| import { pluginCapabilityValidator } from "./plugin-capability-validator.js"; |
| import { pluginRegistryService } from "./plugin-registry.js"; |
| import type { PluginWorkerManager, WorkerStartOptions, WorkerToHostHandlers } from "./plugin-worker-manager.js"; |
| import type { PluginEventBus } from "./plugin-event-bus.js"; |
| import type { PluginJobScheduler } from "./plugin-job-scheduler.js"; |
| import type { PluginJobStore } from "./plugin-job-store.js"; |
| import type { PluginToolDispatcher } from "./plugin-tool-dispatcher.js"; |
| import type { PluginLifecycleManager } from "./plugin-lifecycle.js"; |
|
|
| const execFileAsync = promisify(execFile); |
| const __dirname = path.dirname(fileURLToPath(import.meta.url)); |
|
|
| |
| |
| |
|
|
| |
| |
| |
| |
| |
| |
| export const NPM_PLUGIN_PACKAGE_PREFIX = "paperclip-plugin-"; |
|
|
| |
| |
| |
| |
| |
| |
| export const DEFAULT_LOCAL_PLUGIN_DIR = path.join( |
| os.homedir(), |
| ".paperclip", |
| "plugins", |
| ); |
|
|
| const DEV_TSX_LOADER_PATH = path.resolve(__dirname, "../../../cli/node_modules/tsx/dist/loader.mjs"); |
|
|
| |
| |
| |
|
|
| |
| |
| |
| export interface DiscoveredPlugin { |
| |
| packagePath: string; |
| |
| packageName: string; |
| |
| version: string; |
| |
| source: PluginSource; |
| |
| manifest: PaperclipPluginManifestV1 | null; |
| } |
|
|
| |
| |
| |
| |
| |
| export type PluginSource = |
| | "local-filesystem" |
| | "npm" |
| | "registry"; |
|
|
| type ParsedSemver = { |
| major: number; |
| minor: number; |
| patch: number; |
| prerelease: string[]; |
| }; |
|
|
| |
| |
| |
| export interface PluginDiscoveryResult { |
| |
| discovered: DiscoveredPlugin[]; |
| |
| errors: Array<{ packagePath: string; packageName: string; error: string }>; |
| |
| sources: PluginSource[]; |
| } |
|
|
| function getDeclaredPageRoutePaths(manifest: PaperclipPluginManifestV1): string[] { |
| return (manifest.ui?.slots ?? []) |
| .filter((slot): slot is PluginUiSlotDeclaration => slot.type === "page" && typeof slot.routePath === "string" && slot.routePath.length > 0) |
| .map((slot) => slot.routePath!); |
| } |
|
|
| |
| |
| |
|
|
| |
| |
| |
| export interface PluginLoaderOptions { |
| |
| |
| |
| |
| localPluginDir?: string; |
|
|
| |
| |
| |
| |
| enableLocalFilesystem?: boolean; |
|
|
| |
| |
| |
| |
| |
| enableNpmDiscovery?: boolean; |
|
|
| |
| |
| |
| |
| |
| registryUrl?: string; |
| } |
|
|
| |
| |
| |
|
|
| |
| |
| |
| export interface PluginInstallOptions { |
| |
| |
| |
| |
| packageName?: string; |
|
|
| |
| |
| |
| |
| |
| localPath?: string; |
|
|
| |
| |
| |
| |
| version?: string; |
|
|
| |
| |
| |
| |
| installDir?: string; |
| } |
|
|
| |
| |
| |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| export interface PluginRuntimeServices { |
| |
| workerManager: PluginWorkerManager; |
| |
| eventBus: PluginEventBus; |
| |
| jobScheduler: PluginJobScheduler; |
| |
| jobStore: PluginJobStore; |
| |
| toolDispatcher: PluginToolDispatcher; |
| |
| lifecycleManager: PluginLifecycleManager; |
| |
| |
| |
| |
| |
| |
| |
| buildHostHandlers: (pluginId: string, manifest: PaperclipPluginManifestV1) => WorkerToHostHandlers; |
| |
| |
| |
| |
| instanceInfo: { |
| instanceId: string; |
| hostVersion: string; |
| }; |
| } |
|
|
| |
| |
| |
|
|
| |
| |
| |
| |
| |
| |
| export interface PluginLoadResult { |
| |
| plugin: PluginRecord; |
| |
| success: boolean; |
| |
| error?: string; |
| |
| registered: { |
| |
| worker: boolean; |
| |
| eventSubscriptions: number; |
| |
| jobs: number; |
| |
| webhooks: number; |
| |
| tools: number; |
| }; |
| } |
|
|
| |
| |
| |
| export interface PluginLoadAllResult { |
| |
| total: number; |
| |
| succeeded: number; |
| |
| failed: number; |
| |
| results: PluginLoadResult[]; |
| } |
|
|
| |
| |
| |
| |
| |
| |
| export interface PluginUiContributionMetadata { |
| uiEntryFile: string; |
| slots: PluginUiSlotDeclaration[]; |
| launchers: PluginLauncherDeclaration[]; |
| } |
|
|
| |
| |
| |
|
|
| export interface PluginLoader { |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| discoverAll(npmSearchDirs?: string[]): Promise<PluginDiscoveryResult>; |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| discoverFromLocalFilesystem(dir?: string): Promise<PluginDiscoveryResult>; |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| discoverFromNpm(searchDirs?: string[]): Promise<PluginDiscoveryResult>; |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| loadManifest(packagePath: string): Promise<PaperclipPluginManifestV1 | null>; |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| installPlugin(options: PluginInstallOptions): Promise<DiscoveredPlugin>; |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| upgradePlugin(pluginId: string, options: Omit<PluginInstallOptions, "installDir">): Promise<{ |
| oldManifest: PaperclipPluginManifestV1; |
| newManifest: PaperclipPluginManifestV1; |
| discovered: DiscoveredPlugin; |
| }>; |
|
|
| |
| |
| |
| isSupportedApiVersion(apiVersion: number): boolean; |
|
|
| |
| |
| |
| |
| |
| |
| cleanupInstallArtifacts(plugin: PluginRecord): Promise<void>; |
|
|
| |
| |
| |
| getLocalPluginDir(): string; |
|
|
| |
| |
| |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| loadAll(): Promise<PluginLoadAllResult>; |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| loadSingle(pluginId: string): Promise<PluginLoadResult>; |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| unloadSingle(pluginId: string, pluginKey: string): Promise<void>; |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| shutdownAll(): Promise<void>; |
|
|
| |
| |
| |
| hasRuntimeServices(): boolean; |
| } |
|
|
| |
| |
| |
|
|
| |
| |
| |
| |
| |
| |
| export function isPluginPackageName(name: string): boolean { |
| if (name.startsWith(NPM_PLUGIN_PACKAGE_PREFIX)) return true; |
| |
| if (name.includes("/")) { |
| const localPart = name.split("/")[1] ?? ""; |
| return localPart.startsWith("plugin-"); |
| } |
| return false; |
| } |
|
|
| |
| |
| |
| |
| async function readPackageJson( |
| dir: string, |
| ): Promise<Record<string, unknown> | null> { |
| const pkgPath = path.join(dir, "package.json"); |
| if (!existsSync(pkgPath)) return null; |
|
|
| try { |
| const raw = await readFile(pkgPath, "utf-8"); |
| return JSON.parse(raw) as Record<string, unknown>; |
| } catch { |
| return null; |
| } |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| function resolveManifestPath( |
| packageRoot: string, |
| pkgJson: Record<string, unknown>, |
| ): string | null { |
| const paperclipPlugin = pkgJson["paperclipPlugin"]; |
| if ( |
| paperclipPlugin !== null && |
| typeof paperclipPlugin === "object" && |
| !Array.isArray(paperclipPlugin) |
| ) { |
| const manifestRelPath = (paperclipPlugin as Record<string, unknown>)[ |
| "manifest" |
| ]; |
| if (typeof manifestRelPath === "string") { |
| |
| |
| |
| return path.resolve(packageRoot, manifestRelPath); |
| } |
| } |
|
|
| |
| const conventionalPath = path.join(packageRoot, "dist", "manifest.js"); |
| if (existsSync(conventionalPath)) { |
| return conventionalPath; |
| } |
|
|
| |
| const rootManifestPath = path.join(packageRoot, "manifest.js"); |
| if (existsSync(rootManifestPath)) { |
| return rootManifestPath; |
| } |
|
|
| return null; |
| } |
|
|
| function parseSemver(version: string): ParsedSemver | null { |
| const match = version.match( |
| /^(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?(?:\+[0-9A-Za-z.-]+)?$/, |
| ); |
| if (!match) return null; |
|
|
| return { |
| major: Number(match[1]), |
| minor: Number(match[2]), |
| patch: Number(match[3]), |
| prerelease: match[4] ? match[4].split(".") : [], |
| }; |
| } |
|
|
| function compareIdentifiers(left: string, right: string): number { |
| const leftIsNumeric = /^\d+$/.test(left); |
| const rightIsNumeric = /^\d+$/.test(right); |
|
|
| if (leftIsNumeric && rightIsNumeric) { |
| return Number(left) - Number(right); |
| } |
|
|
| if (leftIsNumeric) return -1; |
| if (rightIsNumeric) return 1; |
| return left.localeCompare(right); |
| } |
|
|
| function compareSemver(left: string, right: string): number { |
| const leftParsed = parseSemver(left); |
| const rightParsed = parseSemver(right); |
|
|
| if (!leftParsed || !rightParsed) { |
| throw new Error(`Invalid semver comparison: '${left}' vs '${right}'`); |
| } |
|
|
| const coreOrder = ( |
| ["major", "minor", "patch"] as const |
| ).map((key) => leftParsed[key] - rightParsed[key]).find((delta) => delta !== 0); |
| if (coreOrder) { |
| return coreOrder; |
| } |
|
|
| if (leftParsed.prerelease.length === 0 && rightParsed.prerelease.length === 0) { |
| return 0; |
| } |
| if (leftParsed.prerelease.length === 0) return 1; |
| if (rightParsed.prerelease.length === 0) return -1; |
|
|
| const maxLength = Math.max(leftParsed.prerelease.length, rightParsed.prerelease.length); |
| for (let index = 0; index < maxLength; index += 1) { |
| const leftId = leftParsed.prerelease[index]; |
| const rightId = rightParsed.prerelease[index]; |
| if (leftId === undefined) return -1; |
| if (rightId === undefined) return 1; |
|
|
| const diff = compareIdentifiers(leftId, rightId); |
| if (diff !== 0) return diff; |
| } |
|
|
| return 0; |
| } |
|
|
| function getMinimumHostVersion(manifest: PaperclipPluginManifestV1): string | undefined { |
| return manifest.minimumHostVersion ?? manifest.minimumPaperclipVersion; |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| export function getPluginUiContributionMetadata( |
| manifest: PaperclipPluginManifestV1, |
| ): PluginUiContributionMetadata | null { |
| const slots = manifest.ui?.slots ?? []; |
| const launchers = [ |
| ...(manifest.launchers ?? []), |
| ...(manifest.ui?.launchers ?? []), |
| ]; |
|
|
| if (slots.length === 0 && launchers.length === 0) { |
| return null; |
| } |
|
|
| return { |
| uiEntryFile: "index.js", |
| slots, |
| launchers, |
| }; |
| } |
|
|
| |
| |
| |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| export function pluginLoader( |
| db: Db, |
| options: PluginLoaderOptions = {}, |
| runtimeServices?: PluginRuntimeServices, |
| ): PluginLoader { |
| const { |
| localPluginDir = DEFAULT_LOCAL_PLUGIN_DIR, |
| enableLocalFilesystem = true, |
| enableNpmDiscovery = true, |
| } = options; |
|
|
| const registry = pluginRegistryService(db); |
| const manifestValidator = pluginManifestValidator(); |
| const capabilityValidator = pluginCapabilityValidator(); |
| const log = logger.child({ service: "plugin-loader" }); |
| const hostVersion = runtimeServices?.instanceInfo.hostVersion; |
|
|
| async function assertPageRoutePathsAvailable(manifest: PaperclipPluginManifestV1): Promise<void> { |
| const requestedRoutePaths = getDeclaredPageRoutePaths(manifest); |
| if (requestedRoutePaths.length === 0) return; |
|
|
| const uniqueRequested = new Set(requestedRoutePaths); |
| if (uniqueRequested.size !== requestedRoutePaths.length) { |
| throw new Error(`Plugin ${manifest.id} declares duplicate page routePath values`); |
| } |
|
|
| const installedPlugins = await registry.listInstalled(); |
| for (const plugin of installedPlugins) { |
| if (plugin.pluginKey === manifest.id) continue; |
| const installedManifest = plugin.manifestJson as PaperclipPluginManifestV1 | null; |
| if (!installedManifest) continue; |
| const installedRoutePaths = new Set(getDeclaredPageRoutePaths(installedManifest)); |
| const conflictingRoute = requestedRoutePaths.find((routePath) => installedRoutePaths.has(routePath)); |
| if (conflictingRoute) { |
| throw new Error( |
| `Plugin ${manifest.id} routePath "${conflictingRoute}" conflicts with installed plugin ${plugin.pluginKey}`, |
| ); |
| } |
| } |
| } |
|
|
| |
| |
| |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| async function fetchAndValidate( |
| installOptions: PluginInstallOptions, |
| ): Promise<DiscoveredPlugin> { |
| const { packageName, localPath, version, installDir } = installOptions; |
|
|
| if (!packageName && !localPath) { |
| throw new Error("Either packageName or localPath must be provided"); |
| } |
|
|
| const targetInstallDir = installDir ?? localPluginDir; |
|
|
| |
| let resolvedPackagePath: string; |
| let resolvedPackageName: string; |
|
|
| if (localPath) { |
| |
| const absLocalPath = path.resolve(localPath); |
| if (!existsSync(absLocalPath)) { |
| throw new Error(`Local plugin path does not exist: ${absLocalPath}`); |
| } |
| resolvedPackagePath = absLocalPath; |
| const pkgJson = await readPackageJson(absLocalPath); |
| resolvedPackageName = |
| typeof pkgJson?.["name"] === "string" |
| ? pkgJson["name"] |
| : path.basename(absLocalPath); |
|
|
| log.info( |
| { localPath: absLocalPath, packageName: resolvedPackageName }, |
| "plugin-loader: fetching plugin from local path", |
| ); |
| } else { |
| |
| const spec = version ? `${packageName}@${version}` : packageName!; |
|
|
| log.info( |
| { spec, installDir: targetInstallDir }, |
| "plugin-loader: fetching plugin from npm", |
| ); |
|
|
| try { |
| |
| |
| |
| await execFileAsync( |
| "npm", |
| ["install", spec, "--prefix", targetInstallDir, "--save", "--ignore-scripts"], |
| { timeout: 120_000 }, |
| ); |
| } catch (err) { |
| throw new Error(`npm install failed for ${spec}: ${String(err)}`); |
| } |
|
|
| |
| const nodeModulesPath = path.join(targetInstallDir, "node_modules"); |
| resolvedPackageName = packageName!; |
|
|
| |
| if (resolvedPackageName.startsWith("@")) { |
| const [scope, name] = resolvedPackageName.split("/"); |
| resolvedPackagePath = path.join(nodeModulesPath, scope!, name!); |
| } else { |
| resolvedPackagePath = path.join(nodeModulesPath, resolvedPackageName); |
| } |
|
|
| if (!existsSync(resolvedPackagePath)) { |
| throw new Error( |
| `Package directory not found after installation: ${resolvedPackagePath}`, |
| ); |
| } |
| } |
|
|
| |
| |
| const pkgJson = await readPackageJson(resolvedPackagePath); |
| if (!pkgJson) throw new Error(`Missing package.json at ${resolvedPackagePath}`); |
|
|
| const manifestPath = resolveManifestPath(resolvedPackagePath, pkgJson); |
| if (!manifestPath || !existsSync(manifestPath)) { |
| throw new Error( |
| `Package ${resolvedPackageName} at ${resolvedPackagePath} does not appear to be a Paperclip plugin (no manifest found).`, |
| ); |
| } |
|
|
| const manifest = await loadManifestFromPath(manifestPath); |
|
|
| |
| if (!manifestValidator.getSupportedVersions().includes(manifest.apiVersion)) { |
| throw new Error( |
| `Plugin ${manifest.id} declares apiVersion ${manifest.apiVersion} which is not supported by this host. ` + |
| `Supported versions: ${manifestValidator.getSupportedVersions().join(", ")}`, |
| ); |
| } |
|
|
| |
| const capResult = capabilityValidator.validateManifestCapabilities(manifest); |
| if (!capResult.allowed) { |
| throw new Error( |
| `Plugin ${manifest.id} manifest has inconsistent capabilities. ` + |
| `Missing required capabilities for declared features: ${capResult.missing.join(", ")}`, |
| ); |
| } |
|
|
| await assertPageRoutePathsAvailable(manifest); |
|
|
| |
| const minimumHostVersion = getMinimumHostVersion(manifest); |
| if (minimumHostVersion && hostVersion) { |
| if (compareSemver(hostVersion, minimumHostVersion) < 0) { |
| throw new Error( |
| `Plugin ${manifest.id} requires host version ${minimumHostVersion} or newer, ` + |
| `but this server is running ${hostVersion}`, |
| ); |
| } |
| } |
|
|
| |
| const resolvedVersion = manifest.version; |
|
|
| return { |
| packagePath: resolvedPackagePath, |
| packageName: resolvedPackageName, |
| version: resolvedVersion, |
| source: localPath ? "local-filesystem" : "npm", |
| manifest, |
| }; |
| } |
|
|
| |
| |
| |
| |
| async function loadManifestFromPath( |
| manifestPath: string, |
| ): Promise<PaperclipPluginManifestV1> { |
| let raw: unknown; |
|
|
| try { |
| |
| const mod = await import(manifestPath) as Record<string, unknown>; |
| |
| raw = mod["default"] ?? mod; |
| } catch (err) { |
| throw new Error( |
| `Failed to load manifest module at ${manifestPath}: ${String(err)}`, |
| ); |
| } |
|
|
| return manifestValidator.parseOrThrow(raw); |
| } |
|
|
| |
| |
| |
| |
| async function buildDiscoveredPlugin( |
| packagePath: string, |
| source: PluginSource, |
| ): Promise<DiscoveredPlugin | null> { |
| const pkgJson = await readPackageJson(packagePath); |
| if (!pkgJson) return null; |
|
|
| const packageName = typeof pkgJson["name"] === "string" ? pkgJson["name"] : ""; |
| const version = typeof pkgJson["version"] === "string" ? pkgJson["version"] : "0.0.0"; |
|
|
| |
| const hasPaperclipPlugin = "paperclipPlugin" in pkgJson; |
| const nameMatchesConvention = isPluginPackageName(packageName); |
|
|
| if (!hasPaperclipPlugin && !nameMatchesConvention) { |
| return null; |
| } |
|
|
| const manifestPath = resolveManifestPath(packagePath, pkgJson); |
| if (!manifestPath || !existsSync(manifestPath)) { |
| |
| |
| return { |
| packagePath, |
| packageName, |
| version, |
| source, |
| manifest: null, |
| }; |
| } |
|
|
| try { |
| const manifest = await loadManifestFromPath(manifestPath); |
| return { |
| packagePath, |
| packageName, |
| version, |
| source, |
| manifest, |
| }; |
| } catch (err) { |
| |
| throw new Error( |
| `Plugin ${packageName}: ${String(err)}`, |
| ); |
| } |
| } |
|
|
| |
| |
| |
|
|
| return { |
| |
| |
| |
|
|
| async discoverAll(npmSearchDirs?: string[]): Promise<PluginDiscoveryResult> { |
| const allDiscovered: DiscoveredPlugin[] = []; |
| const allErrors: Array<{ packagePath: string; packageName: string; error: string }> = []; |
| const sources: PluginSource[] = []; |
|
|
| if (enableLocalFilesystem) { |
| sources.push("local-filesystem"); |
| const fsResult = await this.discoverFromLocalFilesystem(); |
| allDiscovered.push(...fsResult.discovered); |
| allErrors.push(...fsResult.errors); |
| } |
|
|
| if (enableNpmDiscovery) { |
| sources.push("npm"); |
| const npmResult = await this.discoverFromNpm(npmSearchDirs); |
| |
| const existingPaths = new Set(allDiscovered.map((d) => d.packagePath)); |
| for (const plugin of npmResult.discovered) { |
| if (!existingPaths.has(plugin.packagePath)) { |
| allDiscovered.push(plugin); |
| } |
| } |
| allErrors.push(...npmResult.errors); |
| } |
|
|
| |
| if (options.registryUrl) { |
| sources.push("registry"); |
| log.warn( |
| { registryUrl: options.registryUrl }, |
| "plugin-loader: remote registry discovery is not yet implemented", |
| ); |
| } |
|
|
| log.info( |
| { |
| discovered: allDiscovered.length, |
| errors: allErrors.length, |
| sources, |
| }, |
| "plugin-loader: discovery complete", |
| ); |
|
|
| return { discovered: allDiscovered, errors: allErrors, sources }; |
| }, |
|
|
| |
| |
| |
|
|
| async discoverFromLocalFilesystem(dir?: string): Promise<PluginDiscoveryResult> { |
| const scanDir = dir ?? localPluginDir; |
| const discovered: DiscoveredPlugin[] = []; |
| const errors: Array<{ packagePath: string; packageName: string; error: string }> = []; |
|
|
| if (!existsSync(scanDir)) { |
| log.debug( |
| { dir: scanDir }, |
| "plugin-loader: local plugin directory does not exist, skipping", |
| ); |
| return { discovered, errors, sources: ["local-filesystem"] }; |
| } |
|
|
| let entries: string[]; |
| try { |
| entries = await readdir(scanDir); |
| } catch (err) { |
| log.warn({ dir: scanDir, err }, "plugin-loader: failed to read local plugin directory"); |
| return { discovered, errors, sources: ["local-filesystem"] }; |
| } |
|
|
| for (const entry of entries) { |
| const entryPath = path.join(scanDir, entry); |
|
|
| |
| let entryStat; |
| try { |
| entryStat = await stat(entryPath); |
| } catch { |
| continue; |
| } |
| if (!entryStat.isDirectory()) continue; |
|
|
| |
| if (entry.startsWith("@")) { |
| let scopedEntries: string[]; |
| try { |
| scopedEntries = await readdir(entryPath); |
| } catch { |
| continue; |
| } |
| for (const scopedEntry of scopedEntries) { |
| const scopedPath = path.join(entryPath, scopedEntry); |
| try { |
| const scopedStat = await stat(scopedPath); |
| if (!scopedStat.isDirectory()) continue; |
| const plugin = await buildDiscoveredPlugin(scopedPath, "local-filesystem"); |
| if (plugin) discovered.push(plugin); |
| } catch (err) { |
| errors.push({ |
| packagePath: scopedPath, |
| packageName: `${entry}/${scopedEntry}`, |
| error: String(err), |
| }); |
| } |
| } |
| continue; |
| } |
|
|
| try { |
| const plugin = await buildDiscoveredPlugin(entryPath, "local-filesystem"); |
| if (plugin) discovered.push(plugin); |
| } catch (err) { |
| const pkgJson = await readPackageJson(entryPath); |
| const packageName = |
| typeof pkgJson?.["name"] === "string" ? pkgJson["name"] : entry; |
| errors.push({ packagePath: entryPath, packageName, error: String(err) }); |
| } |
| } |
|
|
| log.debug( |
| { dir: scanDir, discovered: discovered.length, errors: errors.length }, |
| "plugin-loader: local filesystem scan complete", |
| ); |
|
|
| return { discovered, errors, sources: ["local-filesystem"] }; |
| }, |
|
|
| |
| |
| |
|
|
| async discoverFromNpm(searchDirs?: string[]): Promise<PluginDiscoveryResult> { |
| const discovered: DiscoveredPlugin[] = []; |
| const errors: Array<{ packagePath: string; packageName: string; error: string }> = []; |
|
|
| |
| |
| |
| |
| const dirsToSearch: string[] = searchDirs && searchDirs.length > 0 ? searchDirs : []; |
|
|
| if (dirsToSearch.length === 0) { |
| |
| |
| const cwdNodeModules = path.join(process.cwd(), "node_modules"); |
| const localNodeModules = path.join(localPluginDir, "node_modules"); |
|
|
| if (existsSync(cwdNodeModules)) dirsToSearch.push(cwdNodeModules); |
| if (existsSync(localNodeModules)) dirsToSearch.push(localNodeModules); |
| } |
|
|
| for (const nodeModulesDir of dirsToSearch) { |
| if (!existsSync(nodeModulesDir)) continue; |
|
|
| let entries: string[]; |
| try { |
| entries = await readdir(nodeModulesDir); |
| } catch { |
| continue; |
| } |
|
|
| for (const entry of entries) { |
| const entryPath = path.join(nodeModulesDir, entry); |
|
|
| |
| if (entry.startsWith("@")) { |
| let scopedEntries: string[]; |
| try { |
| scopedEntries = await readdir(entryPath); |
| } catch { |
| continue; |
| } |
| for (const scopedEntry of scopedEntries) { |
| const fullName = `${entry}/${scopedEntry}`; |
| if (!isPluginPackageName(fullName)) continue; |
|
|
| const scopedPath = path.join(entryPath, scopedEntry); |
| try { |
| const plugin = await buildDiscoveredPlugin(scopedPath, "npm"); |
| if (plugin) discovered.push(plugin); |
| } catch (err) { |
| errors.push({ |
| packagePath: scopedPath, |
| packageName: fullName, |
| error: String(err), |
| }); |
| } |
| } |
| continue; |
| } |
|
|
| |
| if (!isPluginPackageName(entry)) continue; |
|
|
| let entryStat; |
| try { |
| entryStat = await stat(entryPath); |
| } catch { |
| continue; |
| } |
| if (!entryStat.isDirectory()) continue; |
|
|
| try { |
| const plugin = await buildDiscoveredPlugin(entryPath, "npm"); |
| if (plugin) discovered.push(plugin); |
| } catch (err) { |
| const pkgJson = await readPackageJson(entryPath); |
| const packageName = |
| typeof pkgJson?.["name"] === "string" ? pkgJson["name"] : entry; |
| errors.push({ packagePath: entryPath, packageName, error: String(err) }); |
| } |
| } |
| } |
|
|
| log.debug( |
| { searchDirs: dirsToSearch, discovered: discovered.length, errors: errors.length }, |
| "plugin-loader: npm discovery scan complete", |
| ); |
|
|
| return { discovered, errors, sources: ["npm"] }; |
| }, |
|
|
| |
| |
| |
|
|
| async loadManifest(packagePath: string): Promise<PaperclipPluginManifestV1 | null> { |
| const pkgJson = await readPackageJson(packagePath); |
| if (!pkgJson) return null; |
|
|
| const hasPaperclipPlugin = "paperclipPlugin" in pkgJson; |
| const packageName = typeof pkgJson["name"] === "string" ? pkgJson["name"] : ""; |
| const nameMatchesConvention = isPluginPackageName(packageName); |
|
|
| if (!hasPaperclipPlugin && !nameMatchesConvention) { |
| return null; |
| } |
|
|
| const manifestPath = resolveManifestPath(packagePath, pkgJson); |
| if (!manifestPath || !existsSync(manifestPath)) return null; |
|
|
| return loadManifestFromPath(manifestPath); |
| }, |
|
|
| |
| |
| |
|
|
| async installPlugin(installOptions: PluginInstallOptions): Promise<DiscoveredPlugin> { |
| const discovered = await fetchAndValidate(installOptions); |
|
|
| |
| await registry.install( |
| { |
| packageName: discovered.packageName, |
| packagePath: discovered.source === "local-filesystem" ? discovered.packagePath : undefined, |
| }, |
| discovered.manifest!, |
| ); |
|
|
| log.info( |
| { |
| pluginId: discovered.manifest!.id, |
| packageName: discovered.packageName, |
| version: discovered.version, |
| capabilities: discovered.manifest!.capabilities, |
| }, |
| "plugin-loader: plugin installed successfully", |
| ); |
|
|
| return discovered; |
| }, |
|
|
| |
| |
| |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| async upgradePlugin( |
| pluginId: string, |
| upgradeOptions: Omit<PluginInstallOptions, "installDir">, |
| ): Promise<{ |
| oldManifest: PaperclipPluginManifestV1; |
| newManifest: PaperclipPluginManifestV1; |
| discovered: DiscoveredPlugin; |
| }> { |
| const plugin = (await registry.getById(pluginId)) as { |
| id: string; |
| packageName: string; |
| packagePath: string | null; |
| manifestJson: PaperclipPluginManifestV1; |
| } | null; |
| if (!plugin) throw new Error(`Plugin not found: ${pluginId}`); |
|
|
| const oldManifest = plugin.manifestJson; |
| const { |
| packageName = plugin.packageName, |
| |
| |
| |
| localPath = plugin.packagePath ?? undefined, |
| version, |
| } = upgradeOptions; |
|
|
| log.info( |
| { pluginId, packageName, version, localPath }, |
| "plugin-loader: upgrading plugin", |
| ); |
|
|
| |
| const discovered = await fetchAndValidate({ |
| packageName, |
| localPath, |
| version, |
| installDir: localPluginDir, |
| }); |
|
|
| const newManifest = discovered.manifest!; |
|
|
| |
| if (newManifest.id !== oldManifest.id) { |
| throw new Error( |
| `Upgrade failed: new manifest ID '${newManifest.id}' does not match existing plugin ID '${oldManifest.id}'`, |
| ); |
| } |
|
|
| |
| const oldCaps = new Set(oldManifest.capabilities ?? []); |
| const newCaps = newManifest.capabilities ?? []; |
| const escalated = newCaps.filter((c) => !oldCaps.has(c)); |
|
|
| if (escalated.length > 0) { |
| log.warn( |
| { pluginId, escalated, oldVersion: oldManifest.version, newVersion: newManifest.version }, |
| "plugin-loader: upgrade introduces new capabilities — requires admin approval", |
| ); |
| throw new Error( |
| `Upgrade for "${pluginId}" introduces new capabilities that require approval: ${escalated.join(", ")}. ` + |
| `The previous version declared [${[...oldCaps].join(", ")}]. ` + |
| `Please review and approve the capability escalation before upgrading.`, |
| ); |
| } |
|
|
| |
| await registry.update(pluginId, { |
| packageName: discovered.packageName, |
| version: discovered.version, |
| manifest: newManifest, |
| }); |
|
|
| return { |
| oldManifest, |
| newManifest, |
| discovered, |
| }; |
| }, |
|
|
| |
| |
| |
|
|
| isSupportedApiVersion(apiVersion: number): boolean { |
| return manifestValidator.getSupportedVersions().includes(apiVersion); |
| }, |
|
|
| |
| |
| |
|
|
| async cleanupInstallArtifacts(plugin: PluginRecord): Promise<void> { |
| const managedTargets = new Set<string>(); |
| const managedNodeModulesDir = resolveManagedInstallPackageDir(localPluginDir, plugin.packageName); |
| const directManagedDir = path.join(localPluginDir, plugin.packageName); |
|
|
| managedTargets.add(managedNodeModulesDir); |
| if (isPathInsideDir(directManagedDir, localPluginDir)) { |
| managedTargets.add(directManagedDir); |
| } |
| if (plugin.packagePath && isPathInsideDir(plugin.packagePath, localPluginDir)) { |
| managedTargets.add(path.resolve(plugin.packagePath)); |
| } |
|
|
| const packageJsonPath = path.join(localPluginDir, "package.json"); |
| if (existsSync(packageJsonPath)) { |
| try { |
| await execFileAsync( |
| "npm", |
| ["uninstall", plugin.packageName, "--prefix", localPluginDir, "--ignore-scripts"], |
| { timeout: 120_000 }, |
| ); |
| } catch (err) { |
| log.warn( |
| { |
| pluginId: plugin.id, |
| pluginKey: plugin.pluginKey, |
| packageName: plugin.packageName, |
| err: err instanceof Error ? err.message : String(err), |
| }, |
| "plugin-loader: npm uninstall failed during cleanup, falling back to direct removal", |
| ); |
| } |
| } |
|
|
| for (const target of managedTargets) { |
| if (!existsSync(target)) continue; |
| await rm(target, { recursive: true, force: true }); |
| } |
| }, |
|
|
| |
| |
| |
|
|
| getLocalPluginDir(): string { |
| return localPluginDir; |
| }, |
|
|
| |
| |
| |
|
|
| hasRuntimeServices(): boolean { |
| return runtimeServices !== undefined; |
| }, |
|
|
| |
| |
| |
| |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| async loadAll(): Promise<PluginLoadAllResult> { |
| if (!runtimeServices) { |
| throw new Error( |
| "Cannot loadAll: no PluginRuntimeServices provided. " + |
| "Pass runtime services as the third argument to pluginLoader().", |
| ); |
| } |
|
|
| log.info("plugin-loader: loading all ready plugins"); |
|
|
| |
| const readyPlugins = (await registry.listByStatus("ready")) as PluginRecord[]; |
|
|
| if (readyPlugins.length === 0) { |
| log.info("plugin-loader: no ready plugins to load"); |
| return { total: 0, succeeded: 0, failed: 0, results: [] }; |
| } |
|
|
| log.info( |
| { count: readyPlugins.length }, |
| "plugin-loader: found ready plugins to load", |
| ); |
|
|
| |
| const results = await Promise.allSettled( |
| readyPlugins.map((plugin) => activatePlugin(plugin)) |
| ); |
|
|
| const loadResults = results.map((r, i) => { |
| if (r.status === "fulfilled") return r.value; |
| return { |
| plugin: readyPlugins[i]!, |
| success: false, |
| error: String(r.reason), |
| registered: { worker: false, eventSubscriptions: 0, jobs: 0, webhooks: 0, tools: 0 }, |
| }; |
| }); |
|
|
| const succeeded = loadResults.filter((r) => r.success).length; |
| const failed = loadResults.filter((r) => !r.success).length; |
|
|
| log.info( |
| { |
| total: readyPlugins.length, |
| succeeded, |
| failed, |
| }, |
| "plugin-loader: loadAll complete", |
| ); |
|
|
| return { |
| total: readyPlugins.length, |
| succeeded, |
| failed, |
| results: loadResults, |
| }; |
| }, |
|
|
| |
| |
| |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| async loadSingle(pluginId: string): Promise<PluginLoadResult> { |
| if (!runtimeServices) { |
| throw new Error( |
| "Cannot loadSingle: no PluginRuntimeServices provided. " + |
| "Pass runtime services as the third argument to pluginLoader().", |
| ); |
| } |
|
|
| const plugin = (await registry.getById(pluginId)) as PluginRecord | null; |
| if (!plugin) { |
| throw new Error(`Plugin not found: ${pluginId}`); |
| } |
|
|
| |
| |
| |
| |
| |
| if (plugin.status === "installed") { |
| await runtimeServices.lifecycleManager.load(pluginId); |
| const updated = (await registry.getById(pluginId)) as PluginRecord | null; |
| if (!updated) throw new Error(`Plugin not found after status update: ${pluginId}`); |
| return { |
| plugin: updated, |
| success: true, |
| registered: { worker: true, eventSubscriptions: 0, jobs: 0, webhooks: 0, tools: 0 }, |
| }; |
| } |
|
|
| if (plugin.status !== "ready") { |
| throw new Error( |
| `Cannot load plugin in status '${plugin.status}'. ` + |
| `Plugin must be in 'installed' or 'ready' status.`, |
| ); |
| } |
|
|
| return activatePlugin(plugin); |
| }, |
|
|
| |
| |
| |
|
|
| async unloadSingle(pluginId: string, pluginKey: string): Promise<void> { |
| if (!runtimeServices) { |
| throw new Error( |
| "Cannot unloadSingle: no PluginRuntimeServices provided.", |
| ); |
| } |
|
|
| log.info( |
| { pluginId, pluginKey }, |
| "plugin-loader: unloading single plugin", |
| ); |
|
|
| const { |
| workerManager, |
| eventBus, |
| jobScheduler, |
| toolDispatcher, |
| } = runtimeServices; |
|
|
| |
| try { |
| await jobScheduler.unregisterPlugin(pluginId); |
| } catch (err) { |
| log.warn( |
| { pluginId, err: err instanceof Error ? err.message : String(err) }, |
| "plugin-loader: failed to unregister from job scheduler (best-effort)", |
| ); |
| } |
|
|
| |
| eventBus.clearPlugin(pluginKey); |
|
|
| |
| toolDispatcher.unregisterPluginTools(pluginKey); |
|
|
| |
| try { |
| if (workerManager.isRunning(pluginId)) { |
| await workerManager.stopWorker(pluginId); |
| } |
| } catch (err) { |
| log.warn( |
| { pluginId, err: err instanceof Error ? err.message : String(err) }, |
| "plugin-loader: failed to stop worker during unload (best-effort)", |
| ); |
| } |
|
|
| log.info( |
| { pluginId, pluginKey }, |
| "plugin-loader: plugin unloaded successfully", |
| ); |
| }, |
|
|
| |
| |
| |
|
|
| async shutdownAll(): Promise<void> { |
| if (!runtimeServices) { |
| throw new Error( |
| "Cannot shutdownAll: no PluginRuntimeServices provided.", |
| ); |
| } |
|
|
| log.info("plugin-loader: shutting down all plugins"); |
|
|
| const { workerManager, jobScheduler } = runtimeServices; |
|
|
| |
| jobScheduler.stop(); |
|
|
| |
| await workerManager.stopAll(); |
|
|
| log.info("plugin-loader: all plugins shut down"); |
| }, |
| }; |
|
|
| |
| |
| |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| async function activatePlugin(plugin: PluginRecord): Promise<PluginLoadResult> { |
| const manifest = plugin.manifestJson; |
| const pluginId = plugin.id; |
| const pluginKey = plugin.pluginKey; |
|
|
| const registered: PluginLoadResult["registered"] = { |
| worker: false, |
| eventSubscriptions: 0, |
| jobs: 0, |
| webhooks: 0, |
| tools: 0, |
| }; |
|
|
| |
| if (!runtimeServices) { |
| return { |
| plugin, |
| success: false, |
| error: "No runtime services available", |
| registered, |
| }; |
| } |
|
|
| const { |
| workerManager, |
| eventBus, |
| jobScheduler, |
| jobStore, |
| toolDispatcher, |
| lifecycleManager, |
| buildHostHandlers, |
| instanceInfo, |
| } = runtimeServices; |
|
|
| try { |
| log.info( |
| { pluginId, pluginKey, version: plugin.version }, |
| "plugin-loader: activating plugin", |
| ); |
|
|
| |
| |
| |
| const workerEntrypoint = resolveWorkerEntrypoint(plugin, localPluginDir); |
|
|
| |
| |
| |
| const hostHandlers = buildHostHandlers(pluginId, manifest); |
|
|
| |
| |
| |
| let config: Record<string, unknown> = {}; |
| try { |
| const configRow = await registry.getConfig(pluginId); |
| if (configRow && typeof configRow === "object" && "configJson" in configRow) { |
| config = (configRow as { configJson: Record<string, unknown> }).configJson ?? {}; |
| } |
| } catch { |
| |
| log.debug({ pluginId }, "plugin-loader: no config found, using empty config"); |
| } |
|
|
| |
| |
| |
| const workerOptions: WorkerStartOptions = { |
| entrypointPath: workerEntrypoint, |
| manifest, |
| config, |
| instanceInfo, |
| apiVersion: manifest.apiVersion, |
| hostHandlers, |
| autoRestart: true, |
| }; |
|
|
| |
| |
| |
| if (plugin.packagePath && existsSync(DEV_TSX_LOADER_PATH)) { |
| workerOptions.execArgv = ["--import", DEV_TSX_LOADER_PATH]; |
| } |
|
|
| await workerManager.startWorker(pluginId, workerOptions); |
| registered.worker = true; |
|
|
| log.info( |
| { pluginId, pluginKey }, |
| "plugin-loader: worker started", |
| ); |
|
|
| |
| |
| |
| const jobDeclarations = manifest.jobs ?? []; |
| if (jobDeclarations.length > 0) { |
| await jobStore.syncJobDeclarations(pluginId, jobDeclarations); |
| await jobScheduler.registerPlugin(pluginId); |
| registered.jobs = jobDeclarations.length; |
|
|
| log.info( |
| { pluginId, pluginKey, jobs: jobDeclarations.length }, |
| "plugin-loader: job declarations synced and plugin registered with scheduler", |
| ); |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| const _scopedBus = eventBus.forPlugin(pluginKey); |
| registered.eventSubscriptions = eventBus.subscriptionCount(pluginKey); |
|
|
| log.debug( |
| { pluginId, pluginKey }, |
| "plugin-loader: event bus scoped handle ready", |
| ); |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| const webhookDeclarations = manifest.webhooks ?? []; |
| registered.webhooks = webhookDeclarations.length; |
|
|
| if (webhookDeclarations.length > 0) { |
| log.info( |
| { pluginId, pluginKey, webhooks: webhookDeclarations.length }, |
| "plugin-loader: webhook endpoints declared in manifest", |
| ); |
| } |
|
|
| |
| |
| |
| const toolDeclarations = manifest.tools ?? []; |
| if (toolDeclarations.length > 0) { |
| toolDispatcher.registerPluginTools(pluginKey, manifest); |
| registered.tools = toolDeclarations.length; |
|
|
| log.info( |
| { pluginId, pluginKey, tools: toolDeclarations.length }, |
| "plugin-loader: agent tools registered", |
| ); |
| } |
|
|
| |
| |
| |
| log.info( |
| { |
| pluginId, |
| pluginKey, |
| version: plugin.version, |
| registered, |
| }, |
| "plugin-loader: plugin activated successfully", |
| ); |
|
|
| return { plugin, success: true, registered }; |
| } catch (err) { |
| const errorMessage = err instanceof Error ? err.message : String(err); |
|
|
| log.error( |
| { pluginId, pluginKey, err: errorMessage }, |
| "plugin-loader: failed to activate plugin", |
| ); |
|
|
| |
| |
| try { |
| await lifecycleManager.markError(pluginId, `Activation failed: ${errorMessage}`); |
| } catch (markErr) { |
| log.error( |
| { |
| pluginId, |
| err: markErr instanceof Error ? markErr.message : String(markErr), |
| }, |
| "plugin-loader: failed to mark plugin as error after activation failure", |
| ); |
| } |
|
|
| return { |
| plugin, |
| success: false, |
| error: errorMessage, |
| registered, |
| }; |
| } |
| } |
| } |
|
|
| |
| |
| |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| function resolveWorkerEntrypoint( |
| plugin: PluginRecord & { packagePath?: string | null }, |
| localPluginDir: string, |
| ): string { |
| const manifest = plugin.manifestJson; |
| const workerRelPath = manifest.entrypoints.worker; |
|
|
| |
| if (plugin.packagePath && existsSync(plugin.packagePath)) { |
| const entrypoint = path.resolve(plugin.packagePath, workerRelPath); |
| if (entrypoint.startsWith(path.resolve(plugin.packagePath)) && existsSync(entrypoint)) { |
| return entrypoint; |
| } |
| } |
|
|
| |
| const packageName = plugin.packageName; |
| let packageDir: string; |
|
|
| if (packageName.startsWith("@")) { |
| |
| const [scope, name] = packageName.split("/"); |
| packageDir = path.join(localPluginDir, "node_modules", scope!, name!); |
| } else { |
| packageDir = path.join(localPluginDir, "node_modules", packageName); |
| } |
|
|
| |
| |
| const directDir = path.join(localPluginDir, packageName); |
|
|
| |
| for (const dir of [packageDir, directDir]) { |
| const entrypoint = path.resolve(dir, workerRelPath); |
|
|
| |
| if (!entrypoint.startsWith(path.resolve(dir))) { |
| continue; |
| } |
|
|
| if (existsSync(entrypoint)) { |
| return entrypoint; |
| } |
| } |
|
|
| |
| |
| if (path.isAbsolute(workerRelPath) && existsSync(workerRelPath)) { |
| return workerRelPath; |
| } |
|
|
| throw new Error( |
| `Worker entrypoint not found for plugin "${plugin.pluginKey}". ` + |
| `Checked: ${path.resolve(packageDir, workerRelPath)}, ` + |
| `${path.resolve(directDir, workerRelPath)}`, |
| ); |
| } |
|
|
| function resolveManagedInstallPackageDir(localPluginDir: string, packageName: string): string { |
| if (packageName.startsWith("@")) { |
| return path.join(localPluginDir, "node_modules", ...packageName.split("/")); |
| } |
| return path.join(localPluginDir, "node_modules", packageName); |
| } |
|
|
| function isPathInsideDir(candidatePath: string, parentDir: string): boolean { |
| const resolvedCandidate = path.resolve(candidatePath); |
| const resolvedParent = path.resolve(parentDir); |
| const relative = path.relative(resolvedParent, resolvedCandidate); |
| return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative)); |
| } |
|
|