refactor: install heavy plugins on demand

This commit is contained in:
Peter Steinberger
2026-03-19 03:36:57 +00:00
parent 83c5bc946d
commit b7ca56f662
19 changed files with 671 additions and 156 deletions

View File

@@ -2,17 +2,33 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
import { runChannelLogin, runChannelLogout } from "./channel-auth.js";
const mocks = vi.hoisted(() => ({
resolveAgentWorkspaceDir: vi.fn(),
resolveDefaultAgentId: vi.fn(),
getChannelPluginCatalogEntry: vi.fn(),
resolveChannelDefaultAccountId: vi.fn(),
getChannelPlugin: vi.fn(),
normalizeChannelId: vi.fn(),
loadConfig: vi.fn(),
writeConfigFile: vi.fn(),
resolveMessageChannelSelection: vi.fn(),
setVerbose: vi.fn(),
createClackPrompter: vi.fn(),
ensureChannelSetupPluginInstalled: vi.fn(),
loadChannelSetupPluginRegistrySnapshotForChannel: vi.fn(),
login: vi.fn(),
logoutAccount: vi.fn(),
resolveAccount: vi.fn(),
}));
vi.mock("../agents/agent-scope.js", () => ({
resolveAgentWorkspaceDir: mocks.resolveAgentWorkspaceDir,
resolveDefaultAgentId: mocks.resolveDefaultAgentId,
}));
vi.mock("../channels/plugins/catalog.js", () => ({
getChannelPluginCatalogEntry: mocks.getChannelPluginCatalogEntry,
}));
vi.mock("../channels/plugins/helpers.js", () => ({
resolveChannelDefaultAccountId: mocks.resolveChannelDefaultAccountId,
}));
@@ -24,6 +40,7 @@ vi.mock("../channels/plugins/index.js", () => ({
vi.mock("../config/config.js", () => ({
loadConfig: mocks.loadConfig,
writeConfigFile: mocks.writeConfigFile,
}));
vi.mock("../infra/outbound/channel-selection.js", () => ({
@@ -34,9 +51,20 @@ vi.mock("../globals.js", () => ({
setVerbose: mocks.setVerbose,
}));
vi.mock("../wizard/clack-prompter.js", () => ({
createClackPrompter: mocks.createClackPrompter,
}));
vi.mock("../commands/channel-setup/plugin-install.js", () => ({
ensureChannelSetupPluginInstalled: mocks.ensureChannelSetupPluginInstalled,
loadChannelSetupPluginRegistrySnapshotForChannel:
mocks.loadChannelSetupPluginRegistrySnapshotForChannel,
}));
describe("channel-auth", () => {
const runtime = { log: vi.fn(), error: vi.fn(), exit: vi.fn() };
const plugin = {
id: "whatsapp",
auth: { login: mocks.login },
gateway: { logoutAccount: mocks.logoutAccount },
config: { resolveAccount: mocks.resolveAccount },
@@ -46,12 +74,26 @@ describe("channel-auth", () => {
vi.clearAllMocks();
mocks.normalizeChannelId.mockReturnValue("whatsapp");
mocks.getChannelPlugin.mockReturnValue(plugin);
mocks.getChannelPluginCatalogEntry.mockReturnValue(undefined);
mocks.loadConfig.mockReturnValue({ channels: {} });
mocks.writeConfigFile.mockResolvedValue(undefined);
mocks.resolveMessageChannelSelection.mockResolvedValue({
channel: "whatsapp",
configured: ["whatsapp"],
});
mocks.resolveDefaultAgentId.mockReturnValue("main");
mocks.resolveAgentWorkspaceDir.mockReturnValue("/tmp/workspace");
mocks.resolveChannelDefaultAccountId.mockReturnValue("default-account");
mocks.createClackPrompter.mockReturnValue({} as object);
mocks.ensureChannelSetupPluginInstalled.mockResolvedValue({
cfg: { channels: {} },
installed: true,
pluginId: "whatsapp",
});
mocks.loadChannelSetupPluginRegistrySnapshotForChannel.mockReturnValue({
channels: [{ plugin }],
channelSetups: [],
});
mocks.resolveAccount.mockReturnValue({ id: "resolved-account" });
mocks.login.mockResolvedValue(undefined);
mocks.logoutAccount.mockResolvedValue(undefined);
@@ -115,6 +157,52 @@ describe("channel-auth", () => {
);
});
it("installs a catalog-backed channel plugin on demand for login", async () => {
mocks.getChannelPlugin.mockReturnValueOnce(undefined);
mocks.getChannelPluginCatalogEntry.mockReturnValueOnce({
id: "whatsapp",
pluginId: "@openclaw/whatsapp",
meta: {
id: "whatsapp",
label: "WhatsApp",
selectionLabel: "WhatsApp",
docsPath: "/channels/whatsapp",
blurb: "wa",
},
install: {
npmSpec: "@openclaw/whatsapp",
},
});
mocks.loadChannelSetupPluginRegistrySnapshotForChannel
.mockReturnValueOnce({
channels: [],
channelSetups: [],
})
.mockReturnValueOnce({
channels: [{ plugin }],
channelSetups: [],
});
await runChannelLogin({ channel: "whatsapp" }, runtime);
expect(mocks.ensureChannelSetupPluginInstalled).toHaveBeenCalledWith(
expect.objectContaining({
entry: expect.objectContaining({ id: "whatsapp" }),
runtime,
workspaceDir: "/tmp/workspace",
}),
);
expect(mocks.loadChannelSetupPluginRegistrySnapshotForChannel).toHaveBeenCalledWith(
expect.objectContaining({
channel: "whatsapp",
pluginId: "whatsapp",
workspaceDir: "/tmp/workspace",
}),
);
expect(mocks.writeConfigFile).toHaveBeenCalledWith({ channels: {} });
expect(mocks.login).toHaveBeenCalled();
});
it("runs logout with resolved account and explicit account id", async () => {
await runChannelLogout({ channel: "whatsapp", account: " acct-2 " }, runtime);

View File

@@ -1,6 +1,7 @@
import { resolveChannelDefaultAccountId } from "../channels/plugins/helpers.js";
import { getChannelPlugin, normalizeChannelId } from "../channels/plugins/index.js";
import { loadConfig, type OpenClawConfig } from "../config/config.js";
import { resolveInstallableChannelPlugin } from "../commands/channel-setup/channel-plugin-resolution.js";
import { loadConfig, writeConfigFile, type OpenClawConfig } from "../config/config.js";
import { setVerbose } from "../globals.js";
import { resolveMessageChannelSelection } from "../infra/outbound/channel-selection.js";
import { defaultRuntime, type RuntimeEnv } from "../runtime.js";
@@ -18,7 +19,14 @@ async function resolveChannelPluginForMode(
opts: ChannelAuthOptions,
mode: ChannelAuthMode,
cfg: OpenClawConfig,
): Promise<{ channelInput: string; channelId: string; plugin: ChannelPlugin }> {
runtime: RuntimeEnv,
): Promise<{
cfg: OpenClawConfig;
configChanged: boolean;
channelInput: string;
channelId: string;
plugin: ChannelPlugin;
}> {
const explicitChannel = opts.channel?.trim();
const channelInput = explicitChannel
? explicitChannel
@@ -27,13 +35,28 @@ async function resolveChannelPluginForMode(
if (!channelId) {
throw new Error(`Unsupported channel: ${channelInput}`);
}
const plugin = getChannelPlugin(channelId);
const resolved = await resolveInstallableChannelPlugin({
cfg,
runtime,
channelId,
allowInstall: true,
supports: (candidate) =>
mode === "login" ? Boolean(candidate.auth?.login) : Boolean(candidate.gateway?.logoutAccount),
});
const plugin = resolved.plugin;
const supportsMode =
mode === "login" ? Boolean(plugin?.auth?.login) : Boolean(plugin?.gateway?.logoutAccount);
if (!supportsMode) {
throw new Error(`Channel ${channelId} does not support ${mode}`);
}
return { channelInput, channelId, plugin: plugin as ChannelPlugin };
return {
cfg: resolved.cfg,
configChanged: resolved.configChanged,
channelInput,
channelId,
plugin: plugin as ChannelPlugin,
};
}
function resolveAccountContext(
@@ -49,8 +72,16 @@ export async function runChannelLogin(
opts: ChannelAuthOptions,
runtime: RuntimeEnv = defaultRuntime,
) {
const cfg = loadConfig();
const { channelInput, plugin } = await resolveChannelPluginForMode(opts, "login", cfg);
const loadedCfg = loadConfig();
const { cfg, configChanged, channelInput, plugin } = await resolveChannelPluginForMode(
opts,
"login",
loadedCfg,
runtime,
);
if (configChanged) {
await writeConfigFile(cfg);
}
const login = plugin.auth?.login;
if (!login) {
throw new Error(`Channel ${channelInput} does not support login`);
@@ -71,8 +102,16 @@ export async function runChannelLogout(
opts: ChannelAuthOptions,
runtime: RuntimeEnv = defaultRuntime,
) {
const cfg = loadConfig();
const { channelInput, plugin } = await resolveChannelPluginForMode(opts, "logout", cfg);
const loadedCfg = loadConfig();
const { cfg, configChanged, channelInput, plugin } = await resolveChannelPluginForMode(
opts,
"logout",
loadedCfg,
runtime,
);
if (configChanged) {
await writeConfigFile(cfg);
}
const logoutAccount = plugin.gateway?.logoutAccount;
if (!logoutAccount) {
throw new Error(`Channel ${channelInput} does not support logout`);