refactor(plugins): split installed plugin index modules

This commit is contained in:
Vincent Koc
2026-04-26 11:44:23 -07:00
parent 487f8c5d3a
commit 71e361af8a
8 changed files with 762 additions and 686 deletions

View File

@@ -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;
}
}

View File

@@ -0,0 +1,104 @@
import type { PluginInstallRecord } from "../config/types.plugins.js";
import type {
InstalledPluginIndex,
InstalledPluginInstallRecordInfo,
} from "./installed-plugin-index-types.js";
function setInstallStringField<Key extends keyof Omit<InstalledPluginInstallRecordInfo, "source">>(
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<string, PluginInstallRecord> | undefined,
): Record<string, InstalledPluginInstallRecordInfo> {
const normalized: Record<string, InstalledPluginInstallRecordInfo> = {};
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<Record<string, InstalledPluginInstallRecordInfo>> | undefined,
): Record<string, PluginInstallRecord> {
const restored: Record<string, PluginInstallRecord> = {};
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<string, PluginInstallRecord> {
if (index && Object.prototype.hasOwnProperty.call(index, "installRecords")) {
return restoreInstallRecordMap(index.installRecords);
}
const records: Record<string, PluginInstallRecord> = {};
for (const plugin of index?.plugins ?? []) {
const record = restoreInstallRecord(plugin.installRecord);
if (record) {
records[plugin.pluginId] = record;
}
}
return records;
}

View File

@@ -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<InstalledPluginIndexRefreshReason>();
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));
}

View File

@@ -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<string, boolean> = {};
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<string, unknown>).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)),
),
});
}

View File

@@ -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<string, PluginCandidate> {
const byRootDir = new Map<string, PluginCandidate>();
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<string, InstalledPluginInstallRecordInfo>;
}): 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;
});
}

View File

@@ -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,
}),
};
}

View File

@@ -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<Record<string, InstalledPluginInstallRecordInfo>>;
plugins: readonly InstalledPluginIndexRecord[];
diagnostics: readonly PluginDiagnostic[];
};
export type LoadInstalledPluginIndexParams = {
config?: OpenClawConfig;
workspaceDir?: string;
env?: NodeJS.ProcessEnv;
stateDir?: string;
pluginIndexFilePath?: string;
installRecords?: Record<string, PluginInstallRecord>;
cache?: boolean;
candidates?: PluginCandidate[];
diagnostics?: PluginDiagnostic[];
now?: () => Date;
};
export type RefreshInstalledPluginIndexParams = LoadInstalledPluginIndexParams & {
reason: InstalledPluginIndexRefreshReason;
};

View File

@@ -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<Record<string, InstalledPluginInstallRecordInfo>>;
plugins: readonly InstalledPluginIndexRecord[];
diagnostics: readonly PluginDiagnostic[];
};
export type LoadInstalledPluginIndexParams = {
config?: OpenClawConfig;
workspaceDir?: string;
env?: NodeJS.ProcessEnv;
stateDir?: string;
pluginIndexFilePath?: string;
installRecords?: Record<string, PluginInstallRecord>;
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<Key extends keyof Omit<InstalledPluginInstallRecordInfo, "source">>(
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<string, PluginInstallRecord> | undefined,
): Record<string, InstalledPluginInstallRecordInfo> {
const normalized: Record<string, InstalledPluginInstallRecordInfo> = {};
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<Record<string, InstalledPluginInstallRecordInfo>> | undefined,
): Record<string, PluginInstallRecord> {
const restored: Record<string, PluginInstallRecord> = {};
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<string, PluginInstallRecord> {
if (index && Object.prototype.hasOwnProperty.call(index, "installRecords")) {
return restoreInstallRecordMap(index.installRecords);
}
const records: Record<string, PluginInstallRecord> = {};
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<string, PluginCandidate> {
const byRootDir = new Map<string, PluginCandidate>();
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<string, boolean> = {};
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<string, unknown>).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<InstalledPluginIndexRefreshReason>();
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));
}