mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 05:40:44 +00:00
feat(plugins): add installed plugin index
This commit is contained in:
279
src/plugins/installed-plugin-index.test.ts
Normal file
279
src/plugins/installed-plugin-index.test.ts
Normal file
@@ -0,0 +1,279 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import type { PluginCandidate } from "./discovery.js";
|
||||
import {
|
||||
diffInstalledPluginIndexInvalidationReasons,
|
||||
loadInstalledPluginIndex,
|
||||
refreshInstalledPluginIndex,
|
||||
resolveInstalledPluginContributions,
|
||||
} from "./installed-plugin-index.js";
|
||||
import type { OpenClawPackageManifest } from "./manifest.js";
|
||||
import { cleanupTrackedTempDirs, makeTrackedTempDir } from "./test-helpers/fs-fixtures.js";
|
||||
|
||||
vi.unmock("../version.js");
|
||||
|
||||
const tempDirs: string[] = [];
|
||||
|
||||
afterEach(() => {
|
||||
cleanupTrackedTempDirs(tempDirs);
|
||||
});
|
||||
|
||||
function makeTempDir() {
|
||||
return makeTrackedTempDir("openclaw-installed-plugin-index", tempDirs);
|
||||
}
|
||||
|
||||
function writePluginManifest(rootDir: string, manifest: Record<string, unknown>) {
|
||||
fs.writeFileSync(path.join(rootDir, "openclaw.plugin.json"), JSON.stringify(manifest), "utf-8");
|
||||
}
|
||||
|
||||
function writePackageJson(rootDir: string, packageJson: Record<string, unknown>) {
|
||||
fs.writeFileSync(path.join(rootDir, "package.json"), JSON.stringify(packageJson), "utf-8");
|
||||
}
|
||||
|
||||
function writeRuntimeEntry(rootDir: string) {
|
||||
fs.writeFileSync(
|
||||
path.join(rootDir, "index.ts"),
|
||||
"throw new Error('runtime entry should not load while building installed plugin index');\n",
|
||||
"utf-8",
|
||||
);
|
||||
}
|
||||
|
||||
function hermeticEnv(overrides: NodeJS.ProcessEnv = {}): NodeJS.ProcessEnv {
|
||||
return {
|
||||
OPENCLAW_BUNDLED_PLUGINS_DIR: undefined,
|
||||
OPENCLAW_DISABLE_PLUGIN_DISCOVERY_CACHE: "1",
|
||||
OPENCLAW_DISABLE_PLUGIN_MANIFEST_CACHE: "1",
|
||||
OPENCLAW_VERSION: "2026.4.25",
|
||||
VITEST: "true",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function createPluginCandidate(params: {
|
||||
rootDir: string;
|
||||
idHint?: string;
|
||||
origin?: PluginCandidate["origin"];
|
||||
packageName?: string;
|
||||
packageVersion?: string;
|
||||
packageManifest?: OpenClawPackageManifest;
|
||||
}): PluginCandidate {
|
||||
return {
|
||||
idHint: params.idHint ?? "demo",
|
||||
source: path.join(params.rootDir, "index.ts"),
|
||||
rootDir: params.rootDir,
|
||||
origin: params.origin ?? "global",
|
||||
packageName: params.packageName,
|
||||
packageVersion: params.packageVersion,
|
||||
packageDir: params.rootDir,
|
||||
packageManifest: params.packageManifest,
|
||||
};
|
||||
}
|
||||
|
||||
function createRichPluginFixture(params: { packageVersion?: string } = {}) {
|
||||
const rootDir = makeTempDir();
|
||||
writeRuntimeEntry(rootDir);
|
||||
writePackageJson(rootDir, {
|
||||
name: "@vendor/demo-plugin",
|
||||
version: params.packageVersion ?? "1.2.3",
|
||||
});
|
||||
writePluginManifest(rootDir, {
|
||||
id: "demo",
|
||||
name: "Demo",
|
||||
configSchema: { type: "object" },
|
||||
providers: ["demo"],
|
||||
channels: ["demo-chat"],
|
||||
cliBackends: ["demo-cli"],
|
||||
channelConfigs: {
|
||||
"demo-chat": {
|
||||
schema: { type: "object" },
|
||||
},
|
||||
},
|
||||
modelCatalog: {
|
||||
providers: {
|
||||
demo: {
|
||||
models: [{ id: "demo-model" }],
|
||||
},
|
||||
},
|
||||
discovery: {
|
||||
demo: "static",
|
||||
},
|
||||
},
|
||||
setup: {
|
||||
providers: [{ id: "demo", envVars: ["DEMO_API_KEY"] }],
|
||||
cliBackends: ["setup-cli"],
|
||||
},
|
||||
commandAliases: [{ name: "demo-command" }],
|
||||
contracts: {
|
||||
tools: ["demo-tool"],
|
||||
},
|
||||
providerAuthEnvVars: {
|
||||
demo: ["DEMO_API_KEY"],
|
||||
},
|
||||
channelEnvVars: {
|
||||
"demo-chat": ["DEMO_CHAT_TOKEN"],
|
||||
},
|
||||
activation: {
|
||||
onProviders: ["demo"],
|
||||
onChannels: ["demo-chat"],
|
||||
},
|
||||
});
|
||||
return {
|
||||
rootDir,
|
||||
candidate: createPluginCandidate({
|
||||
rootDir,
|
||||
packageName: "@vendor/demo-plugin",
|
||||
packageVersion: params.packageVersion ?? "1.2.3",
|
||||
packageManifest: {
|
||||
install: {
|
||||
npmSpec: "@vendor/demo-plugin@1.2.3",
|
||||
expectedIntegrity: "sha512-demo",
|
||||
defaultChoice: "npm",
|
||||
},
|
||||
},
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
describe("installed plugin index", () => {
|
||||
it("builds a runtime-free installed plugin snapshot from manifest and package metadata", () => {
|
||||
const fixture = createRichPluginFixture();
|
||||
|
||||
const index = loadInstalledPluginIndex({
|
||||
candidates: [fixture.candidate],
|
||||
env: hermeticEnv(),
|
||||
now: () => new Date("2026-04-25T12:00:00.000Z"),
|
||||
});
|
||||
|
||||
expect(index).toMatchObject({
|
||||
version: 1,
|
||||
generatedAt: "2026-04-25T12:00:00.000Z",
|
||||
plugins: [
|
||||
{
|
||||
pluginId: "demo",
|
||||
packageName: "@vendor/demo-plugin",
|
||||
packageVersion: "1.2.3",
|
||||
origin: "global",
|
||||
rootDir: fixture.rootDir,
|
||||
enabled: true,
|
||||
sourceFacts: {
|
||||
defaultChoice: "npm",
|
||||
npm: {
|
||||
spec: "@vendor/demo-plugin@1.2.3",
|
||||
packageName: "@vendor/demo-plugin",
|
||||
selector: "1.2.3",
|
||||
selectorKind: "exact-version",
|
||||
exactVersion: true,
|
||||
expectedIntegrity: "sha512-demo",
|
||||
pinState: "exact-with-integrity",
|
||||
},
|
||||
warnings: [],
|
||||
},
|
||||
contributions: {
|
||||
providers: ["demo"],
|
||||
channels: ["demo-chat"],
|
||||
channelConfigs: ["demo-chat"],
|
||||
setupProviders: ["demo"],
|
||||
cliBackends: ["demo-cli", "setup-cli"],
|
||||
modelCatalogProviders: ["demo"],
|
||||
commandAliases: ["demo-command"],
|
||||
contracts: ["tools"],
|
||||
},
|
||||
compat: [
|
||||
"activation-channel-hint",
|
||||
"activation-provider-hint",
|
||||
"channel-env-vars",
|
||||
"provider-auth-env-vars",
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(index.plugins[0]?.manifestHash).toMatch(/^[a-f0-9]{64}$/u);
|
||||
expect(index.plugins[0]?.packageJsonHash).toMatch(/^[a-f0-9]{64}$/u);
|
||||
expect(index.plugins[0]?.packageJsonPath).toBe(path.join(fixture.rootDir, "package.json"));
|
||||
|
||||
const contributions = resolveInstalledPluginContributions(index);
|
||||
expect(contributions.providers.get("demo")).toEqual(["demo"]);
|
||||
expect(contributions.channels.get("demo-chat")).toEqual(["demo"]);
|
||||
expect(contributions.contracts.get("tools")).toEqual(["demo"]);
|
||||
});
|
||||
|
||||
it("marks disabled plugins without dropping their cold contributions", () => {
|
||||
const fixture = createRichPluginFixture();
|
||||
|
||||
const index = loadInstalledPluginIndex({
|
||||
candidates: [fixture.candidate],
|
||||
config: {
|
||||
plugins: {
|
||||
entries: {
|
||||
demo: {
|
||||
enabled: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
env: hermeticEnv(),
|
||||
});
|
||||
|
||||
expect(index.plugins[0]?.enabled).toBe(false);
|
||||
expect(index.plugins[0]?.contributions.providers).toEqual(["demo"]);
|
||||
});
|
||||
|
||||
it("tracks refresh reason without using the manifest cache", () => {
|
||||
const fixture = createRichPluginFixture();
|
||||
|
||||
const index = refreshInstalledPluginIndex({
|
||||
reason: "manual",
|
||||
candidates: [fixture.candidate],
|
||||
env: hermeticEnv(),
|
||||
});
|
||||
|
||||
expect(index.refreshReason).toBe("manual");
|
||||
});
|
||||
|
||||
it("diffs invalidation reasons for manifest, package, source, host, and compat changes", () => {
|
||||
const fixture = createRichPluginFixture();
|
||||
const previous = loadInstalledPluginIndex({
|
||||
candidates: [fixture.candidate],
|
||||
env: hermeticEnv({ OPENCLAW_VERSION: "2026.4.25" }),
|
||||
});
|
||||
|
||||
writePackageJson(fixture.rootDir, {
|
||||
name: "@vendor/demo-plugin",
|
||||
version: "1.2.4",
|
||||
});
|
||||
writePluginManifest(fixture.rootDir, {
|
||||
id: "demo",
|
||||
configSchema: { type: "object" },
|
||||
providers: ["demo", "demo-next"],
|
||||
});
|
||||
const current = {
|
||||
...loadInstalledPluginIndex({
|
||||
candidates: [
|
||||
{
|
||||
...fixture.candidate,
|
||||
packageVersion: "1.2.4",
|
||||
},
|
||||
],
|
||||
env: hermeticEnv({ OPENCLAW_VERSION: "2026.4.26" }),
|
||||
}),
|
||||
compatRegistryVersion: "different-compat-registry",
|
||||
};
|
||||
|
||||
expect(diffInstalledPluginIndexInvalidationReasons(previous, current)).toEqual([
|
||||
"compat-registry-changed",
|
||||
"host-contract-changed",
|
||||
"stale-manifest",
|
||||
"stale-package",
|
||||
]);
|
||||
|
||||
const moved = {
|
||||
...current,
|
||||
plugins: current.plugins.map((plugin) => ({
|
||||
...plugin,
|
||||
rootDir: path.join(plugin.rootDir, "moved"),
|
||||
})),
|
||||
};
|
||||
expect(diffInstalledPluginIndexInvalidationReasons(current, moved)).toContain("source-changed");
|
||||
});
|
||||
});
|
||||
486
src/plugins/installed-plugin-index.ts
Normal file
486
src/plugins/installed-plugin-index.ts
Normal file
@@ -0,0 +1,486 @@
|
||||
import crypto from "node:crypto";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import type { OpenClawConfig } from "../config/types.js";
|
||||
import { resolveCompatibilityHostVersion } from "../version.js";
|
||||
import { listPluginCompatRecords, type PluginCompatCode } from "./compat/registry.js";
|
||||
import {
|
||||
normalizePluginsConfigWithResolver,
|
||||
resolveEffectiveEnableState,
|
||||
} from "./config-policy.js";
|
||||
import { discoverOpenClawPlugins, type PluginCandidate } from "./discovery.js";
|
||||
import {
|
||||
describePluginInstallSource,
|
||||
type PluginInstallSourceInfo,
|
||||
} from "./install-source-info.js";
|
||||
import type { PluginManifestCommandAlias } from "./manifest-command-aliases.js";
|
||||
import {
|
||||
loadPluginManifestRegistry,
|
||||
type PluginManifestRecord,
|
||||
type PluginManifestRegistry,
|
||||
} from "./manifest-registry.js";
|
||||
import type { PluginDiagnostic } from "./manifest-types.js";
|
||||
|
||||
export const INSTALLED_PLUGIN_INDEX_VERSION = 1;
|
||||
|
||||
export type InstalledPluginIndexRefreshReason =
|
||||
| "missing"
|
||||
| "stale-manifest"
|
||||
| "stale-package"
|
||||
| "source-changed"
|
||||
| "host-contract-changed"
|
||||
| "compat-registry-changed"
|
||||
| "manual";
|
||||
|
||||
export type InstalledPluginIndexContributions = {
|
||||
providers: readonly string[];
|
||||
channels: readonly string[];
|
||||
channelConfigs: readonly string[];
|
||||
setupProviders: readonly string[];
|
||||
cliBackends: readonly string[];
|
||||
modelCatalogProviders: readonly string[];
|
||||
commandAliases: readonly string[];
|
||||
contracts: readonly string[];
|
||||
};
|
||||
|
||||
export type InstalledPluginIndexRecord = {
|
||||
pluginId: string;
|
||||
packageName?: string;
|
||||
packageVersion?: string;
|
||||
sourceFacts?: PluginInstallSourceInfo;
|
||||
manifestPath: string;
|
||||
manifestHash: string;
|
||||
packageJsonPath?: string;
|
||||
packageJsonHash?: string;
|
||||
rootDir: string;
|
||||
origin: PluginManifestRecord["origin"];
|
||||
enabled: boolean;
|
||||
contributions: InstalledPluginIndexContributions;
|
||||
compat: readonly PluginCompatCode[];
|
||||
};
|
||||
|
||||
export type InstalledPluginIndex = {
|
||||
version: typeof INSTALLED_PLUGIN_INDEX_VERSION;
|
||||
hostContractVersion: string;
|
||||
compatRegistryVersion: string;
|
||||
generatedAt: string;
|
||||
refreshReason?: InstalledPluginIndexRefreshReason;
|
||||
plugins: readonly InstalledPluginIndexRecord[];
|
||||
diagnostics: readonly PluginDiagnostic[];
|
||||
};
|
||||
|
||||
export type InstalledPluginContributions = {
|
||||
providers: ReadonlyMap<string, readonly string[]>;
|
||||
channels: ReadonlyMap<string, readonly string[]>;
|
||||
channelConfigs: ReadonlyMap<string, readonly string[]>;
|
||||
setupProviders: ReadonlyMap<string, readonly string[]>;
|
||||
cliBackends: ReadonlyMap<string, readonly string[]>;
|
||||
modelCatalogProviders: ReadonlyMap<string, readonly string[]>;
|
||||
commandAliases: ReadonlyMap<string, readonly string[]>;
|
||||
contracts: ReadonlyMap<string, readonly string[]>;
|
||||
};
|
||||
|
||||
export type LoadInstalledPluginIndexParams = {
|
||||
config?: OpenClawConfig;
|
||||
workspaceDir?: string;
|
||||
env?: NodeJS.ProcessEnv;
|
||||
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 collectObjectKeys(value: Record<string, unknown> | undefined): readonly string[] {
|
||||
return sortUnique(value ? Object.keys(value) : []);
|
||||
}
|
||||
|
||||
function collectCommandAliasNames(
|
||||
aliases: readonly PluginManifestCommandAlias[] | undefined,
|
||||
): readonly string[] {
|
||||
return sortUnique(aliases?.map((alias) => alias.name) ?? []);
|
||||
}
|
||||
|
||||
function collectContractKeys(record: PluginManifestRecord): readonly string[] {
|
||||
const contracts = record.contracts;
|
||||
if (!contracts) {
|
||||
return [];
|
||||
}
|
||||
return sortUnique(
|
||||
Object.entries(contracts).flatMap(([key, value]) =>
|
||||
Array.isArray(value) && value.length > 0 ? [key] : [],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
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?.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 buildContributions(record: PluginManifestRecord): InstalledPluginIndexContributions {
|
||||
return {
|
||||
providers: sortUnique(record.providers),
|
||||
channels: sortUnique(record.channels),
|
||||
channelConfigs: collectObjectKeys(record.channelConfigs),
|
||||
setupProviders: sortUnique(record.setup?.providers?.map((provider) => provider.id) ?? []),
|
||||
cliBackends: sortUnique([...(record.cliBackends ?? []), ...(record.setup?.cliBackends ?? [])]),
|
||||
modelCatalogProviders: collectObjectKeys(record.modelCatalog?.providers),
|
||||
commandAliases: collectCommandAliasNames(record.commandAliases),
|
||||
contracts: collectContractKeys(record),
|
||||
};
|
||||
}
|
||||
|
||||
function resolvePackageJsonPath(candidate: PluginCandidate | undefined): string | undefined {
|
||||
if (!candidate?.packageDir) {
|
||||
return undefined;
|
||||
}
|
||||
const packageJsonPath = path.join(candidate.packageDir, "package.json");
|
||||
return fs.existsSync(packageJsonPath) ? packageJsonPath : undefined;
|
||||
}
|
||||
|
||||
function describePackageInstallSource(
|
||||
candidate: PluginCandidate | undefined,
|
||||
): PluginInstallSourceInfo | undefined {
|
||||
const install = candidate?.packageManifest?.install;
|
||||
if (!install) {
|
||||
return undefined;
|
||||
}
|
||||
return describePluginInstallSource(install, {
|
||||
expectedPackageName: candidate?.packageName,
|
||||
});
|
||||
}
|
||||
|
||||
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,
|
||||
})),
|
||||
);
|
||||
}
|
||||
|
||||
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,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
const normalized = normalizePluginsConfigWithResolver(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,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
function buildInstalledPluginIndex(
|
||||
params: LoadInstalledPluginIndexParams & { refreshReason?: InstalledPluginIndexRefreshReason },
|
||||
): InstalledPluginIndex {
|
||||
const env = params.env ?? process.env;
|
||||
const { candidates, registry } = resolveRegistry(params);
|
||||
const candidateByRootDir = buildCandidateLookup(candidates);
|
||||
const normalizedConfig = normalizePluginsConfigWithResolver(params.config?.plugins);
|
||||
const diagnostics: PluginDiagnostic[] = [...registry.diagnostics];
|
||||
const generatedAt = (params.now?.() ?? new Date()).toISOString();
|
||||
const plugins = registry.plugins.map((record): InstalledPluginIndexRecord => {
|
||||
const candidate = candidateByRootDir.get(record.rootDir);
|
||||
const packageJsonPath = resolvePackageJsonPath(candidate);
|
||||
const sourceFacts = describePackageInstallSource(candidate);
|
||||
const manifestHash =
|
||||
safeHashFile({
|
||||
filePath: record.manifestPath,
|
||||
pluginId: record.id,
|
||||
diagnostics,
|
||||
required: true,
|
||||
}) ?? "";
|
||||
const packageJsonHash = packageJsonPath
|
||||
? safeHashFile({
|
||||
filePath: packageJsonPath,
|
||||
pluginId: record.id,
|
||||
diagnostics,
|
||||
required: false,
|
||||
})
|
||||
: undefined;
|
||||
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,
|
||||
rootDir: record.rootDir,
|
||||
origin: record.origin,
|
||||
enabled,
|
||||
contributions: buildContributions(record),
|
||||
compat: collectCompatCodes(record),
|
||||
};
|
||||
if (candidate?.packageName) {
|
||||
indexRecord.packageName = candidate.packageName;
|
||||
}
|
||||
if (candidate?.packageVersion) {
|
||||
indexRecord.packageVersion = candidate.packageVersion;
|
||||
}
|
||||
if (sourceFacts) {
|
||||
indexRecord.sourceFacts = sourceFacts;
|
||||
}
|
||||
if (packageJsonPath) {
|
||||
indexRecord.packageJsonPath = packageJsonPath;
|
||||
}
|
||||
if (packageJsonHash) {
|
||||
indexRecord.packageJsonHash = packageJsonHash;
|
||||
}
|
||||
return indexRecord;
|
||||
});
|
||||
|
||||
return {
|
||||
version: INSTALLED_PLUGIN_INDEX_VERSION,
|
||||
hostContractVersion: resolveCompatibilityHostVersion(env),
|
||||
compatRegistryVersion: resolveCompatRegistryVersion(),
|
||||
generatedAt,
|
||||
...(params.refreshReason ? { refreshReason: params.refreshReason } : {}),
|
||||
plugins,
|
||||
diagnostics,
|
||||
};
|
||||
}
|
||||
|
||||
export function loadInstalledPluginIndex(
|
||||
params: LoadInstalledPluginIndexParams = {},
|
||||
): InstalledPluginIndex {
|
||||
return buildInstalledPluginIndex(params);
|
||||
}
|
||||
|
||||
export function refreshInstalledPluginIndex(
|
||||
params: RefreshInstalledPluginIndexParams,
|
||||
): InstalledPluginIndex {
|
||||
return buildInstalledPluginIndex({ ...params, cache: false, refreshReason: params.reason });
|
||||
}
|
||||
|
||||
function addContribution(
|
||||
target: Map<string, string[]>,
|
||||
contributionId: string,
|
||||
pluginId: string,
|
||||
): void {
|
||||
const existing = target.get(contributionId);
|
||||
if (existing) {
|
||||
existing.push(pluginId);
|
||||
} else {
|
||||
target.set(contributionId, [pluginId]);
|
||||
}
|
||||
}
|
||||
|
||||
function freezeContributionMap(
|
||||
source: Map<string, string[]>,
|
||||
): ReadonlyMap<string, readonly string[]> {
|
||||
const frozen = new Map<string, readonly string[]>();
|
||||
for (const [key, pluginIds] of source) {
|
||||
frozen.set(key, sortUnique(pluginIds));
|
||||
}
|
||||
return frozen;
|
||||
}
|
||||
|
||||
export function resolveInstalledPluginContributions(
|
||||
index: InstalledPluginIndex,
|
||||
): InstalledPluginContributions {
|
||||
const providers = new Map<string, string[]>();
|
||||
const channels = new Map<string, string[]>();
|
||||
const channelConfigs = new Map<string, string[]>();
|
||||
const setupProviders = new Map<string, string[]>();
|
||||
const cliBackends = new Map<string, string[]>();
|
||||
const modelCatalogProviders = new Map<string, string[]>();
|
||||
const commandAliases = new Map<string, string[]>();
|
||||
const contracts = new Map<string, string[]>();
|
||||
|
||||
for (const plugin of index.plugins) {
|
||||
for (const provider of plugin.contributions.providers) {
|
||||
addContribution(providers, provider, plugin.pluginId);
|
||||
}
|
||||
for (const channel of plugin.contributions.channels) {
|
||||
addContribution(channels, channel, plugin.pluginId);
|
||||
}
|
||||
for (const channelConfig of plugin.contributions.channelConfigs) {
|
||||
addContribution(channelConfigs, channelConfig, plugin.pluginId);
|
||||
}
|
||||
for (const setupProvider of plugin.contributions.setupProviders) {
|
||||
addContribution(setupProviders, setupProvider, plugin.pluginId);
|
||||
}
|
||||
for (const cliBackend of plugin.contributions.cliBackends) {
|
||||
addContribution(cliBackends, cliBackend, plugin.pluginId);
|
||||
}
|
||||
for (const modelCatalogProvider of plugin.contributions.modelCatalogProviders) {
|
||||
addContribution(modelCatalogProviders, modelCatalogProvider, plugin.pluginId);
|
||||
}
|
||||
for (const commandAlias of plugin.contributions.commandAliases) {
|
||||
addContribution(commandAliases, commandAlias, plugin.pluginId);
|
||||
}
|
||||
for (const contract of plugin.contributions.contracts) {
|
||||
addContribution(contracts, contract, plugin.pluginId);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
providers: freezeContributionMap(providers),
|
||||
channels: freezeContributionMap(channels),
|
||||
channelConfigs: freezeContributionMap(channelConfigs),
|
||||
setupProviders: freezeContributionMap(setupProviders),
|
||||
cliBackends: freezeContributionMap(cliBackends),
|
||||
modelCatalogProviders: freezeContributionMap(modelCatalogProviders),
|
||||
commandAliases: freezeContributionMap(commandAliases),
|
||||
contracts: freezeContributionMap(contracts),
|
||||
};
|
||||
}
|
||||
|
||||
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");
|
||||
}
|
||||
|
||||
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
|
||||
) {
|
||||
reasons.add("source-changed");
|
||||
}
|
||||
if (previousPlugin.manifestHash !== currentPlugin.manifestHash) {
|
||||
reasons.add("stale-manifest");
|
||||
}
|
||||
if (
|
||||
previousPlugin.packageVersion !== currentPlugin.packageVersion ||
|
||||
previousPlugin.packageJsonHash !== currentPlugin.packageJsonHash
|
||||
) {
|
||||
reasons.add("stale-package");
|
||||
}
|
||||
}
|
||||
for (const pluginId of currentByPluginId.keys()) {
|
||||
if (!previousByPluginId.has(pluginId)) {
|
||||
reasons.add("source-changed");
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(reasons).toSorted((left, right) => left.localeCompare(right));
|
||||
}
|
||||
Reference in New Issue
Block a user