diff --git a/src/commands/channels.remove.test.ts b/src/commands/channels.remove.test.ts new file mode 100644 index 00000000000..1c223d8a75a --- /dev/null +++ b/src/commands/channels.remove.test.ts @@ -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(); + return { + ...actual, + listChannelPluginCatalogEntries: catalogMocks.listChannelPluginCatalogEntries, + }; +}); + +vi.mock("./channel-setup/plugin-install.js", async (importOriginal) => { + const actual = await importOriginal(); + 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 }) => { + const channels = (cfg.channels as Record | 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(); + }); +}); diff --git a/src/commands/channels/remove.ts b/src/commands/channels/remove.ts index 1cd5fded7d3..f48a85f8521 100644 --- a/src/commands/channels/remove.ts +++ b/src/commands/channels/remove.ts @@ -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 ."); 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);