1951 lines
68 KiB
TypeScript
1951 lines
68 KiB
TypeScript
/**
|
|
* PluginLoader — discovery, installation, and runtime activation of plugins.
|
|
*
|
|
* This service is the entry point for the plugin system's I/O boundary:
|
|
*
|
|
* 1. **Discovery** — Scans the local plugin directory
|
|
* (`~/.paperclip/plugins/`) and `node_modules` for packages matching
|
|
* the `paperclip-plugin-*` naming convention. Aggregates results with
|
|
* path-based deduplication.
|
|
*
|
|
* 2. **Installation** — `installPlugin()` downloads from npm (or reads a
|
|
* local path), validates the manifest, checks capability consistency,
|
|
* and persists the install record.
|
|
*
|
|
* 3. **Runtime activation** — `activatePlugin()` wires up a loaded plugin
|
|
* with all runtime services: resolves its entrypoint, builds
|
|
* capability-gated host handlers, spawns a worker process, syncs job
|
|
* declarations, registers event subscriptions, and discovers tools.
|
|
*
|
|
* 4. **Shutdown** — `shutdownAll()` gracefully stops all active workers
|
|
* and unregisters runtime hooks.
|
|
*
|
|
* @see PLUGIN_SPEC.md §8 — Plugin Discovery
|
|
* @see PLUGIN_SPEC.md §10 — Package Contract
|
|
* @see PLUGIN_SPEC.md §12 — Process Model
|
|
*/
|
|
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));
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Constants
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/**
|
|
* Naming convention for npm-published Paperclip plugins.
|
|
* Packages matching this pattern are considered Paperclip plugins.
|
|
*
|
|
* @see PLUGIN_SPEC.md §10 — Package Contract
|
|
*/
|
|
export const NPM_PLUGIN_PACKAGE_PREFIX = "paperclip-plugin-";
|
|
|
|
/**
|
|
* Default local plugin directory. The loader scans this directory for
|
|
* locally-installed plugin packages.
|
|
*
|
|
* @see PLUGIN_SPEC.md §8.1 — On-Disk Layout
|
|
*/
|
|
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");
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Discovery result types
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/**
|
|
* A plugin package found during discovery from any source.
|
|
*/
|
|
export interface DiscoveredPlugin {
|
|
/** Absolute path to the root of the npm package directory. */
|
|
packagePath: string;
|
|
/** The npm package name as declared in package.json. */
|
|
packageName: string;
|
|
/** Semver version from package.json. */
|
|
version: string;
|
|
/** Source that found this package. */
|
|
source: PluginSource;
|
|
/** The parsed and validated manifest if available, null if discovery-only. */
|
|
manifest: PaperclipPluginManifestV1 | null;
|
|
}
|
|
|
|
/**
|
|
* Sources from which plugins can be discovered.
|
|
*
|
|
* @see PLUGIN_SPEC.md §8.1 — On-Disk Layout
|
|
*/
|
|
export type PluginSource =
|
|
| "local-filesystem" // ~/.paperclip/plugins/ local directory
|
|
| "npm" // npm packages matching paperclip-plugin-* convention
|
|
| "registry"; // future: remote plugin registry URL
|
|
|
|
type ParsedSemver = {
|
|
major: number;
|
|
minor: number;
|
|
patch: number;
|
|
prerelease: string[];
|
|
};
|
|
|
|
/**
|
|
* Result of a discovery scan.
|
|
*/
|
|
export interface PluginDiscoveryResult {
|
|
/** Plugins successfully discovered and validated. */
|
|
discovered: DiscoveredPlugin[];
|
|
/** Packages found but with validation errors. */
|
|
errors: Array<{ packagePath: string; packageName: string; error: string }>;
|
|
/** Source(s) that were scanned. */
|
|
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!);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Loader options
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/**
|
|
* Options for the plugin loader service.
|
|
*/
|
|
export interface PluginLoaderOptions {
|
|
/**
|
|
* Path to the local plugin directory to scan.
|
|
* Defaults to ~/.paperclip/plugins/
|
|
*/
|
|
localPluginDir?: string;
|
|
|
|
/**
|
|
* Whether to scan the local filesystem directory for plugins.
|
|
* Defaults to true.
|
|
*/
|
|
enableLocalFilesystem?: boolean;
|
|
|
|
/**
|
|
* Whether to discover installed npm packages matching the paperclip-plugin-*
|
|
* naming convention.
|
|
* Defaults to true.
|
|
*/
|
|
enableNpmDiscovery?: boolean;
|
|
|
|
/**
|
|
* Future: URL of the remote plugin registry to query.
|
|
* When set, the loader will also fetch available plugins from this endpoint.
|
|
* Registry support is not yet implemented; this field is reserved.
|
|
*/
|
|
registryUrl?: string;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Install options
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/**
|
|
* Options for installing a single plugin package.
|
|
*/
|
|
export interface PluginInstallOptions {
|
|
/**
|
|
* npm package name to install (e.g. "paperclip-plugin-linear" or "@acme/plugin-linear").
|
|
* Either packageName or localPath must be set.
|
|
*/
|
|
packageName?: string;
|
|
|
|
/**
|
|
* Absolute or relative path to a local plugin directory for development installs.
|
|
* When set, the plugin is loaded from this path without npm install.
|
|
* Either packageName or localPath must be set.
|
|
*/
|
|
localPath?: string;
|
|
|
|
/**
|
|
* Version specifier passed to npm install (e.g. "^1.2.0", "latest").
|
|
* Ignored when localPath is set.
|
|
*/
|
|
version?: string;
|
|
|
|
/**
|
|
* Plugin install directory where packages are managed.
|
|
* Defaults to the localPluginDir configured on the service.
|
|
*/
|
|
installDir?: string;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Runtime options — services needed for initializing loaded plugins
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/**
|
|
* Runtime services passed to the loader for plugin initialization.
|
|
*
|
|
* When these are provided, the loader can fully activate plugins (spawn
|
|
* workers, register event subscriptions, sync jobs, register tools).
|
|
* When omitted, the loader operates in discovery/install-only mode.
|
|
*
|
|
* @see PLUGIN_SPEC.md §8.3 — Install Process
|
|
* @see PLUGIN_SPEC.md §12 — Process Model
|
|
*/
|
|
export interface PluginRuntimeServices {
|
|
/** Worker process manager for spawning and managing plugin workers. */
|
|
workerManager: PluginWorkerManager;
|
|
/** Event bus for registering plugin event subscriptions. */
|
|
eventBus: PluginEventBus;
|
|
/** Job scheduler for registering plugin cron jobs. */
|
|
jobScheduler: PluginJobScheduler;
|
|
/** Job store for syncing manifest job declarations to the DB. */
|
|
jobStore: PluginJobStore;
|
|
/** Tool dispatcher for registering plugin-contributed agent tools. */
|
|
toolDispatcher: PluginToolDispatcher;
|
|
/** Lifecycle manager for state transitions and worker lifecycle events. */
|
|
lifecycleManager: PluginLifecycleManager;
|
|
/**
|
|
* Factory that creates worker-to-host RPC handlers for a given plugin.
|
|
*
|
|
* The returned handlers service worker→host calls (e.g. state.get,
|
|
* events.emit, config.get). Each plugin gets its own set of handlers
|
|
* scoped to its capabilities and plugin ID.
|
|
*/
|
|
buildHostHandlers: (pluginId: string, manifest: PaperclipPluginManifestV1) => WorkerToHostHandlers;
|
|
/**
|
|
* Host instance information passed to the worker during initialization.
|
|
* Includes the instance ID and host version.
|
|
*/
|
|
instanceInfo: {
|
|
instanceId: string;
|
|
hostVersion: string;
|
|
};
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Load results
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/**
|
|
* Result of activating (loading) a single plugin at runtime.
|
|
*
|
|
* Contains the plugin record, activation status, and any error that
|
|
* occurred during the process.
|
|
*/
|
|
export interface PluginLoadResult {
|
|
/** The plugin record from the database. */
|
|
plugin: PluginRecord;
|
|
/** Whether the plugin was successfully activated. */
|
|
success: boolean;
|
|
/** Error message if activation failed. */
|
|
error?: string;
|
|
/** Which subsystems were registered during activation. */
|
|
registered: {
|
|
/** True if the worker process was started. */
|
|
worker: boolean;
|
|
/** Number of event subscriptions registered (from manifest event declarations). */
|
|
eventSubscriptions: number;
|
|
/** Number of job declarations synced to the database. */
|
|
jobs: number;
|
|
/** Number of webhook endpoints declared in manifest. */
|
|
webhooks: number;
|
|
/** Number of agent tools registered. */
|
|
tools: number;
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Result of activating all ready plugins at server startup.
|
|
*/
|
|
export interface PluginLoadAllResult {
|
|
/** Total number of plugins that were attempted. */
|
|
total: number;
|
|
/** Number of plugins successfully activated. */
|
|
succeeded: number;
|
|
/** Number of plugins that failed to activate. */
|
|
failed: number;
|
|
/** Per-plugin results. */
|
|
results: PluginLoadResult[];
|
|
}
|
|
|
|
/**
|
|
* Normalized UI contribution metadata extracted from a plugin manifest.
|
|
*
|
|
* The host serves all plugin UI bundles from the manifest's `entrypoints.ui`
|
|
* directory and currently expects the bundle entry module to be `index.js`.
|
|
*/
|
|
export interface PluginUiContributionMetadata {
|
|
uiEntryFile: string;
|
|
slots: PluginUiSlotDeclaration[];
|
|
launchers: PluginLauncherDeclaration[];
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Service interface
|
|
// ---------------------------------------------------------------------------
|
|
|
|
export interface PluginLoader {
|
|
/**
|
|
* Discover all available plugins from configured sources.
|
|
*
|
|
* This performs a non-destructive scan of all enabled sources and returns
|
|
* the discovered plugins with their parsed manifests. No installs or DB
|
|
* writes happen during discovery.
|
|
*
|
|
* @param npmSearchDirs - Optional override for node_modules directories to search.
|
|
* Passed through to discoverFromNpm. When omitted the defaults are used.
|
|
*
|
|
* @see PLUGIN_SPEC.md §8.1 — On-Disk Layout
|
|
* @see PLUGIN_SPEC.md §8.3 — Install Process
|
|
*/
|
|
discoverAll(npmSearchDirs?: string[]): Promise<PluginDiscoveryResult>;
|
|
|
|
/**
|
|
* Scan the local filesystem plugin directory for installed plugin packages.
|
|
*
|
|
* Reads the plugin directory, attempts to load each subdirectory as an npm
|
|
* package, and validates the plugin manifest.
|
|
*
|
|
* @param dir - Directory to scan (defaults to configured localPluginDir).
|
|
*/
|
|
discoverFromLocalFilesystem(dir?: string): Promise<PluginDiscoveryResult>;
|
|
|
|
/**
|
|
* Discover Paperclip plugins installed as npm packages in the current
|
|
* Node.js environment matching the "paperclip-plugin-*" naming convention.
|
|
*
|
|
* Looks for packages in node_modules that match the naming convention.
|
|
*
|
|
* @param searchDirs - node_modules directories to search (defaults to process cwd resolution).
|
|
*/
|
|
discoverFromNpm(searchDirs?: string[]): Promise<PluginDiscoveryResult>;
|
|
|
|
/**
|
|
* Load and parse the plugin manifest from a package directory.
|
|
*
|
|
* Reads the package.json, finds the manifest entrypoint declared under
|
|
* the "paperclipPlugin.manifest" key, loads the manifest module, and
|
|
* validates it against the plugin manifest schema.
|
|
*
|
|
* Returns null if the package is not a Paperclip plugin.
|
|
* Throws if the package is a Paperclip plugin but the manifest is invalid.
|
|
*
|
|
* @see PLUGIN_SPEC.md §10 — Package Contract
|
|
*/
|
|
loadManifest(packagePath: string): Promise<PaperclipPluginManifestV1 | null>;
|
|
|
|
/**
|
|
* Install a plugin package and register it in the database.
|
|
*
|
|
* Follows the install process described in PLUGIN_SPEC.md §8.3:
|
|
* 1. Resolve npm package / local path.
|
|
* 2. Install into the plugin directory (npm install).
|
|
* 3. Read and validate plugin manifest.
|
|
* 4. Reject incompatible plugin API versions.
|
|
* 5. Validate manifest capabilities.
|
|
* 6. Persist install record in Postgres.
|
|
* 7. Return the discovered plugin for the caller to use.
|
|
*
|
|
* Worker spawning and lifecycle management are handled by the caller
|
|
* (pluginLifecycleManager and the server startup orchestration).
|
|
*
|
|
* @see PLUGIN_SPEC.md §8.3 — Install Process
|
|
*/
|
|
installPlugin(options: PluginInstallOptions): Promise<DiscoveredPlugin>;
|
|
|
|
/**
|
|
* Upgrade an already-installed plugin to a newer version.
|
|
*
|
|
* Similar to installPlugin, but:
|
|
* 1. Requires the plugin to already exist in the database.
|
|
* 2. Uses the existing packageName if not provided in options.
|
|
* 3. Updates the existing plugin record instead of creating a new one.
|
|
* 4. Returns the old and new manifests for capability comparison.
|
|
*
|
|
* @see PLUGIN_SPEC.md §25.3 — Upgrade Lifecycle
|
|
*/
|
|
upgradePlugin(pluginId: string, options: Omit<PluginInstallOptions, "installDir">): Promise<{
|
|
oldManifest: PaperclipPluginManifestV1;
|
|
newManifest: PaperclipPluginManifestV1;
|
|
discovered: DiscoveredPlugin;
|
|
}>;
|
|
|
|
/**
|
|
* Check whether a plugin API version is supported by this host.
|
|
*/
|
|
isSupportedApiVersion(apiVersion: number): boolean;
|
|
|
|
/**
|
|
* Remove runtime-managed on-disk install artifacts for a plugin.
|
|
*
|
|
* This only cleans files under the managed local plugin directory. Local-path
|
|
* source checkouts outside that directory are intentionally left alone.
|
|
*/
|
|
cleanupInstallArtifacts(plugin: PluginRecord): Promise<void>;
|
|
|
|
/**
|
|
* Get the local plugin directory this loader is configured to use.
|
|
*/
|
|
getLocalPluginDir(): string;
|
|
|
|
// -----------------------------------------------------------------------
|
|
// Runtime initialization (requires PluginRuntimeServices)
|
|
// -----------------------------------------------------------------------
|
|
|
|
/**
|
|
* Load and activate all plugins that are in `ready` status.
|
|
*
|
|
* This is the main server-startup orchestration method. For each plugin
|
|
* that is persisted as `ready`, it:
|
|
* 1. Resolves the worker entrypoint from the manifest.
|
|
* 2. Spawns the worker process via the worker manager.
|
|
* 3. Syncs job declarations from the manifest to the `plugin_jobs` table.
|
|
* 4. Registers the plugin with the job scheduler.
|
|
* 5. Registers event subscriptions declared in the manifest (scoped via the event bus).
|
|
* 6. Registers agent tools from the manifest via the tool dispatcher.
|
|
*
|
|
* Plugins that fail to activate are marked as `error` in the database.
|
|
* Activation failures are non-fatal — other plugins continue loading.
|
|
*
|
|
* **Requires** `PluginRuntimeServices` to have been provided at construction.
|
|
* Throws if runtime services are not available.
|
|
*
|
|
* @returns Aggregated results for all attempted plugin loads.
|
|
*
|
|
* @see PLUGIN_SPEC.md §8.4 — Server-Start Plugin Loading
|
|
* @see PLUGIN_SPEC.md §12 — Process Model
|
|
*/
|
|
loadAll(): Promise<PluginLoadAllResult>;
|
|
|
|
/**
|
|
* Activate a single plugin that is in `installed` or `ready` status.
|
|
*
|
|
* Used after a fresh install (POST /api/plugins/install) or after
|
|
* enabling a previously disabled plugin. Performs the same subsystem
|
|
* registration as `loadAll()` but for a single plugin.
|
|
*
|
|
* If the plugin is in `installed` status, transitions it to `ready`
|
|
* via the lifecycle manager before spawning the worker.
|
|
*
|
|
* **Requires** `PluginRuntimeServices` to have been provided at construction.
|
|
*
|
|
* @param pluginId - UUID of the plugin to activate
|
|
* @returns The activation result for this plugin
|
|
*
|
|
* @see PLUGIN_SPEC.md §8.3 — Install Process
|
|
*/
|
|
loadSingle(pluginId: string): Promise<PluginLoadResult>;
|
|
|
|
/**
|
|
* Deactivate a single plugin — stop its worker and unregister all
|
|
* subsystem registrations (events, jobs, tools).
|
|
*
|
|
* Used during plugin disable, uninstall, and before upgrade. Does NOT
|
|
* change the plugin's status in the database — that is the caller's
|
|
* responsibility (via the lifecycle manager).
|
|
*
|
|
* **Requires** `PluginRuntimeServices` to have been provided at construction.
|
|
*
|
|
* @param pluginId - UUID of the plugin to deactivate
|
|
* @param pluginKey - The plugin key (manifest ID) for scoped cleanup
|
|
*
|
|
* @see PLUGIN_SPEC.md §8.5 — Uninstall Process
|
|
*/
|
|
unloadSingle(pluginId: string, pluginKey: string): Promise<void>;
|
|
|
|
/**
|
|
* Stop all managed plugin workers. Called during server shutdown.
|
|
*
|
|
* Stops the job scheduler and then stops all workers via the worker
|
|
* manager. Does NOT change plugin statuses in the database — plugins
|
|
* remain in `ready` so they are restarted on next boot.
|
|
*
|
|
* **Requires** `PluginRuntimeServices` to have been provided at construction.
|
|
*/
|
|
shutdownAll(): Promise<void>;
|
|
|
|
/**
|
|
* Whether runtime services are available for plugin activation.
|
|
*/
|
|
hasRuntimeServices(): boolean;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Helpers
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/**
|
|
* Check whether a package name matches the Paperclip plugin naming convention.
|
|
* Accepts both the "paperclip-plugin-" prefix and scoped "@scope/plugin-" packages.
|
|
*
|
|
* @see PLUGIN_SPEC.md §10 — Package Contract
|
|
*/
|
|
export function isPluginPackageName(name: string): boolean {
|
|
if (name.startsWith(NPM_PLUGIN_PACKAGE_PREFIX)) return true;
|
|
// Also accept scoped packages like @acme/plugin-linear or @paperclipai/plugin-*
|
|
if (name.includes("/")) {
|
|
const localPart = name.split("/")[1] ?? "";
|
|
return localPart.startsWith("plugin-");
|
|
}
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* Read and parse a package.json from a directory path.
|
|
* Returns null if no package.json exists.
|
|
*/
|
|
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;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Resolve the manifest entrypoint from a package.json and package root.
|
|
*
|
|
* The spec defines a "paperclipPlugin" key in package.json with a "manifest"
|
|
* subkey pointing to the manifest module. This helper resolves the path.
|
|
*
|
|
* @see PLUGIN_SPEC.md §10 — Package Contract
|
|
*/
|
|
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") {
|
|
// NOTE: the resolved path is returned as-is even if the file does not yet
|
|
// exist on disk (e.g. the package has not been built). Callers MUST guard
|
|
// with existsSync() before passing the path to loadManifestFromPath().
|
|
return path.resolve(packageRoot, manifestRelPath);
|
|
}
|
|
}
|
|
|
|
// Fallback: look for dist/manifest.js as a convention
|
|
const conventionalPath = path.join(packageRoot, "dist", "manifest.js");
|
|
if (existsSync(conventionalPath)) {
|
|
return conventionalPath;
|
|
}
|
|
|
|
// Fallback: look for manifest.js at package root
|
|
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;
|
|
}
|
|
|
|
/**
|
|
* Extract UI contribution metadata from a manifest for route serialization.
|
|
*
|
|
* Returns `null` when the plugin does not declare any UI slots or launchers.
|
|
* Launcher declarations are aggregated from both the legacy top-level
|
|
* `launchers` field and the preferred `ui.launchers` field.
|
|
*/
|
|
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,
|
|
};
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Factory
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/**
|
|
* Create a PluginLoader service.
|
|
*
|
|
* The loader is responsible for plugin discovery, installation, and runtime
|
|
* activation. It reads plugin packages from the local filesystem and npm,
|
|
* validates their manifests, registers them in the database, and — when
|
|
* runtime services are provided — initialises worker processes, event
|
|
* subscriptions, job schedules, webhook endpoints, and agent tools.
|
|
*
|
|
* Usage (discovery & install only):
|
|
* ```ts
|
|
* const loader = pluginLoader(db, { enableLocalFilesystem: true });
|
|
*
|
|
* // Discover all available plugins
|
|
* const result = await loader.discoverAll();
|
|
* for (const plugin of result.discovered) {
|
|
* console.log(plugin.packageName, plugin.manifest?.id);
|
|
* }
|
|
*
|
|
* // Install a specific plugin
|
|
* const discovered = await loader.installPlugin({
|
|
* packageName: "paperclip-plugin-linear",
|
|
* version: "^1.0.0",
|
|
* });
|
|
* ```
|
|
*
|
|
* Usage (full runtime activation at server startup):
|
|
* ```ts
|
|
* const loader = pluginLoader(db, loaderOpts, {
|
|
* workerManager,
|
|
* eventBus,
|
|
* jobScheduler,
|
|
* jobStore,
|
|
* toolDispatcher,
|
|
* lifecycleManager,
|
|
* buildHostHandlers: (pluginId, manifest) => ({ ... }),
|
|
* instanceInfo: { instanceId: "inst-1", hostVersion: "1.0.0" },
|
|
* });
|
|
*
|
|
* // Load all ready plugins at startup
|
|
* const loadResult = await loader.loadAll();
|
|
* console.log(`Loaded ${loadResult.succeeded}/${loadResult.total} plugins`);
|
|
*
|
|
* // Load a single plugin after install
|
|
* const singleResult = await loader.loadSingle(pluginId);
|
|
*
|
|
* // Shutdown all plugin workers on server exit
|
|
* await loader.shutdownAll();
|
|
* ```
|
|
*
|
|
* @see PLUGIN_SPEC.md §8.1 — On-Disk Layout
|
|
* @see PLUGIN_SPEC.md §8.3 — Install Process
|
|
* @see PLUGIN_SPEC.md §12 — Process Model
|
|
*/
|
|
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}`,
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
// -------------------------------------------------------------------------
|
|
// Internal helpers
|
|
// -------------------------------------------------------------------------
|
|
|
|
/**
|
|
* Fetch a plugin from npm or local path, then parse and validate its manifest.
|
|
*
|
|
* This internal helper encapsulates the core plugin retrieval and validation
|
|
* logic used by both install and upgrade operations. It handles:
|
|
* 1. Resolving the package from npm or local filesystem.
|
|
* 2. Installing the package via npm if necessary.
|
|
* 3. Reading and parsing the plugin manifest.
|
|
* 4. Validating API version compatibility.
|
|
* 5. Validating manifest capabilities.
|
|
*
|
|
* @param installOptions - Options specifying the package to fetch.
|
|
* @returns A `DiscoveredPlugin` object containing the validated manifest.
|
|
*/
|
|
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;
|
|
|
|
// Step 1 & 2: Resolve and install package
|
|
let resolvedPackagePath: string;
|
|
let resolvedPackageName: string;
|
|
|
|
if (localPath) {
|
|
// Local path install — validate the directory exists
|
|
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 {
|
|
// npm install
|
|
const spec = version ? `${packageName}@${version}` : packageName!;
|
|
|
|
log.info(
|
|
{ spec, installDir: targetInstallDir },
|
|
"plugin-loader: fetching plugin from npm",
|
|
);
|
|
|
|
try {
|
|
// Use execFile (not exec) to avoid shell injection from package name/version.
|
|
// --ignore-scripts prevents preinstall/install/postinstall hooks from
|
|
// executing arbitrary code on the host before manifest validation.
|
|
await execFileAsync(
|
|
"npm",
|
|
["install", spec, "--prefix", targetInstallDir, "--save", "--ignore-scripts"],
|
|
{ timeout: 120_000 }, // 2 minute timeout for npm install
|
|
);
|
|
} catch (err) {
|
|
throw new Error(`npm install failed for ${spec}: ${String(err)}`);
|
|
}
|
|
|
|
// Resolve the package path after installation
|
|
const nodeModulesPath = path.join(targetInstallDir, "node_modules");
|
|
resolvedPackageName = packageName!;
|
|
|
|
// Handle scoped packages
|
|
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}`,
|
|
);
|
|
}
|
|
}
|
|
|
|
// Step 3: Read and validate plugin manifest
|
|
// Note: this.loadManifest (used via current context)
|
|
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);
|
|
|
|
// Step 4: Reject incompatible plugin API versions
|
|
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(", ")}`,
|
|
);
|
|
}
|
|
|
|
// Step 5: Validate manifest capabilities are consistent
|
|
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);
|
|
|
|
// Step 6: Reject plugins that require a newer host than the running server
|
|
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}`,
|
|
);
|
|
}
|
|
}
|
|
|
|
// Use the version declared in the manifest (required field per the spec)
|
|
const resolvedVersion = manifest.version;
|
|
|
|
return {
|
|
packagePath: resolvedPackagePath,
|
|
packageName: resolvedPackageName,
|
|
version: resolvedVersion,
|
|
source: localPath ? "local-filesystem" : "npm",
|
|
manifest,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Attempt to load and validate a plugin manifest from a resolved path.
|
|
* Returns the manifest on success or throws with a descriptive error.
|
|
*/
|
|
async function loadManifestFromPath(
|
|
manifestPath: string,
|
|
): Promise<PaperclipPluginManifestV1> {
|
|
let raw: unknown;
|
|
|
|
try {
|
|
// Dynamic import works for both .js (ESM) and .cjs (CJS) manifests
|
|
const mod = await import(manifestPath) as Record<string, unknown>;
|
|
// The manifest may be the default export or the module itself
|
|
raw = mod["default"] ?? mod;
|
|
} catch (err) {
|
|
throw new Error(
|
|
`Failed to load manifest module at ${manifestPath}: ${String(err)}`,
|
|
);
|
|
}
|
|
|
|
return manifestValidator.parseOrThrow(raw);
|
|
}
|
|
|
|
/**
|
|
* Build a DiscoveredPlugin from a resolved package directory, or null
|
|
* if the package is not a Paperclip plugin.
|
|
*/
|
|
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";
|
|
|
|
// Determine if this is a plugin package at all
|
|
const hasPaperclipPlugin = "paperclipPlugin" in pkgJson;
|
|
const nameMatchesConvention = isPluginPackageName(packageName);
|
|
|
|
if (!hasPaperclipPlugin && !nameMatchesConvention) {
|
|
return null;
|
|
}
|
|
|
|
const manifestPath = resolveManifestPath(packagePath, pkgJson);
|
|
if (!manifestPath || !existsSync(manifestPath)) {
|
|
// Found a potential plugin package but no manifest entry point — treat
|
|
// as a discovery-only result with no manifest
|
|
return {
|
|
packagePath,
|
|
packageName,
|
|
version,
|
|
source,
|
|
manifest: null,
|
|
};
|
|
}
|
|
|
|
try {
|
|
const manifest = await loadManifestFromPath(manifestPath);
|
|
return {
|
|
packagePath,
|
|
packageName,
|
|
version,
|
|
source,
|
|
manifest,
|
|
};
|
|
} catch (err) {
|
|
// Rethrow with context — callers catch and route to the errors array
|
|
throw new Error(
|
|
`Plugin ${packageName}: ${String(err)}`,
|
|
);
|
|
}
|
|
}
|
|
|
|
// -------------------------------------------------------------------------
|
|
// Public API
|
|
// -------------------------------------------------------------------------
|
|
|
|
return {
|
|
// -----------------------------------------------------------------------
|
|
// discoverAll
|
|
// -----------------------------------------------------------------------
|
|
|
|
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);
|
|
// Deduplicate against already-discovered packages (same package path)
|
|
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);
|
|
}
|
|
|
|
// Future: registry source (options.registryUrl)
|
|
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 };
|
|
},
|
|
|
|
// -----------------------------------------------------------------------
|
|
// discoverFromLocalFilesystem
|
|
// -----------------------------------------------------------------------
|
|
|
|
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);
|
|
|
|
// Check if entry is a directory
|
|
let entryStat;
|
|
try {
|
|
entryStat = await stat(entryPath);
|
|
} catch {
|
|
continue;
|
|
}
|
|
if (!entryStat.isDirectory()) continue;
|
|
|
|
// Handle scoped packages: @scope/plugin-name is a subdirectory
|
|
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"] };
|
|
},
|
|
|
|
// -----------------------------------------------------------------------
|
|
// discoverFromNpm
|
|
// -----------------------------------------------------------------------
|
|
|
|
async discoverFromNpm(searchDirs?: string[]): Promise<PluginDiscoveryResult> {
|
|
const discovered: DiscoveredPlugin[] = [];
|
|
const errors: Array<{ packagePath: string; packageName: string; error: string }> = [];
|
|
|
|
// Determine the node_modules directories to search.
|
|
// When searchDirs is undefined OR empty, fall back to the conventional
|
|
// defaults (cwd/node_modules and localPluginDir/node_modules).
|
|
// To search nowhere explicitly, pass a non-empty array of non-existent paths.
|
|
const dirsToSearch: string[] = searchDirs && searchDirs.length > 0 ? searchDirs : [];
|
|
|
|
if (dirsToSearch.length === 0) {
|
|
// Default: search node_modules relative to the process working directory
|
|
// and also the local plugin dir's node_modules
|
|
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);
|
|
|
|
// Handle scoped packages (@scope/*)
|
|
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;
|
|
}
|
|
|
|
// Non-scoped packages: check naming convention
|
|
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"] };
|
|
},
|
|
|
|
// -----------------------------------------------------------------------
|
|
// loadManifest
|
|
// -----------------------------------------------------------------------
|
|
|
|
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);
|
|
},
|
|
|
|
// -----------------------------------------------------------------------
|
|
// installPlugin
|
|
// -----------------------------------------------------------------------
|
|
|
|
async installPlugin(installOptions: PluginInstallOptions): Promise<DiscoveredPlugin> {
|
|
const discovered = await fetchAndValidate(installOptions);
|
|
|
|
// Step 6: Persist install record in Postgres (include packagePath for local installs so the worker can be resolved)
|
|
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;
|
|
},
|
|
|
|
// -----------------------------------------------------------------------
|
|
// upgradePlugin
|
|
// -----------------------------------------------------------------------
|
|
|
|
/**
|
|
* Upgrade an already-installed plugin to a newer version.
|
|
*
|
|
* This method:
|
|
* 1. Fetches and validates the new plugin package using `fetchAndValidate`.
|
|
* 2. Ensures the new manifest ID matches the existing plugin ID for safety.
|
|
* 3. Updates the plugin record in the registry with the new version and manifest.
|
|
*
|
|
* @param pluginId - The UUID of the plugin to upgrade.
|
|
* @param upgradeOptions - Options for the upgrade (packageName, localPath, version).
|
|
* @returns The old and new manifests, along with the discovery metadata.
|
|
* @throws {Error} If the plugin is not found or if the new manifest ID differs.
|
|
*/
|
|
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;
|
|
manifestJson: PaperclipPluginManifestV1;
|
|
} | null;
|
|
if (!plugin) throw new Error(`Plugin not found: ${pluginId}`);
|
|
|
|
const oldManifest = plugin.manifestJson;
|
|
const {
|
|
packageName = plugin.packageName,
|
|
localPath,
|
|
version,
|
|
} = upgradeOptions;
|
|
|
|
log.info(
|
|
{ pluginId, packageName, version, localPath },
|
|
"plugin-loader: upgrading plugin",
|
|
);
|
|
|
|
// 1. Fetch/Install the new version
|
|
const discovered = await fetchAndValidate({
|
|
packageName,
|
|
localPath,
|
|
version,
|
|
installDir: localPluginDir,
|
|
});
|
|
|
|
const newManifest = discovered.manifest!;
|
|
|
|
// 2. Validate it's the same plugin ID
|
|
if (newManifest.id !== oldManifest.id) {
|
|
throw new Error(
|
|
`Upgrade failed: new manifest ID '${newManifest.id}' does not match existing plugin ID '${oldManifest.id}'`,
|
|
);
|
|
}
|
|
|
|
// 3. Detect capability escalation — new capabilities not in the old manifest
|
|
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.`,
|
|
);
|
|
}
|
|
|
|
// 4. Update the existing record
|
|
await registry.update(pluginId, {
|
|
packageName: discovered.packageName,
|
|
version: discovered.version,
|
|
manifest: newManifest,
|
|
});
|
|
|
|
return {
|
|
oldManifest,
|
|
newManifest,
|
|
discovered,
|
|
};
|
|
},
|
|
|
|
// -----------------------------------------------------------------------
|
|
// isSupportedApiVersion
|
|
// -----------------------------------------------------------------------
|
|
|
|
isSupportedApiVersion(apiVersion: number): boolean {
|
|
return manifestValidator.getSupportedVersions().includes(apiVersion);
|
|
},
|
|
|
|
// -----------------------------------------------------------------------
|
|
// cleanupInstallArtifacts
|
|
// -----------------------------------------------------------------------
|
|
|
|
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
|
|
// -----------------------------------------------------------------------
|
|
|
|
getLocalPluginDir(): string {
|
|
return localPluginDir;
|
|
},
|
|
|
|
// -----------------------------------------------------------------------
|
|
// hasRuntimeServices
|
|
// -----------------------------------------------------------------------
|
|
|
|
hasRuntimeServices(): boolean {
|
|
return runtimeServices !== undefined;
|
|
},
|
|
|
|
// -----------------------------------------------------------------------
|
|
// -----------------------------------------------------------------------
|
|
// loadAll
|
|
// -----------------------------------------------------------------------
|
|
|
|
/**
|
|
* loadAll — Loads and activates all plugins that are currently in 'ready' status.
|
|
*
|
|
* This method is typically called during server startup. It fetches all ready
|
|
* plugins from the registry and attempts to activate them in parallel using
|
|
* Promise.allSettled. Failures in individual plugins do not prevent others from loading.
|
|
*
|
|
* @returns A promise that resolves with summary statistics of the load operation.
|
|
*/
|
|
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");
|
|
|
|
// Fetch all plugins in ready status, ordered by installOrder
|
|
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",
|
|
);
|
|
|
|
// Load plugins in parallel
|
|
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,
|
|
};
|
|
},
|
|
|
|
// -----------------------------------------------------------------------
|
|
// loadSingle
|
|
// -----------------------------------------------------------------------
|
|
|
|
/**
|
|
* loadSingle — Loads and activates a single plugin by its ID.
|
|
*
|
|
* This method retrieves the plugin from the registry, ensures it's in a valid
|
|
* state, and then calls activatePlugin to start its worker and register its
|
|
* capabilities (tools, jobs, etc.).
|
|
*
|
|
* @param pluginId - The UUID of the plugin to load.
|
|
* @returns A promise that resolves with the result of the activation.
|
|
*/
|
|
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 the plugin is in 'installed' status, transition it to 'ready' first.
|
|
// lifecycleManager.load() transitions the status AND activates the plugin
|
|
// via activateReadyPlugin() → loadSingle() (recursive call with 'ready'
|
|
// status) → activatePlugin(). We must NOT call activatePlugin() again here,
|
|
// as that would double-start the worker and duplicate registrations.
|
|
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);
|
|
},
|
|
|
|
// -----------------------------------------------------------------------
|
|
// unloadSingle
|
|
// -----------------------------------------------------------------------
|
|
|
|
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;
|
|
|
|
// 1. Unregister from job scheduler (cancels in-flight runs)
|
|
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)",
|
|
);
|
|
}
|
|
|
|
// 2. Clear event subscriptions
|
|
eventBus.clearPlugin(pluginKey);
|
|
|
|
// 3. Unregister agent tools
|
|
toolDispatcher.unregisterPluginTools(pluginKey);
|
|
|
|
// 4. Stop the worker process
|
|
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",
|
|
);
|
|
},
|
|
|
|
// -----------------------------------------------------------------------
|
|
// shutdownAll
|
|
// -----------------------------------------------------------------------
|
|
|
|
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;
|
|
|
|
// 1. Stop the job scheduler tick loop
|
|
jobScheduler.stop();
|
|
|
|
// 2. Stop all worker processes
|
|
await workerManager.stopAll();
|
|
|
|
log.info("plugin-loader: all plugins shut down");
|
|
},
|
|
};
|
|
|
|
// -------------------------------------------------------------------------
|
|
// Internal: activatePlugin — shared logic for loadAll and loadSingle
|
|
// -------------------------------------------------------------------------
|
|
|
|
/**
|
|
* Activate a single plugin: spawn its worker, register event subscriptions,
|
|
* sync jobs, register tools.
|
|
*
|
|
* This is the core orchestration logic shared by `loadAll()` and `loadSingle()`.
|
|
* Failures are caught and reported in the result; the plugin is marked as
|
|
* `error` in the database when activation fails.
|
|
*/
|
|
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,
|
|
};
|
|
|
|
// Guard: runtime services must exist (callers already checked)
|
|
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",
|
|
);
|
|
|
|
// ------------------------------------------------------------------
|
|
// 1. Resolve worker entrypoint
|
|
// ------------------------------------------------------------------
|
|
const workerEntrypoint = resolveWorkerEntrypoint(plugin, localPluginDir);
|
|
|
|
// ------------------------------------------------------------------
|
|
// 2. Build host handlers for this plugin
|
|
// ------------------------------------------------------------------
|
|
const hostHandlers = buildHostHandlers(pluginId, manifest);
|
|
|
|
// ------------------------------------------------------------------
|
|
// 3. Retrieve plugin config (if any)
|
|
// ------------------------------------------------------------------
|
|
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 {
|
|
// Config may not exist yet — use empty object
|
|
log.debug({ pluginId }, "plugin-loader: no config found, using empty config");
|
|
}
|
|
|
|
// ------------------------------------------------------------------
|
|
// 4. Spawn worker process
|
|
// ------------------------------------------------------------------
|
|
const workerOptions: WorkerStartOptions = {
|
|
entrypointPath: workerEntrypoint,
|
|
manifest,
|
|
config,
|
|
instanceInfo,
|
|
apiVersion: manifest.apiVersion,
|
|
hostHandlers,
|
|
autoRestart: true,
|
|
};
|
|
|
|
// Repo-local plugin installs can resolve workspace TS sources at runtime
|
|
// (for example @paperclipai/shared exports). Run those workers through
|
|
// the tsx loader so first-party example plugins work in development.
|
|
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",
|
|
);
|
|
|
|
// ------------------------------------------------------------------
|
|
// 5. Sync job declarations and register with scheduler
|
|
// ------------------------------------------------------------------
|
|
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",
|
|
);
|
|
}
|
|
|
|
// ------------------------------------------------------------------
|
|
// 6. Register event subscriptions
|
|
//
|
|
// Note: Event subscriptions are declared at runtime by the plugin
|
|
// worker via the SDK's ctx.events.on() calls. The event bus manages
|
|
// per-plugin subscription scoping. Here we ensure the event bus has
|
|
// a scoped handle ready for this plugin — the actual subscriptions
|
|
// are registered by the host handler layer when the worker calls
|
|
// events.subscribe via RPC.
|
|
//
|
|
// The bus.forPlugin() call creates the scoped handle if needed;
|
|
// any previous subscriptions for this plugin are preserved if the
|
|
// worker is restarting.
|
|
// ------------------------------------------------------------------
|
|
const _scopedBus = eventBus.forPlugin(pluginKey);
|
|
registered.eventSubscriptions = eventBus.subscriptionCount(pluginKey);
|
|
|
|
log.debug(
|
|
{ pluginId, pluginKey },
|
|
"plugin-loader: event bus scoped handle ready",
|
|
);
|
|
|
|
// ------------------------------------------------------------------
|
|
// 7. Register webhook endpoints (manifest-declared)
|
|
//
|
|
// Webhooks are statically declared in the manifest. The actual
|
|
// endpoint routing is handled by the plugin routes module which
|
|
// checks the manifest for declared webhooks. No explicit
|
|
// registration step is needed here — the manifest is persisted
|
|
// in the DB and the route handler reads it at request time.
|
|
//
|
|
// We track the count for the result reporting.
|
|
// ------------------------------------------------------------------
|
|
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",
|
|
);
|
|
}
|
|
|
|
// ------------------------------------------------------------------
|
|
// 8. Register agent tools
|
|
// ------------------------------------------------------------------
|
|
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",
|
|
);
|
|
}
|
|
|
|
// ------------------------------------------------------------------
|
|
// Done — plugin fully activated
|
|
// ------------------------------------------------------------------
|
|
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",
|
|
);
|
|
|
|
// Mark the plugin as errored in the database so it is not retried
|
|
// automatically on next startup without operator intervention.
|
|
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,
|
|
};
|
|
}
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Worker entrypoint resolution
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/**
|
|
* Resolve the absolute path to a plugin's worker entrypoint from its manifest
|
|
* and known install locations.
|
|
*
|
|
* The manifest `entrypoints.worker` field is relative to the package root.
|
|
* We check the local plugin directory (where the package was installed) and
|
|
* also the package directory if it was a local-path install.
|
|
*
|
|
* @see PLUGIN_SPEC.md §10 — Package Contract
|
|
*/
|
|
function resolveWorkerEntrypoint(
|
|
plugin: PluginRecord & { packagePath?: string | null },
|
|
localPluginDir: string,
|
|
): string {
|
|
const manifest = plugin.manifestJson;
|
|
const workerRelPath = manifest.entrypoints.worker;
|
|
|
|
// For local-path installs we persist the resolved package path; use it first
|
|
if (plugin.packagePath && existsSync(plugin.packagePath)) {
|
|
const entrypoint = path.resolve(plugin.packagePath, workerRelPath);
|
|
if (entrypoint.startsWith(path.resolve(plugin.packagePath)) && existsSync(entrypoint)) {
|
|
return entrypoint;
|
|
}
|
|
}
|
|
|
|
// Try the local plugin directory (standard npm install location)
|
|
const packageName = plugin.packageName;
|
|
let packageDir: string;
|
|
|
|
if (packageName.startsWith("@")) {
|
|
// Scoped package: @scope/plugin-name → localPluginDir/node_modules/@scope/plugin-name
|
|
const [scope, name] = packageName.split("/");
|
|
packageDir = path.join(localPluginDir, "node_modules", scope!, name!);
|
|
} else {
|
|
packageDir = path.join(localPluginDir, "node_modules", packageName);
|
|
}
|
|
|
|
// Also check if the package exists directly under localPluginDir
|
|
// (for direct local-path installs or symlinked packages)
|
|
const directDir = path.join(localPluginDir, packageName);
|
|
|
|
// Try in order: node_modules path, direct path
|
|
for (const dir of [packageDir, directDir]) {
|
|
const entrypoint = path.resolve(dir, workerRelPath);
|
|
|
|
// Security: ensure entrypoint is actually inside the directory (prevent path traversal)
|
|
if (!entrypoint.startsWith(path.resolve(dir))) {
|
|
continue;
|
|
}
|
|
|
|
if (existsSync(entrypoint)) {
|
|
return entrypoint;
|
|
}
|
|
}
|
|
|
|
// Fallback: try the worker path as-is (absolute or relative to cwd)
|
|
// ONLY if it's already an absolute path and we trust the manifest (which we've already validated)
|
|
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));
|
|
}
|