diff --git a/src/commands/channel-setup/channel-plugin-resolution.ts b/src/commands/channel-setup/channel-plugin-resolution.ts index 300468865d1..6768ea48bcf 100644 --- a/src/commands/channel-setup/channel-plugin-resolution.ts +++ b/src/commands/channel-setup/channel-plugin-resolution.ts @@ -29,6 +29,7 @@ type ResolveInstallableChannelPluginResult = { plugin?: ChannelPlugin; catalogEntry?: ChannelPluginCatalogEntry; configChanged: boolean; + pluginInstalled: boolean; }; function resolveWorkspaceDir(cfg: OpenClawConfig) { @@ -197,6 +198,7 @@ export async function resolveInstallableChannelPlugin(params: { cfg: nextCfg, catalogEntry, configChanged: false, + pluginInstalled: false, }; } @@ -208,6 +210,7 @@ export async function resolveInstallableChannelPlugin(params: { plugin: existing, catalogEntry, configChanged: false, + pluginInstalled: false, }; } @@ -227,6 +230,7 @@ export async function resolveInstallableChannelPlugin(params: { plugin: scoped, catalogEntry, configChanged: false, + pluginInstalled: false, }; } @@ -258,6 +262,7 @@ export async function resolveInstallableChannelPlugin(params: { ? { ...catalogEntry, pluginId: installedPluginId } : catalogEntry, configChanged: nextCfg !== params.cfg, + pluginInstalled: installResult.installed, }; } } @@ -268,5 +273,6 @@ export async function resolveInstallableChannelPlugin(params: { plugin: existing, catalogEntry, configChanged: false, + pluginInstalled: false, }; } diff --git a/src/commands/channels.add.test.ts b/src/commands/channels.add.test.ts index 6dc22fe8340..05455e4862f 100644 --- a/src/commands/channels.add.test.ts +++ b/src/commands/channels.add.test.ts @@ -30,6 +30,10 @@ const pluginInstallMocks = vi.hoisted(() => ({ loadChannelSetupPluginRegistrySnapshotForChannel: vi.fn(), })); +const registryRefreshMocks = vi.hoisted(() => ({ + refreshPluginRegistryAfterConfigMutation: vi.fn(async () => undefined), +})); + vi.mock("../channels/plugins/catalog.js", () => ({ listChannelPluginCatalogEntries: catalogMocks.listChannelPluginCatalogEntries, })); @@ -50,6 +54,8 @@ vi.mock("../channels/plugins/bundled.js", async () => { vi.mock("./channel-setup/plugin-install.js", () => pluginInstallMocks); +vi.mock("../cli/plugins-registry-refresh.js", () => registryRefreshMocks); + const runtime = createTestRuntime(); function listConfiguredAccountIds( @@ -268,6 +274,7 @@ describe("channelsAddCommand", () => { vi.mocked(loadChannelSetupPluginRegistrySnapshotForChannel).mockReturnValue( createTestRegistry(), ); + registryRefreshMocks.refreshPluginRegistryAfterConfigMutation.mockClear(); setMinimalChannelsAddRegistryForTests(); }); @@ -481,6 +488,16 @@ describe("channelsAddCommand", () => { expect(loadChannelSetupPluginRegistrySnapshotForChannel).toHaveBeenCalledWith( expect.objectContaining({ installRuntimeDeps: false }), ); + expect(registryRefreshMocks.refreshPluginRegistryAfterConfigMutation).toHaveBeenCalledWith( + expect.objectContaining({ + config: expect.objectContaining({ + channels: expect.objectContaining({ + "external-chat": expect.objectContaining({ enabled: true }), + }), + }), + reason: "source-changed", + }), + ); expectExternalChatEnabledConfigWrite(); expect(runtime.error).not.toHaveBeenCalled(); expect(runtime.exit).not.toHaveBeenCalled(); diff --git a/src/commands/channels.remove.test.ts b/src/commands/channels.remove.test.ts index 7aed9275e32..9c90a93d58d 100644 --- a/src/commands/channels.remove.test.ts +++ b/src/commands/channels.remove.test.ts @@ -19,6 +19,10 @@ const catalogMocks = vi.hoisted(() => ({ listChannelPluginCatalogEntries: vi.fn((): ChannelPluginCatalogEntry[] => []), })); +const registryRefreshMocks = vi.hoisted(() => ({ + refreshPluginRegistryAfterConfigMutation: vi.fn(async () => undefined), +})); + vi.mock("../channels/plugins/catalog.js", async () => { const actual = await vi.importActual( "../channels/plugins/catalog.js", @@ -48,6 +52,8 @@ vi.mock("./channel-setup/plugin-install.js", async () => { return createMockChannelSetupPluginInstallModule(actual); }); +vi.mock("../cli/plugins-registry-refresh.js", () => registryRefreshMocks); + const runtime = createTestRuntime(); describe("channelsRemoveCommand", () => { @@ -79,6 +85,7 @@ describe("channelsRemoveCommand", () => { vi.mocked(loadChannelSetupPluginRegistrySnapshotForChannel).mockReturnValue( createTestRegistry(), ); + registryRefreshMocks.refreshPluginRegistryAfterConfigMutation.mockClear(); setActivePluginRegistry(createTestRegistry()); }); @@ -123,6 +130,11 @@ describe("channelsRemoveCommand", () => { expect.objectContaining({ entry: catalogEntry }), ); expect(loadChannelSetupPluginRegistrySnapshotForChannel).toHaveBeenCalledTimes(2); + expect(registryRefreshMocks.refreshPluginRegistryAfterConfigMutation).toHaveBeenCalledWith( + expect.objectContaining({ + reason: "source-changed", + }), + ); expect(configMocks.writeConfigFile).toHaveBeenCalledWith( expect.not.objectContaining({ channels: expect.objectContaining({ diff --git a/src/commands/channels.resolve.test.ts b/src/commands/channels.resolve.test.ts index 0a44af519ed..daad7185398 100644 --- a/src/commands/channels.resolve.test.ts +++ b/src/commands/channels.resolve.test.ts @@ -8,6 +8,7 @@ const mocks = vi.hoisted(() => ({ readConfigFileSnapshot: vi.fn(), applyPluginAutoEnable: vi.fn(), replaceConfigFile: vi.fn(), + refreshPluginRegistryAfterConfigMutation: vi.fn(async () => undefined), resolveMessageChannelSelection: vi.fn(), resolveInstallableChannelPlugin: vi.fn(), getChannelPlugin: vi.fn(), @@ -27,6 +28,10 @@ vi.mock("../config/config.js", () => ({ replaceConfigFile: mocks.replaceConfigFile, })); +vi.mock("../cli/plugins-registry-refresh.js", () => ({ + refreshPluginRegistryAfterConfigMutation: mocks.refreshPluginRegistryAfterConfigMutation, +})); + vi.mock("../config/plugin-auto-enable.js", () => ({ applyPluginAutoEnable: mocks.applyPluginAutoEnable, })); @@ -54,6 +59,7 @@ describe("channelsResolveCommand", () => { vi.clearAllMocks(); mocks.loadConfig.mockReturnValue({ channels: {} }); mocks.readConfigFileSnapshot.mockResolvedValue({ hash: "config-1" }); + mocks.refreshPluginRegistryAfterConfigMutation.mockResolvedValue(undefined); mocks.applyPluginAutoEnable.mockImplementation(({ config }) => ({ config, changes: [] })); mocks.replaceConfigFile.mockResolvedValue(undefined); mocks.resolveCommandSecretRefsViaGateway.mockResolvedValue({ @@ -88,6 +94,7 @@ describe("channelsResolveCommand", () => { cfg: installedCfg, channelId: "whatsapp", configChanged: true, + pluginInstalled: true, plugin: { id: "whatsapp", resolver: { resolveTargets }, @@ -112,6 +119,12 @@ describe("channelsResolveCommand", () => { nextConfig: installedCfg, baseHash: "config-1", }); + expect(mocks.refreshPluginRegistryAfterConfigMutation).toHaveBeenCalledWith( + expect.objectContaining({ + config: installedCfg, + reason: "source-changed", + }), + ); expect(resolveTargets).toHaveBeenCalledWith( expect.objectContaining({ cfg: installedCfg, diff --git a/src/commands/channels/add.ts b/src/commands/channels/add.ts index 36dccfb1e65..bf86af0751b 100644 --- a/src/commands/channels/add.ts +++ b/src/commands/channels/add.ts @@ -5,6 +5,7 @@ import { moveSingleAccountChannelSectionToDefaultAccount } from "../../channels/ import type { ChannelSetupPlugin } from "../../channels/plugins/setup-wizard-types.js"; import type { ChannelPlugin } from "../../channels/plugins/types.plugin.js"; import type { ChannelId, ChannelSetupInput } from "../../channels/plugins/types.public.js"; +import { refreshPluginRegistryAfterConfigMutation } from "../../cli/plugins-registry-refresh.js"; import { replaceConfigFile, type OpenClawConfig } from "../../config/config.js"; import { PLUGIN_INSTALLS_CONFIG_PATH, @@ -116,6 +117,7 @@ export async function channelsAddCommand( const cfg = (configSnapshot.sourceConfig ?? configSnapshot.config) as OpenClawConfig; const baseHash = configSnapshot.hash; let nextConfig = cfg; + let pluginRegistrySourceChanged = false; const useWizard = shouldUseWizard(params); if (useWizard) { @@ -258,6 +260,13 @@ export async function channelsAddCommand( ? { writeOptions: { unsetPaths: [Array.from(PLUGIN_INSTALLS_CONFIG_PATH)] } } : {}), }); + if (shouldMovePluginInstalls) { + await refreshPluginRegistryAfterConfigMutation({ + config: writtenConfig, + reason: "source-changed", + logger: { warn: (message) => runtime.log(message) }, + }); + } await onboardChannels.runCollectedChannelOnboardingPostWriteHooks({ hooks: postWriteHooks.drain(), cfg: writtenConfig, @@ -320,6 +329,7 @@ export async function channelsAddCommand( if (!result.installed) { return; } + pluginRegistrySourceChanged = true; catalogEntry = { ...catalogEntry, ...(result.pluginId ? { pluginId: result.pluginId } : {}), @@ -401,6 +411,13 @@ export async function channelsAddCommand( ? { writeOptions: { unsetPaths: [Array.from(PLUGIN_INSTALLS_CONFIG_PATH)] } } : {}), }); + if (shouldMovePluginInstalls || pluginRegistrySourceChanged) { + await refreshPluginRegistryAfterConfigMutation({ + config: writtenConfig, + reason: "source-changed", + logger: { warn: (message) => runtime.log(message) }, + }); + } runtime.log(`Added ${plugin.meta.label ?? channelLabel(channel)} account "${accountId}".`); const afterAccountConfigWritten = plugin.setup?.afterAccountConfigWritten; if (afterAccountConfigWritten) { diff --git a/src/commands/channels/capabilities.test.ts b/src/commands/channels/capabilities.test.ts index bdc947a887f..d9e41927c14 100644 --- a/src/commands/channels/capabilities.test.ts +++ b/src/commands/channels/capabilities.test.ts @@ -12,6 +12,7 @@ const resolveDefaultAccountId = () => DEFAULT_ACCOUNT_ID; const mocks = vi.hoisted(() => ({ readConfigFileSnapshot: vi.fn(), replaceConfigFile: vi.fn(), + refreshPluginRegistryAfterConfigMutation: vi.fn(async () => undefined), resolveInstallableChannelPlugin: vi.fn(), })); @@ -37,6 +38,10 @@ vi.mock("../../config/config.js", async () => { }; }); +vi.mock("../../cli/plugins-registry-refresh.js", () => ({ + refreshPluginRegistryAfterConfigMutation: mocks.refreshPluginRegistryAfterConfigMutation, +})); + vi.mock("../channel-setup/channel-plugin-resolution.js", () => ({ resolveInstallableChannelPlugin: mocks.resolveInstallableChannelPlugin, })); @@ -203,6 +208,7 @@ describe("channelsCapabilitiesCommand", () => { channelId: "whatsapp", plugin, configChanged: true, + pluginInstalled: true, }); vi.mocked(listChannelPlugins).mockReturnValue([]); vi.mocked(getChannelPlugin).mockReturnValue(undefined); @@ -221,6 +227,11 @@ describe("channelsCapabilitiesCommand", () => { }), baseHash: "config-1", }); + expect(mocks.refreshPluginRegistryAfterConfigMutation).toHaveBeenCalledWith( + expect.objectContaining({ + reason: "source-changed", + }), + ); expect(logs.join("\n")).toContain("Probe: linked"); }); }); diff --git a/src/commands/channels/capabilities.ts b/src/commands/channels/capabilities.ts index 9fe6f29e250..a6a037aad93 100644 --- a/src/commands/channels/capabilities.ts +++ b/src/commands/channels/capabilities.ts @@ -10,6 +10,7 @@ import type { ChannelCapabilitiesDisplayLine, ChannelPlugin, } from "../../channels/plugins/types.public.js"; +import { refreshPluginRegistryAfterConfigMutation } from "../../cli/plugins-registry-refresh.js"; import { readConfigFileSnapshot, replaceConfigFile, @@ -269,6 +270,13 @@ export async function channelsCapabilitiesCommand( ? { writeOptions: { unsetPaths: [Array.from(PLUGIN_INSTALLS_CONFIG_PATH)] } } : {}), }); + if (shouldMovePluginInstalls || resolved.pluginInstalled) { + await refreshPluginRegistryAfterConfigMutation({ + config: cfg, + reason: "source-changed", + logger: { warn: (message) => runtime.log(message) }, + }); + } } return resolved.plugin ? [resolved.plugin] : null; })(); diff --git a/src/commands/channels/remove.ts b/src/commands/channels/remove.ts index b8813d34531..9486aa6ed82 100644 --- a/src/commands/channels/remove.ts +++ b/src/commands/channels/remove.ts @@ -4,6 +4,7 @@ import { listChannelPlugins, normalizeChannelId, } from "../../channels/plugins/index.js"; +import { refreshPluginRegistryAfterConfigMutation } from "../../cli/plugins-registry-refresh.js"; import { replaceConfigFile, type OpenClawConfig } from "../../config/config.js"; import { PLUGIN_INSTALLS_CONFIG_PATH, @@ -190,6 +191,13 @@ export async function channelsRemoveCommand( ? { writeOptions: { unsetPaths: [Array.from(PLUGIN_INSTALLS_CONFIG_PATH)] } } : {}), }); + if (shouldMovePluginInstalls || resolvedPluginState?.pluginInstalled) { + await refreshPluginRegistryAfterConfigMutation({ + config: next, + reason: "source-changed", + logger: { warn: (message) => runtime.log(message) }, + }); + } if (useWizard && prompter) { await prompter.outro( deleteConfig diff --git a/src/commands/channels/resolve.ts b/src/commands/channels/resolve.ts index 9163b4568f0..2f2d8c58f42 100644 --- a/src/commands/channels/resolve.ts +++ b/src/commands/channels/resolve.ts @@ -5,6 +5,7 @@ import type { } from "../../channels/plugins/types.adapters.js"; import { resolveCommandConfigWithSecrets } from "../../cli/command-config-resolution.js"; import { getChannelsCommandSecretTargetIds } from "../../cli/command-secret-targets.js"; +import { refreshPluginRegistryAfterConfigMutation } from "../../cli/plugins-registry-refresh.js"; import { loadConfig, readConfigFileSnapshot, replaceConfigFile } from "../../config/config.js"; import { danger } from "../../globals.js"; import { resolveMessageChannelSelection } from "../../infra/outbound/channel-selection.js"; @@ -158,6 +159,13 @@ export async function channelsResolveCommand(opts: ChannelsResolveOptions, runti ? { writeOptions: { unsetPaths: [Array.from(PLUGIN_INSTALLS_CONFIG_PATH)] } } : {}), }); + if (shouldMovePluginInstalls || resolvedExplicit.pluginInstalled) { + await refreshPluginRegistryAfterConfigMutation({ + config: cfg, + reason: "source-changed", + logger: { warn: (message) => runtime.log(message) }, + }); + } } const selection = explicitChannel