Files
openclaw/src/plugins/installed-plugin-index-record-reader.ts
2026-05-03 23:17:49 +01:00

195 lines
6.7 KiB
TypeScript

import fs from "node:fs";
import path from "node:path";
import type { PluginInstallRecord } from "../config/types.plugins.js";
import { readJsonFile, readJsonFileSync } from "../infra/json-files.js";
import { resolveDefaultPluginNpmDir, validatePluginId } from "./install-paths.js";
import {
resolveInstalledPluginIndexStorePath,
type InstalledPluginIndexStoreOptions,
} from "./installed-plugin-index-store-path.js";
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function cloneInstallRecords(
records: Record<string, PluginInstallRecord> | undefined,
): Record<string, PluginInstallRecord> {
return structuredClone(records ?? {});
}
function readRecordMap(value: unknown): Record<string, PluginInstallRecord> | null {
if (!isRecord(value)) {
return null;
}
const records: Record<string, PluginInstallRecord> = {};
for (const [pluginId, record] of Object.entries(value).toSorted(([left], [right]) =>
left.localeCompare(right),
)) {
if (isRecord(record) && typeof record.source === "string") {
records[pluginId] = structuredClone(record) as PluginInstallRecord;
}
}
return records;
}
function readJsonObjectFileSync(filePath: string): Record<string, unknown> | null {
try {
const parsed = JSON.parse(fs.readFileSync(filePath, "utf8")) as unknown;
return isRecord(parsed) ? parsed : null;
} catch {
return null;
}
}
function readStringRecord(value: unknown): Record<string, string> {
if (!isRecord(value)) {
return {};
}
const record: Record<string, string> = {};
for (const [key, raw] of Object.entries(value).toSorted(([left], [right]) =>
left.localeCompare(right),
)) {
if (typeof raw === "string" && raw.trim()) {
record[key] = raw.trim();
}
}
return record;
}
function hasPackagePluginMetadata(manifest: Record<string, unknown>): boolean {
const openclaw = manifest.openclaw;
if (!isRecord(openclaw)) {
return false;
}
const extensions = openclaw.extensions;
return Array.isArray(extensions) && extensions.some((entry) => typeof entry === "string");
}
function readManifestPluginId(packageDir: string): string | undefined {
const manifest = readJsonObjectFileSync(path.join(packageDir, "openclaw.plugin.json"));
const id = typeof manifest?.id === "string" ? manifest.id.trim() : "";
return id || undefined;
}
function resolveRecoveredManagedNpmPluginId(params: {
packageName: string;
packageDir: string;
}): string | undefined {
const packageManifest = readJsonObjectFileSync(path.join(params.packageDir, "package.json"));
if (!packageManifest || !hasPackagePluginMetadata(packageManifest)) {
return undefined;
}
const packageName =
typeof packageManifest.name === "string" && packageManifest.name.trim()
? packageManifest.name.trim()
: params.packageName;
const pluginId = readManifestPluginId(params.packageDir) ?? packageName;
return validatePluginId(pluginId) ? undefined : pluginId;
}
function buildRecoveredManagedNpmInstallRecords(
options: InstalledPluginIndexStoreOptions = {},
): Record<string, PluginInstallRecord> {
const npmRoot = options.stateDir
? path.join(options.stateDir, "npm")
: resolveDefaultPluginNpmDir(options.env);
const rootManifest = readJsonObjectFileSync(path.join(npmRoot, "package.json"));
const dependencies = readStringRecord(rootManifest?.dependencies);
const records: Record<string, PluginInstallRecord> = {};
for (const [packageName, dependencySpec] of Object.entries(dependencies)) {
const packageDir = path.join(npmRoot, "node_modules", packageName);
let stat: fs.Stats;
try {
stat = fs.statSync(packageDir);
} catch {
continue;
}
if (!stat.isDirectory()) {
continue;
}
const pluginId = resolveRecoveredManagedNpmPluginId({ packageName, packageDir });
if (!pluginId) {
continue;
}
const packageManifest = readJsonObjectFileSync(path.join(packageDir, "package.json"));
const version =
typeof packageManifest?.version === "string" && packageManifest.version.trim()
? packageManifest.version.trim()
: undefined;
records[pluginId] = {
source: "npm",
spec: `${packageName}@${dependencySpec}`,
installPath: packageDir,
...(version ? { version, resolvedName: packageName, resolvedVersion: version } : {}),
...(version ? { resolvedSpec: `${packageName}@${version}` } : {}),
};
}
return records;
}
function mergeRecoveredManagedNpmInstallRecords(
persisted: Record<string, PluginInstallRecord> | null,
options: InstalledPluginIndexStoreOptions,
): Record<string, PluginInstallRecord> {
return {
...buildRecoveredManagedNpmInstallRecords(options),
...persisted,
};
}
function extractPluginInstallRecordsFromPersistedInstalledPluginIndex(
index: unknown,
): Record<string, PluginInstallRecord> | null {
if (!isRecord(index) || !Array.isArray(index.plugins)) {
return null;
}
if (Object.prototype.hasOwnProperty.call(index, "installRecords")) {
return readRecordMap(index.installRecords) ?? {};
}
const records: Record<string, PluginInstallRecord> = {};
for (const entry of index.plugins) {
if (!isRecord(entry) || typeof entry.pluginId !== "string" || !isRecord(entry.installRecord)) {
continue;
}
records[entry.pluginId] = structuredClone(entry.installRecord) as PluginInstallRecord;
}
return records;
}
export async function readPersistedInstalledPluginIndexInstallRecords(
options: InstalledPluginIndexStoreOptions = {},
): Promise<Record<string, PluginInstallRecord> | null> {
const parsed = await readJsonFile<unknown>(resolveInstalledPluginIndexStorePath(options));
return extractPluginInstallRecordsFromPersistedInstalledPluginIndex(parsed);
}
export function readPersistedInstalledPluginIndexInstallRecordsSync(
options: InstalledPluginIndexStoreOptions = {},
): Record<string, PluginInstallRecord> | null {
const parsed = readJsonFileSync(resolveInstalledPluginIndexStorePath(options));
return extractPluginInstallRecordsFromPersistedInstalledPluginIndex(parsed);
}
export async function loadInstalledPluginIndexInstallRecords(
params: InstalledPluginIndexStoreOptions = {},
): Promise<Record<string, PluginInstallRecord>> {
return cloneInstallRecords(
mergeRecoveredManagedNpmInstallRecords(
await readPersistedInstalledPluginIndexInstallRecords(params),
params,
),
);
}
export function loadInstalledPluginIndexInstallRecordsSync(
params: InstalledPluginIndexStoreOptions = {},
): Record<string, PluginInstallRecord> {
return cloneInstallRecords(
mergeRecoveredManagedNpmInstallRecords(
readPersistedInstalledPluginIndexInstallRecordsSync(params),
params,
),
);
}