Configure: defer channel status until selection (#68007)

Merged via squash.

Prepared head SHA: 24cafcd5fe
Co-authored-by: gumadeiras <5599352+gumadeiras@users.noreply.github.com>
Co-authored-by: gumadeiras <5599352+gumadeiras@users.noreply.github.com>
Reviewed-by: @gumadeiras
This commit is contained in:
Gustavo Madeira Santana
2026-04-17 13:34:13 -04:00
committed by GitHub
parent 474b08bfbd
commit 9bcf8f8243
11 changed files with 1226 additions and 74 deletions

View File

@@ -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) {

View File

@@ -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;

View File

@@ -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: "<invalid channel key>",
}),
]),
}),
);
expect(confirm).toHaveBeenCalledWith(
expect.objectContaining({
message: "Delete <invalid channel key> configuration from ~/.openclaw/openclaw.json?",
}),
);
expect(next.channels).toEqual({ telegram: { token: "secret" } });
expect(note).toHaveBeenCalledWith(
"<invalid channel key> removed from config.\nNote: credentials/sessions on disk are unchanged.",
"Channel removed",
);
});
});

View File

@@ -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) || "<invalid channel key>";
}
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<OpenClawConfig> {
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<ChannelRemovalSelectValue>({
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)}?`,

View File

@@ -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([

View File

@@ -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,
});

View File

@@ -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"]);
});
});

View File

@@ -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() ||
"<invalid channel>"
);
}
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() ||
"<invalid channel>"
);
}
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, "<invalid channel>");
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<void> {
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<string, string>();
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<ChannelChoice, { selectionHint?: string }>;
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" })
);
}

View File

@@ -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<unknown, unknown>;
installableCatalogById: Map<unknown, unknown>;
} => ({
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<string, unknown> }) => ({
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<string, unknown> }) => ({
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",
);
});
});

View File

@@ -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<OpenClawConfig> {
let next = cfg;
const deferStatusUntilSelection = options?.deferStatusUntilSelection === true;
const includeRegistryBeforeSelection = !deferStatusUntilSelection;
const forceAllowFromChannels = new Set(options?.forceAllowFromChannels ?? []);
const accountOverrides: Partial<Record<ChannelChoice, string>> = {
...options?.accountIds,
@@ -121,11 +125,24 @@ export async function setupChannels(
scopedPluginsById.set(channel, plugin);
options?.onResolvedPlugin?.(channel, plugin);
};
const activePluginsById = new Map<ChannelChoice, ChannelSetupPlugin>();
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<string, ChannelSetupPlugin>();
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<ChannelChoice, ChannelSetupStatus>(), 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<ChannelChoice, string>();
@@ -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<string, { enabled?: boolean }> | 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) {