mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 06:40:44 +00:00
feat(plugins): persist installed plugin index snapshots
This commit is contained in:
140
src/plugins/installed-plugin-index-store.test.ts
Normal file
140
src/plugins/installed-plugin-index-store.test.ts
Normal file
@@ -0,0 +1,140 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import type { PluginCandidate } from "./discovery.js";
|
||||
import {
|
||||
readPersistedInstalledPluginIndex,
|
||||
refreshPersistedInstalledPluginIndex,
|
||||
resolveInstalledPluginIndexStorePath,
|
||||
writePersistedInstalledPluginIndex,
|
||||
} from "./installed-plugin-index-store.js";
|
||||
import type { InstalledPluginIndex } from "./installed-plugin-index.js";
|
||||
import { cleanupTrackedTempDirs, makeTrackedTempDir } from "./test-helpers/fs-fixtures.js";
|
||||
|
||||
const tempDirs: string[] = [];
|
||||
|
||||
afterEach(() => {
|
||||
cleanupTrackedTempDirs(tempDirs);
|
||||
});
|
||||
|
||||
function makeTempDir() {
|
||||
return makeTrackedTempDir("openclaw-installed-plugin-index-store", tempDirs);
|
||||
}
|
||||
|
||||
function createIndex(overrides: Partial<InstalledPluginIndex> = {}): InstalledPluginIndex {
|
||||
return {
|
||||
version: 1,
|
||||
hostContractVersion: "2026.4.25",
|
||||
compatRegistryVersion: "compat-v1",
|
||||
generatedAt: "2026-04-25T12:00:00.000Z",
|
||||
plugins: [
|
||||
{
|
||||
pluginId: "demo",
|
||||
manifestPath: "/plugins/demo/openclaw.plugin.json",
|
||||
manifestHash: "manifest-hash",
|
||||
rootDir: "/plugins/demo",
|
||||
origin: "global",
|
||||
enabled: true,
|
||||
contributions: {
|
||||
providers: ["demo"],
|
||||
channels: ["demo-chat"],
|
||||
channelConfigs: ["demo-chat"],
|
||||
setupProviders: [],
|
||||
cliBackends: [],
|
||||
modelCatalogProviders: [],
|
||||
commandAliases: [],
|
||||
contracts: [],
|
||||
},
|
||||
compat: [],
|
||||
},
|
||||
],
|
||||
diagnostics: [],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function createCandidate(rootDir: string): PluginCandidate {
|
||||
fs.writeFileSync(
|
||||
path.join(rootDir, "index.ts"),
|
||||
"throw new Error('runtime entry should not load while persisting installed plugin index');\n",
|
||||
"utf8",
|
||||
);
|
||||
fs.writeFileSync(
|
||||
path.join(rootDir, "openclaw.plugin.json"),
|
||||
JSON.stringify({
|
||||
id: "demo",
|
||||
name: "Demo",
|
||||
configSchema: { type: "object" },
|
||||
providers: ["demo"],
|
||||
}),
|
||||
"utf8",
|
||||
);
|
||||
return {
|
||||
idHint: "demo",
|
||||
source: path.join(rootDir, "index.ts"),
|
||||
rootDir,
|
||||
origin: "global",
|
||||
};
|
||||
}
|
||||
|
||||
describe("installed plugin index persistence", () => {
|
||||
it("resolves the persisted index path under the state plugins directory", () => {
|
||||
const stateDir = makeTempDir();
|
||||
|
||||
expect(resolveInstalledPluginIndexStorePath({ stateDir })).toBe(
|
||||
path.join(stateDir, "plugins", "installed-index.json"),
|
||||
);
|
||||
});
|
||||
|
||||
it("writes and reads the installed plugin index atomically", async () => {
|
||||
const stateDir = makeTempDir();
|
||||
const filePath = resolveInstalledPluginIndexStorePath({ stateDir });
|
||||
const index = createIndex();
|
||||
|
||||
await expect(writePersistedInstalledPluginIndex(index, { stateDir })).resolves.toBe(filePath);
|
||||
|
||||
expect(fs.readFileSync(filePath, "utf8")).toContain('"pluginId": "demo"');
|
||||
if (process.platform !== "win32") {
|
||||
expect(fs.statSync(filePath).mode & 0o777).toBe(0o600);
|
||||
}
|
||||
await expect(readPersistedInstalledPluginIndex({ stateDir })).resolves.toEqual(index);
|
||||
});
|
||||
|
||||
it("returns null for missing or invalid persisted indexes", async () => {
|
||||
const stateDir = makeTempDir();
|
||||
await expect(readPersistedInstalledPluginIndex({ stateDir })).resolves.toBeNull();
|
||||
|
||||
const filePath = resolveInstalledPluginIndexStorePath({ stateDir });
|
||||
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
||||
fs.writeFileSync(filePath, JSON.stringify({ version: 999 }), "utf8");
|
||||
|
||||
await expect(readPersistedInstalledPluginIndex({ stateDir })).resolves.toBeNull();
|
||||
});
|
||||
|
||||
it("refreshes and persists a rebuilt index without loading plugin runtime", async () => {
|
||||
const stateDir = makeTempDir();
|
||||
const pluginDir = path.join(stateDir, "plugins", "demo");
|
||||
fs.mkdirSync(pluginDir, { recursive: true });
|
||||
const candidate = createCandidate(pluginDir);
|
||||
|
||||
const index = await refreshPersistedInstalledPluginIndex({
|
||||
reason: "manual",
|
||||
stateDir,
|
||||
candidates: [candidate],
|
||||
env: {
|
||||
OPENCLAW_BUNDLED_PLUGINS_DIR: undefined,
|
||||
OPENCLAW_DISABLE_PLUGIN_DISCOVERY_CACHE: "1",
|
||||
OPENCLAW_DISABLE_PLUGIN_MANIFEST_CACHE: "1",
|
||||
OPENCLAW_VERSION: "2026.4.25",
|
||||
VITEST: "true",
|
||||
},
|
||||
});
|
||||
|
||||
expect(index.refreshReason).toBe("manual");
|
||||
expect(index.plugins.map((plugin) => plugin.pluginId)).toEqual(["demo"]);
|
||||
await expect(readPersistedInstalledPluginIndex({ stateDir })).resolves.toMatchObject({
|
||||
refreshReason: "manual",
|
||||
plugins: [expect.objectContaining({ pluginId: "demo" })],
|
||||
});
|
||||
});
|
||||
});
|
||||
118
src/plugins/installed-plugin-index-store.ts
Normal file
118
src/plugins/installed-plugin-index-store.ts
Normal file
@@ -0,0 +1,118 @@
|
||||
import path from "node:path";
|
||||
import { z } from "zod";
|
||||
import { resolveStateDir } from "../config/paths.js";
|
||||
import { readJsonFile, writeJsonAtomic } from "../infra/json-files.js";
|
||||
import { safeParseWithSchema } from "../utils/zod-parse.js";
|
||||
import {
|
||||
INSTALLED_PLUGIN_INDEX_VERSION,
|
||||
refreshInstalledPluginIndex,
|
||||
type InstalledPluginIndex,
|
||||
type RefreshInstalledPluginIndexParams,
|
||||
} from "./installed-plugin-index.js";
|
||||
|
||||
const INSTALLED_PLUGIN_INDEX_STORE_PATH = path.join("plugins", "installed-index.json");
|
||||
|
||||
export type InstalledPluginIndexStoreOptions = {
|
||||
env?: NodeJS.ProcessEnv;
|
||||
stateDir?: string;
|
||||
filePath?: string;
|
||||
};
|
||||
|
||||
const ContributionArraySchema = z.array(z.string());
|
||||
|
||||
const InstalledPluginIndexContributionsSchema = z
|
||||
.object({
|
||||
providers: ContributionArraySchema,
|
||||
channels: ContributionArraySchema,
|
||||
channelConfigs: ContributionArraySchema,
|
||||
setupProviders: ContributionArraySchema,
|
||||
cliBackends: ContributionArraySchema,
|
||||
modelCatalogProviders: ContributionArraySchema,
|
||||
commandAliases: ContributionArraySchema,
|
||||
contracts: ContributionArraySchema,
|
||||
})
|
||||
.passthrough();
|
||||
|
||||
const InstalledPluginIndexRecordSchema = z
|
||||
.object({
|
||||
pluginId: z.string(),
|
||||
packageName: z.string().optional(),
|
||||
packageVersion: z.string().optional(),
|
||||
installRecord: z.record(z.string(), z.unknown()).optional(),
|
||||
installRecordHash: z.string().optional(),
|
||||
packageInstall: z.unknown().optional(),
|
||||
manifestPath: z.string(),
|
||||
manifestHash: z.string(),
|
||||
packageJsonPath: z.string().optional(),
|
||||
packageJsonHash: z.string().optional(),
|
||||
rootDir: z.string(),
|
||||
origin: z.string(),
|
||||
enabled: z.boolean(),
|
||||
contributions: InstalledPluginIndexContributionsSchema,
|
||||
compat: z.array(z.string()),
|
||||
})
|
||||
.passthrough();
|
||||
|
||||
const PluginDiagnosticSchema = z
|
||||
.object({
|
||||
level: z.union([z.literal("warn"), z.literal("error")]),
|
||||
message: z.string(),
|
||||
pluginId: z.string().optional(),
|
||||
source: z.string().optional(),
|
||||
})
|
||||
.passthrough();
|
||||
|
||||
const InstalledPluginIndexSchema = z
|
||||
.object({
|
||||
version: z.literal(INSTALLED_PLUGIN_INDEX_VERSION),
|
||||
hostContractVersion: z.string(),
|
||||
compatRegistryVersion: z.string(),
|
||||
generatedAt: z.string(),
|
||||
refreshReason: z.string().optional(),
|
||||
plugins: z.array(InstalledPluginIndexRecordSchema),
|
||||
diagnostics: z.array(PluginDiagnosticSchema),
|
||||
})
|
||||
.passthrough();
|
||||
|
||||
function parseInstalledPluginIndex(value: unknown): InstalledPluginIndex | null {
|
||||
return safeParseWithSchema(InstalledPluginIndexSchema, value) as InstalledPluginIndex | null;
|
||||
}
|
||||
|
||||
export function resolveInstalledPluginIndexStorePath(
|
||||
options: InstalledPluginIndexStoreOptions = {},
|
||||
): string {
|
||||
if (options.filePath) {
|
||||
return options.filePath;
|
||||
}
|
||||
const env = options.env ?? process.env;
|
||||
const stateDir = options.stateDir ?? resolveStateDir(env);
|
||||
return path.join(stateDir, INSTALLED_PLUGIN_INDEX_STORE_PATH);
|
||||
}
|
||||
|
||||
export async function readPersistedInstalledPluginIndex(
|
||||
options: InstalledPluginIndexStoreOptions = {},
|
||||
): Promise<InstalledPluginIndex | null> {
|
||||
const parsed = await readJsonFile<unknown>(resolveInstalledPluginIndexStorePath(options));
|
||||
return parseInstalledPluginIndex(parsed);
|
||||
}
|
||||
|
||||
export async function writePersistedInstalledPluginIndex(
|
||||
index: InstalledPluginIndex,
|
||||
options: InstalledPluginIndexStoreOptions = {},
|
||||
): Promise<string> {
|
||||
const filePath = resolveInstalledPluginIndexStorePath(options);
|
||||
await writeJsonAtomic(filePath, index, {
|
||||
trailingNewline: true,
|
||||
ensureDirMode: 0o700,
|
||||
mode: 0o600,
|
||||
});
|
||||
return filePath;
|
||||
}
|
||||
|
||||
export async function refreshPersistedInstalledPluginIndex(
|
||||
params: RefreshInstalledPluginIndexParams & InstalledPluginIndexStoreOptions,
|
||||
): Promise<InstalledPluginIndex> {
|
||||
const index = refreshInstalledPluginIndex(params);
|
||||
await writePersistedInstalledPluginIndex(index, params);
|
||||
return index;
|
||||
}
|
||||
Reference in New Issue
Block a user