Files
openclaw/src/plugins/bundled-plugin-metadata.ts
2026-04-06 17:35:47 +01:00

286 lines
8.9 KiB
TypeScript

import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { collectBundledChannelConfigs } from "./bundled-channel-config-metadata.js";
import {
collectBundledPluginPublicSurfaceArtifacts,
collectBundledPluginRuntimeSidecarArtifacts,
deriveBundledPluginIdHint,
resolveBundledPluginScanDir,
} from "./bundled-plugin-scan.js";
import {
getPackageManifestMetadata,
loadPluginManifest,
type OpenClawPackageManifest,
type PackageManifest,
type PluginManifest,
} from "./manifest.js";
import { resolveLoaderPackageRoot } from "./sdk-alias.js";
const OPENCLAW_PACKAGE_ROOT =
resolveLoaderPackageRoot({
modulePath: fileURLToPath(import.meta.url),
moduleUrl: import.meta.url,
}) ?? fileURLToPath(new URL("../..", import.meta.url));
const CURRENT_MODULE_PATH = fileURLToPath(import.meta.url);
const RUNNING_FROM_BUILT_ARTIFACT =
CURRENT_MODULE_PATH.includes(`${path.sep}dist${path.sep}`) ||
CURRENT_MODULE_PATH.includes(`${path.sep}dist-runtime${path.sep}`);
type BundledPluginPathPair = {
source: string;
built: string;
};
export type BundledPluginMetadata = {
dirName: string;
idHint: string;
source: BundledPluginPathPair;
setupSource?: BundledPluginPathPair;
publicSurfaceArtifacts?: readonly string[];
runtimeSidecarArtifacts?: readonly string[];
packageName?: string;
packageVersion?: string;
packageDescription?: string;
packageManifest?: OpenClawPackageManifest;
manifest: PluginManifest;
};
const bundledPluginMetadataCache = new Map<string, readonly BundledPluginMetadata[]>();
export function clearBundledPluginMetadataCache(): void {
bundledPluginMetadataCache.clear();
}
function trimString(value: unknown): string | undefined {
return typeof value === "string" && value.trim().length > 0 ? value.trim() : undefined;
}
function normalizeStringList(value: unknown): string[] {
if (!Array.isArray(value)) {
return [];
}
return value.map((entry) => trimString(entry) ?? "").filter(Boolean);
}
function rewriteEntryToBuiltPath(entry: string | undefined): string | undefined {
if (!entry) {
return undefined;
}
const normalized = entry.replace(/^\.\//u, "");
return normalized.replace(/\.[^.]+$/u, ".js");
}
function readPackageManifest(pluginDir: string): PackageManifest | undefined {
const packagePath = path.join(pluginDir, "package.json");
if (!fs.existsSync(packagePath)) {
return undefined;
}
try {
return JSON.parse(fs.readFileSync(packagePath, "utf-8")) as PackageManifest;
} catch {
return undefined;
}
}
function collectBundledPluginMetadataForPackageRoot(
packageRoot: string,
includeChannelConfigs: boolean,
includeSyntheticChannelConfigs: boolean,
): readonly BundledPluginMetadata[] {
const scanDir = resolveBundledPluginScanDir({
packageRoot,
runningFromBuiltArtifact: RUNNING_FROM_BUILT_ARTIFACT,
});
if (!scanDir || !fs.existsSync(scanDir)) {
return [];
}
const entries: BundledPluginMetadata[] = [];
for (const dirName of fs
.readdirSync(scanDir, { withFileTypes: true })
.filter((entry) => entry.isDirectory())
.map((entry) => entry.name)
.toSorted((left, right) => left.localeCompare(right))) {
const pluginDir = path.join(scanDir, dirName);
const manifestResult = loadPluginManifest(pluginDir, false);
if (!manifestResult.ok) {
continue;
}
const packageJson = readPackageManifest(pluginDir);
const packageManifest = getPackageManifestMetadata(packageJson);
const extensions = normalizeStringList(packageManifest?.extensions);
if (extensions.length === 0) {
continue;
}
const sourceEntry = trimString(extensions[0]);
const builtEntry = rewriteEntryToBuiltPath(sourceEntry);
if (!sourceEntry || !builtEntry) {
continue;
}
const setupSourcePath = trimString(packageManifest?.setupEntry);
const setupSource =
setupSourcePath && rewriteEntryToBuiltPath(setupSourcePath)
? {
source: setupSourcePath,
built: rewriteEntryToBuiltPath(setupSourcePath)!,
}
: undefined;
const publicSurfaceArtifacts = collectBundledPluginPublicSurfaceArtifacts({
pluginDir,
sourceEntry,
...(setupSourcePath ? { setupEntry: setupSourcePath } : {}),
});
const runtimeSidecarArtifacts =
collectBundledPluginRuntimeSidecarArtifacts(publicSurfaceArtifacts);
const channelConfigs =
includeChannelConfigs && includeSyntheticChannelConfigs
? collectBundledChannelConfigs({
pluginDir,
manifest: manifestResult.manifest,
packageManifest,
})
: manifestResult.manifest.channelConfigs;
entries.push({
dirName,
idHint: deriveBundledPluginIdHint({
entryPath: sourceEntry,
manifestId: manifestResult.manifest.id,
packageName: trimString(packageJson?.name),
hasMultipleExtensions: extensions.length > 1,
}),
source: {
source: sourceEntry,
built: builtEntry,
},
...(setupSource ? { setupSource } : {}),
...(publicSurfaceArtifacts ? { publicSurfaceArtifacts } : {}),
...(runtimeSidecarArtifacts ? { runtimeSidecarArtifacts } : {}),
...(trimString(packageJson?.name) ? { packageName: trimString(packageJson?.name) } : {}),
...(trimString(packageJson?.version)
? { packageVersion: trimString(packageJson?.version) }
: {}),
...(trimString(packageJson?.description)
? { packageDescription: trimString(packageJson?.description) }
: {}),
...(packageManifest ? { packageManifest } : {}),
manifest: {
...manifestResult.manifest,
...(channelConfigs ? { channelConfigs } : {}),
},
});
}
return entries;
}
export function listBundledPluginMetadata(params?: {
rootDir?: string;
includeChannelConfigs?: boolean;
includeSyntheticChannelConfigs?: boolean;
}): readonly BundledPluginMetadata[] {
const rootDir = path.resolve(params?.rootDir ?? OPENCLAW_PACKAGE_ROOT);
const includeChannelConfigs = params?.includeChannelConfigs ?? !RUNNING_FROM_BUILT_ARTIFACT;
const includeSyntheticChannelConfigs =
params?.includeSyntheticChannelConfigs ?? includeChannelConfigs;
const cacheKey = JSON.stringify({
rootDir,
includeChannelConfigs,
includeSyntheticChannelConfigs,
});
const cached = bundledPluginMetadataCache.get(cacheKey);
if (cached) {
return cached;
}
const entries = Object.freeze(
collectBundledPluginMetadataForPackageRoot(
rootDir,
includeChannelConfigs,
includeSyntheticChannelConfigs,
),
);
bundledPluginMetadataCache.set(cacheKey, entries);
return entries;
}
export function findBundledPluginMetadataById(
pluginId: string,
params?: { rootDir?: string },
): BundledPluginMetadata | undefined {
return listBundledPluginMetadata(params).find((entry) => entry.manifest.id === pluginId);
}
export function resolveBundledPluginWorkspaceSourcePath(params: {
rootDir: string;
pluginId: string;
}): string | null {
const metadata = findBundledPluginMetadataById(params.pluginId, { rootDir: params.rootDir });
if (!metadata) {
return null;
}
return path.resolve(params.rootDir, "extensions", metadata.dirName);
}
export function resolveBundledPluginGeneratedPath(
rootDir: string,
entry: BundledPluginPathPair | undefined,
pluginDirName?: string,
): string | null {
if (!entry) {
return null;
}
const entryOrder = [entry.built, entry.source].filter(
(candidate): candidate is string => typeof candidate === "string" && candidate.length > 0,
);
const baseDirs = [
path.resolve(rootDir, "dist", "extensions", pluginDirName ?? ""),
path.resolve(rootDir, "extensions", pluginDirName ?? ""),
];
for (const baseDir of baseDirs) {
for (const entryPath of entryOrder) {
const candidate = path.resolve(baseDir, normalizeRelativePluginEntryPath(entryPath));
if (fs.existsSync(candidate)) {
return candidate;
}
}
}
return null;
}
function normalizeRelativePluginEntryPath(entryPath: string): string {
return entryPath.replace(/^\.\//u, "");
}
export function resolveBundledPluginRepoEntryPath(params: {
rootDir: string;
pluginId: string;
preferBuilt?: boolean;
}): string | null {
const metadata = findBundledPluginMetadataById(params.pluginId, { rootDir: params.rootDir });
if (!metadata) {
return null;
}
const entryOrder = params.preferBuilt
? [metadata.source.built, metadata.source.source]
: [metadata.source.source, metadata.source.built];
const baseDirs = [
path.resolve(params.rootDir, "dist", "extensions", metadata.dirName),
path.resolve(params.rootDir, "extensions", metadata.dirName),
];
for (const baseDir of baseDirs) {
for (const entryPath of entryOrder) {
const candidate = path.resolve(baseDir, normalizeRelativePluginEntryPath(entryPath));
if (fs.existsSync(candidate)) {
return candidate;
}
}
}
return null;
}