diff --git a/src/plugins/installed-plugin-index-hash.ts b/src/plugins/installed-plugin-index-hash.ts new file mode 100644 index 00000000000..d8f95d9575c --- /dev/null +++ b/src/plugins/installed-plugin-index-hash.ts @@ -0,0 +1,34 @@ +import crypto from "node:crypto"; +import fs from "node:fs"; +import type { PluginDiagnostic } from "./manifest-types.js"; + +export function hashString(value: string): string { + return crypto.createHash("sha256").update(value).digest("hex"); +} + +export function hashJson(value: unknown): string { + return hashString(JSON.stringify(value)); +} + +export function safeHashFile(params: { + filePath: string; + pluginId?: string; + diagnostics: PluginDiagnostic[]; + required: boolean; +}): string | undefined { + try { + return crypto.createHash("sha256").update(fs.readFileSync(params.filePath)).digest("hex"); + } catch (err) { + if (params.required) { + params.diagnostics.push({ + level: "warn", + ...(params.pluginId ? { pluginId: params.pluginId } : {}), + source: params.filePath, + message: `installed plugin index could not hash ${params.filePath}: ${ + err instanceof Error ? err.message : String(err) + }`, + }); + } + return undefined; + } +} diff --git a/src/plugins/installed-plugin-index-install-records.ts b/src/plugins/installed-plugin-index-install-records.ts new file mode 100644 index 00000000000..a0096211530 --- /dev/null +++ b/src/plugins/installed-plugin-index-install-records.ts @@ -0,0 +1,104 @@ +import type { PluginInstallRecord } from "../config/types.plugins.js"; +import type { + InstalledPluginIndex, + InstalledPluginInstallRecordInfo, +} from "./installed-plugin-index-types.js"; + +function setInstallStringField>( + target: InstalledPluginInstallRecordInfo, + key: Key, + value: PluginInstallRecord[Key], +): void { + if (typeof value !== "string") { + return; + } + const normalized = value.trim(); + if (normalized) { + target[key] = normalized as InstalledPluginInstallRecordInfo[Key]; + } +} + +function normalizeInstallRecord( + record: PluginInstallRecord | undefined, +): InstalledPluginInstallRecordInfo | undefined { + if (!record) { + return undefined; + } + const normalized: InstalledPluginInstallRecordInfo = { + source: record.source, + }; + setInstallStringField(normalized, "spec", record.spec); + setInstallStringField(normalized, "sourcePath", record.sourcePath); + setInstallStringField(normalized, "installPath", record.installPath); + setInstallStringField(normalized, "version", record.version); + setInstallStringField(normalized, "resolvedName", record.resolvedName); + setInstallStringField(normalized, "resolvedVersion", record.resolvedVersion); + setInstallStringField(normalized, "resolvedSpec", record.resolvedSpec); + setInstallStringField(normalized, "integrity", record.integrity); + setInstallStringField(normalized, "shasum", record.shasum); + setInstallStringField(normalized, "resolvedAt", record.resolvedAt); + setInstallStringField(normalized, "installedAt", record.installedAt); + setInstallStringField(normalized, "clawhubUrl", record.clawhubUrl); + setInstallStringField(normalized, "clawhubPackage", record.clawhubPackage); + setInstallStringField(normalized, "clawhubFamily", record.clawhubFamily); + setInstallStringField(normalized, "clawhubChannel", record.clawhubChannel); + setInstallStringField(normalized, "marketplaceName", record.marketplaceName); + setInstallStringField(normalized, "marketplaceSource", record.marketplaceSource); + setInstallStringField(normalized, "marketplacePlugin", record.marketplacePlugin); + return normalized; +} + +function restoreInstallRecord( + record: InstalledPluginInstallRecordInfo | undefined, +): PluginInstallRecord | undefined { + if (!record?.source) { + return undefined; + } + return structuredClone(record) as PluginInstallRecord; +} + +export function normalizeInstallRecordMap( + records: Record | undefined, +): Record { + const normalized: Record = {}; + for (const [pluginId, record] of Object.entries(records ?? {}).toSorted(([left], [right]) => + left.localeCompare(right), + )) { + const installRecord = normalizeInstallRecord(record); + if (installRecord) { + normalized[pluginId] = installRecord; + } + } + return normalized; +} + +function restoreInstallRecordMap( + records: Readonly> | undefined, +): Record { + const restored: Record = {}; + for (const [pluginId, record] of Object.entries(records ?? {}).toSorted(([left], [right]) => + left.localeCompare(right), + )) { + const installRecord = restoreInstallRecord(record); + if (installRecord) { + restored[pluginId] = installRecord; + } + } + return restored; +} + +export function extractPluginInstallRecordsFromInstalledPluginIndex( + index: InstalledPluginIndex | null | undefined, +): Record { + if (index && Object.prototype.hasOwnProperty.call(index, "installRecords")) { + return restoreInstallRecordMap(index.installRecords); + } + const records: Record = {}; + for (const plugin of index?.plugins ?? []) { + const record = restoreInstallRecord(plugin.installRecord); + if (record) { + records[plugin.pluginId] = record; + } + } + return records; +} diff --git a/src/plugins/installed-plugin-index-invalidation.ts b/src/plugins/installed-plugin-index-invalidation.ts new file mode 100644 index 00000000000..c8ece3570d9 --- /dev/null +++ b/src/plugins/installed-plugin-index-invalidation.ts @@ -0,0 +1,71 @@ +import { hashJson } from "./installed-plugin-index-hash.js"; +import type { + InstalledPluginIndex, + InstalledPluginIndexRefreshReason, +} from "./installed-plugin-index-types.js"; + +export function diffInstalledPluginIndexInvalidationReasons( + previous: InstalledPluginIndex, + current: InstalledPluginIndex, +): readonly InstalledPluginIndexRefreshReason[] { + const reasons = new Set(); + if (previous.version !== current.version) { + reasons.add("missing"); + } + if (previous.hostContractVersion !== current.hostContractVersion) { + reasons.add("host-contract-changed"); + } + if (previous.compatRegistryVersion !== current.compatRegistryVersion) { + reasons.add("compat-registry-changed"); + } + if (previous.migrationVersion !== current.migrationVersion) { + reasons.add("migration"); + } + if (previous.policyHash !== current.policyHash) { + reasons.add("policy-changed"); + } + if (hashJson(previous.installRecords ?? {}) !== hashJson(current.installRecords ?? {})) { + reasons.add("source-changed"); + } + + const previousByPluginId = new Map(previous.plugins.map((plugin) => [plugin.pluginId, plugin])); + const currentByPluginId = new Map(current.plugins.map((plugin) => [plugin.pluginId, plugin])); + for (const [pluginId, previousPlugin] of previousByPluginId) { + const currentPlugin = currentByPluginId.get(pluginId); + if (!currentPlugin) { + reasons.add("source-changed"); + continue; + } + if ( + previousPlugin.rootDir !== currentPlugin.rootDir || + previousPlugin.manifestPath !== currentPlugin.manifestPath || + previousPlugin.installRecordHash !== currentPlugin.installRecordHash + ) { + reasons.add("source-changed"); + } + if (previousPlugin.enabled !== currentPlugin.enabled) { + reasons.add("policy-changed"); + } + if (previousPlugin.manifestHash !== currentPlugin.manifestHash) { + reasons.add("stale-manifest"); + } + if ( + previousPlugin.packageVersion !== currentPlugin.packageVersion || + previousPlugin.packageJson?.path !== currentPlugin.packageJson?.path || + previousPlugin.packageJson?.hash !== currentPlugin.packageJson?.hash + ) { + reasons.add("stale-package"); + } + } + for (const pluginId of currentByPluginId.keys()) { + if (!previousByPluginId.has(pluginId)) { + const currentPlugin = currentByPluginId.get(pluginId); + if (currentPlugin?.enabled === false) { + continue; + } + reasons.add("source-changed"); + } + } + + return Array.from(reasons).toSorted((left, right) => left.localeCompare(right)); +} diff --git a/src/plugins/installed-plugin-index-policy.ts b/src/plugins/installed-plugin-index-policy.ts new file mode 100644 index 00000000000..c28777f86d9 --- /dev/null +++ b/src/plugins/installed-plugin-index-policy.ts @@ -0,0 +1,51 @@ +import type { OpenClawConfig } from "../config/types.js"; +import { listPluginCompatRecords } from "./compat/registry.js"; +import { normalizePluginsConfig } from "./config-state.js"; +import { hashJson } from "./installed-plugin-index-hash.js"; + +export function resolveCompatRegistryVersion(): string { + return hashJson( + listPluginCompatRecords().map((record) => ({ + code: record.code, + status: record.status, + deprecated: record.deprecated, + warningStarts: record.warningStarts, + removeAfter: record.removeAfter, + replacement: record.replacement, + })), + ); +} + +export function resolveInstalledPluginIndexPolicyHash(config: OpenClawConfig | undefined): string { + const normalized = normalizePluginsConfig(config?.plugins); + const channelPolicy: Record = {}; + const channels = config?.channels; + if (channels && typeof channels === "object" && !Array.isArray(channels)) { + for (const [channelId, value] of Object.entries(channels)) { + if (value && typeof value === "object" && !Array.isArray(value)) { + const enabled = (value as Record).enabled; + if (typeof enabled === "boolean") { + channelPolicy[channelId] = enabled; + } + } + } + } + return hashJson({ + plugins: { + enabled: normalized.enabled, + allow: normalized.allow, + deny: normalized.deny, + slots: normalized.slots, + entries: Object.fromEntries( + Object.entries(normalized.entries) + .flatMap(([pluginId, entry]) => + typeof entry.enabled === "boolean" ? [[pluginId, entry.enabled] as const] : [], + ) + .toSorted(([left], [right]) => left.localeCompare(right)), + ), + }, + channels: Object.fromEntries( + Object.entries(channelPolicy).toSorted(([left], [right]) => left.localeCompare(right)), + ), + }); +} diff --git a/src/plugins/installed-plugin-index-record-builder.ts b/src/plugins/installed-plugin-index-record-builder.ts new file mode 100644 index 00000000000..d63c83d0a06 --- /dev/null +++ b/src/plugins/installed-plugin-index-record-builder.ts @@ -0,0 +1,291 @@ +import fs from "node:fs"; +import path from "node:path"; +import type { OpenClawConfig } from "../config/types.js"; +import type { PluginCompatCode } from "./compat/registry.js"; +import { normalizePluginsConfig, resolveEffectiveEnableState } from "./config-state.js"; +import type { PluginCandidate } from "./discovery.js"; +import type { PluginInstallSourceInfo } from "./install-source-info.js"; +import { describePluginInstallSource } from "./install-source-info.js"; +import { hashJson, safeHashFile } from "./installed-plugin-index-hash.js"; +import type { + InstalledPluginIndexRecord, + InstalledPluginInstallRecordInfo, + InstalledPluginPackageChannelInfo, + InstalledPluginStartupInfo, +} from "./installed-plugin-index-types.js"; +import type { PluginManifestRecord, PluginManifestRegistry } from "./manifest-registry.js"; +import type { PluginDiagnostic } from "./manifest-types.js"; +import type { PluginPackageChannel } from "./manifest.js"; +import { safeRealpathSync } from "./path-safety.js"; +import { hasKind } from "./slots.js"; + +function sortUnique(values: readonly string[] | undefined): readonly string[] { + if (!values || values.length === 0) { + return []; + } + return Array.from(new Set(values.map((value) => value.trim()).filter(Boolean))).toSorted( + (left, right) => left.localeCompare(right), + ); +} + +function hasRuntimeContractSurface(record: PluginManifestRecord): boolean { + const providers = record.providers ?? []; + const cliBackends = record.cliBackends ?? []; + return Boolean( + providers.length > 0 || + cliBackends.length > 0 || + record.contracts?.speechProviders?.length || + record.contracts?.mediaUnderstandingProviders?.length || + record.contracts?.documentExtractors?.length || + record.contracts?.imageGenerationProviders?.length || + record.contracts?.videoGenerationProviders?.length || + record.contracts?.musicGenerationProviders?.length || + record.contracts?.webContentExtractors?.length || + record.contracts?.webFetchProviders?.length || + record.contracts?.webSearchProviders?.length || + record.contracts?.memoryEmbeddingProviders?.length || + hasKind(record.kind, "memory"), + ); +} + +function buildStartupInfo(record: PluginManifestRecord): InstalledPluginStartupInfo { + const channels = record.channels ?? []; + return { + sidecar: channels.length === 0 && !hasRuntimeContractSurface(record), + memory: hasKind(record.kind, "memory"), + deferConfiguredChannelFullLoadUntilAfterListen: + record.startupDeferConfiguredChannelFullLoadUntilAfterListen === true, + agentHarnesses: sortUnique([ + ...(record.activation?.onAgentHarnesses ?? []), + ...(record.cliBackends ?? []), + ]), + }; +} + +function collectCompatCodes(record: PluginManifestRecord): readonly PluginCompatCode[] { + const codes: PluginCompatCode[] = []; + if (record.providerAuthEnvVars && Object.keys(record.providerAuthEnvVars).length > 0) { + codes.push("provider-auth-env-vars"); + } + if (record.channelEnvVars && Object.keys(record.channelEnvVars).length > 0) { + codes.push("channel-env-vars"); + } + if (record.activation?.onProviders?.length) { + codes.push("activation-provider-hint"); + } + if (record.activation?.onAgentHarnesses?.length) { + codes.push("activation-agent-harness-hint"); + } + if (record.activation?.onChannels?.length) { + codes.push("activation-channel-hint"); + } + if (record.activation?.onCommands?.length) { + codes.push("activation-command-hint"); + } + if (record.activation?.onRoutes?.length) { + codes.push("activation-route-hint"); + } + if (record.activation?.onCapabilities?.length) { + codes.push("activation-capability-hint"); + } + return sortUnique(codes) as readonly PluginCompatCode[]; +} + +function resolvePackageJsonPath(candidate: PluginCandidate | undefined): string | undefined { + if (!candidate?.packageDir) { + return undefined; + } + const packageDir = safeRealpathSync(candidate.packageDir) ?? path.resolve(candidate.packageDir); + const packageJsonPath = path.join(packageDir, "package.json"); + return fs.existsSync(packageJsonPath) ? packageJsonPath : undefined; +} + +function resolvePackageJsonRelativePath(rootDir: string, packageJsonPath: string): string { + const resolvedRootDir = safeRealpathSync(rootDir) ?? path.resolve(rootDir); + const relativePath = path.relative(resolvedRootDir, packageJsonPath) || "package.json"; + return relativePath.split(path.sep).join("/"); +} + +function resolvePackageJsonRecord(params: { + candidate: PluginCandidate | undefined; + packageJsonPath: string | undefined; + diagnostics: PluginDiagnostic[]; + pluginId: string; +}): InstalledPluginIndexRecord["packageJson"] | undefined { + if (!params.candidate?.packageDir || !params.packageJsonPath) { + return undefined; + } + const hash = safeHashFile({ + filePath: params.packageJsonPath, + pluginId: params.pluginId, + diagnostics: params.diagnostics, + required: false, + }); + if (!hash) { + return undefined; + } + return { + path: resolvePackageJsonRelativePath(params.candidate.rootDir, params.packageJsonPath), + hash, + }; +} + +function describePackageInstallSource( + candidate: PluginCandidate | undefined, +): PluginInstallSourceInfo | undefined { + const install = candidate?.packageManifest?.install; + if (!install) { + return undefined; + } + return describePluginInstallSource(install, { + expectedPackageName: candidate?.packageName, + }); +} + +function normalizeStringField(value: unknown): string | undefined { + if (typeof value !== "string") { + return undefined; + } + const normalized = value.trim(); + return normalized ? normalized : undefined; +} + +function normalizeStringListField(value: unknown): readonly string[] | undefined { + if (!Array.isArray(value)) { + return undefined; + } + const normalized = value + .flatMap((entry) => { + const normalizedEntry = normalizeStringField(entry); + return normalizedEntry ? [normalizedEntry] : []; + }) + .filter((entry, index, all) => all.indexOf(entry) === index); + return normalized.length > 0 ? normalized : undefined; +} + +function normalizePackageChannel( + channel: PluginPackageChannel | undefined, +): InstalledPluginPackageChannelInfo | undefined { + const id = normalizeStringField(channel?.id); + if (!id) { + return undefined; + } + const label = normalizeStringField(channel?.label); + const blurb = normalizeStringField(channel?.blurb); + const preferOver = normalizeStringListField(channel?.preferOver); + const commands = + channel?.commands && + typeof channel.commands === "object" && + !Array.isArray(channel.commands) && + (typeof channel.commands.nativeCommandsAutoEnabled === "boolean" || + typeof channel.commands.nativeSkillsAutoEnabled === "boolean") + ? { + ...(typeof channel.commands.nativeCommandsAutoEnabled === "boolean" + ? { nativeCommandsAutoEnabled: channel.commands.nativeCommandsAutoEnabled } + : {}), + ...(typeof channel.commands.nativeSkillsAutoEnabled === "boolean" + ? { nativeSkillsAutoEnabled: channel.commands.nativeSkillsAutoEnabled } + : {}), + } + : undefined; + return { + id, + ...(label ? { label } : {}), + ...(blurb ? { blurb } : {}), + ...(preferOver ? { preferOver } : {}), + ...(commands ? { commands } : {}), + }; +} + +function buildCandidateLookup( + candidates: readonly PluginCandidate[], +): Map { + const byRootDir = new Map(); + for (const candidate of candidates) { + byRootDir.set(candidate.rootDir, candidate); + } + return byRootDir; +} + +export function buildInstalledPluginIndexRecords(params: { + candidates: readonly PluginCandidate[]; + registry: PluginManifestRegistry; + config?: OpenClawConfig; + diagnostics: PluginDiagnostic[]; + installRecords: Record; +}): InstalledPluginIndexRecord[] { + const candidateByRootDir = buildCandidateLookup(params.candidates); + const normalizedConfig = normalizePluginsConfig(params.config?.plugins); + return params.registry.plugins.map((record): InstalledPluginIndexRecord => { + const candidate = candidateByRootDir.get(record.rootDir); + const packageJsonPath = resolvePackageJsonPath(candidate); + const installRecord = params.installRecords[record.id]; + const packageInstall = describePackageInstallSource(candidate); + const packageChannel = normalizePackageChannel(candidate?.packageManifest?.channel); + const manifestHash = + safeHashFile({ + filePath: record.manifestPath, + pluginId: record.id, + diagnostics: params.diagnostics, + required: true, + }) ?? ""; + const packageJson = resolvePackageJsonRecord({ + candidate, + packageJsonPath, + diagnostics: params.diagnostics, + pluginId: record.id, + }); + const enabled = resolveEffectiveEnableState({ + id: record.id, + origin: record.origin, + config: normalizedConfig, + rootConfig: params.config, + enabledByDefault: record.enabledByDefault, + }).enabled; + const indexRecord: InstalledPluginIndexRecord = { + pluginId: record.id, + manifestPath: record.manifestPath, + manifestHash, + source: record.source, + rootDir: record.rootDir, + origin: record.origin, + enabled, + startup: buildStartupInfo(record), + compat: collectCompatCodes(record), + }; + if (record.format && record.format !== "openclaw") { + indexRecord.format = record.format; + } + if (record.bundleFormat) { + indexRecord.bundleFormat = record.bundleFormat; + } + if (record.enabledByDefault === true) { + indexRecord.enabledByDefault = true; + } + if (record.syntheticAuthRefs && record.syntheticAuthRefs.length > 0) { + indexRecord.syntheticAuthRefs = record.syntheticAuthRefs; + } + if (record.setupSource) { + indexRecord.setupSource = record.setupSource; + } + if (candidate?.packageName) { + indexRecord.packageName = candidate.packageName; + } + if (candidate?.packageVersion) { + indexRecord.packageVersion = candidate.packageVersion; + } + if (installRecord) { + indexRecord.installRecordHash = hashJson(installRecord); + } + if (packageInstall) { + indexRecord.packageInstall = packageInstall; + } + if (packageChannel) { + indexRecord.packageChannel = packageChannel; + } + if (packageJson) { + indexRecord.packageJson = packageJson; + } + return indexRecord; + }); +} diff --git a/src/plugins/installed-plugin-index-registry.ts b/src/plugins/installed-plugin-index-registry.ts new file mode 100644 index 00000000000..e94e657cf4f --- /dev/null +++ b/src/plugins/installed-plugin-index-registry.ts @@ -0,0 +1,44 @@ +import { normalizePluginsConfig } from "./config-state.js"; +import { discoverOpenClawPlugins, type PluginCandidate } from "./discovery.js"; +import type { LoadInstalledPluginIndexParams } from "./installed-plugin-index-types.js"; +import { loadPluginManifestRegistry, type PluginManifestRegistry } from "./manifest-registry.js"; + +export function resolveInstalledPluginIndexRegistry(params: LoadInstalledPluginIndexParams): { + registry: PluginManifestRegistry; + candidates: readonly PluginCandidate[]; +} { + if (params.candidates) { + return { + candidates: params.candidates, + registry: loadPluginManifestRegistry({ + config: params.config, + workspaceDir: params.workspaceDir, + cache: false, + env: params.env, + candidates: params.candidates, + diagnostics: params.diagnostics, + installRecords: params.installRecords, + }), + }; + } + + const normalized = normalizePluginsConfig(params.config?.plugins); + const discovery = discoverOpenClawPlugins({ + workspaceDir: params.workspaceDir, + extraPaths: normalized.loadPaths, + cache: params.cache, + env: params.env, + }); + return { + candidates: discovery.candidates, + registry: loadPluginManifestRegistry({ + config: params.config, + workspaceDir: params.workspaceDir, + cache: false, + env: params.env, + candidates: discovery.candidates, + diagnostics: discovery.diagnostics, + installRecords: params.installRecords, + }), + }; +} diff --git a/src/plugins/installed-plugin-index-types.ts b/src/plugins/installed-plugin-index-types.ts new file mode 100644 index 00000000000..3c05812aa16 --- /dev/null +++ b/src/plugins/installed-plugin-index-types.ts @@ -0,0 +1,126 @@ +import type { OpenClawConfig } from "../config/types.js"; +import type { PluginInstallRecord } from "../config/types.plugins.js"; +import type { PluginCompatCode } from "./compat/registry.js"; +import type { PluginCandidate } from "./discovery.js"; +import type { PluginInstallSourceInfo } from "./install-source-info.js"; +import type { PluginManifestRecord } from "./manifest-registry.js"; +import type { PluginDiagnostic } from "./manifest-types.js"; +import type { PluginPackageChannel } from "./manifest.js"; + +export const INSTALLED_PLUGIN_INDEX_VERSION = 1; +export const INSTALLED_PLUGIN_INDEX_MIGRATION_VERSION = 1; +export const INSTALLED_PLUGIN_INDEX_WARNING = + "DO NOT EDIT. This file is generated by OpenClaw from plugin manifests, install records, and config policy. Use `openclaw plugins registry --refresh`, `openclaw plugins install/update/uninstall`, or `openclaw plugins enable/disable` instead."; + +export type InstalledPluginIndexRefreshReason = + | "missing" + | "stale-manifest" + | "stale-package" + | "source-changed" + | "policy-changed" + | "migration" + | "host-contract-changed" + | "compat-registry-changed" + | "manual"; + +export type InstalledPluginStartupInfo = { + sidecar: boolean; + memory: boolean; + deferConfiguredChannelFullLoadUntilAfterListen: boolean; + agentHarnesses: readonly string[]; +}; + +export type InstalledPluginInstallRecordInfo = Pick< + PluginInstallRecord, + | "source" + | "spec" + | "sourcePath" + | "installPath" + | "version" + | "resolvedName" + | "resolvedVersion" + | "resolvedSpec" + | "integrity" + | "shasum" + | "resolvedAt" + | "installedAt" + | "clawhubUrl" + | "clawhubPackage" + | "clawhubFamily" + | "clawhubChannel" + | "marketplaceName" + | "marketplaceSource" + | "marketplacePlugin" +>; + +export type InstalledPluginPackageChannelInfo = Pick< + PluginPackageChannel, + "id" | "label" | "blurb" | "preferOver" | "commands" +>; + +export type InstalledPluginIndexRecord = { + pluginId: string; + packageName?: string; + packageVersion?: string; + /** + * Legacy embedded install record accepted when reading earlier index files. + * New index writes keep install records in InstalledPluginIndex.installRecords. + */ + installRecord?: InstalledPluginInstallRecordInfo; + /** Hash of the top-level installRecords entry; used to detect source-changed invalidation. */ + installRecordHash?: string; + /** + * Package-authored openclaw.install metadata. This describes catalog/package + * install intent and must not be treated as the durable install record. + */ + packageInstall?: PluginInstallSourceInfo; + packageChannel?: InstalledPluginPackageChannelInfo; + manifestPath: string; + manifestHash: string; + format?: PluginManifestRecord["format"]; + bundleFormat?: PluginManifestRecord["bundleFormat"]; + source?: string; + setupSource?: string; + packageJson?: { + path: string; + hash: string; + }; + rootDir: string; + origin: PluginManifestRecord["origin"]; + enabled: boolean; + enabledByDefault?: boolean; + syntheticAuthRefs?: readonly string[]; + startup: InstalledPluginStartupInfo; + compat: readonly PluginCompatCode[]; +}; + +export type InstalledPluginIndex = { + version: typeof INSTALLED_PLUGIN_INDEX_VERSION; + warning?: string; + hostContractVersion: string; + compatRegistryVersion: string; + migrationVersion: typeof INSTALLED_PLUGIN_INDEX_MIGRATION_VERSION; + policyHash: string; + generatedAtMs: number; + refreshReason?: InstalledPluginIndexRefreshReason; + installRecords: Readonly>; + plugins: readonly InstalledPluginIndexRecord[]; + diagnostics: readonly PluginDiagnostic[]; +}; + +export type LoadInstalledPluginIndexParams = { + config?: OpenClawConfig; + workspaceDir?: string; + env?: NodeJS.ProcessEnv; + stateDir?: string; + pluginIndexFilePath?: string; + installRecords?: Record; + cache?: boolean; + candidates?: PluginCandidate[]; + diagnostics?: PluginDiagnostic[]; + now?: () => Date; +}; + +export type RefreshInstalledPluginIndexParams = LoadInstalledPluginIndexParams & { + reason: InstalledPluginIndexRefreshReason; +}; diff --git a/src/plugins/installed-plugin-index.ts b/src/plugins/installed-plugin-index.ts index 5c98dd9d553..44f82e52e1a 100644 --- a/src/plugins/installed-plugin-index.ts +++ b/src/plugins/installed-plugin-index.ts @@ -1,637 +1,58 @@ -import crypto from "node:crypto"; -import fs from "node:fs"; -import path from "node:path"; import type { OpenClawConfig } from "../config/types.js"; -import type { PluginInstallRecord } from "../config/types.plugins.js"; import { resolveCompatibilityHostVersion } from "../version.js"; -import { listPluginCompatRecords, type PluginCompatCode } from "./compat/registry.js"; import { normalizePluginsConfig, resolveEffectiveEnableState } from "./config-state.js"; -import { discoverOpenClawPlugins, type PluginCandidate } from "./discovery.js"; +import { normalizeInstallRecordMap } from "./installed-plugin-index-install-records.js"; import { - describePluginInstallSource, - type PluginInstallSourceInfo, -} from "./install-source-info.js"; + resolveCompatRegistryVersion, + resolveInstalledPluginIndexPolicyHash, +} from "./installed-plugin-index-policy.js"; +import { buildInstalledPluginIndexRecords } from "./installed-plugin-index-record-builder.js"; +import { resolveInstalledPluginIndexRegistry } from "./installed-plugin-index-registry.js"; import { - loadPluginManifestRegistry, - type PluginManifestRecord, - type PluginManifestRegistry, -} from "./manifest-registry.js"; -import type { PluginDiagnostic } from "./manifest-types.js"; -import type { PluginPackageChannel } from "./manifest.js"; -import { safeRealpathSync } from "./path-safety.js"; -import { hasKind } from "./slots.js"; + INSTALLED_PLUGIN_INDEX_MIGRATION_VERSION, + INSTALLED_PLUGIN_INDEX_VERSION, + INSTALLED_PLUGIN_INDEX_WARNING, + type InstalledPluginIndex, + type InstalledPluginIndexRecord, + type InstalledPluginIndexRefreshReason, + type LoadInstalledPluginIndexParams, + type RefreshInstalledPluginIndexParams, +} from "./installed-plugin-index-types.js"; -export const INSTALLED_PLUGIN_INDEX_VERSION = 1; -export const INSTALLED_PLUGIN_INDEX_MIGRATION_VERSION = 1; -export const INSTALLED_PLUGIN_INDEX_WARNING = - "DO NOT EDIT. This file is generated by OpenClaw from plugin manifests, install records, and config policy. Use `openclaw plugins registry --refresh`, `openclaw plugins install/update/uninstall`, or `openclaw plugins enable/disable` instead."; - -export type InstalledPluginIndexRefreshReason = - | "missing" - | "stale-manifest" - | "stale-package" - | "source-changed" - | "policy-changed" - | "migration" - | "host-contract-changed" - | "compat-registry-changed" - | "manual"; - -export type InstalledPluginStartupInfo = { - sidecar: boolean; - memory: boolean; - deferConfiguredChannelFullLoadUntilAfterListen: boolean; - agentHarnesses: readonly string[]; -}; - -export type InstalledPluginInstallRecordInfo = Pick< - PluginInstallRecord, - | "source" - | "spec" - | "sourcePath" - | "installPath" - | "version" - | "resolvedName" - | "resolvedVersion" - | "resolvedSpec" - | "integrity" - | "shasum" - | "resolvedAt" - | "installedAt" - | "clawhubUrl" - | "clawhubPackage" - | "clawhubFamily" - | "clawhubChannel" - | "marketplaceName" - | "marketplaceSource" - | "marketplacePlugin" ->; - -export type InstalledPluginPackageChannelInfo = Pick< - PluginPackageChannel, - "id" | "label" | "blurb" | "preferOver" | "commands" ->; - -export type InstalledPluginIndexRecord = { - pluginId: string; - packageName?: string; - packageVersion?: string; - /** - * Legacy embedded install record accepted when reading earlier index files. - * New index writes keep install records in InstalledPluginIndex.installRecords. - */ - installRecord?: InstalledPluginInstallRecordInfo; - /** Hash of the top-level installRecords entry; used to detect source-changed invalidation. */ - installRecordHash?: string; - /** - * Package-authored openclaw.install metadata. This describes catalog/package - * install intent and must not be treated as the durable install record. - */ - packageInstall?: PluginInstallSourceInfo; - packageChannel?: InstalledPluginPackageChannelInfo; - manifestPath: string; - manifestHash: string; - format?: PluginManifestRecord["format"]; - bundleFormat?: PluginManifestRecord["bundleFormat"]; - source?: string; - setupSource?: string; - packageJson?: { - path: string; - hash: string; - }; - rootDir: string; - origin: PluginManifestRecord["origin"]; - enabled: boolean; - enabledByDefault?: boolean; - syntheticAuthRefs?: readonly string[]; - startup: InstalledPluginStartupInfo; - compat: readonly PluginCompatCode[]; -}; - -export type InstalledPluginIndex = { - version: typeof INSTALLED_PLUGIN_INDEX_VERSION; - warning?: string; - hostContractVersion: string; - compatRegistryVersion: string; - migrationVersion: typeof INSTALLED_PLUGIN_INDEX_MIGRATION_VERSION; - policyHash: string; - generatedAtMs: number; - refreshReason?: InstalledPluginIndexRefreshReason; - installRecords: Readonly>; - plugins: readonly InstalledPluginIndexRecord[]; - diagnostics: readonly PluginDiagnostic[]; -}; - -export type LoadInstalledPluginIndexParams = { - config?: OpenClawConfig; - workspaceDir?: string; - env?: NodeJS.ProcessEnv; - stateDir?: string; - pluginIndexFilePath?: string; - installRecords?: Record; - cache?: boolean; - candidates?: PluginCandidate[]; - diagnostics?: PluginDiagnostic[]; - now?: () => Date; -}; - -export type RefreshInstalledPluginIndexParams = LoadInstalledPluginIndexParams & { - reason: InstalledPluginIndexRefreshReason; -}; - -function hashString(value: string): string { - return crypto.createHash("sha256").update(value).digest("hex"); -} - -function hashJson(value: unknown): string { - return hashString(JSON.stringify(value)); -} - -function safeHashFile(params: { - filePath: string; - pluginId?: string; - diagnostics: PluginDiagnostic[]; - required: boolean; -}): string | undefined { - try { - return crypto.createHash("sha256").update(fs.readFileSync(params.filePath)).digest("hex"); - } catch (err) { - if (params.required) { - params.diagnostics.push({ - level: "warn", - ...(params.pluginId ? { pluginId: params.pluginId } : {}), - source: params.filePath, - message: `installed plugin index could not hash ${params.filePath}: ${ - err instanceof Error ? err.message : String(err) - }`, - }); - } - return undefined; - } -} - -function sortUnique(values: readonly string[] | undefined): readonly string[] { - if (!values || values.length === 0) { - return []; - } - return Array.from(new Set(values.map((value) => value.trim()).filter(Boolean))).toSorted( - (left, right) => left.localeCompare(right), - ); -} - -function hasRuntimeContractSurface(record: PluginManifestRecord): boolean { - const providers = record.providers ?? []; - const cliBackends = record.cliBackends ?? []; - return Boolean( - providers.length > 0 || - cliBackends.length > 0 || - record.contracts?.speechProviders?.length || - record.contracts?.mediaUnderstandingProviders?.length || - record.contracts?.documentExtractors?.length || - record.contracts?.imageGenerationProviders?.length || - record.contracts?.videoGenerationProviders?.length || - record.contracts?.musicGenerationProviders?.length || - record.contracts?.webContentExtractors?.length || - record.contracts?.webFetchProviders?.length || - record.contracts?.webSearchProviders?.length || - record.contracts?.memoryEmbeddingProviders?.length || - hasKind(record.kind, "memory"), - ); -} - -function buildStartupInfo(record: PluginManifestRecord): InstalledPluginStartupInfo { - const channels = record.channels ?? []; - return { - sidecar: channels.length === 0 && !hasRuntimeContractSurface(record), - memory: hasKind(record.kind, "memory"), - deferConfiguredChannelFullLoadUntilAfterListen: - record.startupDeferConfiguredChannelFullLoadUntilAfterListen === true, - agentHarnesses: sortUnique([ - ...(record.activation?.onAgentHarnesses ?? []), - ...(record.cliBackends ?? []), - ]), - }; -} - -function collectCompatCodes(record: PluginManifestRecord): readonly PluginCompatCode[] { - const codes: PluginCompatCode[] = []; - if (record.providerAuthEnvVars && Object.keys(record.providerAuthEnvVars).length > 0) { - codes.push("provider-auth-env-vars"); - } - if (record.channelEnvVars && Object.keys(record.channelEnvVars).length > 0) { - codes.push("channel-env-vars"); - } - if (record.activation?.onProviders?.length) { - codes.push("activation-provider-hint"); - } - if (record.activation?.onAgentHarnesses?.length) { - codes.push("activation-agent-harness-hint"); - } - if (record.activation?.onChannels?.length) { - codes.push("activation-channel-hint"); - } - if (record.activation?.onCommands?.length) { - codes.push("activation-command-hint"); - } - if (record.activation?.onRoutes?.length) { - codes.push("activation-route-hint"); - } - if (record.activation?.onCapabilities?.length) { - codes.push("activation-capability-hint"); - } - return sortUnique(codes) as readonly PluginCompatCode[]; -} - -function resolvePackageJsonPath(candidate: PluginCandidate | undefined): string | undefined { - if (!candidate?.packageDir) { - return undefined; - } - const packageDir = safeRealpathSync(candidate.packageDir) ?? path.resolve(candidate.packageDir); - const packageJsonPath = path.join(packageDir, "package.json"); - return fs.existsSync(packageJsonPath) ? packageJsonPath : undefined; -} - -function resolvePackageJsonRelativePath(rootDir: string, packageJsonPath: string): string { - const resolvedRootDir = safeRealpathSync(rootDir) ?? path.resolve(rootDir); - const relativePath = path.relative(resolvedRootDir, packageJsonPath) || "package.json"; - return relativePath.split(path.sep).join("/"); -} - -function resolvePackageJsonRecord(params: { - candidate: PluginCandidate | undefined; - packageJsonPath: string | undefined; - diagnostics: PluginDiagnostic[]; - pluginId: string; -}): InstalledPluginIndexRecord["packageJson"] | undefined { - if (!params.candidate?.packageDir || !params.packageJsonPath) { - return undefined; - } - const hash = safeHashFile({ - filePath: params.packageJsonPath, - pluginId: params.pluginId, - diagnostics: params.diagnostics, - required: false, - }); - if (!hash) { - return undefined; - } - return { - path: resolvePackageJsonRelativePath(params.candidate.rootDir, params.packageJsonPath), - hash, - }; -} - -function describePackageInstallSource( - candidate: PluginCandidate | undefined, -): PluginInstallSourceInfo | undefined { - const install = candidate?.packageManifest?.install; - if (!install) { - return undefined; - } - return describePluginInstallSource(install, { - expectedPackageName: candidate?.packageName, - }); -} - -function normalizeStringField(value: unknown): string | undefined { - if (typeof value !== "string") { - return undefined; - } - const normalized = value.trim(); - return normalized ? normalized : undefined; -} - -function normalizeStringListField(value: unknown): readonly string[] | undefined { - if (!Array.isArray(value)) { - return undefined; - } - const normalized = value - .flatMap((entry) => { - const normalizedEntry = normalizeStringField(entry); - return normalizedEntry ? [normalizedEntry] : []; - }) - .filter((entry, index, all) => all.indexOf(entry) === index); - return normalized.length > 0 ? normalized : undefined; -} - -function normalizePackageChannel( - channel: PluginPackageChannel | undefined, -): InstalledPluginPackageChannelInfo | undefined { - const id = normalizeStringField(channel?.id); - if (!id) { - return undefined; - } - const label = normalizeStringField(channel?.label); - const blurb = normalizeStringField(channel?.blurb); - const preferOver = normalizeStringListField(channel?.preferOver); - const commands = - channel?.commands && - typeof channel.commands === "object" && - !Array.isArray(channel.commands) && - (typeof channel.commands.nativeCommandsAutoEnabled === "boolean" || - typeof channel.commands.nativeSkillsAutoEnabled === "boolean") - ? { - ...(typeof channel.commands.nativeCommandsAutoEnabled === "boolean" - ? { nativeCommandsAutoEnabled: channel.commands.nativeCommandsAutoEnabled } - : {}), - ...(typeof channel.commands.nativeSkillsAutoEnabled === "boolean" - ? { nativeSkillsAutoEnabled: channel.commands.nativeSkillsAutoEnabled } - : {}), - } - : undefined; - return { - id, - ...(label ? { label } : {}), - ...(blurb ? { blurb } : {}), - ...(preferOver ? { preferOver } : {}), - ...(commands ? { commands } : {}), - }; -} - -function setInstallStringField>( - target: InstalledPluginInstallRecordInfo, - key: Key, - value: PluginInstallRecord[Key], -): void { - if (typeof value !== "string") { - return; - } - const normalized = value.trim(); - if (normalized) { - target[key] = normalized as InstalledPluginInstallRecordInfo[Key]; - } -} - -function normalizeInstallRecord( - record: PluginInstallRecord | undefined, -): InstalledPluginInstallRecordInfo | undefined { - if (!record) { - return undefined; - } - const normalized: InstalledPluginInstallRecordInfo = { - source: record.source, - }; - setInstallStringField(normalized, "spec", record.spec); - setInstallStringField(normalized, "sourcePath", record.sourcePath); - setInstallStringField(normalized, "installPath", record.installPath); - setInstallStringField(normalized, "version", record.version); - setInstallStringField(normalized, "resolvedName", record.resolvedName); - setInstallStringField(normalized, "resolvedVersion", record.resolvedVersion); - setInstallStringField(normalized, "resolvedSpec", record.resolvedSpec); - setInstallStringField(normalized, "integrity", record.integrity); - setInstallStringField(normalized, "shasum", record.shasum); - setInstallStringField(normalized, "resolvedAt", record.resolvedAt); - setInstallStringField(normalized, "installedAt", record.installedAt); - setInstallStringField(normalized, "clawhubUrl", record.clawhubUrl); - setInstallStringField(normalized, "clawhubPackage", record.clawhubPackage); - setInstallStringField(normalized, "clawhubFamily", record.clawhubFamily); - setInstallStringField(normalized, "clawhubChannel", record.clawhubChannel); - setInstallStringField(normalized, "marketplaceName", record.marketplaceName); - setInstallStringField(normalized, "marketplaceSource", record.marketplaceSource); - setInstallStringField(normalized, "marketplacePlugin", record.marketplacePlugin); - return normalized; -} - -function restoreInstallRecord( - record: InstalledPluginInstallRecordInfo | undefined, -): PluginInstallRecord | undefined { - if (!record?.source) { - return undefined; - } - return structuredClone(record) as PluginInstallRecord; -} - -function normalizeInstallRecordMap( - records: Record | undefined, -): Record { - const normalized: Record = {}; - for (const [pluginId, record] of Object.entries(records ?? {}).toSorted(([left], [right]) => - left.localeCompare(right), - )) { - const installRecord = normalizeInstallRecord(record); - if (installRecord) { - normalized[pluginId] = installRecord; - } - } - return normalized; -} - -function restoreInstallRecordMap( - records: Readonly> | undefined, -): Record { - const restored: Record = {}; - for (const [pluginId, record] of Object.entries(records ?? {}).toSorted(([left], [right]) => - left.localeCompare(right), - )) { - const installRecord = restoreInstallRecord(record); - if (installRecord) { - restored[pluginId] = installRecord; - } - } - return restored; -} - -export function extractPluginInstallRecordsFromInstalledPluginIndex( - index: InstalledPluginIndex | null | undefined, -): Record { - if (index && Object.prototype.hasOwnProperty.call(index, "installRecords")) { - return restoreInstallRecordMap(index.installRecords); - } - const records: Record = {}; - for (const plugin of index?.plugins ?? []) { - const record = restoreInstallRecord(plugin.installRecord); - if (record) { - records[plugin.pluginId] = record; - } - } - return records; -} - -function buildCandidateLookup( - candidates: readonly PluginCandidate[], -): Map { - const byRootDir = new Map(); - for (const candidate of candidates) { - byRootDir.set(candidate.rootDir, candidate); - } - return byRootDir; -} - -function resolveCompatRegistryVersion(): string { - return hashJson( - listPluginCompatRecords().map((record) => ({ - code: record.code, - status: record.status, - deprecated: record.deprecated, - warningStarts: record.warningStarts, - removeAfter: record.removeAfter, - replacement: record.replacement, - })), - ); -} - -export function resolveInstalledPluginIndexPolicyHash(config: OpenClawConfig | undefined): string { - const normalized = normalizePluginsConfig(config?.plugins); - const channelPolicy: Record = {}; - const channels = config?.channels; - if (channels && typeof channels === "object" && !Array.isArray(channels)) { - for (const [channelId, value] of Object.entries(channels)) { - if (value && typeof value === "object" && !Array.isArray(value)) { - const enabled = (value as Record).enabled; - if (typeof enabled === "boolean") { - channelPolicy[channelId] = enabled; - } - } - } - } - return hashJson({ - plugins: { - enabled: normalized.enabled, - allow: normalized.allow, - deny: normalized.deny, - slots: normalized.slots, - entries: Object.fromEntries( - Object.entries(normalized.entries) - .flatMap(([pluginId, entry]) => - typeof entry.enabled === "boolean" ? [[pluginId, entry.enabled] as const] : [], - ) - .toSorted(([left], [right]) => left.localeCompare(right)), - ), - }, - channels: Object.fromEntries( - Object.entries(channelPolicy).toSorted(([left], [right]) => left.localeCompare(right)), - ), - }); -} - -function resolveRegistry(params: LoadInstalledPluginIndexParams): { - registry: PluginManifestRegistry; - candidates: readonly PluginCandidate[]; -} { - if (params.candidates) { - return { - candidates: params.candidates, - registry: loadPluginManifestRegistry({ - config: params.config, - workspaceDir: params.workspaceDir, - cache: false, - env: params.env, - candidates: params.candidates, - diagnostics: params.diagnostics, - installRecords: params.installRecords, - }), - }; - } - - const normalized = normalizePluginsConfig(params.config?.plugins); - const discovery = discoverOpenClawPlugins({ - workspaceDir: params.workspaceDir, - extraPaths: normalized.loadPaths, - cache: params.cache, - env: params.env, - }); - return { - candidates: discovery.candidates, - registry: loadPluginManifestRegistry({ - config: params.config, - workspaceDir: params.workspaceDir, - cache: false, - env: params.env, - candidates: discovery.candidates, - diagnostics: discovery.diagnostics, - installRecords: params.installRecords, - }), - }; -} +export { + INSTALLED_PLUGIN_INDEX_MIGRATION_VERSION, + INSTALLED_PLUGIN_INDEX_VERSION, + INSTALLED_PLUGIN_INDEX_WARNING, +} from "./installed-plugin-index-types.js"; +export type { + InstalledPluginIndex, + InstalledPluginIndexRecord, + InstalledPluginIndexRefreshReason, + InstalledPluginInstallRecordInfo, + InstalledPluginPackageChannelInfo, + InstalledPluginStartupInfo, + LoadInstalledPluginIndexParams, + RefreshInstalledPluginIndexParams, +} from "./installed-plugin-index-types.js"; +export { extractPluginInstallRecordsFromInstalledPluginIndex } from "./installed-plugin-index-install-records.js"; +export { diffInstalledPluginIndexInvalidationReasons } from "./installed-plugin-index-invalidation.js"; +export { resolveInstalledPluginIndexPolicyHash } from "./installed-plugin-index-policy.js"; function buildInstalledPluginIndex( params: LoadInstalledPluginIndexParams & { refreshReason?: InstalledPluginIndexRefreshReason }, ): InstalledPluginIndex { const env = params.env ?? process.env; - const { candidates, registry } = resolveRegistry(params); - const candidateByRootDir = buildCandidateLookup(candidates); - const normalizedConfig = normalizePluginsConfig(params.config?.plugins); + const { candidates, registry } = resolveInstalledPluginIndexRegistry(params); const registryDiagnostics = registry.diagnostics ?? []; - const diagnostics: PluginDiagnostic[] = [...registryDiagnostics]; + const diagnostics = [...registryDiagnostics]; const generatedAtMs = (params.now?.() ?? new Date()).getTime(); const installRecords = normalizeInstallRecordMap(params.installRecords); - const plugins = registry.plugins.map((record): InstalledPluginIndexRecord => { - const candidate = candidateByRootDir.get(record.rootDir); - const packageJsonPath = resolvePackageJsonPath(candidate); - const installRecord = installRecords[record.id]; - const packageInstall = describePackageInstallSource(candidate); - const packageChannel = normalizePackageChannel(candidate?.packageManifest?.channel); - const manifestHash = - safeHashFile({ - filePath: record.manifestPath, - pluginId: record.id, - diagnostics, - required: true, - }) ?? ""; - const packageJson = resolvePackageJsonRecord({ - candidate, - packageJsonPath, - diagnostics, - pluginId: record.id, - }); - const enabled = resolveEffectiveEnableState({ - id: record.id, - origin: record.origin, - config: normalizedConfig, - rootConfig: params.config, - enabledByDefault: record.enabledByDefault, - }).enabled; - const indexRecord: InstalledPluginIndexRecord = { - pluginId: record.id, - manifestPath: record.manifestPath, - manifestHash, - source: record.source, - rootDir: record.rootDir, - origin: record.origin, - enabled, - startup: buildStartupInfo(record), - compat: collectCompatCodes(record), - }; - if (record.syntheticAuthRefs && record.syntheticAuthRefs.length > 0) { - indexRecord.syntheticAuthRefs = record.syntheticAuthRefs; - } - if (record.format && record.format !== "openclaw") { - indexRecord.format = record.format; - } - if (record.bundleFormat) { - indexRecord.bundleFormat = record.bundleFormat; - } - if (record.enabledByDefault === true) { - indexRecord.enabledByDefault = true; - } - if (record.syntheticAuthRefs && record.syntheticAuthRefs.length > 0) { - indexRecord.syntheticAuthRefs = record.syntheticAuthRefs; - } - if (record.setupSource) { - indexRecord.setupSource = record.setupSource; - } - if (record.syntheticAuthRefs && record.syntheticAuthRefs.length > 0) { - indexRecord.syntheticAuthRefs = record.syntheticAuthRefs; - } - if (candidate?.packageName) { - indexRecord.packageName = candidate.packageName; - } - if (candidate?.packageVersion) { - indexRecord.packageVersion = candidate.packageVersion; - } - if (installRecord) { - indexRecord.installRecordHash = hashJson(installRecord); - } - if (packageInstall) { - indexRecord.packageInstall = packageInstall; - } - if (packageChannel) { - indexRecord.packageChannel = packageChannel; - } - if (packageJson) { - indexRecord.packageJson = packageJson; - } - return indexRecord; + const plugins = buildInstalledPluginIndexRecords({ + candidates, + registry, + config: params.config, + diagnostics, + installRecords, }); return { @@ -715,69 +136,3 @@ export function isInstalledPluginEnabled( enabledByDefault: record.enabledByDefault, }).enabled; } - -export function diffInstalledPluginIndexInvalidationReasons( - previous: InstalledPluginIndex, - current: InstalledPluginIndex, -): readonly InstalledPluginIndexRefreshReason[] { - const reasons = new Set(); - if (previous.version !== current.version) { - reasons.add("missing"); - } - if (previous.hostContractVersion !== current.hostContractVersion) { - reasons.add("host-contract-changed"); - } - if (previous.compatRegistryVersion !== current.compatRegistryVersion) { - reasons.add("compat-registry-changed"); - } - if (previous.migrationVersion !== current.migrationVersion) { - reasons.add("migration"); - } - if (previous.policyHash !== current.policyHash) { - reasons.add("policy-changed"); - } - if (hashJson(previous.installRecords ?? {}) !== hashJson(current.installRecords ?? {})) { - reasons.add("source-changed"); - } - - const previousByPluginId = new Map(previous.plugins.map((plugin) => [plugin.pluginId, plugin])); - const currentByPluginId = new Map(current.plugins.map((plugin) => [plugin.pluginId, plugin])); - for (const [pluginId, previousPlugin] of previousByPluginId) { - const currentPlugin = currentByPluginId.get(pluginId); - if (!currentPlugin) { - reasons.add("source-changed"); - continue; - } - if ( - previousPlugin.rootDir !== currentPlugin.rootDir || - previousPlugin.manifestPath !== currentPlugin.manifestPath || - previousPlugin.installRecordHash !== currentPlugin.installRecordHash - ) { - reasons.add("source-changed"); - } - if (previousPlugin.enabled !== currentPlugin.enabled) { - reasons.add("policy-changed"); - } - if (previousPlugin.manifestHash !== currentPlugin.manifestHash) { - reasons.add("stale-manifest"); - } - if ( - previousPlugin.packageVersion !== currentPlugin.packageVersion || - previousPlugin.packageJson?.path !== currentPlugin.packageJson?.path || - previousPlugin.packageJson?.hash !== currentPlugin.packageJson?.hash - ) { - reasons.add("stale-package"); - } - } - for (const pluginId of currentByPluginId.keys()) { - if (!previousByPluginId.has(pluginId)) { - const currentPlugin = currentByPluginId.get(pluginId); - if (currentPlugin?.enabled === false) { - continue; - } - reasons.add("source-changed"); - } - } - - return Array.from(reasons).toSorted((left, right) => left.localeCompare(right)); -}