diff --git a/CHANGELOG.md b/CHANGELOG.md index 24934997d5d..4035bea08e9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,7 @@ Docs: https://docs.openclaw.ai - Gateway/update: allow beta binaries to refresh gateway services when the config was last written by the matching stable release version, avoiding false newer-config downgrade blocks during beta channel updates. - Channels: keep Matrix and Mattermost bundled in the core package instead of advertising external npm installs before those channels are cut over. Thanks @vincentkoc. - Bonjour: disable LAN mDNS advertising after a repeated stuck-announcing recovery instead of repeatedly restarting ciao and saturating the Gateway event loop. +- Channels/setup: label installable channel picker hints as remote npm installs and hide remote install hints for bundled plugins that already ship with OpenClaw. - CLI/plugins: stop treating the non-plugin `auth` command root as a bundled plugin id, so restrictive `plugins.allow` configs no longer tell users to add stale `auth` plugin entries. - Doctor/plugins: update configured plugin installs whose stale manifests still declare channels without `channelConfigs`, so beta upgrades repair old Discord-style package payloads during `doctor --fix`. - Active Memory: keep non-empty `memory_search` results from being fast-failed as empty when debug telemetry reports zero hits. diff --git a/src/commands/channel-setup/plugin-install.test.ts b/src/commands/channel-setup/plugin-install.test.ts index 0103ea230ec..395d6b8d94b 100644 --- a/src/commands/channel-setup/plugin-install.test.ts +++ b/src/commands/channel-setup/plugin-install.test.ts @@ -518,7 +518,7 @@ describe("ensureChannelSetupPluginInstalled", () => { options: [ expect.objectContaining({ value: "npm", - label: `Download from npm (${bundledChatForkNpmSpec})`, + label: `Remote install from npm (${bundledChatForkNpmSpec})`, }), expect.objectContaining({ value: "skip", @@ -562,7 +562,7 @@ describe("ensureChannelSetupPluginInstalled", () => { options: [ expect.objectContaining({ value: "clawhub", - label: "Download from ClawHub (clawhub:openclaw/clawhub-chat@2026.5.2)", + label: "Remote install from ClawHub (clawhub:openclaw/clawhub-chat@2026.5.2)", }), expect.objectContaining({ value: "skip", diff --git a/src/commands/onboarding-plugin-install.test.ts b/src/commands/onboarding-plugin-install.test.ts index 76bbafa2b68..74d9391fad6 100644 --- a/src/commands/onboarding-plugin-install.test.ts +++ b/src/commands/onboarding-plugin-install.test.ts @@ -72,6 +72,23 @@ vi.mock("../utils/with-timeout.js", () => ({ import { ensureOnboardingPluginInstalled } from "./onboarding-plugin-install.js"; +function createDeferred() { + let resolve!: (value: T) => void; + const promise = new Promise((next) => { + resolve = next; + }); + return { promise, resolve }; +} + +async function waitForMockCall(mock: { mock: { calls: unknown[][] } }) { + for (let i = 0; i < 20; i += 1) { + if (mock.mock.calls.length > 0) { + return; + } + await new Promise((resolve) => setTimeout(resolve, 0)); + } +} + describe("ensureOnboardingPluginInstalled", () => { beforeEach(() => { vi.clearAllMocks(); @@ -241,6 +258,114 @@ describe("ensureOnboardingPluginInstalled", () => { expect(refreshPluginRegistryAfterConfigMutation).not.toHaveBeenCalled(); }); + it("animates ClawHub install progress while the remote install is running", async () => { + const deferred = createDeferred>>(); + installPluginFromClawHub.mockImplementation(async (params) => { + params.logger?.info?.("Downloading demo-plugin from ClawHub…"); + return await deferred.promise; + }); + const stop = vi.fn(); + const update = vi.fn(); + + const install = ensureOnboardingPluginInstalled({ + cfg: {}, + entry: { + pluginId: "demo-plugin", + label: "Demo Provider", + install: { + clawhubSpec: "clawhub:demo-plugin@2026.5.2", + defaultChoice: "clawhub", + }, + }, + prompter: { + select: vi.fn(async () => "clawhub"), + progress: vi.fn(() => ({ update, stop })), + } as never, + runtime: {} as never, + }); + + await waitForMockCall(installPluginFromClawHub); + expect(installPluginFromClawHub).toHaveBeenCalled(); + + await new Promise((resolve) => setTimeout(resolve, 250)); + expect(update).toHaveBeenCalledWith("Downloading"); + expect( + update.mock.calls.some( + ([message]) => + typeof message === "string" && /^Downloading {2}\[[█░]{16}\] \d+%$/u.test(message), + ), + ).toBe(true); + + deferred.resolve({ + ok: true, + pluginId: "demo-plugin", + targetDir: "/tmp/demo-plugin", + version: "2026.5.2", + packageName: "demo-plugin", + clawhub: { + source: "clawhub", + clawhubUrl: "https://clawhub.ai", + clawhubPackage: "demo-plugin", + clawhubFamily: "code-plugin", + clawhubChannel: "official", + version: "2026.5.2", + integrity: "sha256-clawpack", + resolvedAt: "2026-05-02T00:00:00.000Z", + clawpackSha256: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + clawpackSpecVersion: 1, + clawpackManifestSha256: "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + clawpackSize: 4096, + }, + }); + await install; + }); + + it("animates npm install progress while the remote install is running", async () => { + const deferred = createDeferred>>(); + installPluginFromNpmSpec.mockImplementation(async (params) => { + params.logger?.info?.("Resolving npm package…"); + return await deferred.promise; + }); + const stop = vi.fn(); + const update = vi.fn(); + + const install = ensureOnboardingPluginInstalled({ + cfg: {}, + entry: { + pluginId: "demo-plugin", + label: "Demo Plugin", + install: { + npmSpec: "@demo/plugin@1.2.3", + }, + }, + prompter: { + select: vi.fn(async () => "npm"), + progress: vi.fn(() => ({ update, stop })), + } as never, + runtime: {} as never, + }); + + await waitForMockCall(installPluginFromNpmSpec); + expect(installPluginFromNpmSpec).toHaveBeenCalled(); + + await new Promise((resolve) => setTimeout(resolve, 250)); + expect(update).toHaveBeenCalledWith("Resolving"); + expect( + update.mock.calls.some( + ([message]) => + typeof message === "string" && /^Resolving {2}\[[█░]{16}\] \d+%$/u.test(message), + ), + ).toBe(true); + + deferred.resolve({ + ok: true, + pluginId: "demo-plugin", + targetDir: "/tmp/demo-plugin", + version: "1.2.3", + }); + await install; + }); + it("returns a timed out status and notes the retry path when npm install hangs", async () => { const note = vi.fn(async () => {}); const stop = vi.fn(); @@ -310,7 +435,7 @@ describe("ensureOnboardingPluginInstalled", () => { }); expect(captured?.options).toEqual([ - { value: "npm", label: "Download from npm (@demo/plugin)" }, + { value: "npm", label: "Remote install from npm (@demo/plugin)" }, { value: "skip", label: "Skip for now" }, ]); expect(captured?.initialValue).toBe("npm"); @@ -349,8 +474,11 @@ describe("ensureOnboardingPluginInstalled", () => { }); expect(captured?.options).toEqual([ - { value: "clawhub", label: "Download from ClawHub (clawhub:demo-plugin@2026.5.2)" }, - { value: "npm", label: "Download from npm (@openclaw/demo-plugin@2026.5.2)" }, + { + value: "clawhub", + label: "Remote install from ClawHub (clawhub:demo-plugin@2026.5.2)", + }, + { value: "npm", label: "Remote install from npm (@openclaw/demo-plugin@2026.5.2)" }, { value: "skip", label: "Skip for now" }, ]); expect(captured?.initialValue).toBe("clawhub"); @@ -460,7 +588,7 @@ describe("ensureOnboardingPluginInstalled", () => { expect(captured).toBeDefined(); expect(captured?.message).toBe("Install Demo Plugin\\n plugin?"); expect(captured?.options).toEqual([ - { value: "npm", label: "Download from npm (@demo/plugin@1.2.3)" }, + { value: "npm", label: "Remote install from npm (@demo/plugin@1.2.3)" }, { value: "local", label: "Use local plugin path", @@ -674,7 +802,7 @@ describe("ensureOnboardingPluginInstalled", () => { }); expect(captured).toBeDefined(); - // "Download from npm (@openclaw/tlon)" must NOT appear: the bundled + // "Remote install from npm (@openclaw/tlon)" must NOT appear: the bundled // copy is what gets enabled, so the npm hint would only confuse // users into thinking the plugin is missing. expect(captured?.options).toEqual([ diff --git a/src/commands/onboarding-plugin-install.ts b/src/commands/onboarding-plugin-install.ts index 4b28b9ea7d0..f2430cd488b 100644 --- a/src/commands/onboarding-plugin-install.ts +++ b/src/commands/onboarding-plugin-install.ts @@ -320,7 +320,7 @@ async function promptInstallChoice(params: { // `extensions/` and is discovered via `resolveBundledPluginSources`), // the bundled copy is the source of truth: it is version-locked to the // current host build and is what `defaultChoice` will pick anyway (see - // `resolveInstallDefaultChoice`). Surfacing remote download options in that + // `resolveInstallDefaultChoice`). Surfacing remote install options in that // case is misleading; those catalog specs only exist as fallback metadata for // non-bundled builds. Hide them so bundled channels like Tlon look identical // to Twitch / Slack in the menu. @@ -334,13 +334,13 @@ async function promptInstallChoice(params: { if (safeClawHubSpec) { options.push({ value: "clawhub", - label: `Download from ClawHub (${safeClawHubSpec})`, + label: formatRemoteInstallChoiceLabel("clawhub", safeClawHubSpec), }); } if (safeNpmSpec) { options.push({ value: "npm", - label: `Download from npm (${safeNpmSpec})`, + label: formatRemoteInstallChoiceLabel("npm", safeNpmSpec), }); } if (params.localPath) { @@ -420,6 +420,11 @@ function isTimeoutError(error: unknown): boolean { return error instanceof Error && error.message === "timeout"; } +function formatRemoteInstallChoiceLabel(source: "clawhub" | "npm", spec: string): string { + const sourceLabel = source === "clawhub" ? "ClawHub" : "npm"; + return `Remote install from ${sourceLabel} (${spec})`; +} + async function applyPluginEnablement(params: { cfg: OpenClawConfig; pluginId: string; diff --git a/src/flows/channel-setup.status.test.ts b/src/flows/channel-setup.status.test.ts index 38e9bc40352..45fdea6ef13 100644 --- a/src/flows/channel-setup.status.test.ts +++ b/src/flows/channel-setup.status.test.ts @@ -12,6 +12,10 @@ type FormatChannelPrimerLine = typeof import("../channels/registry.js").formatCh type FormatChannelSelectionLine = typeof import("../channels/registry.js").formatChannelSelectionLine; type IsChannelConfigured = typeof import("../config/channel-configured.js").isChannelConfigured; +type ResolveBundledPluginSources = + typeof import("../plugins/bundled-sources.js").resolveBundledPluginSources; +type FindBundledPluginSourceInMap = + typeof import("../plugins/bundled-sources.js").findBundledPluginSourceInMap; type NoteChannelPrimerChannels = Parameters< typeof import("./channel-setup.status.js").noteChannelPrimer >[1]; @@ -33,6 +37,21 @@ const formatChannelSelectionLine = vi.hoisted(() => vi.fn((meta) => `${meta.label} — ${meta.blurb}`), ); const isChannelConfigured = vi.hoisted(() => vi.fn(() => false)); +const resolveBundledPluginSources = vi.hoisted(() => + vi.fn(() => new Map()), +); +const findBundledPluginSourceInMap = vi.hoisted(() => + vi.fn(({ bundled, lookup }) => { + const value = lookup.value.trim(); + if (!value) { + return undefined; + } + if (lookup.kind === "pluginId") { + return bundled.get(value); + } + return Array.from(bundled.values()).find((source) => source.npmSpec === value); + }), +); vi.mock("../channels/chat-meta.js", () => ({ listChatChannels: () => listChatChannels(), @@ -62,20 +81,20 @@ vi.mock("../config/channel-configured.js", () => ({ ) => isChannelConfigured(cfg, channelId), })); -// Avoid touching the real `extensions/` tree from unit tests. Status -// rendering for installable catalog entries asks `bundled-sources` whether -// a plugin already lives in-tree to decide between -// "install plugin to enable" vs "bundled · enable to use". For these tests -// we want the installable-catalog branch unconditionally, so we stub the -// bundled lookup to "nothing is bundled". +// Avoid touching the real `extensions/` tree from unit tests. Tests opt +// into bundled-source entries explicitly when they cover bundled catalog +// rendering; the default fixture behaves as if nothing is bundled. vi.mock("../plugins/bundled-sources.js", () => ({ - resolveBundledPluginSources: () => new Map(), - findBundledPluginSourceInMap: () => undefined, + resolveBundledPluginSources: (params: Parameters[0]) => + resolveBundledPluginSources(params), + findBundledPluginSourceInMap: (params: Parameters[0]) => + findBundledPluginSourceInMap(params), })); import { collectChannelStatus, noteChannelPrimer, + resolveCatalogChannelSelectionHint, resolveChannelSelectionNoteLines, resolveChannelSetupSelectionContributions, } from "./channel-setup.status.js"; @@ -93,6 +112,17 @@ describe("resolveChannelSetupSelectionContributions", () => { ); formatChannelSelectionLine.mockImplementation((meta) => `${meta.label} — ${meta.blurb}`); isChannelConfigured.mockReturnValue(false); + resolveBundledPluginSources.mockReturnValue(new Map()); + findBundledPluginSourceInMap.mockImplementation(({ bundled, lookup }) => { + const value = lookup.value.trim(); + if (!value) { + return undefined; + } + if (lookup.kind === "pluginId") { + return bundled.get(value); + } + return Array.from(bundled.values()).find((source) => source.npmSpec === value); + }); }); it("sorts channels alphabetically by picker label", () => { @@ -158,6 +188,67 @@ describe("resolveChannelSetupSelectionContributions", () => { ]); }); + it("describes installable catalog choices as remote npm installs", () => { + expect( + resolveCatalogChannelSelectionHint({ + install: { npmSpec: "@openclaw/googlechat" }, + }), + ).toBe("remote install from npm: @openclaw/googlechat"); + }); + + it("sanitizes remote npm install hints", () => { + expect( + resolveCatalogChannelSelectionHint({ + install: { npmSpec: "@openclaw/googlechat\u001B[31m\nbeta" }, + }), + ).toBe("remote install from npm: @openclaw/googlechat\\nbeta"); + }); + + it("suppresses remote install hints for bundled channels", () => { + expect( + resolveCatalogChannelSelectionHint( + { + install: { npmSpec: "@openclaw/googlechat" }, + }, + { bundledLocalPath: "extensions/googlechat" }, + ), + ).toBe(""); + }); + + it("renders bundled catalog statuses without remote install hints", async () => { + const entry = makeCatalogEntry("slack", "Slack", { + pluginId: "@openclaw/slack", + install: { npmSpec: "@openclaw/slack" }, + }); + listChatChannels.mockReturnValue([]); + resolveBundledPluginSources.mockReturnValue( + new Map([ + [ + "@openclaw/slack", + { + pluginId: "@openclaw/slack", + localPath: "extensions/slack", + npmSpec: "@openclaw/slack", + }, + ], + ]), + ); + resolveChannelSetupEntries.mockReturnValue( + makeChannelSetupEntries({ + installableCatalogEntries: [entry], + }), + ); + + const summary = await collectChannelStatus({ + cfg: {} as never, + accountOverrides: {}, + installedPlugins: [], + }); + + expect(summary.statusLines).toEqual(["Slack: bundled · enable to use"]); + expect(summary.statusByChannel.get("slack")?.selectionHint).toBe(""); + }); + it("combines real status and disabled hints when available", () => { const contributions = resolveChannelSetupSelectionContributions({ entries: [ diff --git a/src/flows/channel-setup.status.ts b/src/flows/channel-setup.status.ts index d6a32e1850e..e043950fd13 100644 --- a/src/flows/channel-setup.status.ts +++ b/src/flows/channel-setup.status.ts @@ -135,17 +135,17 @@ function formatSetupDisplayMeta(meta: ChannelMeta): ChannelMeta { /** * Hint shown next to an installable channel option in the selection menu when * we don't yet have a runtime-collected status. Mirrors the "configured" / - * "installed" affordance other channels get so users can see "download from - * " before committing to install. + * "installed" affordance other channels get so users can see "remote install + * from npm: " before committing to install. * * Bundled channels (the plugin lives under `extensions/` in the host * repo, e.g. Signal / Tlon / Twitch / Slack) are NOT downloaded from npm — * they ship with the host. Even when their `package.json` declares an * `npmSpec` (or the catalog falls back to the package name), surfacing - * "download from " misleads users into believing the plugin is - * missing. For bundled channels we suppress the npm hint entirely so the - * menu shows the same neutral "plugin · install" affordance used when no - * npm source is known. + * "remote install from npm: " misleads users into believing the + * plugin is missing. For bundled channels we suppress the npm hint entirely + * so the menu shows the same neutral "plugin · install" affordance used when + * no npm source is known. */ export function resolveCatalogChannelSelectionHint( entry: { install?: { npmSpec?: string } }, @@ -153,7 +153,7 @@ export function resolveCatalogChannelSelectionHint( ): string { const npmSpec = entry.install?.npmSpec?.trim(); if (npmSpec && !options?.bundledLocalPath) { - return `download from ${formatSetupSelectionLabel(npmSpec, npmSpec)}`; + return `remote install from npm: ${formatSetupSelectionLabel(npmSpec, npmSpec)}`; } return ""; } @@ -162,8 +162,8 @@ export function resolveCatalogChannelSelectionHint( * Look up the bundled-source entry for a catalog channel, regardless of * whether the catalog refers to it by `pluginId` or `npmSpec`. We use this * to detect bundled channels in the selection menu so we can suppress the - * misleading "download from " hint for plugins that already ship - * with the host (Signal / Tlon / Twitch / Slack ...). + * misleading "remote install from npm: " hint for plugins that + * already ship with the host (Signal / Tlon / Twitch / Slack ...). */ export function findBundledSourceForCatalogChannel(params: { bundled: ReadonlyMap; diff --git a/src/flows/channel-setup.test.ts b/src/flows/channel-setup.test.ts index 864d8452048..41f5ffafcb3 100644 --- a/src/flows/channel-setup.test.ts +++ b/src/flows/channel-setup.test.ts @@ -9,6 +9,12 @@ type ChannelSetupPlugin = import("../channels/plugins/setup-wizard-types.js").Ch type ResolveChannelSetupEntries = typeof import("../commands/channel-setup/discovery.js").resolveChannelSetupEntries; type CollectChannelStatus = typeof import("./channel-setup.status.js").collectChannelStatus; +type FindBundledSourceForCatalogChannel = + typeof import("./channel-setup.status.js").findBundledSourceForCatalogChannel; +type ResolveCatalogChannelSelectionHint = + typeof import("./channel-setup.status.js").resolveCatalogChannelSelectionHint; +type ResolveChannelSetupSelectionContributions = + typeof import("./channel-setup.status.js").resolveChannelSetupSelectionContributions; type EnsureChannelSetupPluginInstalled = typeof import("../commands/channel-setup/plugin-install.js").ensureChannelSetupPluginInstalled; type LoadChannelSetupPluginRegistrySnapshotForChannel = @@ -117,6 +123,18 @@ const collectChannelStatus = vi.hoisted(() => statusLines: [], })), ); +const findBundledSourceForCatalogChannel = vi.hoisted(() => + vi.fn(() => undefined), +); +const resolveCatalogChannelSelectionHint = vi.hoisted(() => + vi.fn((entry, options) => { + const npmSpec = entry.install?.npmSpec?.trim(); + return npmSpec && !options?.bundledLocalPath ? `remote install from npm: ${npmSpec}` : ""; + }), +); +const resolveChannelSetupSelectionContributions = vi.hoisted(() => + vi.fn(() => []), +); const isChannelConfigured = vi.hoisted(() => vi.fn((_cfg?: unknown, _channel?: unknown) => true)); vi.mock("../agents/agent-scope.js", () => ({ @@ -178,12 +196,18 @@ vi.mock("./channel-setup.prompts.js", () => ({ vi.mock("./channel-setup.status.js", () => ({ collectChannelStatus: (params: Parameters[0]) => collectChannelStatus(params), - findBundledSourceForCatalogChannel: vi.fn(() => undefined), + findBundledSourceForCatalogChannel: (params: Parameters[0]) => + findBundledSourceForCatalogChannel(params), noteChannelPrimer: vi.fn(), noteChannelStatus: vi.fn(), - resolveCatalogChannelSelectionHint: vi.fn(() => "download from "), + resolveCatalogChannelSelectionHint: ( + entry: Parameters[0], + options: Parameters[1], + ) => resolveCatalogChannelSelectionHint(entry, options), resolveChannelSelectionNoteLines: vi.fn(() => []), - resolveChannelSetupSelectionContributions: vi.fn(() => []), + resolveChannelSetupSelectionContributions: ( + params: Parameters[0], + ) => resolveChannelSetupSelectionContributions(params), resolveQuickstartDefault: vi.fn(() => undefined), })); @@ -219,6 +243,12 @@ describe("setupChannels workspace shadow exclusion", () => { statusByChannel: new Map(), statusLines: [], }); + findBundledSourceForCatalogChannel.mockReturnValue(undefined); + resolveCatalogChannelSelectionHint.mockImplementation((entry, options) => { + const npmSpec = entry.install?.npmSpec?.trim(); + return npmSpec && !options?.bundledLocalPath ? `remote install from npm: ${npmSpec}` : ""; + }); + resolveChannelSetupSelectionContributions.mockReturnValue([]); isChannelConfigured.mockReturnValue(true); }); @@ -338,6 +368,48 @@ describe("setupChannels workspace shadow exclusion", () => { expect(collectChannelStatus).not.toHaveBeenCalled(); }); + it("suppresses deferred picker remote install hints for bundled catalog choices", async () => { + const installableCatalogEntry = makeCatalogEntry("external-chat", "External Chat", { + pluginId: "@openclaw/external-chat", + install: { npmSpec: "@openclaw/external-chat" }, + }); + resolveChannelSetupEntries.mockReturnValue( + externalChatSetupEntries({ + installableCatalogEntries: [installableCatalogEntry], + installableCatalogById: new Map([["external-chat", installableCatalogEntry]]), + }), + ); + findBundledSourceForCatalogChannel.mockReturnValue({ + pluginId: "@openclaw/external-chat", + localPath: "extensions/external-chat", + npmSpec: "@openclaw/external-chat", + }); + const select = vi.fn(async () => "__done__"); + + await setupChannels( + {} as never, + {} as never, + { + confirm: vi.fn(async () => true), + note: vi.fn(async () => undefined), + select, + } as never, + { + deferStatusUntilSelection: true, + skipConfirm: true, + }, + ); + + expect(resolveCatalogChannelSelectionHint).toHaveBeenCalledWith(installableCatalogEntry, { + bundledLocalPath: "extensions/external-chat", + }); + expect( + resolveChannelSetupSelectionContributions.mock.calls[0]?.[0].statusByChannel.get( + "external-chat", + )?.selectionHint, + ).toBe(""); + }); + it("uses an active deferred setup plugin without enabling config on selection", async () => { const setupWizard = { channel: "custom-chat", diff --git a/src/flows/channel-setup.ts b/src/flows/channel-setup.ts index 3f5996c77ad..fa9d613564f 100644 --- a/src/flows/channel-setup.ts +++ b/src/flows/channel-setup.ts @@ -320,13 +320,14 @@ export async function setupChannels( // installable catalog channels (e.g. WeCom shipped via npm). In QuickStart we // run with `deferStatusUntilSelection`, which leaves `statusByChannel` empty // until the user picks a channel — without this overlay the selection menu - // would render those options without any "download from " hint. + // would render those options without any "remote install from npm: + // " hint. // // Bundled channels (Signal / Tlon / Twitch / Slack ...) reach this code path // too whenever their plugin is not yet enabled, because they share the same - // "installable catalog" bucket. For those we must NOT show "download from - // " — the plugin already lives under `extensions/` and the - // hint would mislead users into thinking the plugin is missing. + // "installable catalog" bucket. For those we must NOT show "remote install + // from npm: " — the plugin already lives under `extensions/` + // and the hint would mislead users into thinking the plugin is missing. const buildStatusByChannelForSelection = ( catalogById: ReturnType["catalogById"], ): Map => {