From 1ca444081e5062eb0b3c8e386a18518d2153ceaf Mon Sep 17 00:00:00 2001 From: Gustavo Madeira Santana Date: Fri, 17 Apr 2026 12:04:44 -0400 Subject: [PATCH] CLI: sanitize channel setup notes --- src/flows/channel-setup.status.test.ts | 203 +++++++++++++++++++++++-- src/flows/channel-setup.status.ts | 79 ++++++++-- 2 files changed, 255 insertions(+), 27 deletions(-) diff --git a/src/flows/channel-setup.status.test.ts b/src/flows/channel-setup.status.test.ts index d1e8c576be4..b9f3816d428 100644 --- a/src/flows/channel-setup.status.test.ts +++ b/src/flows/channel-setup.status.test.ts @@ -1,31 +1,78 @@ -import { describe, expect, it, vi } from "vitest"; +import { beforeEach, describe, expect, it, vi } from "vitest"; -const listChatChannels = vi.hoisted(() => vi.fn(() => [{ id: "discord" }, { id: "bluebubbles" }])); - -vi.mock("../channels/chat-meta.js", () => ({ - listChatChannels: () => listChatChannels(), -})); - -vi.mock("../channels/registry.js", () => ({ - formatChannelPrimerLine: vi.fn(() => ""), - formatChannelSelectionLine: vi.fn(() => ""), -})); - -vi.mock("../commands/channel-setup/discovery.js", () => ({ - resolveChannelSetupEntries: vi.fn(() => ({ +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, })); -import { resolveChannelSetupSelectionContributions } from "./channel-setup.status.js"; +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: [ @@ -135,4 +182,130 @@ describe("resolveChannelSetupSelectionContributions", () => { 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 bdbbf6172cb..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"; @@ -69,7 +70,11 @@ function buildChannelSetupSelectionContribution(params: { } function formatSetupSelectionLabel(label: string, fallback: string): string { - return sanitizeTerminalText(label) || fallback; + return ( + sanitizeTerminalText(label).trim() || + sanitizeTerminalText(fallback).trim() || + "" + ); } function formatSetupSelectionHint(hint: string | undefined): string | undefined { @@ -79,6 +84,49 @@ function formatSetupSelectionHint(hint: string | undefined): string | 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; @@ -125,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, }; @@ -146,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, }; @@ -154,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, })); @@ -200,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( [ @@ -251,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))