Files
openclaw/src/plugins/bundled-plugin-metadata.ts
2026-06-03 21:01:33 -04:00

395 lines
13 KiB
TypeScript

import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { uniqueStrings } from "@openclaw/normalization-core/string-normalization";
import { tryReadJsonSync } from "../infra/json-files.js";
import { collectBundledChannelConfigs } from "./bundled-channel-config-metadata.js";
import {
collectBundledPluginPublicSurfaceArtifacts,
collectBundledPluginRuntimeSidecarArtifacts,
deriveBundledPluginIdHint,
normalizeBundledPluginStringList,
rewriteBundledPluginEntryToBuiltPath,
resolveBundledPluginScanDir,
trimBundledPluginString,
} 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;
};
/** Metadata collected from a bundled plugin package and manifest. */
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;
};
function readPackageManifest(pluginDir: string): PackageManifest | undefined {
const packagePath = path.join(pluginDir, "package.json");
return tryReadJsonSync<PackageManifest>(packagePath) ?? undefined;
}
function resolveBundledPluginMetadataScanDir(
packageRoot: string,
scanDir?: string,
): string | undefined {
if (scanDir) {
return path.resolve(scanDir);
}
return resolveBundledPluginScanDir({
packageRoot,
runningFromBuiltArtifact: RUNNING_FROM_BUILT_ARTIFACT,
});
}
function resolveBundledPluginLookupParams(params: { rootDir: string; scanDir?: string }): {
rootDir: string;
scanDir?: string;
} {
return params.scanDir ? params : { rootDir: params.rootDir };
}
function collectBundledPluginMetadata(
resolvedScanDir: string | undefined,
includeChannelConfigs: boolean,
includeSyntheticChannelConfigs: boolean,
): readonly BundledPluginMetadata[] {
if (!resolvedScanDir || !fs.existsSync(resolvedScanDir)) {
return [];
}
const entries: BundledPluginMetadata[] = [];
for (const dirName of fs
.readdirSync(resolvedScanDir, { withFileTypes: true })
.filter((entry) => entry.isDirectory())
.map((entry) => entry.name)
.toSorted((left, right) => left.localeCompare(right))) {
const pluginDir = path.join(resolvedScanDir, dirName);
const manifestResult = loadPluginManifest(pluginDir, false);
if (!manifestResult.ok) {
continue;
}
const packageJson = readPackageManifest(pluginDir);
const packageManifest = getPackageManifestMetadata(packageJson);
const extensions = normalizeBundledPluginStringList(packageManifest?.extensions);
if (extensions.length === 0) {
continue;
}
const sourceEntry = trimBundledPluginString(extensions[0]);
const builtEntry = rewriteBundledPluginEntryToBuiltPath(sourceEntry);
if (!sourceEntry || !builtEntry) {
continue;
}
const setupSourcePath = trimBundledPluginString(packageManifest?.setupEntry);
const setupSource =
setupSourcePath && rewriteBundledPluginEntryToBuiltPath(setupSourcePath)
? {
source: setupSourcePath,
built: rewriteBundledPluginEntryToBuiltPath(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: trimBundledPluginString(packageJson?.name),
hasMultipleExtensions: extensions.length > 1,
}),
source: {
source: sourceEntry,
built: builtEntry,
},
...(setupSource ? { setupSource } : {}),
...(publicSurfaceArtifacts ? { publicSurfaceArtifacts } : {}),
...(runtimeSidecarArtifacts ? { runtimeSidecarArtifacts } : {}),
...(trimBundledPluginString(packageJson?.name)
? { packageName: trimBundledPluginString(packageJson?.name) }
: {}),
...(trimBundledPluginString(packageJson?.version)
? { packageVersion: trimBundledPluginString(packageJson?.version) }
: {}),
...(trimBundledPluginString(packageJson?.description)
? { packageDescription: trimBundledPluginString(packageJson?.description) }
: {}),
...(packageManifest ? { packageManifest } : {}),
manifest: {
...manifestResult.manifest,
...(channelConfigs ? { channelConfigs } : {}),
},
});
}
return entries;
}
/** Lists bundled plugin metadata from source or built package layouts. */
export function listBundledPluginMetadata(params?: {
rootDir?: string;
scanDir?: string;
includeChannelConfigs?: boolean;
includeSyntheticChannelConfigs?: boolean;
}): readonly BundledPluginMetadata[] {
const rootDir = path.resolve(params?.rootDir ?? OPENCLAW_PACKAGE_ROOT);
const scanDir = params?.scanDir ? path.resolve(params.scanDir) : undefined;
const resolvedScanDir = resolveBundledPluginMetadataScanDir(rootDir, scanDir);
const includeChannelConfigs = params?.includeChannelConfigs ?? !RUNNING_FROM_BUILT_ARTIFACT;
const includeSyntheticChannelConfigs =
params?.includeSyntheticChannelConfigs ?? includeChannelConfigs;
const metadata = Object.freeze(
collectBundledPluginMetadata(
resolvedScanDir,
includeChannelConfigs,
includeSyntheticChannelConfigs,
),
);
return metadata;
}
/** Finds bundled plugin metadata by manifest id. */
export function findBundledPluginMetadataById(
pluginId: string,
params?: {
rootDir?: string;
scanDir?: string;
includeChannelConfigs?: boolean;
includeSyntheticChannelConfigs?: boolean;
},
): BundledPluginMetadata | undefined {
return listBundledPluginMetadata(params).find((entry) => entry.manifest.id === pluginId);
}
/** Resolves the source directory for a bundled plugin in the current workspace. */
export function resolveBundledPluginWorkspaceSourcePath(params: {
rootDir: string;
scanDir?: string;
pluginId: string;
}): string | null {
const metadata = findBundledPluginMetadataById(params.pluginId, {
...resolveBundledPluginLookupParams({
rootDir: params.rootDir,
scanDir: params.scanDir,
}),
includeChannelConfigs: false,
includeSyntheticChannelConfigs: false,
});
if (!metadata) {
return null;
}
if (params.scanDir) {
return path.resolve(params.scanDir, metadata.dirName);
}
return path.resolve(params.rootDir, "extensions", metadata.dirName);
}
function listBundledPluginEntryBaseDirs(params: {
rootDir: string;
pluginDirName?: string;
scanDir?: string;
}): string[] {
const scanPluginRoot = params.scanDir
? path.resolve(params.scanDir, params.pluginDirName ?? "")
: undefined;
const baseDirs = [
...(scanPluginRoot ? [path.resolve(scanPluginRoot, "dist")] : []),
...(scanPluginRoot ? [scanPluginRoot] : []),
path.resolve(params.rootDir, "dist", "extensions", params.pluginDirName ?? ""),
path.resolve(params.rootDir, "dist-runtime", "extensions", params.pluginDirName ?? ""),
path.resolve(params.rootDir, "extensions", params.pluginDirName ?? "", "dist"),
path.resolve(params.rootDir, "extensions", params.pluginDirName ?? ""),
];
return uniqueStrings(baseDirs);
}
function isPathInsideRoot(rootDir: string, targetPath: string): boolean {
const relative = path.relative(rootDir, targetPath);
return relative !== ".." && !relative.startsWith(`..${path.sep}`) && !path.isAbsolute(relative);
}
function listBundledPluginEntryRoots(params: {
rootDir: string;
pluginDirName?: string;
scanDir?: string;
}): string[] {
const roots = [
...(params.scanDir ? [path.resolve(params.scanDir, params.pluginDirName ?? "")] : []),
path.resolve(params.rootDir, "extensions", params.pluginDirName ?? ""),
path.resolve(params.rootDir, "dist", "extensions", params.pluginDirName ?? ""),
path.resolve(params.rootDir, "dist-runtime", "extensions", params.pluginDirName ?? ""),
];
return uniqueStrings(roots);
}
function listBundledPluginEntrySearchPaths(
entry: BundledPluginPathPair,
params: {
rootDir: string;
pluginDirName?: string;
scanDir?: string;
},
): string[] {
const paths: string[] = [];
const roots = listBundledPluginEntryRoots(params);
for (const rawEntry of [entry.built, entry.source]) {
if (typeof rawEntry !== "string" || rawEntry.length === 0) {
continue;
}
if (!path.isAbsolute(rawEntry)) {
paths.push(rawEntry);
continue;
}
const normalizedEntry = path.normalize(rawEntry);
for (const root of roots) {
if (!isPathInsideRoot(root, normalizedEntry)) {
continue;
}
const relativeEntry = path.relative(root, normalizedEntry);
const builtEntry = rewriteBundledPluginEntryToBuiltPath(relativeEntry);
if (builtEntry) {
paths.push(builtEntry);
}
paths.push(relativeEntry);
}
}
return uniqueStrings(paths);
}
/** Resolves a generated runtime path for a bundled plugin entry. */
export function resolveBundledPluginGeneratedPath(
rootDir: string,
entry: BundledPluginPathPair | undefined,
pluginDirName?: string,
scanDir?: string,
): string | null {
if (!entry) {
return null;
}
const entryOrder = listBundledPluginEntrySearchPaths(entry, {
rootDir,
pluginDirName,
...(scanDir ? { scanDir } : {}),
});
const baseDirs = listBundledPluginEntryBaseDirs({
rootDir,
pluginDirName,
...(scanDir ? { scanDir } : {}),
});
for (const baseDir of baseDirs) {
for (const entryPath of entryOrder) {
const candidate = resolveBundledPluginEntryCandidate(baseDir, entryPath);
if (!candidate) {
continue;
}
if (fs.existsSync(candidate)) {
return candidate;
}
}
}
return null;
}
function normalizeRelativePluginEntryPath(entryPath: string): string {
return entryPath.replace(/^\.\//u, "");
}
function resolveBundledPluginEntryCandidate(baseDir: string, entryPath: string): string | null {
const normalizedEntryPath = normalizeRelativePluginEntryPath(entryPath);
const candidate = path.isAbsolute(normalizedEntryPath)
? path.normalize(normalizedEntryPath)
: path.resolve(baseDir, normalizedEntryPath);
const relative = path.relative(baseDir, candidate);
if (relative === ".." || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)) {
return null;
}
return candidate;
}
/** Resolves the repo entry path for a bundled plugin, preferring source unless requested. */
export function resolveBundledPluginRepoEntryPath(params: {
rootDir: string;
pluginId: string;
preferBuilt?: boolean;
scanDir?: string;
}): string | null {
const metadata = findBundledPluginMetadataById(params.pluginId, {
...resolveBundledPluginLookupParams({
rootDir: params.rootDir,
scanDir: params.scanDir,
}),
includeChannelConfigs: false,
includeSyntheticChannelConfigs: false,
});
if (!metadata) {
return null;
}
const entryOrder = params.preferBuilt
? [metadata.source.built, metadata.source.source]
: [metadata.source.source, metadata.source.built];
const baseDirs = listBundledPluginEntryBaseDirs({
rootDir: params.rootDir,
pluginDirName: metadata.dirName,
...(params.scanDir ? { scanDir: params.scanDir } : {}),
});
for (const baseDir of baseDirs) {
for (const entryPath of entryOrder) {
const candidate = resolveBundledPluginEntryCandidate(baseDir, entryPath);
if (!candidate) {
continue;
}
if (fs.existsSync(candidate)) {
return candidate;
}
}
}
return null;
}