fix(cli): reject missing plugin ids before config writes

This commit is contained in:
ai-hpc
2026-04-30 16:22:47 +00:00
committed by Mason Huang
parent c81c0171cd
commit 09c5888cca
2 changed files with 107 additions and 0 deletions

View File

@@ -1,19 +1,36 @@
import { beforeEach, describe, expect, it } from "vitest";
import type { OpenClawConfig } from "../config/config.js";
import {
buildPluginRegistrySnapshotReport,
enablePluginInConfig,
loadConfig,
refreshPluginRegistry,
resetPluginsCliTestState,
runtimeErrors,
runPluginsCommand,
writeConfigFile,
} from "./plugins-cli-test-helpers.js";
describe("plugins cli policy mutations", () => {
const compatibilityPluginIds = [
{ alias: "openai-codex", pluginId: "openai" },
{ alias: "google-gemini-cli", pluginId: "google" },
{ alias: "minimax-portal-auth", pluginId: "minimax" },
] as const;
beforeEach(() => {
resetPluginsCliTestState();
});
function mockPluginRegistry(ids: string[]) {
buildPluginRegistrySnapshotReport.mockReturnValue({
plugins: ids.map((id) => ({ id })),
diagnostics: [],
registrySource: "derived",
registryDiagnostics: [],
});
}
it("refreshes the persisted plugin registry after enabling a plugin", async () => {
const enabledConfig = {
plugins: {
@@ -28,6 +45,7 @@ describe("plugins cli policy mutations", () => {
enabled: true,
pluginId: "alpha",
});
mockPluginRegistry(["alpha"]);
await runPluginsCommand(["plugins", "enable", "alpha"]);
@@ -48,6 +66,7 @@ describe("plugins cli policy mutations", () => {
},
},
} as OpenClawConfig);
mockPluginRegistry(["alpha"]);
await runPluginsCommand(["plugins", "disable", "alpha"]);
@@ -60,4 +79,67 @@ describe("plugins cli policy mutations", () => {
reason: "policy-changed",
});
});
it.each(compatibilityPluginIds)(
"enables compatibility id $alias through canonical plugin $pluginId",
async ({ alias, pluginId }) => {
const sourceConfig = {} as OpenClawConfig;
const enabledConfig = {
plugins: {
entries: {
[pluginId]: { enabled: true },
},
},
} as OpenClawConfig;
loadConfig.mockReturnValue(sourceConfig);
enablePluginInConfig.mockReturnValue({
config: enabledConfig,
enabled: true,
});
mockPluginRegistry([pluginId]);
await runPluginsCommand(["plugins", "enable", alias]);
expect(enablePluginInConfig).toHaveBeenCalledWith(sourceConfig, pluginId);
expect(writeConfigFile).toHaveBeenCalledWith(enabledConfig);
},
);
it.each(compatibilityPluginIds)(
"disables compatibility id $alias through canonical plugin $pluginId",
async ({ alias, pluginId }) => {
loadConfig.mockReturnValue({
plugins: {
entries: {
[pluginId]: { enabled: true },
},
},
} as OpenClawConfig);
mockPluginRegistry([pluginId]);
await runPluginsCommand(["plugins", "disable", alias]);
const nextConfig = writeConfigFile.mock.calls[0]?.[0] as OpenClawConfig;
expect(nextConfig.plugins?.entries?.[pluginId]?.enabled).toBe(false);
expect(nextConfig.plugins?.entries?.[alias]).toBeUndefined();
},
);
it.each(["enable", "disable"] as const)(
"rejects %s for a plugin that is not discovered",
async (command) => {
mockPluginRegistry(["alpha"]);
await expect(runPluginsCommand(["plugins", command, "missing-plugin"])).rejects.toThrow(
"__exit__:1",
);
expect(runtimeErrors).toContain(
"Plugin not found: missing-plugin. Run `openclaw plugins list` to see installed plugins.",
);
expect(enablePluginInConfig).not.toHaveBeenCalled();
expect(writeConfigFile).not.toHaveBeenCalled();
expect(refreshPluginRegistry).not.toHaveBeenCalled();
},
);
});

View File

@@ -51,6 +51,17 @@ function formatRegistryState(state: "missing" | "fresh" | "stale"): string {
return theme.warn(state);
}
function reportMissingPlugin(id: string) {
defaultRuntime.error(
`Plugin not found: ${id}. Run \`openclaw plugins list\` to see installed plugins.`,
);
return defaultRuntime.exit(1);
}
function matchesPluginId(plugin: { id: string }, id: string) {
return plugin.id === id;
}
export function registerPluginsCli(program: Command) {
const plugins = program
.command("plugins")
@@ -102,12 +113,19 @@ export function registerPluginsCli(program: Command) {
.argument("<id>", "Plugin id")
.action(async (id: string) => {
const { enablePluginInConfig } = await import("../plugins/enable.js");
const { normalizePluginId } = await import("../plugins/config-state.js");
const { buildPluginRegistrySnapshotReport } = await import("../plugins/status.js");
const { applySlotSelectionForPlugin, logSlotWarnings } =
await import("./plugins-command-helpers.js");
const { refreshPluginRegistryAfterConfigMutation } =
await import("./plugins-registry-refresh.js");
const snapshot = await readConfigFileSnapshot();
const cfg = (snapshot.sourceConfig ?? snapshot.config) as OpenClawConfig;
const report = buildPluginRegistrySnapshotReport({ config: cfg });
id = normalizePluginId(id);
if (!report.plugins.some((plugin) => matchesPluginId(plugin, id))) {
return reportMissingPlugin(id);
}
const enableResult = enablePluginInConfig(cfg, id);
let next: OpenClawConfig = enableResult.config;
const slotResult = applySlotSelectionForPlugin(next, id);
@@ -141,11 +159,18 @@ export function registerPluginsCli(program: Command) {
.description("Disable a plugin in config")
.argument("<id>", "Plugin id")
.action(async (id: string) => {
const { normalizePluginId } = await import("../plugins/config-state.js");
const { buildPluginRegistrySnapshotReport } = await import("../plugins/status.js");
const { setPluginEnabledInConfig } = await import("./plugins-config.js");
const { refreshPluginRegistryAfterConfigMutation } =
await import("./plugins-registry-refresh.js");
const snapshot = await readConfigFileSnapshot();
const cfg = (snapshot.sourceConfig ?? snapshot.config) as OpenClawConfig;
const report = buildPluginRegistrySnapshotReport({ config: cfg });
id = normalizePluginId(id);
if (!report.plugins.some((plugin) => matchesPluginId(plugin, id))) {
return reportMissingPlugin(id);
}
const next = setPluginEnabledInConfig(cfg, id, false);
await replaceConfigFile({
nextConfig: next,