From 9bcf8f824388d3b084b325ef8fe098458f6e6879 Mon Sep 17 00:00:00 2001 From: Gustavo Madeira Santana Date: Fri, 17 Apr 2026 13:34:13 -0400 Subject: [PATCH] Configure: defer channel status until selection (#68007) Merged via squash. Prepared head SHA: 24cafcd5fedfae694f75bd0079d1129b9a77870b Co-authored-by: gumadeiras <5599352+gumadeiras@users.noreply.github.com> Co-authored-by: gumadeiras <5599352+gumadeiras@users.noreply.github.com> Reviewed-by: @gumadeiras --- CHANGELOG.md | 1 + src/channels/plugins/setup-registry.ts | 6 + src/channels/plugins/setup-wizard-types.ts | 1 + src/commands/configure.channels.test.ts | 267 +++++++++++++++ src/commands/configure.channels.ts | 74 ++++- src/commands/configure.wizard.test.ts | 25 +- src/commands/configure.wizard.ts | 4 +- src/flows/channel-setup.status.test.ts | 311 +++++++++++++++++ src/flows/channel-setup.status.ts | 139 ++++++-- src/flows/channel-setup.test.ts | 367 ++++++++++++++++++++- src/flows/channel-setup.ts | 105 ++++-- 11 files changed, 1226 insertions(+), 74 deletions(-) create mode 100644 src/commands/configure.channels.test.ts create mode 100644 src/flows/channel-setup.status.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 7a2544280d2..2ac54687b96 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,6 +26,7 @@ Docs: https://docs.openclaw.ai - Matrix: fix `sessions_spawn --thread` subagent session spawning — thread binding creation, cleanup on session end, and completion-message delivery target resolution now work end-to-end. (#67643) Thanks @eejohnso-ops and @gumadeiras. - macOS/webchat: enable Undo and Redo in the composer text input by turning on the native `NSTextView` undo manager. (#34962) Thanks @tylerbittner. - macOS/remote SSH: require an already-trusted host key on the macOS remote command, gateway probe, port tunnel, and pairing probe paths by switching `StrictHostKeyChecking=accept-new` to `StrictHostKeyChecking=yes` and centralizing the shared SSH option fragments in `CommandResolver`, so first-time macOS remote connections no longer silently accept an unknown host key and must be trusted ahead of time via `~/.ssh/known_hosts`. (#68199) +- CLI/configure: show the channel picker before probing statuses and let remove mode delete configured channel blocks directly from config. (#68007) Thanks @gumadeiras. ## 2026.4.15 diff --git a/src/channels/plugins/setup-registry.ts b/src/channels/plugins/setup-registry.ts index fa8ed0fb6e8..3e7269a5a32 100644 --- a/src/channels/plugins/setup-registry.ts +++ b/src/channels/plugins/setup-registry.ts @@ -1,4 +1,5 @@ import { + getActivePluginChannelRegistry, getActivePluginRegistryVersion, requireActivePluginRegistry, } from "../../plugins/runtime.js"; @@ -82,6 +83,11 @@ export function listChannelSetupPlugins(): ChannelPlugin[] { return resolveCachedChannelSetupPlugins().sorted.slice(); } +export function listActiveChannelSetupPlugins(): ChannelPlugin[] { + const registry = getActivePluginChannelRegistry(); + return sortChannelSetupPlugins((registry?.channelSetups ?? []).map((entry) => entry.plugin)); +} + export function getChannelSetupPlugin(id: ChannelId): ChannelPlugin | undefined { const resolvedId = normalizeOptionalString(id) ?? ""; if (!resolvedId) { diff --git a/src/channels/plugins/setup-wizard-types.ts b/src/channels/plugins/setup-wizard-types.ts index e1e100ebde4..ab8b00dceba 100644 --- a/src/channels/plugins/setup-wizard-types.ts +++ b/src/channels/plugins/setup-wizard-types.ts @@ -293,6 +293,7 @@ export type SetupChannelsOptions = { onResolvedPlugin?: (channel: ChannelId, plugin: ChannelSetupPlugin) => void; promptAccountIds?: boolean; forceAllowFromChannels?: ChannelId[]; + deferStatusUntilSelection?: boolean; skipStatusNote?: boolean; skipDmPolicyPrompt?: boolean; skipConfirm?: boolean; diff --git a/src/commands/configure.channels.test.ts b/src/commands/configure.channels.test.ts new file mode 100644 index 00000000000..2fce7d59635 --- /dev/null +++ b/src/commands/configure.channels.test.ts @@ -0,0 +1,267 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const select = vi.hoisted(() => vi.fn()); +const confirm = vi.hoisted(() => vi.fn()); +const note = vi.hoisted(() => vi.fn()); +const chatChannels = vi.hoisted(() => + vi.fn(() => [ + { id: "telegram", label: "Telegram" }, + { id: "twitch", label: "Twitch" }, + ]), +); + +vi.mock("../channels/chat-meta.js", () => ({ + listChatChannels: () => chatChannels(), +})); + +vi.mock("../terminal/note.js", () => ({ + note: (...args: unknown[]) => note(...args), +})); + +vi.mock("./configure.shared.js", () => ({ + select: (params: unknown) => select(params), + confirm: (params: unknown) => confirm(params), +})); + +import { removeChannelConfigWizard } from "./configure.channels.js"; + +const channelChoice = (id: string) => ({ kind: "channel" as const, id }); +const doneChoice = { kind: "done" as const }; + +describe("removeChannelConfigWizard", () => { + beforeEach(() => { + vi.resetAllMocks(); + chatChannels.mockReturnValue([ + { id: "telegram", label: "Telegram" }, + { id: "twitch", label: "Twitch" }, + ]); + confirm.mockResolvedValue(true); + }); + + it("lists configured channels from openclaw.json even when no plugins are loaded", async () => { + select.mockResolvedValue(doneChoice); + + await removeChannelConfigWizard( + { + channels: { + defaults: { groupPolicy: "open" }, + modelByChannel: { openai: { telegram: "gpt-5.4" } }, + twitch: {}, + unknown: {}, + telegram: {}, + }, + } as never, + {} as never, + ); + + expect(select).toHaveBeenCalledWith( + expect.objectContaining({ + message: "Remove which channel config?", + options: [ + expect.objectContaining({ value: channelChoice("telegram"), label: "Telegram" }), + expect.objectContaining({ value: channelChoice("twitch"), label: "Twitch" }), + expect.objectContaining({ value: channelChoice("unknown"), label: "unknown" }), + { value: doneChoice, label: "Done" }, + ], + }), + ); + }); + + it("deletes the selected channel block from openclaw.json", async () => { + select.mockResolvedValueOnce(channelChoice("telegram")).mockResolvedValueOnce(doneChoice); + + const next = await removeChannelConfigWizard( + { + channels: { + telegram: { token: "secret" }, + twitch: { token: "secret" }, + }, + } as never, + {} as never, + ); + + expect(confirm).toHaveBeenCalledWith( + expect.objectContaining({ + message: "Delete Telegram configuration from ~/.openclaw/openclaw.json?", + }), + ); + expect(next.channels).toEqual({ twitch: { token: "secret" } }); + expect(note).toHaveBeenCalledWith( + "Telegram removed from config.\nNote: credentials/sessions on disk are unchanged.", + "Channel removed", + ); + }); + + it("deletes a real channel block named done", async () => { + select.mockResolvedValueOnce(channelChoice("done")).mockResolvedValueOnce(doneChoice); + + const next = await removeChannelConfigWizard( + { + channels: { + done: { token: "secret" }, + telegram: { token: "secret" }, + }, + } as never, + {} as never, + ); + + expect(confirm).toHaveBeenCalledWith( + expect.objectContaining({ + message: "Delete done configuration from ~/.openclaw/openclaw.json?", + }), + ); + expect(next.channels).toEqual({ telegram: { token: "secret" } }); + expect(note).toHaveBeenCalledWith( + "done removed from config.\nNote: credentials/sessions on disk are unchanged.", + "Channel removed", + ); + }); + + it("preserves channel-wide defaults when deleting the last channel block", async () => { + select.mockResolvedValueOnce(channelChoice("telegram")).mockResolvedValueOnce(doneChoice); + + const next = await removeChannelConfigWizard( + { + channels: { + defaults: { groupPolicy: "open" }, + modelByChannel: { openai: { telegram: "gpt-5.4" } }, + telegram: { token: "secret" }, + }, + } as never, + {} as never, + ); + + expect(next.channels).toEqual({ + defaults: { groupPolicy: "open" }, + modelByChannel: { openai: { telegram: "gpt-5.4" } }, + }); + }); + + it("does not list blocked object keys as removable channels", async () => { + select.mockResolvedValue(doneChoice); + + await removeChannelConfigWizard( + { + channels: { + __proto__: { token: "secret" }, + constructor: { token: "secret" }, + prototype: { token: "secret" }, + telegram: { token: "secret" }, + }, + } as never, + {} as never, + ); + + expect(select).toHaveBeenCalledWith( + expect.objectContaining({ + options: [ + expect.objectContaining({ value: channelChoice("telegram"), label: "Telegram" }), + { value: doneChoice, label: "Done" }, + ], + }), + ); + }); + + it("sanitizes known channel labels before rendering prompts", async () => { + chatChannels.mockReturnValue([ + { id: "telegram", label: "Telegram\u001B[31m\nBot\u0007" }, + { id: "twitch", label: "Twitch" }, + ]); + select.mockResolvedValueOnce(channelChoice("telegram")).mockResolvedValueOnce(doneChoice); + + await removeChannelConfigWizard( + { + channels: { + telegram: { token: "secret" }, + }, + } as never, + {} as never, + ); + + expect(select).toHaveBeenCalledWith( + expect.objectContaining({ + options: expect.arrayContaining([ + expect.objectContaining({ value: channelChoice("telegram"), label: "Telegram\\nBot" }), + ]), + }), + ); + expect(confirm).toHaveBeenCalledWith( + expect.objectContaining({ + message: "Delete Telegram\\nBot configuration from ~/.openclaw/openclaw.json?", + }), + ); + expect(note).toHaveBeenCalledWith( + "Telegram\\nBot removed from config.\nNote: credentials/sessions on disk are unchanged.", + "Channel removed", + ); + }); + + it("sanitizes unknown channel keys before rendering prompts", async () => { + const unsafeChannel = "bad\u001B[31m\nkey\u0007"; + select.mockResolvedValueOnce(channelChoice(unsafeChannel)).mockResolvedValueOnce(doneChoice); + + const next = await removeChannelConfigWizard( + { + channels: { + [unsafeChannel]: { token: "secret" }, + telegram: { token: "secret" }, + }, + } as never, + {} as never, + ); + + expect(select).toHaveBeenCalledWith( + expect.objectContaining({ + options: expect.arrayContaining([ + expect.objectContaining({ value: channelChoice(unsafeChannel), label: "bad\\nkey" }), + ]), + }), + ); + expect(confirm).toHaveBeenCalledWith( + expect.objectContaining({ + message: "Delete bad\\nkey configuration from ~/.openclaw/openclaw.json?", + }), + ); + expect(next.channels).toEqual({ telegram: { token: "secret" } }); + expect(note).toHaveBeenCalledWith( + "bad\\nkey removed from config.\nNote: credentials/sessions on disk are unchanged.", + "Channel removed", + ); + }); + + it("uses a placeholder when an unknown channel key sanitizes to empty", async () => { + const unsafeChannel = "\u001B[31m\u0007"; + select.mockResolvedValueOnce(channelChoice(unsafeChannel)).mockResolvedValueOnce(doneChoice); + + const next = await removeChannelConfigWizard( + { + channels: { + [unsafeChannel]: { token: "secret" }, + telegram: { token: "secret" }, + }, + } as never, + {} as never, + ); + + expect(select).toHaveBeenCalledWith( + expect.objectContaining({ + options: expect.arrayContaining([ + expect.objectContaining({ + value: channelChoice(unsafeChannel), + label: "", + }), + ]), + }), + ); + expect(confirm).toHaveBeenCalledWith( + expect.objectContaining({ + message: "Delete configuration from ~/.openclaw/openclaw.json?", + }), + ); + expect(next.channels).toEqual({ telegram: { token: "secret" } }); + expect(note).toHaveBeenCalledWith( + " removed from config.\nNote: credentials/sessions on disk are unchanged.", + "Channel removed", + ); + }); +}); diff --git a/src/commands/configure.channels.ts b/src/commands/configure.channels.ts index c90da7a4aa2..69321f09f95 100644 --- a/src/commands/configure.channels.ts +++ b/src/commands/configure.channels.ts @@ -1,28 +1,71 @@ -import { getChannelPlugin, listChannelPlugins } from "../channels/plugins/index.js"; +import { listChatChannels } from "../channels/chat-meta.js"; import { formatCliCommand } from "../cli/command-format.js"; import { CONFIG_PATH } from "../config/config.js"; +import { isBlockedObjectKey } from "../config/prototype-keys.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; import type { RuntimeEnv } from "../runtime.js"; import { note } from "../terminal/note.js"; +import { sanitizeTerminalText } from "../terminal/safe-text.js"; import { shortenHomePath } from "../utils.js"; -import { shouldShowChannelInSetup } from "./channel-setup/discovery.js"; import { confirm, select } from "./configure.shared.js"; import { guardCancel } from "./onboard-helpers.js"; +type ConfiguredChannelRemovalChoice = { + id: string; + label: string; +}; + +type ChannelRemovalSelectValue = { kind: "channel"; id: string } | { kind: "done" }; + +const RESERVED_CHANNEL_CONFIG_KEYS = new Set(["defaults", "modelByChannel"]); +const DONE_VALUE: ChannelRemovalSelectValue = { kind: "done" }; + +function listConfiguredChannelRemovalChoices( + cfg: OpenClawConfig, +): ConfiguredChannelRemovalChoice[] { + const channels = cfg.channels; + if (!channels) { + return []; + } + const labelsById = new Map( + listChatChannels().map((meta) => [meta.id, formatChannelRemovalLabel(meta.label, meta.id)]), + ); + return Object.keys(channels) + .filter((id) => !RESERVED_CHANNEL_CONFIG_KEYS.has(id)) + .filter((id) => !isBlockedObjectKey(id)) + .map((id) => ({ + id, + label: labelsById.get(id) ?? formatUnknownChannelRemovalLabel(id), + })) + .toSorted(compareChannelRemovalChoices); +} + +function formatChannelRemovalLabel(label: string, fallback: string): string { + return sanitizeTerminalText(label) || formatUnknownChannelRemovalLabel(fallback); +} + +function formatUnknownChannelRemovalLabel(id: string): string { + return sanitizeTerminalText(id) || ""; +} + +function compareChannelRemovalChoices( + left: ConfiguredChannelRemovalChoice, + right: ConfiguredChannelRemovalChoice, +): number { + return ( + left.label.localeCompare(right.label, undefined, { numeric: true, sensitivity: "base" }) || + left.id.localeCompare(right.id, undefined, { numeric: true, sensitivity: "base" }) + ); +} + export async function removeChannelConfigWizard( cfg: OpenClawConfig, runtime: RuntimeEnv, ): Promise { let next = { ...cfg }; - const listConfiguredChannels = () => - listChannelPlugins() - .map((plugin) => plugin.meta) - .filter((meta) => shouldShowChannelInSetup(meta)) - .filter((meta) => next.channels?.[meta.id] !== undefined); - while (true) { - const configured = listConfiguredChannels(); + const configured = listConfiguredChannelRemovalChoices(next); if (configured.length === 0) { note( [ @@ -34,26 +77,27 @@ export async function removeChannelConfigWizard( return next; } - const channel = guardCancel( - await select({ + const choice = guardCancel( + await select({ message: "Remove which channel config?", options: [ ...configured.map((meta) => ({ - value: meta.id, + value: { kind: "channel" as const, id: meta.id }, label: meta.label, hint: "Deletes tokens + settings from config (credentials stay on disk)", })), - { value: "done", label: "Done" }, + { value: DONE_VALUE, label: "Done" }, ], }), runtime, ); - if (channel === "done") { + if (choice.kind === "done") { return next; } - const label = getChannelPlugin(channel)?.meta.label ?? channel; + const channel = choice.id; + const label = configured.find((entry) => entry.id === channel)?.label ?? channel; const confirmed = guardCancel( await confirm({ message: `Delete ${label} configuration from ${shortenHomePath(CONFIG_PATH)}?`, diff --git a/src/commands/configure.wizard.test.ts b/src/commands/configure.wizard.test.ts index b17d421425d..842d1d9c3b5 100644 --- a/src/commands/configure.wizard.test.ts +++ b/src/commands/configure.wizard.test.ts @@ -28,6 +28,7 @@ const mocks = vi.hoisted(() => { isCodexNativeWebSearchRelevant: vi.fn(({ config }: { config: OpenClawConfig }) => Boolean(config.auth?.profiles?.["openai-codex:default"]), ), + setupChannels: vi.fn(async (cfg: OpenClawConfig) => cfg), }; }); @@ -104,7 +105,7 @@ vi.mock("./onboard-skills.js", () => ({ })); vi.mock("./onboard-channels.js", () => ({ - setupChannels: vi.fn(), + setupChannels: mocks.setupChannels, })); vi.mock("./onboard-search.js", () => ({ @@ -327,6 +328,28 @@ describe("runConfigureWizard", () => { ); }); + it("defers channel status checks until a channel is selected", async () => { + setupBaseWizardState(); + queueWizardPrompts({ + select: ["local", "configure"], + confirm: [], + }); + + await runConfigureWizard({ command: "configure", sections: ["channels"] }, createRuntime()); + + expect(mocks.setupChannels).toHaveBeenCalledWith( + expect.objectContaining({ + gateway: expect.objectContaining({ mode: "local" }), + }), + expect.anything(), + expect.anything(), + expect.objectContaining({ + deferStatusUntilSelection: true, + skipStatusNote: true, + }), + ); + }); + it("still supports keyless web search providers through the shared setup flow", async () => { setupBaseWizardState(); mocks.resolveSearchProviderOptions.mockReturnValue([ diff --git a/src/commands/configure.wizard.ts b/src/commands/configure.wizard.ts index e64b7cf2630..91de71280f4 100644 --- a/src/commands/configure.wizard.ts +++ b/src/commands/configure.wizard.ts @@ -35,7 +35,7 @@ import { } from "./configure.shared.js"; import { formatHealthCheckFailure } from "./health-format.js"; import { healthCommand } from "./health.js"; -import { noteChannelStatus, setupChannels } from "./onboard-channels.js"; +import { setupChannels } from "./onboard-channels.js"; import { applyWizardMetadata, DEFAULT_WORKSPACE, @@ -561,12 +561,12 @@ export async function runConfigureWizard( }; const configureChannelsSection = async () => { - await noteChannelStatus({ cfg: nextConfig, prompter }); const channelMode = await promptChannelMode(runtime); if (channelMode === "configure") { nextConfig = await setupChannels(nextConfig, runtime, prompter, { allowDisable: true, allowSignalInstall: true, + deferStatusUntilSelection: true, skipConfirm: true, skipStatusNote: true, }); diff --git a/src/flows/channel-setup.status.test.ts b/src/flows/channel-setup.status.test.ts new file mode 100644 index 00000000000..b9f3816d428 --- /dev/null +++ b/src/flows/channel-setup.status.test.ts @@ -0,0 +1,311 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const listChatChannels = vi.hoisted(() => + vi.fn(() => [ + { id: "discord", label: "Discord" }, + { id: "bluebubbles", label: "BlueBubbles" }, + ]), +); +const resolveChannelSetupEntries = vi.hoisted(() => + vi.fn(() => ({ + entries: [], + installedCatalogEntries: [], + installableCatalogEntries: [], + installedCatalogById: new Map(), + installableCatalogById: new Map(), + })), +); +const formatChannelPrimerLine = vi.hoisted(() => + vi.fn((meta: { label: string; blurb: string }) => `${meta.label}: ${meta.blurb}`), +); +const formatChannelSelectionLine = vi.hoisted(() => + vi.fn((meta: { label: string; blurb: string }) => `${meta.label} — ${meta.blurb}`), +); +const isChannelConfigured = vi.hoisted(() => vi.fn(() => false)); + +vi.mock("../channels/chat-meta.js", () => ({ + listChatChannels: () => listChatChannels(), +})); + +vi.mock("../channels/registry.js", () => ({ + formatChannelPrimerLine: (meta: unknown) => formatChannelPrimerLine(meta), + formatChannelSelectionLine: (meta: unknown, docsLink: unknown) => + formatChannelSelectionLine(meta, docsLink), +})); + +vi.mock("../commands/channel-setup/discovery.js", () => ({ + resolveChannelSetupEntries: (params: unknown) => resolveChannelSetupEntries(params), + shouldShowChannelInSetup: (meta: { exposure?: { setup?: boolean }; showInSetup?: boolean }) => + meta.showInSetup !== false && meta.exposure?.setup !== false, +})); + +vi.mock("../config/channel-configured.js", () => ({ + isChannelConfigured: (cfg: unknown, channelId: string) => isChannelConfigured(cfg, channelId), +})); + +import { + collectChannelStatus, + noteChannelPrimer, + resolveChannelSelectionNoteLines, + resolveChannelSetupSelectionContributions, +} from "./channel-setup.status.js"; + +describe("resolveChannelSetupSelectionContributions", () => { + beforeEach(() => { + vi.clearAllMocks(); + listChatChannels.mockReturnValue([ + { id: "discord", label: "Discord" }, + { id: "bluebubbles", label: "BlueBubbles" }, + ]); + resolveChannelSetupEntries.mockReturnValue({ + entries: [], + installedCatalogEntries: [], + installableCatalogEntries: [], + installedCatalogById: new Map(), + installableCatalogById: new Map(), + }); + formatChannelPrimerLine.mockImplementation( + (meta: { label: string; blurb: string }) => `${meta.label}: ${meta.blurb}`, + ); + formatChannelSelectionLine.mockImplementation( + (meta: { label: string; blurb: string }) => `${meta.label} — ${meta.blurb}`, + ); + isChannelConfigured.mockReturnValue(false); + }); + + it("sorts channels alphabetically by picker label", () => { + const contributions = resolveChannelSetupSelectionContributions({ + entries: [ + { + id: "zalo", + meta: { + id: "zalo", + label: "Zalo", + selectionLabel: "Zalo (Bot API)", + }, + }, + { + id: "discord", + meta: { + id: "discord", + label: "Discord", + selectionLabel: "Discord (Bot API)", + }, + }, + { + id: "bluebubbles", + meta: { + id: "bluebubbles", + label: "BlueBubbles", + selectionLabel: "BlueBubbles (macOS app)", + }, + }, + ] as never, + statusByChannel: new Map(), + resolveDisabledHint: () => undefined, + }); + + expect(contributions.map((contribution) => contribution.option.label)).toEqual([ + "BlueBubbles (macOS app)", + "Discord (Bot API)", + "Zalo (Bot API)", + ]); + }); + + it("does not invent hints before status has been collected", () => { + const contributions = resolveChannelSetupSelectionContributions({ + entries: [ + { + id: "zalo", + meta: { + id: "zalo", + label: "Zalo", + selectionLabel: "Zalo (Bot API)", + quickstartAllowFrom: true, + }, + }, + ] as never, + statusByChannel: new Map(), + resolveDisabledHint: () => undefined, + }); + + expect(contributions.map((contribution) => contribution.option)).toEqual([ + { + value: "zalo", + label: "Zalo (Bot API)", + }, + ]); + }); + + it("combines real status and disabled hints when available", () => { + const contributions = resolveChannelSetupSelectionContributions({ + entries: [ + { + id: "zalo", + meta: { + id: "zalo", + label: "Zalo", + selectionLabel: "Zalo (Bot API)", + quickstartAllowFrom: true, + }, + }, + ] as never, + statusByChannel: new Map([["zalo", { selectionHint: "configured" }]]), + resolveDisabledHint: () => "disabled", + }); + + expect(contributions[0]?.option).toEqual({ + value: "zalo", + label: "Zalo (Bot API)", + hint: "configured · disabled", + }); + }); + + it("sanitizes picker labels and hints before terminal rendering", () => { + const contributions = resolveChannelSetupSelectionContributions({ + entries: [ + { + id: "zalo", + meta: { + id: "zalo", + label: "Zalo\u001B[31m\nBot\u0007", + }, + }, + ] as never, + statusByChannel: new Map([["zalo", { selectionHint: "configured\u001B[2K\nnow" }]]), + resolveDisabledHint: () => "disabled\u0007", + }); + + expect(contributions[0]?.option).toEqual({ + value: "zalo", + label: "Zalo\\nBot", + hint: "configured\\nnow · disabled", + }); + }); + + it("sanitizes the picker fallback label when metadata sanitizes to empty", () => { + const contributions = resolveChannelSetupSelectionContributions({ + entries: [ + { + id: "bad\u001B[31m\nid", + meta: { + id: "bad\u001B[31m\nid", + label: "\u001B[31m\u0007", + }, + }, + ] as never, + statusByChannel: new Map(), + resolveDisabledHint: () => undefined, + }); + + expect(contributions[0]?.option).toEqual({ + value: "bad\u001B[31m\nid", + label: "bad\\nid", + }); + }); + + it("sanitizes channel labels in status note lines", async () => { + listChatChannels.mockReturnValue([{ id: "discord", label: "Discord\u001B[31m\nCore\u0007" }]); + resolveChannelSetupEntries.mockReturnValue({ + entries: [], + installedCatalogEntries: [ + { + id: "matrix", + pluginId: "matrix", + meta: { id: "matrix", label: "Matrix\u001B[2K\nPlugin\u0007" }, + }, + ], + installableCatalogEntries: [ + { + id: "zalo", + pluginId: "zalo", + meta: { id: "zalo", label: "Zalo\u001B[2K\nPlugin\u0007" }, + }, + ], + installedCatalogById: new Map(), + installableCatalogById: new Map(), + }); + + const summary = await collectChannelStatus({ + cfg: {} as never, + accountOverrides: {}, + installedPlugins: [], + }); + + expect(summary.statusLines).toEqual([ + "Discord\\nCore: not configured", + "Matrix\\nPlugin: installed", + "Zalo\\nPlugin: install plugin to enable", + ]); + }); + + it("sanitizes channel metadata before primer notes", async () => { + const note = vi.fn(async () => undefined); + + await noteChannelPrimer( + { note } as never, + [ + { + id: "bad\u001B[31m\nid", + label: "\u001B[31m\u0007", + blurb: "Blurb\u001B[2K\nline\u0007", + }, + ] as never, + ); + + expect(formatChannelPrimerLine).toHaveBeenCalledWith( + expect.objectContaining({ + id: "bad\\nid", + label: "bad\\nid", + selectionLabel: "bad\\nid", + blurb: "Blurb\\nline", + }), + ); + expect(note).toHaveBeenCalledWith( + expect.stringContaining("bad\\nid: Blurb\\nline"), + "How channels work", + ); + }); + + it("sanitizes channel metadata before selection notes", () => { + resolveChannelSetupEntries.mockReturnValue({ + entries: [ + { + id: "zalo", + meta: { + id: "zalo", + label: "Zalo\u001B[31m\nBot\u0007", + selectionLabel: "Zalo", + docsPath: "/channels/zalo", + docsLabel: "Docs\u001B[2K\nLabel", + blurb: "Setup\u001B[2K\nhelp\u0007", + selectionDocsPrefix: "Docs\u001B[2K\nPrefix", + selectionExtras: ["Extra\u001B[2K\nOne", "\u001B[31m\u0007"], + }, + }, + ], + installedCatalogEntries: [], + installableCatalogEntries: [], + installedCatalogById: new Map(), + installableCatalogById: new Map(), + }); + + const lines = resolveChannelSelectionNoteLines({ + cfg: {} as never, + installedPlugins: [], + selection: ["zalo"], + }); + + expect(formatChannelSelectionLine).toHaveBeenCalledWith( + expect.objectContaining({ + label: "Zalo\\nBot", + blurb: "Setup\\nhelp", + docsLabel: "Docs\\nLabel", + selectionDocsPrefix: "Docs\\nPrefix", + selectionExtras: ["Extra\\nOne"], + }), + expect.any(Function), + ); + expect(lines).toEqual(["Zalo\\nBot — Setup\\nhelp"]); + }); +}); diff --git a/src/flows/channel-setup.status.ts b/src/flows/channel-setup.status.ts index fd1a5d97fc3..919454eb58e 100644 --- a/src/flows/channel-setup.status.ts +++ b/src/flows/channel-setup.status.ts @@ -3,6 +3,7 @@ import { listChatChannels } from "../channels/chat-meta.js"; import { listChannelPluginCatalogEntries } from "../channels/plugins/catalog.js"; import { listChannelSetupPlugins } from "../channels/plugins/setup-registry.js"; import type { ChannelSetupPlugin } from "../channels/plugins/setup-wizard-types.js"; +import type { ChannelMeta } from "../channels/plugins/types.core.js"; import { formatChannelPrimerLine, formatChannelSelectionLine } from "../channels/registry.js"; import { formatCliCommand } from "../cli/command-format.js"; import { resolveChannelSetupEntries } from "../commands/channel-setup/discovery.js"; @@ -17,6 +18,7 @@ import type { ChannelChoice } from "../commands/onboard-types.js"; import { isChannelConfigured } from "../config/channel-configured.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; import { formatDocsLink } from "../terminal/links.js"; +import { sanitizeTerminalText } from "../terminal/safe-text.js"; import type { WizardPrompter } from "../wizard/prompts.js"; import type { FlowContribution } from "./types.js"; @@ -35,6 +37,18 @@ export type ChannelSetupSelectionContribution = FlowContribution & { source: "catalog" | "core" | "plugin"; }; +type ChannelSetupSelectionEntry = { + id: ChannelChoice; + meta: { + id: string; + label: string; + selectionLabel?: string; + exposure?: { setup?: boolean }; + showConfigured?: boolean; + showInSetup?: boolean; + }; +}; + function buildChannelSetupSelectionContribution(params: { channel: ChannelChoice; label: string; @@ -55,6 +69,64 @@ function buildChannelSetupSelectionContribution(params: { }; } +function formatSetupSelectionLabel(label: string, fallback: string): string { + return ( + sanitizeTerminalText(label).trim() || + sanitizeTerminalText(fallback).trim() || + "" + ); +} + +function formatSetupSelectionHint(hint: string | undefined): string | undefined { + if (!hint) { + return undefined; + } + return sanitizeTerminalText(hint) || undefined; +} + +function formatSetupDisplayText(value: string | undefined, fallback = ""): string { + return ( + sanitizeTerminalText(value ?? "").trim() || + sanitizeTerminalText(fallback).trim() || + "" + ); +} + +function formatSetupFreeText(value: string | undefined): string { + return sanitizeTerminalText(value ?? "").trim(); +} + +function formatSetupOptionalDisplayText(value: string | undefined): string | undefined { + const safe = sanitizeTerminalText(value ?? "").trim(); + return safe || undefined; +} + +function formatSetupDisplayList(values: readonly string[] | undefined): string[] | undefined { + const safe = (values ?? []).flatMap((value) => { + const sanitized = formatSetupOptionalDisplayText(value); + return sanitized ? [sanitized] : []; + }); + return safe.length > 0 ? safe : undefined; +} + +function formatSetupDisplayMeta(meta: ChannelMeta): ChannelMeta { + const safeId = formatSetupDisplayText(meta.id, ""); + const safeLabel = formatSetupDisplayText(meta.label, safeId); + const safeSelectionDocsPrefix = formatSetupOptionalDisplayText(meta.selectionDocsPrefix); + const safeSelectionExtras = formatSetupDisplayList(meta.selectionExtras); + return { + ...meta, + id: safeId, + label: safeLabel, + selectionLabel: formatSetupDisplayText(meta.selectionLabel, safeLabel), + docsPath: formatSetupDisplayText(meta.docsPath, "/"), + ...(meta.docsLabel ? { docsLabel: formatSetupDisplayText(meta.docsLabel, safeId) } : {}), + blurb: formatSetupFreeText(meta.blurb), + ...(safeSelectionDocsPrefix ? { selectionDocsPrefix: safeSelectionDocsPrefix } : {}), + ...(safeSelectionExtras ? { selectionExtras: safeSelectionExtras } : {}), + }; +} + export async function collectChannelStatus(params: { cfg: OpenClawConfig; options?: SetupChannelsOptions; @@ -101,7 +173,7 @@ export async function collectChannelStatus(params: { return { channel: meta.id, configured, - statusLines: [`${meta.label}: ${statusLabel}`], + statusLines: [`${formatSetupSelectionLabel(meta.label, meta.id)}: ${statusLabel}`], selectionHint: configured ? "configured · plugin disabled" : "not configured", quickstartScore: 0, }; @@ -122,7 +194,7 @@ export async function collectChannelStatus(params: { return { channel: entry.id as ChannelChoice, configured, - statusLines: [`${entry.meta.label}: ${statusLabel}`], + statusLines: [`${formatSetupSelectionLabel(entry.meta.label, entry.id)}: ${statusLabel}`], selectionHint: statusLabel, quickstartScore: 0, }; @@ -130,7 +202,9 @@ export async function collectChannelStatus(params: { const catalogStatuses = installableCatalogEntries.map((entry) => ({ channel: entry.id, configured: false, - statusLines: [`${entry.meta.label}: install plugin to enable`], + statusLines: [ + `${formatSetupSelectionLabel(entry.meta.label, entry.id)}: install plugin to enable`, + ], selectionHint: "plugin · install", quickstartScore: 0, })); @@ -176,13 +250,15 @@ export async function noteChannelPrimer( channels: Array<{ id: ChannelChoice; blurb: string; label: string }>, ): Promise { const channelLines = channels.map((channel) => - formatChannelPrimerLine({ - id: channel.id, - label: channel.label, - selectionLabel: channel.label, - docsPath: "/", - blurb: channel.blurb, - }), + formatChannelPrimerLine( + formatSetupDisplayMeta({ + id: channel.id, + label: channel.label, + selectionLabel: channel.label, + docsPath: "/", + blurb: channel.blurb, + }), + ), ); await prompter.note( [ @@ -227,7 +303,10 @@ export function resolveChannelSelectionNoteLines(params: { }); const selectionNotes = new Map(); for (const entry of entries) { - selectionNotes.set(entry.id, formatChannelSelectionLine(entry.meta, formatDocsLink)); + selectionNotes.set( + entry.id, + formatChannelSelectionLine(formatSetupDisplayMeta(entry.meta), formatDocsLink), + ); } return params.selection .map((channel) => selectionNotes.get(channel)) @@ -235,33 +314,35 @@ export function resolveChannelSelectionNoteLines(params: { } export function resolveChannelSetupSelectionContributions(params: { - entries: Array<{ - id: ChannelChoice; - meta: { - id: string; - label: string; - selectionLabel?: string; - exposure?: { setup?: boolean }; - showConfigured?: boolean; - showInSetup?: boolean; - }; - }>; + entries: ChannelSetupSelectionEntry[]; statusByChannel: Map; resolveDisabledHint: (channel: ChannelChoice) => string | undefined; }): ChannelSetupSelectionContribution[] { + const bundledChannelIds = new Set(listChatChannels().map((channel) => channel.id)); return params.entries .filter((entry) => shouldShowChannelInSetup(entry.meta)) + .toSorted((left, right) => compareChannelSetupSelectionEntries(left, right)) .map((entry) => { const disabledHint = params.resolveDisabledHint(entry.id); - const hint = - [params.statusByChannel.get(entry.id)?.selectionHint, disabledHint] - .filter(Boolean) - .join(" · ") || undefined; + const statusHint = params.statusByChannel.get(entry.id)?.selectionHint; + const hint = [statusHint, disabledHint].filter(Boolean).join(" · ") || undefined; return buildChannelSetupSelectionContribution({ channel: entry.id, - label: entry.meta.selectionLabel ?? entry.meta.label, - hint, - source: listChatChannels().some((channel) => channel.id === entry.id) ? "core" : "plugin", + label: formatSetupSelectionLabel(entry.meta.selectionLabel ?? entry.meta.label, entry.id), + hint: formatSetupSelectionHint(hint), + source: bundledChannelIds.has(entry.id) ? "core" : "plugin", }); }); } + +function compareChannelSetupSelectionEntries( + left: ChannelSetupSelectionEntry, + right: ChannelSetupSelectionEntry, +): number { + const leftLabel = left.meta.selectionLabel ?? left.meta.label; + const rightLabel = right.meta.selectionLabel ?? right.meta.label; + return ( + leftLabel.localeCompare(rightLabel, undefined, { numeric: true, sensitivity: "base" }) || + left.id.localeCompare(right.id, undefined, { numeric: true, sensitivity: "base" }) + ); +} diff --git a/src/flows/channel-setup.test.ts b/src/flows/channel-setup.test.ts index 5cee897c568..040c2ea4b46 100644 --- a/src/flows/channel-setup.test.ts +++ b/src/flows/channel-setup.test.ts @@ -9,9 +9,29 @@ const listTrustedChannelPluginCatalogEntries = vi.hoisted(() => ); const getChannelSetupPlugin = vi.hoisted(() => vi.fn((_channel?: unknown) => undefined)); const listChannelSetupPlugins = vi.hoisted(() => vi.fn((): unknown[] => [])); +const listActiveChannelSetupPlugins = vi.hoisted(() => vi.fn((): unknown[] => [])); const loadChannelSetupPluginRegistrySnapshotForChannel = vi.hoisted(() => vi.fn((_params?: unknown) => ({ channels: [], channelSetups: [] })), ); +const resolveChannelSetupEntries = vi.hoisted(() => + vi.fn( + ( + _params?: unknown, + ): { + entries: unknown[]; + installedCatalogEntries: unknown[]; + installableCatalogEntries: unknown[]; + installedCatalogById: Map; + installableCatalogById: Map; + } => ({ + entries: [], + installedCatalogEntries: [], + installableCatalogEntries: [], + installedCatalogById: new Map(), + installableCatalogById: new Map(), + }), + ), +); const collectChannelStatus = vi.hoisted(() => vi.fn(async (_params?: unknown) => ({ installedPlugins: [], @@ -31,6 +51,7 @@ vi.mock("../agents/agent-scope.js", () => ({ vi.mock("../channels/plugins/setup-registry.js", () => ({ getChannelSetupPlugin: (channel?: unknown) => getChannelSetupPlugin(channel), + listActiveChannelSetupPlugins: () => listActiveChannelSetupPlugins(), listChannelSetupPlugins: () => listChannelSetupPlugins(), })); @@ -42,7 +63,7 @@ vi.mock("../channels/registry.js", () => ({ })); vi.mock("../commands/channel-setup/discovery.js", () => ({ - resolveChannelSetupEntries: vi.fn(), + resolveChannelSetupEntries: (params?: unknown) => resolveChannelSetupEntries(params), shouldShowChannelInSetup: () => true, })); @@ -53,7 +74,8 @@ vi.mock("../commands/channel-setup/plugin-install.js", () => ({ })); vi.mock("../commands/channel-setup/registry.js", () => ({ - resolveChannelSetupWizardAdapterForPlugin: () => undefined, + resolveChannelSetupWizardAdapterForPlugin: (plugin?: { setupWizard?: unknown }) => + plugin?.setupWizard, })); vi.mock("../commands/channel-setup/trusted-catalog.js", () => ({ @@ -96,11 +118,19 @@ describe("setupChannels workspace shadow exclusion", () => { }, ]); getChannelSetupPlugin.mockReturnValue(undefined); + listActiveChannelSetupPlugins.mockReturnValue([]); listChannelSetupPlugins.mockReturnValue([]); loadChannelSetupPluginRegistrySnapshotForChannel.mockReturnValue({ channels: [], channelSetups: [], }); + resolveChannelSetupEntries.mockReturnValue({ + entries: [], + installedCatalogEntries: [], + installableCatalogEntries: [], + installedCatalogById: new Map(), + installableCatalogById: new Map(), + }); collectChannelStatus.mockResolvedValue({ installedPlugins: [], catalogEntries: [], @@ -163,4 +193,337 @@ describe("setupChannels workspace shadow exclusion", () => { }), ); }); + + it("defers status and setup-plugin loads until a channel is selected", async () => { + resolveChannelSetupEntries.mockReturnValue({ + entries: [ + { + id: "telegram", + meta: { id: "telegram", label: "Telegram", blurb: "" }, + }, + ], + installedCatalogEntries: [], + installableCatalogEntries: [], + installedCatalogById: new Map(), + installableCatalogById: new Map(), + }); + 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(select).toHaveBeenCalledWith(expect.objectContaining({ message: "Select a channel" })); + expect(collectChannelStatus).not.toHaveBeenCalled(); + expect(listTrustedChannelPluginCatalogEntries).not.toHaveBeenCalled(); + expect(listChannelSetupPlugins).not.toHaveBeenCalled(); + expect(getChannelSetupPlugin).not.toHaveBeenCalled(); + expect(loadChannelSetupPluginRegistrySnapshotForChannel).not.toHaveBeenCalled(); + }); + + it("keeps already-active setup plugins in the deferred picker without registry fallback", async () => { + const activePlugin = { + id: "custom-chat", + meta: { id: "custom-chat", label: "Custom Chat", blurb: "" }, + }; + listActiveChannelSetupPlugins.mockReturnValue([activePlugin]); + resolveChannelSetupEntries.mockImplementation(() => ({ + entries: [], + installedCatalogEntries: [], + installableCatalogEntries: [], + installedCatalogById: new Map(), + installableCatalogById: new Map(), + })); + 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(resolveChannelSetupEntries).toHaveBeenCalledWith( + expect.objectContaining({ + installedPlugins: [activePlugin], + }), + ); + expect(listChannelSetupPlugins).not.toHaveBeenCalled(); + expect(collectChannelStatus).not.toHaveBeenCalled(); + }); + + it("uses an active deferred setup plugin without enabling config on selection", async () => { + const setupWizard = { + channel: "custom-chat", + getStatus: vi.fn(async () => ({ + channel: "custom-chat", + configured: false, + statusLines: [], + })), + configure: vi.fn(async ({ cfg }: { cfg: Record }) => ({ + cfg: { + ...cfg, + channels: { + "custom-chat": { token: "secret" }, + }, + }, + })), + }; + const activePlugin = { + id: "custom-chat", + meta: { id: "custom-chat", label: "Custom Chat", blurb: "" }, + capabilities: {}, + config: { + resolveAccount: vi.fn(() => ({})), + }, + setupWizard, + }; + listActiveChannelSetupPlugins.mockReturnValue([activePlugin]); + resolveChannelSetupEntries.mockReturnValue({ + entries: [ + { + id: "custom-chat", + meta: { id: "custom-chat", label: "Custom Chat", blurb: "" }, + }, + ], + installedCatalogEntries: [], + installableCatalogEntries: [], + installedCatalogById: new Map(), + installableCatalogById: new Map(), + }); + const select = vi.fn().mockResolvedValueOnce("custom-chat").mockResolvedValueOnce("__done__"); + + const next = await setupChannels( + {} as never, + {} as never, + { + confirm: vi.fn(async () => true), + note: vi.fn(async () => undefined), + select, + } as never, + { + deferStatusUntilSelection: true, + skipConfirm: true, + skipDmPolicyPrompt: true, + }, + ); + + expect(loadChannelSetupPluginRegistrySnapshotForChannel).not.toHaveBeenCalled(); + expect(setupWizard.configure).toHaveBeenCalledWith( + expect.objectContaining({ + cfg: {}, + }), + ); + expect(next).toEqual({ + channels: { + "custom-chat": { token: "secret" }, + }, + }); + }); + + it("loads the selected bundled catalog plugin without writing explicit plugin enablement", async () => { + const setupWizard = { + channel: "telegram", + getStatus: vi.fn(async () => ({ + channel: "telegram", + configured: false, + statusLines: [], + })), + configure: vi.fn(async ({ cfg }: { cfg: Record }) => ({ + cfg: { + ...cfg, + channels: { + telegram: { token: "secret" }, + }, + }, + })), + }; + const telegramPlugin = { + id: "telegram", + meta: { id: "telegram", label: "Telegram", blurb: "" }, + capabilities: {}, + config: { + resolveAccount: vi.fn(() => ({})), + }, + setupWizard, + }; + const installedCatalogEntry = { + id: "telegram", + pluginId: "telegram", + origin: "bundled", + meta: { id: "telegram", label: "Telegram", blurb: "" }, + }; + resolveChannelSetupEntries.mockReturnValue({ + entries: [ + { + id: "telegram", + meta: { id: "telegram", label: "Telegram", blurb: "" }, + }, + ], + installedCatalogEntries: [installedCatalogEntry], + installableCatalogEntries: [], + installedCatalogById: new Map([["telegram", installedCatalogEntry]]), + installableCatalogById: new Map(), + }); + loadChannelSetupPluginRegistrySnapshotForChannel.mockReturnValue({ + channels: [{ plugin: telegramPlugin }], + channelSetups: [], + }); + const select = vi.fn().mockResolvedValueOnce("telegram").mockResolvedValueOnce("__done__"); + + const next = await setupChannels( + {} as never, + {} as never, + { + confirm: vi.fn(async () => true), + note: vi.fn(async () => undefined), + select, + } as never, + { + deferStatusUntilSelection: true, + skipConfirm: true, + skipDmPolicyPrompt: true, + }, + ); + + expect(loadChannelSetupPluginRegistrySnapshotForChannel).toHaveBeenCalledTimes(1); + expect(loadChannelSetupPluginRegistrySnapshotForChannel).toHaveBeenCalledWith( + expect.objectContaining({ + channel: "telegram", + pluginId: "telegram", + workspaceDir: "/tmp/openclaw-workspace", + }), + ); + expect(getChannelSetupPlugin).not.toHaveBeenCalled(); + expect(collectChannelStatus).not.toHaveBeenCalled(); + expect(setupWizard.configure).toHaveBeenCalledWith( + expect.objectContaining({ + cfg: {}, + }), + ); + expect(next).toEqual({ + channels: { + telegram: { token: "secret" }, + }, + }); + }); + + it("does not load or re-enable an explicitly disabled channel when selected lazily", async () => { + const setupWizard = { + channel: "telegram", + getStatus: vi.fn(async () => ({ + channel: "telegram", + configured: true, + statusLines: [], + })), + configure: vi.fn(), + }; + resolveChannelSetupEntries.mockReturnValue({ + entries: [ + { + id: "telegram", + meta: { id: "telegram", label: "Telegram", blurb: "" }, + }, + ], + installedCatalogEntries: [], + installableCatalogEntries: [], + installedCatalogById: new Map(), + installableCatalogById: new Map(), + }); + const select = vi.fn().mockResolvedValueOnce("telegram").mockResolvedValueOnce("__done__"); + const note = vi.fn(async () => undefined); + const cfg = { + channels: { + telegram: { enabled: false, token: "secret" }, + }, + }; + + const next = await setupChannels( + cfg as never, + {} as never, + { + confirm: vi.fn(async () => true), + note, + select, + } as never, + { + deferStatusUntilSelection: true, + skipConfirm: true, + skipDmPolicyPrompt: true, + }, + ); + + expect(loadChannelSetupPluginRegistrySnapshotForChannel).not.toHaveBeenCalled(); + expect(note).toHaveBeenCalledWith( + "telegram cannot be configured while disabled. Enable it before setup.", + "Channel setup", + ); + expect(setupWizard.configure).not.toHaveBeenCalled(); + expect(next).toEqual({ + channels: { + telegram: { enabled: false, token: "secret" }, + }, + }); + }); + + it("honors global plugin disablement before lazy channel setup loads plugins", async () => { + resolveChannelSetupEntries.mockReturnValue({ + entries: [ + { + id: "telegram", + meta: { id: "telegram", label: "Telegram", blurb: "" }, + }, + ], + installedCatalogEntries: [], + installableCatalogEntries: [], + installedCatalogById: new Map(), + installableCatalogById: new Map(), + }); + const select = vi.fn().mockResolvedValueOnce("telegram").mockResolvedValueOnce("__done__"); + const note = vi.fn(async () => undefined); + const cfg = { + plugins: { enabled: false }, + channels: { + telegram: { enabled: true, token: "secret" }, + }, + }; + + await setupChannels( + cfg as never, + {} as never, + { + confirm: vi.fn(async () => true), + note, + select, + } as never, + { + deferStatusUntilSelection: true, + skipConfirm: true, + skipDmPolicyPrompt: true, + }, + ); + + expect(loadChannelSetupPluginRegistrySnapshotForChannel).not.toHaveBeenCalled(); + expect(note).toHaveBeenCalledWith( + "telegram cannot be configured while plugins disabled. Enable it before setup.", + "Channel setup", + ); + }); }); diff --git a/src/flows/channel-setup.ts b/src/flows/channel-setup.ts index 0ca01cdc69b..6faf4160430 100644 --- a/src/flows/channel-setup.ts +++ b/src/flows/channel-setup.ts @@ -2,6 +2,7 @@ import { resolveAgentWorkspaceDir, resolveDefaultAgentId } from "../agents/agent import { resolveChannelDefaultAccountId } from "../channels/plugins/helpers.js"; import { getChannelSetupPlugin, + listActiveChannelSetupPlugins, listChannelSetupPlugins, } from "../channels/plugins/setup-registry.js"; import type { @@ -22,6 +23,7 @@ import { listTrustedChannelPluginCatalogEntries } from "../commands/channel-setu import type { ChannelSetupConfiguredResult, ChannelSetupResult, + ChannelSetupStatus, ChannelOnboardingPostWriteHook, SetupChannelsOptions, } from "../commands/channel-setup/types.js"; @@ -110,6 +112,8 @@ export async function setupChannels( options?: SetupChannelsOptions, ): Promise { let next = cfg; + const deferStatusUntilSelection = options?.deferStatusUntilSelection === true; + const includeRegistryBeforeSelection = !deferStatusUntilSelection; const forceAllowFromChannels = new Set(options?.forceAllowFromChannels ?? []); const accountOverrides: Partial> = { ...options?.accountIds, @@ -121,11 +125,24 @@ export async function setupChannels( scopedPluginsById.set(channel, plugin); options?.onResolvedPlugin?.(channel, plugin); }; + const activePluginsById = new Map(); + const rememberActivePlugin = (plugin: ChannelSetupPlugin) => { + activePluginsById.set(plugin.id, plugin); + return plugin; + }; const getVisibleChannelPlugin = (channel: ChannelChoice): ChannelSetupPlugin | undefined => - scopedPluginsById.get(channel) ?? getChannelSetupPlugin(channel); - const listVisibleInstalledPlugins = (): ChannelSetupPlugin[] => { + scopedPluginsById.get(channel) ?? + activePluginsById.get(channel) ?? + (deferStatusUntilSelection ? undefined : getChannelSetupPlugin(channel)); + const listVisibleInstalledPlugins = (params?: { + includeRegistry?: boolean; + }): ChannelSetupPlugin[] => { + const includeRegistry = params?.includeRegistry ?? includeRegistryBeforeSelection; const merged = new Map(); - for (const plugin of listChannelSetupPlugins()) { + const registryPlugins = includeRegistry + ? listChannelSetupPlugins() + : listActiveChannelSetupPlugins().map(rememberActivePlugin); + for (const plugin of registryPlugins) { if (shouldShowChannelInSetup(plugin.meta)) { merged.set(plugin.id, plugin); } @@ -137,10 +154,10 @@ export async function setupChannels( } return Array.from(merged.values()); }; - const resolveVisibleChannelEntries = () => + const resolveVisibleChannelEntries = (params?: { includeRegistry?: boolean }) => resolveChannelSetupEntries({ cfg: next, - installedPlugins: listVisibleInstalledPlugins(), + installedPlugins: listVisibleInstalledPlugins(params), workspaceDir: resolveWorkspaceDir(), }); const loadScopedChannelPlugin = async ( @@ -172,7 +189,7 @@ export async function setupChannels( if (scopedPlugin) { return resolveChannelSetupWizardAdapterForPlugin(scopedPlugin); } - return resolveChannelSetupWizardAdapterForPlugin(getChannelSetupPlugin(channel)); + return resolveChannelSetupWizardAdapterForPlugin(getVisibleChannelPlugin(channel)); }; const preloadConfiguredExternalPlugins = async () => { // Keep setup memory bounded by snapshot-loading only configured external plugins. @@ -194,15 +211,20 @@ export async function setupChannels( } await Promise.all(preloadTasks); }; - await preloadConfiguredExternalPlugins(); + if (!deferStatusUntilSelection) { + await preloadConfiguredExternalPlugins(); + } - const { statusByChannel, statusLines } = await collectChannelStatus({ - cfg: next, - options, - accountOverrides, - installedPlugins: listVisibleInstalledPlugins(), - resolveAdapter: getVisibleSetupFlowAdapter, - }); + const statusSummary = deferStatusUntilSelection + ? { statusByChannel: new Map(), statusLines: [] } + : await collectChannelStatus({ + cfg: next, + options, + accountOverrides, + installedPlugins: listVisibleInstalledPlugins(), + resolveAdapter: getVisibleSetupFlowAdapter, + }); + const { statusByChannel, statusLines } = statusSummary; if (!options?.skipStatusNote && statusLines.length > 0) { await prompter.note(statusLines.join("\n"), "Channel status"); } @@ -217,7 +239,9 @@ export async function setupChannels( return cfg; } - const primerChannels = resolveVisibleChannelEntries().entries.map((entry) => ({ + const primerChannels = resolveVisibleChannelEntries({ + includeRegistry: includeRegistryBeforeSelection, + }).entries.map((entry) => ({ id: entry.id, label: entry.meta.label, blurb: entry.meta.blurb, @@ -225,7 +249,8 @@ export async function setupChannels( await noteChannelPrimer(prompter, primerChannels); const quickstartDefault = - options?.initialSelection?.[0] ?? resolveQuickstartDefault(statusByChannel); + options?.initialSelection?.[0] ?? + (deferStatusUntilSelection ? undefined : resolveQuickstartDefault(statusByChannel)); const shouldPromptAccountIds = options?.promptAccountIds === true; const accountIdsByChannel = new Map(); @@ -243,7 +268,13 @@ export async function setupChannels( } }; - const resolveDisabledHint = (channel: ChannelChoice): string | undefined => { + const resolveConfigDisabledHint = (channel: ChannelChoice): string | undefined => { + if (next.plugins?.enabled === false) { + return "plugins disabled"; + } + if (next.plugins?.entries?.[channel]?.enabled === false) { + return "plugin disabled"; + } if ( typeof (next.channels as Record | undefined)?.[channel] ?.enabled === "boolean" @@ -252,14 +283,16 @@ export async function setupChannels( ? "disabled" : undefined; } + return undefined; + }; + + const resolveDisabledHint = (channel: ChannelChoice): string | undefined => { + const configDisabledHint = resolveConfigDisabledHint(channel); + if (configDisabledHint || deferStatusUntilSelection) { + return configDisabledHint; + } const plugin = getVisibleChannelPlugin(channel); if (!plugin) { - if (next.plugins?.entries?.[channel]?.enabled === false) { - return "plugin disabled"; - } - if (next.plugins?.enabled === false) { - return "plugins disabled"; - } return undefined; } const accountId = resolveChannelDefaultAccountId({ plugin, cfg: next }); @@ -274,7 +307,9 @@ export async function setupChannels( }; const getChannelEntries = () => { - const resolved = resolveVisibleChannelEntries(); + const resolved = resolveVisibleChannelEntries({ + includeRegistry: includeRegistryBeforeSelection, + }); return { entries: resolved.entries, catalogById: resolved.installableCatalogById, @@ -296,6 +331,14 @@ export async function setupChannels( await refreshStatus(channel); return true; } + const disabledHint = resolveConfigDisabledHint(channel); + if (disabledHint) { + await prompter.note( + `${channel} cannot be configured while ${disabledHint}. Enable it before setup.`, + "Channel setup", + ); + return false; + } const result = enablePluginInConfig(next, channel); next = result.config; if (!result.enabled) { @@ -470,6 +513,16 @@ export async function setupChannels( const { catalogById, installedCatalogById } = getChannelEntries(); const catalogEntry = catalogById.get(channel); const installedCatalogEntry = installedCatalogById.get(channel); + const deferredDisabledHint = deferStatusUntilSelection + ? resolveConfigDisabledHint(channel) + : undefined; + if (deferredDisabledHint) { + await prompter.note( + `${channel} cannot be configured while ${deferredDisabledHint}. Enable it before setup.`, + "Channel setup", + ); + return; + } if (catalogEntry) { const workspaceDir = resolveWorkspaceDir(); const result = await ensureChannelSetupPluginInstalled({ @@ -581,7 +634,9 @@ export async function setupChannels( const selectedLines = resolveChannelSelectionNoteLines({ cfg: next, - installedPlugins: listVisibleInstalledPlugins(), + installedPlugins: listVisibleInstalledPlugins({ + includeRegistry: includeRegistryBeforeSelection, + }), selection, }); if (selectedLines.length > 0) {