import fs from "node:fs"; import path from "node:path"; import type { PluginInstallRecord } from "../config/types.plugins.js"; import { tryReadJson, tryReadJsonSync } 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 { return typeof value === "object" && value !== null && !Array.isArray(value); } function cloneInstallRecords( records: Record | undefined, ): Record { return structuredClone(records ?? {}); } function readRecordMap(value: unknown): Record | null { if (!isRecord(value)) { return null; } const records: Record = {}; 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 | null { const parsed = tryReadJsonSync(filePath); return isRecord(parsed) ? parsed : null; } function readStringRecord(value: unknown): Record { if (!isRecord(value)) { return {}; } const record: Record = {}; 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): 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 { 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 = {}; 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 | null, options: InstalledPluginIndexStoreOptions, ): Record { return { ...buildRecoveredManagedNpmInstallRecords(options), ...persisted, }; } function extractPluginInstallRecordsFromPersistedInstalledPluginIndex( index: unknown, ): Record | null { if (!isRecord(index)) { return null; } if (Object.prototype.hasOwnProperty.call(index, "installRecords")) { return readRecordMap(index.installRecords) ?? {}; } if (!Array.isArray(index.plugins)) { return null; } const records: Record = {}; 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 | null> { const parsed = await tryReadJson(resolveInstalledPluginIndexStorePath(options)); return extractPluginInstallRecordsFromPersistedInstalledPluginIndex(parsed); } export function readPersistedInstalledPluginIndexInstallRecordsSync( options: InstalledPluginIndexStoreOptions = {}, ): Record | null { const parsed = tryReadJsonSync(resolveInstalledPluginIndexStorePath(options)); return extractPluginInstallRecordsFromPersistedInstalledPluginIndex(parsed); } export async function loadInstalledPluginIndexInstallRecords( params: InstalledPluginIndexStoreOptions = {}, ): Promise> { return cloneInstallRecords( mergeRecoveredManagedNpmInstallRecords( await readPersistedInstalledPluginIndexInstallRecords(params), params, ), ); } export function loadInstalledPluginIndexInstallRecordsSync( params: InstalledPluginIndexStoreOptions = {}, ): Record { return cloneInstallRecords( mergeRecoveredManagedNpmInstallRecords( readPersistedInstalledPluginIndexInstallRecordsSync(params), params, ), ); }