refactor: install optional channels for remove

This commit is contained in:
Peter Steinberger 2026-03-19 07:20:34 +00:00
parent 0443ee82be
commit f3097b4c09
2 changed files with 177 additions and 6 deletions

View File

@ -0,0 +1,154 @@
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import type { ChannelPluginCatalogEntry } from "../channels/plugins/catalog.js";
import { setActivePluginRegistry } from "../plugins/runtime.js";
import { createChannelTestPluginBase, createTestRegistry } from "../test-utils/channel-plugins.js";
import {
ensureChannelSetupPluginInstalled,
loadChannelSetupPluginRegistrySnapshotForChannel,
} from "./channel-setup/plugin-install.js";
import { configMocks } from "./channels.mock-harness.js";
import { baseConfigSnapshot, createTestRuntime } from "./test-runtime-config-helpers.js";
const catalogMocks = vi.hoisted(() => ({
listChannelPluginCatalogEntries: vi.fn((): ChannelPluginCatalogEntry[] => []),
}));
vi.mock("../channels/plugins/catalog.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("../channels/plugins/catalog.js")>();
return {
...actual,
listChannelPluginCatalogEntries: catalogMocks.listChannelPluginCatalogEntries,
};
});
vi.mock("./channel-setup/plugin-install.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("./channel-setup/plugin-install.js")>();
return {
...actual,
ensureChannelSetupPluginInstalled: vi.fn(async ({ cfg }) => ({ cfg, installed: true })),
loadChannelSetupPluginRegistrySnapshotForChannel: vi.fn(() => createTestRegistry()),
};
});
const runtime = createTestRuntime();
let channelsRemoveCommand: typeof import("./channels.js").channelsRemoveCommand;
describe("channelsRemoveCommand", () => {
beforeAll(async () => {
({ channelsRemoveCommand } = await import("./channels.js"));
});
beforeEach(() => {
configMocks.readConfigFileSnapshot.mockClear();
configMocks.writeConfigFile.mockClear();
runtime.log.mockClear();
runtime.error.mockClear();
runtime.exit.mockClear();
catalogMocks.listChannelPluginCatalogEntries.mockClear();
catalogMocks.listChannelPluginCatalogEntries.mockReturnValue([]);
vi.mocked(ensureChannelSetupPluginInstalled).mockClear();
vi.mocked(ensureChannelSetupPluginInstalled).mockImplementation(async ({ cfg }) => ({
cfg,
installed: true,
}));
vi.mocked(loadChannelSetupPluginRegistrySnapshotForChannel).mockClear();
vi.mocked(loadChannelSetupPluginRegistrySnapshotForChannel).mockReturnValue(
createTestRegistry(),
);
setActivePluginRegistry(createTestRegistry());
});
it("removes an external channel account after installing its plugin on demand", async () => {
configMocks.readConfigFileSnapshot.mockResolvedValue({
...baseConfigSnapshot,
config: {
channels: {
msteams: {
enabled: true,
tenantId: "tenant-1",
},
},
},
});
const catalogEntry: ChannelPluginCatalogEntry = {
id: "msteams",
pluginId: "@openclaw/msteams-plugin",
meta: {
id: "msteams",
label: "Microsoft Teams",
selectionLabel: "Microsoft Teams",
docsPath: "/channels/msteams",
blurb: "teams channel",
},
install: {
npmSpec: "@openclaw/msteams",
},
};
catalogMocks.listChannelPluginCatalogEntries.mockReturnValue([catalogEntry]);
const scopedPlugin = {
...createChannelTestPluginBase({
id: "msteams",
label: "Microsoft Teams",
docsPath: "/channels/msteams",
}),
config: {
...createChannelTestPluginBase({
id: "msteams",
label: "Microsoft Teams",
docsPath: "/channels/msteams",
}).config,
deleteAccount: vi.fn(({ cfg }: { cfg: Record<string, unknown> }) => {
const channels = (cfg.channels as Record<string, unknown> | undefined) ?? {};
const nextChannels = { ...channels };
delete nextChannels.msteams;
return {
...cfg,
channels: nextChannels,
};
}),
},
};
vi.mocked(loadChannelSetupPluginRegistrySnapshotForChannel)
.mockReturnValueOnce(createTestRegistry())
.mockReturnValueOnce(
createTestRegistry([
{
pluginId: "@openclaw/msteams-plugin",
plugin: scopedPlugin,
source: "test",
},
]),
);
await channelsRemoveCommand(
{
channel: "msteams",
account: "default",
delete: true,
},
runtime,
{ hasFlags: true },
);
expect(ensureChannelSetupPluginInstalled).toHaveBeenCalledWith(
expect.objectContaining({
entry: catalogEntry,
}),
);
expect(loadChannelSetupPluginRegistrySnapshotForChannel).toHaveBeenCalledWith(
expect.objectContaining({
channel: "msteams",
pluginId: "@openclaw/msteams-plugin",
}),
);
expect(configMocks.writeConfigFile).toHaveBeenCalledWith(
expect.not.objectContaining({
channels: expect.objectContaining({
msteams: expect.anything(),
}),
}),
);
expect(runtime.error).not.toHaveBeenCalled();
expect(runtime.exit).not.toHaveBeenCalled();
});
});

View File

@ -8,6 +8,7 @@ import { type OpenClawConfig, writeConfigFile } from "../../config/config.js";
import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../../routing/session-key.js";
import { defaultRuntime, type RuntimeEnv } from "../../runtime.js";
import { createClackPrompter } from "../../wizard/clack-prompter.js";
import { resolveInstallableChannelPlugin } from "../channel-setup/channel-plugin-resolution.js";
import { type ChatChannel, channelLabel, requireValidConfig, shouldUseWizard } from "./shared.js";
export type ChannelsRemoveOptions = {
@ -29,14 +30,16 @@ export async function channelsRemoveCommand(
runtime: RuntimeEnv = defaultRuntime,
params?: { hasFlags?: boolean },
) {
const cfg = await requireValidConfig(runtime);
if (!cfg) {
const loadedCfg = await requireValidConfig(runtime);
if (!loadedCfg) {
return;
}
let cfg = loadedCfg;
const useWizard = shouldUseWizard(params);
const prompter = useWizard ? createClackPrompter() : null;
let channel: ChatChannel | null = normalizeChannelId(opts.channel);
const rawChannel = opts.channel?.trim() ?? "";
let channel: ChatChannel | null = normalizeChannelId(rawChannel);
let accountId = normalizeAccountId(opts.account);
const deleteConfig = Boolean(opts.delete);
@ -73,15 +76,16 @@ export async function channelsRemoveCommand(
return;
}
} else {
if (!channel) {
if (!rawChannel) {
runtime.error("Channel is required. Use --channel <name>.");
runtime.exit(1);
return;
}
if (!deleteConfig) {
const confirm = createClackPrompter();
const channelPromptLabel = channel ? channelLabel(channel) : rawChannel;
const ok = await confirm.confirm({
message: `Disable ${channelLabel(channel)} account "${accountId}"? (keeps config)`,
message: `Disable ${channelPromptLabel} account "${accountId}"? (keeps config)`,
initialValue: true,
});
if (!ok) {
@ -90,7 +94,20 @@ export async function channelsRemoveCommand(
}
}
const plugin = getChannelPlugin(channel);
const resolvedPluginState =
!useWizard && rawChannel
? await resolveInstallableChannelPlugin({
cfg,
runtime,
rawChannel,
allowInstall: true,
})
: null;
if (resolvedPluginState?.configChanged) {
cfg = resolvedPluginState.cfg;
}
channel = resolvedPluginState?.channelId ?? channel;
const plugin = resolvedPluginState?.plugin ?? (channel ? getChannelPlugin(channel) : undefined);
if (!plugin) {
runtime.error(`Unknown channel: ${channel}`);
runtime.exit(1);