From ce9e39319640151e11a30084ea94af60c1b70512 Mon Sep 17 00:00:00 2001 From: Jesse Merhi <79823012+jesse-merhi@users.noreply.github.com> Date: Mon, 20 Jul 2026 14:34:05 +1000 Subject: [PATCH] fix: make Back work within channel setup (#108007) * fix: support Back within channel setup * docs: note channel setup Back navigation * fix: keep navigation outcome type private * style: format navigation outcome type * chore: leave changelog to release prep --- extensions/reef/src/setup.test.ts | 36 ++ extensions/reef/src/setup.ts | 11 +- src/commands/channels.add.test.ts | 34 ++ src/commands/channels/add-wizard.ts | 52 ++- src/flows/channel-setup-navigation.ts | 51 +++ src/flows/channel-setup.test.ts | 609 ++++++++++++++++++++++++- src/flows/channel-setup.ts | 177 ++++--- src/wizard/navigation-prompter.test.ts | 69 ++- src/wizard/navigation-prompter.ts | 82 +++- 9 files changed, 1037 insertions(+), 84 deletions(-) create mode 100644 src/flows/channel-setup-navigation.ts diff --git a/extensions/reef/src/setup.test.ts b/extensions/reef/src/setup.test.ts index b87e01884f6..afca2c58353 100644 --- a/extensions/reef/src/setup.test.ts +++ b/extensions/reef/src/setup.test.ts @@ -13,6 +13,7 @@ import { reefSetupWizard } from "./setup.js"; import { finalizeReefIdentityBinding, generateAndStoreKeys, + loadKeys, loadReefIdentityBinding, reserveReefIdentityBinding, } from "./state.js"; @@ -170,4 +171,39 @@ describe("Reef setup wizard identity binding", () => { relayUrl: "https://reefwire.ai", }); }); + + it("declares its persistence boundary before writing keys or creating the handle", async () => { + const runtime = installRuntime(); + const beforePersistentEffect = vi.fn(async () => { + await expect(loadKeys(runtime)).rejects.toMatchObject({ code: "ENOENT" }); + }); + vi.spyOn(ReefTransportClient.prototype, "createHandle").mockImplementation(async () => { + expect(beforePersistentEffect).toHaveBeenCalledTimes(1); + return { handle: "molty", key_epoch: 1 }; + }); + const textAnswers = [ + "https://reefwire.ai", + "owner@example.com", + "setup-session", + "molty", + "gpt-5.6-terra", + "REEF_GUARD_OPENAI_KEY", + "reef-v1", + ]; + const selectAnswers = ["code-only", "openai"]; + const prompter = { + note: vi.fn(async () => undefined), + text: vi.fn(async () => textAnswers.shift() ?? ""), + select: vi.fn(async () => selectAnswers.shift()), + }; + + await reefSetupWizard.configureInteractive({ + cfg: {}, + prompter: prompter as never, + options: { beforePersistentEffect }, + }); + + expect(beforePersistentEffect).toHaveBeenCalledTimes(1); + await expect(loadKeys(runtime)).resolves.toBeDefined(); + }); }); diff --git a/extensions/reef/src/setup.ts b/extensions/reef/src/setup.ts index eaf76fdde65..16ba0306a39 100644 --- a/extensions/reef/src/setup.ts +++ b/extensions/reef/src/setup.ts @@ -69,7 +69,15 @@ export const reefSetupWizard = { }; }, configure: async ({ cfg }: { cfg: OpenClawConfig }) => ({ cfg }), - configureInteractive: async ({ cfg, prompter }: { cfg: OpenClawConfig; prompter: Prompt }) => { + configureInteractive: async ({ + cfg, + prompter, + options, + }: { + cfg: OpenClawConfig; + prompter: Prompt; + options?: { beforePersistentEffect?: () => Promise }; + }) => { const rawRelayUrl = await prompter.text({ message: "Reef relay origin URL", initialValue: "https://reefwire.ai", @@ -125,6 +133,7 @@ export const reefSetupWizard = { ); } const configuredStateDir = (cfg.channels?.reef as { stateDir?: unknown } | undefined)?.stateDir; + await options?.beforePersistentEffect?.(); const keys = await loadKeys(runtime).catch(async (error: unknown) => { if ((error as NodeJS.ErrnoException).code !== "ENOENT") { throw error; diff --git a/src/commands/channels.add.test.ts b/src/commands/channels.add.test.ts index ee175465099..4a12df9c03e 100644 --- a/src/commands/channels.add.test.ts +++ b/src/commands/channels.add.test.ts @@ -478,6 +478,40 @@ describe("channelsAddCommand", () => { expect(channelWizardMocks.prompter.outro).toHaveBeenCalledWith("No channel changes made."); }); + it("persists an accepted plugin install after setup returns to an empty selection", async () => { + const config: OpenClawConfig = { channels: {} }; + const installedConfig: OpenClawConfig = { + ...config, + plugins: { + entries: { "external-chat": { enabled: true } }, + installs: { + "external-chat": { + source: "npm", + spec: "@vendor/external-chat@1.0.0", + }, + }, + }, + }; + configMocks.readConfigFileSnapshot.mockResolvedValue({ + ...baseConfigSnapshot, + sourceConfig: config, + config, + }); + channelWizardMocks.setupChannels.mockResolvedValueOnce(installedConfig); + + await channelsAddCommand({}, runtime, { hasFlags: false }); + + expect( + pluginInstallRecordCommitMocks.commitConfigWithPendingPluginInstalls, + ).toHaveBeenCalledWith(expect.objectContaining({ nextConfig: installedConfig })); + expect( + pluginInstallRecordCommitMocks.commitConfigWithPendingPluginInstalls, + ).toHaveBeenCalledOnce(); + expect(configMocks.writeConfigFile).toHaveBeenCalledWith(installedConfig); + expect(channelWizardMocks.prompter.confirm).not.toHaveBeenCalled(); + expect(channelWizardMocks.prompter.outro).toHaveBeenCalledWith("Channels updated."); + }); + it("preselects an installable catalog channel in guided setup", async () => { const config: OpenClawConfig = { channels: {} }; configMocks.readConfigFileSnapshot.mockResolvedValue({ diff --git a/src/commands/channels/add-wizard.ts b/src/commands/channels/add-wizard.ts index d25fa79b365..978b07b9819 100644 --- a/src/commands/channels/add-wizard.ts +++ b/src/commands/channels/add-wizard.ts @@ -102,7 +102,36 @@ export async function runChannelsAddWizardFlow(params: ChannelsAddWizardFlowPara resolvedPlugins.set(channel, plugin); }, }); + const commitWizardConfig = async (config: OpenClawConfig) => { + await params.beforePersistentEffect?.(); + const committed = await commitConfigWithPendingPluginInstalls({ + nextConfig: config, + ...(baseHash !== undefined ? { baseHash } : {}), + }); + if (committed.movedInstallRecords) { + await refreshPluginRegistryAfterConfigMutation({ + config: committed.config, + reason: "source-changed", + installRecords: committed.installRecords, + logger: { warn: (message) => runtime.log(message) }, + }); + } + await onboardChannels.runCollectedChannelOnboardingPostWriteHooks({ + hooks: postWriteHooks.drain(), + cfg: committed.config, + runtime, + ...(params.beforePersistentEffect + ? { beforePersistentEffect: params.beforePersistentEffect } + : {}), + }); + return committed.config; + }; if (selection.length === 0) { + if (nextConfig !== cfg) { + await commitWizardConfig(nextConfig); + await prompter.outro("Channels updated."); + return; + } await prompter.outro("No channel changes made."); return; } @@ -198,28 +227,7 @@ export async function runChannelsAddWizardFlow(params: ChannelsAddWizardFlowPara } } - await params.beforePersistentEffect?.(); - const committed = await commitConfigWithPendingPluginInstalls({ - nextConfig, - ...(baseHash !== undefined ? { baseHash } : {}), - }); - const writtenConfig = committed.config; - if (committed.movedInstallRecords) { - await refreshPluginRegistryAfterConfigMutation({ - config: writtenConfig, - reason: "source-changed", - installRecords: committed.installRecords, - logger: { warn: (message) => runtime.log(message) }, - }); - } - await onboardChannels.runCollectedChannelOnboardingPostWriteHooks({ - hooks: postWriteHooks.drain(), - cfg: writtenConfig, - runtime, - ...(params.beforePersistentEffect - ? { beforePersistentEffect: params.beforePersistentEffect } - : {}), - }); + await commitWizardConfig(nextConfig); params.onConfigured?.( selection.map((channel) => ({ channel, diff --git a/src/flows/channel-setup-navigation.ts b/src/flows/channel-setup-navigation.ts new file mode 100644 index 00000000000..43a949164ef --- /dev/null +++ b/src/flows/channel-setup-navigation.ts @@ -0,0 +1,51 @@ +import type { SetupChannelsOptions } from "../channels/plugins/setup-wizard-types.js"; +import { ensureChannelSetupPluginInstalled } from "../commands/channel-setup/plugin-install.js"; +import { runWizardWithPromptNavigationScope } from "../wizard/navigation-prompter.js"; +import type { WizardPrompter } from "../wizard/prompts.js"; + +type ScopedChannelStepParams = { + prompter: WizardPrompter; + options?: SetupChannelsOptions; + runner: (prompter: WizardPrompter, options: SetupChannelsOptions) => Promise; + onPersistentEffect?: () => void; +}; + +export async function runScopedChannelStep(params: ScopedChannelStepParams) { + return await runWizardWithPromptNavigationScope(params.prompter, async (scopedPrompter) => + params.runner(scopedPrompter, { + ...params.options, + beforePersistentEffect: async () => { + params.onPersistentEffect?.(); + scopedPrompter.disableBackNavigation?.(); + await params.options?.beforePersistentEffect?.(); + }, + }), + ); +} + +type ChannelPluginInstallParams = Omit< + Parameters[0], + "prompter" | "beforePersistentEffect" +>; + +export async function ensureChannelSetupPluginInstalledWithNavigation(params: { + install: ChannelPluginInstallParams; + prompter: WizardPrompter; + options?: SetupChannelsOptions; +}) { + let persistentEffectStarted = false; + const outcome = await runScopedChannelStep({ + prompter: params.prompter, + options: params.options, + runner: async (scopedPrompter, scopedOptions) => + await ensureChannelSetupPluginInstalled({ + ...params.install, + prompter: scopedPrompter, + beforePersistentEffect: scopedOptions.beforePersistentEffect, + }), + onPersistentEffect: () => { + persistentEffectStarted = true; + }, + }); + return { ...outcome, persistentEffectStarted }; +} diff --git a/src/flows/channel-setup.test.ts b/src/flows/channel-setup.test.ts index 1acc2c0644b..0c10485a272 100644 --- a/src/flows/channel-setup.test.ts +++ b/src/flows/channel-setup.test.ts @@ -2,6 +2,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import type { OpenClawConfig } from "../config/types.openclaw.js"; import { createEmptyPluginRegistry } from "../plugins/registry-empty.js"; +import { WizardCancelledError, WizardNavigationError } from "../wizard/prompts.js"; import { makeCatalogEntry, makeChannelSetupEntries, @@ -217,6 +218,7 @@ describe("setupChannels workspace shadow exclusion", () => { origin: "bundled", }, ]); + getTrustedChannelPluginCatalogEntry.mockReturnValue(undefined); getChannelSetupPlugin.mockReturnValue(undefined); listActiveChannelSetupPlugins.mockReturnValue([]); listChannelSetupPlugins.mockReturnValue([]); @@ -703,6 +705,609 @@ describe("setupChannels workspace shadow exclusion", () => { expect(configure).toHaveBeenCalledTimes(1); }); + it("returns an external install confirmation to channel selection before installing", async () => { + const installableCatalogEntry = makeCatalogEntry("external-chat", "External Chat", { + pluginId: "@vendor/external-chat-plugin", + }); + resolveChannelSetupEntries.mockReturnValue( + externalChatSetupEntries({ + installableCatalogEntries: [installableCatalogEntry], + installableCatalogById: new Map([["external-chat", installableCatalogEntry]]), + }), + ); + ensureChannelSetupPluginInstalled.mockImplementationOnce(async ({ cfg, prompter }) => { + await prompter.confirm({ + message: "Install External Chat?", + initialValue: true, + }); + return { + cfg, + installed: true, + pluginId: "@vendor/external-chat-plugin", + status: "installed", + }; + }); + const select = vi.fn().mockResolvedValueOnce("external-chat").mockResolvedValueOnce("__done__"); + const confirm = vi.fn(async () => { + throw new WizardNavigationError("back"); + }); + const cfg = { channels: { telegram: { botToken: "keep" } } } as OpenClawConfig; + + const result = await setupChannels( + cfg, + {} as never, + { + confirm, + note: vi.fn(async () => undefined), + select, + } as never, + { + deferStatusUntilSelection: true, + skipConfirm: true, + skipDmPolicyPrompt: true, + }, + ); + + expect(confirm).toHaveBeenCalledWith( + expect.objectContaining({ + message: "Install External Chat?", + navigation: { canGoBack: true, canGoForward: false }, + }), + ); + expect(loadChannelSetupPluginRegistrySnapshotForChannel).not.toHaveBeenCalled(); + expect(result).toEqual(cfg); + }); + + it.each(["installed-catalog", "trusted-catalog-fallback"] as const)( + "returns the %s reinstall confirmation to channel selection", + async (source) => { + const catalogEntry = makeCatalogEntry("external-chat", "External Chat", { + pluginId: "@vendor/external-chat-plugin", + install: { npmSpec: "@vendor/external-chat-plugin" }, + }); + resolveChannelSetupEntries.mockReturnValue( + source === "installed-catalog" + ? externalChatSetupEntries({ + installedCatalogEntries: [catalogEntry], + installedCatalogById: new Map([["external-chat", catalogEntry]]), + }) + : externalChatSetupEntries(), + ); + if (source === "trusted-catalog-fallback") { + getTrustedChannelPluginCatalogEntry.mockReturnValue(catalogEntry); + } + loadChannelSetupPluginRegistrySnapshotForChannel.mockReturnValue(makePluginRegistry()); + ensureChannelSetupPluginInstalled.mockImplementationOnce(async ({ cfg, prompter }) => { + await prompter.confirm({ + message: "Reinstall External Chat?", + initialValue: true, + }); + return { + cfg, + installed: true, + pluginId: "@vendor/external-chat-plugin", + status: "installed", + }; + }); + const confirm = vi.fn(async () => { + throw new WizardNavigationError("back"); + }); + const cfg = { channels: { "external-chat": { token: "keep" } } } as OpenClawConfig; + + const result = await setupChannels( + cfg, + {} as never, + { + confirm, + note: vi.fn(async () => undefined), + select: vi.fn().mockResolvedValueOnce("external-chat").mockResolvedValueOnce("__done__"), + } as never, + { + deferStatusUntilSelection: true, + skipConfirm: true, + skipDmPolicyPrompt: true, + }, + ); + + expect(confirm).toHaveBeenCalledWith( + expect.objectContaining({ + message: "Reinstall External Chat?", + navigation: { canGoBack: true, canGoForward: false }, + }), + ); + expect(result).toEqual(cfg); + }, + ); + + it("returns custom channel setup to channel selection when its first prompt goes back", async () => { + const configureInteractive = vi.fn(async ({ prompter }) => { + await prompter.text({ message: "Custom channel token" }); + return { + cfg: { + channels: { "external-chat": { token: "should-not-apply" } }, + } as OpenClawConfig, + accountId: "custom-account", + }; + }); + const externalChatPlugin = makeSetupPlugin({ + id: "external-chat", + label: "External Chat", + setupWizard: { + channel: "external-chat", + getStatus: vi.fn(async () => ({ + channel: "external-chat", + configured: false, + statusLines: [], + })), + configure: vi.fn(), + configureInteractive, + } as ChannelSetupPlugin["setupWizard"], + }); + resolveChannelSetupEntries.mockReturnValue(externalChatSetupEntries()); + listActiveChannelSetupPlugins.mockReturnValue([externalChatPlugin]); + const select = vi.fn().mockResolvedValueOnce("external-chat").mockResolvedValueOnce("__done__"); + const text = vi.fn(async () => { + throw new WizardNavigationError("back"); + }); + const result = await setupChannels( + { channels: { telegram: { botToken: "keep" } } } as OpenClawConfig, + {} as never, + { + confirm: vi.fn(async () => true), + note: vi.fn(async () => undefined), + select, + text, + } as never, + { + deferStatusUntilSelection: true, + skipConfirm: true, + skipDmPolicyPrompt: true, + }, + ); + + expect(configureInteractive).toHaveBeenCalledTimes(1); + expect(text).toHaveBeenCalledWith( + expect.objectContaining({ + message: "Custom channel token", + navigation: { canGoBack: true, canGoForward: false }, + }), + ); + expect(select).toHaveBeenCalledTimes(2); + expect(result).toEqual({ channels: { telegram: { botToken: "keep" } } }); + }); + + it("returns declarative channel setup to channel selection when its first prompt goes back", async () => { + const configure = vi.fn(async ({ cfg, prompter }) => { + await prompter.text({ message: "Declarative channel token" }); + return { + cfg: { + ...cfg, + channels: { ...cfg.channels, "external-chat": { token: "should-not-apply" } }, + }, + accountId: "declarative-account", + }; + }); + const externalChatPlugin = makeSetupPlugin({ + id: "external-chat", + label: "External Chat", + setupWizard: { + channel: "external-chat", + getStatus: vi.fn(async () => ({ + channel: "external-chat", + configured: false, + statusLines: [], + })), + configure, + } as ChannelSetupPlugin["setupWizard"], + }); + resolveChannelSetupEntries.mockReturnValue(externalChatSetupEntries()); + listActiveChannelSetupPlugins.mockReturnValue([externalChatPlugin]); + const select = vi.fn().mockResolvedValueOnce("external-chat").mockResolvedValueOnce("__done__"); + const text = vi.fn(async () => { + throw new WizardNavigationError("back"); + }); + const result = await setupChannels( + { channels: { telegram: { botToken: "keep" } } } as OpenClawConfig, + {} as never, + { + confirm: vi.fn(async () => true), + note: vi.fn(async () => undefined), + select, + text, + } as never, + { + deferStatusUntilSelection: true, + skipConfirm: true, + skipDmPolicyPrompt: true, + }, + ); + + expect(configure).toHaveBeenCalledTimes(1); + expect(text).toHaveBeenCalledWith( + expect.objectContaining({ + message: "Declarative channel token", + navigation: { canGoBack: true, canGoForward: false }, + }), + ); + expect(select).toHaveBeenCalledTimes(2); + expect(result).toEqual({ channels: { telegram: { botToken: "keep" } } }); + }); + + it("returns configured channel actions to selection when their first prompt goes back", async () => { + const configureWhenConfigured = vi.fn(async ({ cfg, prompter }) => { + await prompter.select({ + message: "Configured channel action", + options: [{ value: "update", label: "Update" }], + }); + return { + cfg: { + ...cfg, + channels: { ...cfg.channels, "external-chat": { token: "should-not-apply" } }, + }, + }; + }); + const externalChatPlugin = makeSetupPlugin({ + id: "external-chat", + label: "External Chat", + setupWizard: { + channel: "external-chat", + getStatus: vi.fn(async () => ({ + channel: "external-chat", + configured: true, + statusLines: [], + })), + configure: vi.fn(), + configureWhenConfigured, + } as ChannelSetupPlugin["setupWizard"], + }); + resolveChannelSetupEntries.mockReturnValue(externalChatSetupEntries()); + listActiveChannelSetupPlugins.mockReturnValue([externalChatPlugin]); + const select = vi + .fn() + .mockResolvedValueOnce("external-chat") + .mockRejectedValueOnce(new WizardNavigationError("back")) + .mockResolvedValueOnce("__done__"); + const cfg = { + channels: { "external-chat": { token: "keep" } }, + } as OpenClawConfig; + + const result = await setupChannels( + cfg, + {} as never, + { + confirm: vi.fn(async () => true), + note: vi.fn(async () => undefined), + select, + } as never, + { + deferStatusUntilSelection: true, + skipConfirm: true, + skipDmPolicyPrompt: true, + }, + ); + + expect(configureWhenConfigured).toHaveBeenCalledTimes(1); + expect(select).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ + message: "Configured channel action", + navigation: { canGoBack: true, canGoForward: false }, + }), + ); + expect(result).toEqual(cfg); + }); + + it("rolls back reversible plugin enablement when channel setup goes back", async () => { + const configure = vi.fn(async ({ cfg, prompter }) => { + await prompter.text({ message: "ClickClack token" }); + return { cfg }; + }); + const clickClackPlugin = makeSetupPlugin({ + id: "clickclack", + label: "ClickClack", + setupWizard: { + channel: "clickclack", + getStatus: vi.fn(async () => ({ + channel: "clickclack", + configured: false, + statusLines: [], + })), + configure, + } as ChannelSetupPlugin["setupWizard"], + }); + resolveChannelSetupEntries.mockReturnValue( + makeChannelSetupEntries({ + entries: [{ id: "clickclack", meta: makeMeta("clickclack", "ClickClack") }], + }), + ); + loadChannelSetupPluginRegistrySnapshotForChannel.mockReturnValue( + makePluginRegistry({ + channelSetups: [ + { + pluginId: "clickclack", + source: "bundled", + enabled: true, + plugin: clickClackPlugin, + }, + ], + }), + ); + const select = vi.fn().mockResolvedValueOnce("clickclack").mockResolvedValueOnce("__done__"); + const text = vi.fn(async () => { + throw new WizardNavigationError("back"); + }); + const cfg = { plugins: { allow: ["memory-core"] } } as OpenClawConfig; + + const result = await setupChannels( + cfg, + {} as never, + { + confirm: vi.fn(async () => true), + note: vi.fn(async () => undefined), + select, + text, + } as never, + { + deferStatusUntilSelection: true, + skipConfirm: true, + skipDmPolicyPrompt: true, + }, + ); + + expect(result).toEqual(cfg); + expect(result.plugins?.allow).toEqual(["memory-core"]); + expect(result.plugins?.entries?.clickclack).toBeUndefined(); + }); + + it("preserves accepted external install metadata when later channel setup goes back", async () => { + const configure = vi.fn(async ({ cfg, prompter }) => { + await prompter.text({ message: "External Chat token" }); + return { cfg, accountId: "external-account" }; + }); + const externalChatPlugin = makeSetupPlugin({ + id: "external-chat", + label: "External Chat", + setupWizard: { + channel: "external-chat", + getStatus: vi.fn(async () => ({ + channel: "external-chat", + configured: false, + statusLines: [], + })), + configure, + } as ChannelSetupPlugin["setupWizard"], + }); + const installableCatalogEntry = makeCatalogEntry("external-chat", "External Chat", { + pluginId: "@vendor/external-chat-plugin", + }); + resolveChannelSetupEntries.mockReturnValue( + externalChatSetupEntries({ + installableCatalogEntries: [installableCatalogEntry], + installableCatalogById: new Map([["external-chat", installableCatalogEntry]]), + }), + ); + loadChannelSetupPluginRegistrySnapshotForChannel.mockReturnValue( + makePluginRegistry({ + channelSetups: [ + { + pluginId: "@vendor/external-chat-plugin", + source: "global", + enabled: true, + plugin: externalChatPlugin, + }, + ], + }), + ); + ensureChannelSetupPluginInstalled.mockImplementationOnce(async (params) => { + await params.beforePersistentEffect?.(); + return { + cfg: { + ...params.cfg, + plugins: { + ...params.cfg.plugins, + entries: { + ...params.cfg.plugins?.entries, + "@vendor/external-chat-plugin": { enabled: true }, + }, + installs: { + ...params.cfg.plugins?.installs, + "@vendor/external-chat-plugin": { + source: "npm", + spec: "@vendor/external-chat-plugin@1.0.0", + }, + }, + }, + }, + installed: true, + pluginId: "@vendor/external-chat-plugin", + status: "installed", + }; + }); + const select = vi.fn().mockResolvedValueOnce("external-chat").mockResolvedValueOnce("__done__"); + const beforePersistentEffect = vi.fn(async () => undefined); + + const result = await setupChannels( + { channels: { telegram: { botToken: "keep" } } } as OpenClawConfig, + {} as never, + { + confirm: vi.fn(async () => true), + note: vi.fn(async () => undefined), + select, + text: vi.fn(async () => { + throw new WizardNavigationError("back"); + }), + } as never, + { + beforePersistentEffect, + deferStatusUntilSelection: true, + skipConfirm: true, + skipDmPolicyPrompt: true, + }, + ); + + expect(result.channels).toEqual({ telegram: { botToken: "keep" } }); + expect(result.plugins?.entries?.["@vendor/external-chat-plugin"]?.enabled).toBe(true); + expect(result.plugins?.installs?.["@vendor/external-chat-plugin"]?.spec).toBe( + "@vendor/external-chat-plugin@1.0.0", + ); + expect(beforePersistentEffect).toHaveBeenCalledTimes(1); + }); + + it("replays earlier prompts when Back is used inside channel setup", async () => { + const configure = vi.fn(async ({ cfg, prompter }) => { + const username = await prompter.text({ message: "Username" }); + const token = await prompter.text({ message: "Token" }); + return { + cfg: { ...cfg, channels: { ...cfg.channels, "external-chat": { username, token } } }, + }; + }); + const externalChatPlugin = makeSetupPlugin({ + id: "external-chat", + label: "External Chat", + setupWizard: { + channel: "external-chat", + getStatus: vi.fn(async () => ({ + channel: "external-chat", + configured: false, + statusLines: [], + })), + configure, + } as ChannelSetupPlugin["setupWizard"], + }); + resolveChannelSetupEntries.mockReturnValue(externalChatSetupEntries()); + listActiveChannelSetupPlugins.mockReturnValue([externalChatPlugin]); + const text = vi + .fn() + .mockResolvedValueOnce("old-name") + .mockRejectedValueOnce(new WizardNavigationError("back")) + .mockResolvedValueOnce("new-name") + .mockResolvedValueOnce("secret-token"); + + const result = await setupChannels( + {} as OpenClawConfig, + {} as never, + { + confirm: vi.fn(async () => true), + note: vi.fn(async () => undefined), + select: vi.fn().mockResolvedValueOnce("external-chat").mockResolvedValueOnce("__done__"), + text, + } as never, + { + deferStatusUntilSelection: true, + skipConfirm: true, + skipDmPolicyPrompt: true, + }, + ); + + expect(configure).toHaveBeenCalledTimes(2); + expect(result.channels?.["external-chat"]).toEqual({ + username: "new-name", + token: "secret-token", + }); + expect(mockCall(text, 2)[0]).toEqual( + expect.objectContaining({ + initialValue: "old-name", + navigation: { canGoBack: true, canGoForward: true }, + }), + ); + }); + + it("disables Back after a channel declares a persistent effect boundary", async () => { + const configure = vi.fn(async ({ cfg, prompter, options }) => { + await prompter.text({ message: "Before effect" }); + await options.beforePersistentEffect?.(); + await prompter.text({ message: "After effect" }); + return { cfg }; + }); + const externalChatPlugin = makeSetupPlugin({ + id: "external-chat", + label: "External Chat", + setupWizard: { + channel: "external-chat", + getStatus: vi.fn(async () => ({ + channel: "external-chat", + configured: false, + statusLines: [], + })), + configure, + } as ChannelSetupPlugin["setupWizard"], + }); + resolveChannelSetupEntries.mockReturnValue(externalChatSetupEntries()); + listActiveChannelSetupPlugins.mockReturnValue([externalChatPlugin]); + const navigationError = new WizardNavigationError("back"); + const text = vi.fn().mockResolvedValueOnce("ready").mockRejectedValueOnce(navigationError); + const beforePersistentEffect = vi.fn(async () => undefined); + + await expect( + setupChannels( + {} as OpenClawConfig, + {} as never, + { + confirm: vi.fn(async () => true), + note: vi.fn(async () => undefined), + select: vi.fn().mockResolvedValueOnce("external-chat"), + text, + } as never, + { + beforePersistentEffect, + deferStatusUntilSelection: true, + skipConfirm: true, + skipDmPolicyPrompt: true, + }, + ), + ).rejects.toBe(navigationError); + + expect(beforePersistentEffect).toHaveBeenCalledTimes(1); + expect(mockCall(text, 1)[0]).toEqual( + expect.objectContaining({ + navigation: { canGoBack: false, canGoForward: false }, + }), + ); + }); + + it("propagates Ctrl-C cancellation from channel setup", async () => { + const cancelled = new WizardCancelledError(); + const configure = vi.fn(async ({ prompter }) => { + await prompter.text({ message: "Token" }); + return { cfg: {} as OpenClawConfig }; + }); + const externalChatPlugin = makeSetupPlugin({ + id: "external-chat", + label: "External Chat", + setupWizard: { + channel: "external-chat", + getStatus: vi.fn(async () => ({ + channel: "external-chat", + configured: false, + statusLines: [], + })), + configure, + } as ChannelSetupPlugin["setupWizard"], + }); + resolveChannelSetupEntries.mockReturnValue(externalChatSetupEntries()); + listActiveChannelSetupPlugins.mockReturnValue([externalChatPlugin]); + + await expect( + setupChannels( + {} as OpenClawConfig, + {} as never, + { + confirm: vi.fn(async () => true), + note: vi.fn(async () => undefined), + select: vi.fn().mockResolvedValueOnce("external-chat"), + text: vi.fn(async () => { + throw cancelled; + }), + } as never, + { + deferStatusUntilSelection: true, + skipConfirm: true, + skipDmPolicyPrompt: true, + }, + ), + ).rejects.toBe(cancelled); + }); + it("does not load or re-enable an explicitly disabled channel when selected lazily", async () => { const setupWizard = { channel: "external-chat", @@ -1140,10 +1745,6 @@ describe("setupChannels workspace shadow exclusion", () => { ).rejects.toBe(guardError); expect(ensureChannelSetupPluginInstalled).toHaveBeenCalledTimes(1); - expect( - callArg[0]>(ensureChannelSetupPluginInstalled) - .beforePersistentEffect, - ).toBe(beforePersistentEffect); expect(beforePersistentEffect).toHaveBeenCalledTimes(1); }); diff --git a/src/flows/channel-setup.ts b/src/flows/channel-setup.ts index e25ba43ff6b..021be626b5c 100644 --- a/src/flows/channel-setup.ts +++ b/src/flows/channel-setup.ts @@ -17,10 +17,7 @@ import { resolveChannelSetupEntries, shouldShowChannelInSetup, } from "../commands/channel-setup/discovery.js"; -import { - ensureChannelSetupPluginInstalled, - loadChannelSetupPluginRegistrySnapshotForChannel, -} from "../commands/channel-setup/plugin-install.js"; +import { loadChannelSetupPluginRegistrySnapshotForChannel } from "../commands/channel-setup/plugin-install.js"; import { resolveChannelSetupWizardAdapterForPlugin } from "../commands/channel-setup/registry.js"; import { getTrustedChannelPluginCatalogEntry, @@ -37,10 +34,14 @@ import type { RuntimeEnv } from "../runtime.js"; import { t } from "../wizard/i18n/index.js"; import type { WizardPrompter } from "../wizard/prompts.js"; import { + ensureChannelSetupPluginInstalledWithNavigation as runPluginInstallWithNavigation, + runScopedChannelStep as runNavigationScope, +} from "./channel-setup-navigation.js"; +import { + formatAccountLabel, maybeConfigureDmPolicies, promptConfiguredAction, promptRemovalAccountId, - formatAccountLabel, } from "./channel-setup.prompts.js"; import { collectChannelStatus, @@ -126,7 +127,6 @@ export async function setupChannels( const rememberScopedPlugin = (plugin: ChannelSetupPlugin) => { const channel = plugin.id; scopedPluginsById.set(channel, plugin); - options?.onResolvedPlugin?.(channel, plugin); }; const activePluginsById = new Map(); const rememberActivePlugin = (plugin: ChannelSetupPlugin) => { @@ -424,6 +424,10 @@ export async function setupChannels( const applySetupResult = async (channel: ChannelChoice, result: ChannelSetupResult) => { const previousCfg = next; next = result.cfg; + const plugin = getVisibleChannelPlugin(channel); + if (plugin) { + options?.onResolvedPlugin?.(channel, plugin); + } const adapter = getVisibleSetupFlowAdapter(channel); if (result.accountId) { recordAccount(channel, result.accountId); @@ -451,8 +455,22 @@ export async function setupChannels( await applySetupResult(channel, result); return true; }; + const runScopedChannelStep = async ( + runner: (prompter: WizardPrompter, options: SetupChannelsOptions) => Promise, + onPersistentEffect?: () => void, + ) => + await runNavigationScope({ + prompter, + options, + runner, + ...(onPersistentEffect ? { onPersistentEffect } : {}), + }); - const configureChannel = async (channel: ChannelChoice) => { + const configureChannel = async ( + channel: ChannelChoice, + setupPrompter: WizardPrompter, + setupOptions: SetupChannelsOptions, + ) => { if (scopedPluginsById.has(channel)) { await loadScopedChannelPlugin(channel, undefined, { forceReload: true, @@ -473,8 +491,8 @@ export async function setupChannels( const result = await adapter.configure({ cfg: next, runtime, - prompter, - options, + prompter: setupPrompter, + options: setupOptions, accountOverrides, shouldPromptAccountIds, forceAllowFrom: forceAllowFromChannels.has(channel), @@ -482,50 +500,52 @@ export async function setupChannels( await applySetupResult(channel, result); }; - const handleConfiguredChannel = async (channel: ChannelChoice, label: string) => { + const handleConfiguredChannel = async ( + channel: ChannelChoice, + label: string, + setupPrompter: WizardPrompter, + setupOptions: SetupChannelsOptions, + ) => { const plugin = getVisibleChannelPlugin(channel); const adapter = getVisibleSetupFlowAdapter(channel); if (adapter?.configureWhenConfigured) { const custom = await adapter.configureWhenConfigured({ cfg: next, runtime, - prompter, - options, + prompter: setupPrompter, + options: setupOptions, accountOverrides, shouldPromptAccountIds, forceAllowFrom: forceAllowFromChannels.has(channel), configured: true, label, }); - if (!(await applyCustomSetupResult(channel, custom))) { - return; - } + await applyCustomSetupResult(channel, custom); return; } + const supportsDisable = Boolean( - options?.allowDisable && (plugin?.config.setAccountEnabled || adapter?.disable), + setupOptions.allowDisable && (plugin?.config.setAccountEnabled || adapter?.disable), ); - const supportsDelete = Boolean(options?.allowDisable && plugin?.config.deleteAccount); + const supportsDelete = Boolean(setupOptions.allowDisable && plugin?.config.deleteAccount); const action = await promptConfiguredAction({ - prompter, + prompter: setupPrompter, label, supportsDisable, supportsDelete, }); - if (action === "skip") { return; } if (action === "update") { - await configureChannel(channel); + await configureChannel(channel, setupPrompter, setupOptions); return; } - if (!options?.allowDisable) { + if (!setupOptions.allowDisable) { return; } - if (action === "delete" && !supportsDelete) { - await prompter.note( + await setupPrompter.note( t("wizard.channels.configuredDeleteUnsupported", { label }), t("wizard.channels.removeTitle"), ); @@ -539,7 +559,7 @@ export async function setupChannels( const accountId = shouldPromptAccount ? await promptRemovalAccountId({ cfg: next, - prompter, + prompter: setupPrompter, label, channel, plugin, @@ -551,7 +571,7 @@ export async function setupChannels( const accountLabel = formatAccountLabel(resolvedAccountId); if (action === "delete") { - const confirmed = await prompter.confirm({ + const confirmed = await setupPrompter.confirm({ message: t("wizard.channels.deleteAccount", { label, account: accountLabel }), initialValue: false, }); @@ -577,9 +597,29 @@ export async function setupChannels( await refreshStatus(channel); }; + const ensureChannelSetupPluginInstalledWithNavigation = async ( + install: Parameters[0]["install"], + ) => await runPluginInstallWithNavigation({ install, prompter, options }); + const handleChannelChoice = async ( channel: ChannelChoice, ): Promise<"done" | "retry_selection"> => { + const cfgBeforeChoice = next; + let cfgOnBack = cfgBeforeChoice; + const scopedPluginsBeforeChoice = new Map(scopedPluginsById); + const statusBeforeChoice = new Map(statusByChannel); + const returnToSelection = (): "retry_selection" => { + next = cfgOnBack; + scopedPluginsById.clear(); + for (const [id, plugin] of scopedPluginsBeforeChoice) { + scopedPluginsById.set(id, plugin); + } + statusByChannel.clear(); + for (const [id, status] of statusBeforeChoice) { + statusByChannel.set(id, status); + } + return "retry_selection"; + }; const { catalogById, installedCatalogById } = getChannelEntries(); const catalogEntry = catalogById.get(channel); const installedCatalogEntry = installedCatalogById.get(channel); @@ -598,21 +638,24 @@ export async function setupChannels( } if (catalogEntry) { const workspaceDir = resolveWorkspaceDir(); - const result = await ensureChannelSetupPluginInstalled({ + const installOutcome = await ensureChannelSetupPluginInstalledWithNavigation({ cfg: next, entry: catalogEntry, - prompter, runtime, workspaceDir, autoConfirmSingleSource: true, - ...(options?.beforePersistentEffect - ? { beforePersistentEffect: options.beforePersistentEffect } - : {}), }); + if (installOutcome.status === "back") { + return returnToSelection(); + } + const result = installOutcome.value; next = result.cfg; if (!result.installed) { return "retry_selection"; } + if (installOutcome.persistentEffectStarted) { + cfgOnBack = next; + } await loadScopedChannelPlugin(channel, result.pluginId ?? catalogEntry.pluginId); await refreshStatus(channel); } else if (installedCatalogEntry) { @@ -638,21 +681,24 @@ export async function setupChannels( return "done"; } const workspaceDir = resolveWorkspaceDir(); - const result = await ensureChannelSetupPluginInstalled({ + const installOutcome = await ensureChannelSetupPluginInstalledWithNavigation({ cfg: next, entry: installedCatalogEntry, - prompter, runtime, workspaceDir, autoConfirmSingleSource: true, - ...(options?.beforePersistentEffect - ? { beforePersistentEffect: options.beforePersistentEffect } - : {}), }); + if (installOutcome.status === "back") { + return returnToSelection(); + } + const result = installOutcome.value; next = result.cfg; if (!result.installed) { return "retry_selection"; } + if (installOutcome.persistentEffectStarted) { + cfgOnBack = next; + } plugin = await loadScopedChannelPlugin( channel, result.pluginId ?? installedCatalogEntry.pluginId, @@ -696,21 +742,24 @@ export async function setupChannels( return "done"; } const workspaceDir = resolveWorkspaceDir(); - const result = await ensureChannelSetupPluginInstalled({ + const installOutcome = await ensureChannelSetupPluginInstalledWithNavigation({ cfg: next, entry: fallbackCatalogEntry, - prompter, runtime, workspaceDir, autoConfirmSingleSource: true, - ...(options?.beforePersistentEffect - ? { beforePersistentEffect: options.beforePersistentEffect } - : {}), }); + if (installOutcome.status === "back") { + return returnToSelection(); + } + const result = installOutcome.value; next = result.cfg; if (!result.installed) { return "retry_selection"; } + if (installOutcome.persistentEffectStarted) { + cfgOnBack = next; + } await loadScopedChannelPlugin(channel, result.pluginId ?? fallbackCatalogEntry.pluginId); await refreshStatus(channel); } else { @@ -726,28 +775,48 @@ export async function setupChannels( const label = plugin?.meta.label ?? catalogEntry?.meta.label ?? channel; const status = statusByChannel.get(channel); const configured = status?.configured ?? false; - if (adapter?.configureInteractive) { - const custom = await adapter.configureInteractive({ - cfg: next, - runtime, - prompter, - options, - accountOverrides, - shouldPromptAccountIds, - forceAllowFrom: forceAllowFromChannels.has(channel), - configured, - label, - }); + const configureInteractive = adapter?.configureInteractive; + if (configureInteractive) { + const outcome = await runScopedChannelStep( + async (scopedPrompter, scopedOptions) => + await configureInteractive({ + cfg: next, + runtime, + prompter: scopedPrompter, + options: scopedOptions, + accountOverrides, + shouldPromptAccountIds, + forceAllowFrom: forceAllowFromChannels.has(channel), + configured, + label, + }), + ); + if (outcome.status === "back") { + return returnToSelection(); + } + const custom = outcome.value; if (!(await applyCustomSetupResult(channel, custom))) { return "done"; } return "done"; } if (configured) { - await handleConfiguredChannel(channel, label); + const outcome = await runScopedChannelStep( + async (scopedPrompter, scopedOptions) => + await handleConfiguredChannel(channel, label, scopedPrompter, scopedOptions), + ); + if (outcome.status === "back") { + return returnToSelection(); + } return "done"; } - await configureChannel(channel); + const outcome = await runScopedChannelStep( + async (scopedPrompter, scopedOptions) => + await configureChannel(channel, scopedPrompter, scopedOptions), + ); + if (outcome.status === "back") { + return returnToSelection(); + } return "done"; }; diff --git a/src/wizard/navigation-prompter.test.ts b/src/wizard/navigation-prompter.test.ts index fbe875ec7c0..9b184bc416c 100644 --- a/src/wizard/navigation-prompter.test.ts +++ b/src/wizard/navigation-prompter.test.ts @@ -1,7 +1,10 @@ // Prompt navigation tests cover setup history replay and forward acceptance. import { describe, expect, it, vi } from "vitest"; import { createWizardPrompter } from "../../test/helpers/wizard-prompter.js"; -import { runWizardWithPromptNavigation } from "./navigation-prompter.js"; +import { + runWizardWithPromptNavigation, + runWizardWithPromptNavigationScope, +} from "./navigation-prompter.js"; import type { WizardPrompter, WizardSelectParams } from "./prompts.js"; import { WizardNavigationError } from "./prompts.js"; @@ -188,3 +191,67 @@ describe("runWizardWithPromptNavigation", () => { }); }); }); + +describe("runWizardWithPromptNavigationScope", () => { + it("returns a typed back outcome from the first prompt", async () => { + const select = vi.fn(async () => { + throw new WizardNavigationError("back"); + }) as unknown as WizardPrompter["select"]; + const prompter = createWizardPrompter({ select }); + + const outcome = await runWizardWithPromptNavigationScope(prompter, async (scopedPrompter) => { + await scopedPrompter.select({ + message: "First channel prompt", + options: selectOptions(["continue"]), + }); + return "completed"; + }); + + expect(outcome).toEqual({ status: "back" }); + expect(selectParamsAt(select as ReturnType, 0).navigation).toEqual({ + canGoBack: true, + canGoForward: false, + }); + }); + + it("creates a fresh scope inside an outer wizard whose back history is disabled", async () => { + const select = vi.fn(async () => { + throw new WizardNavigationError("back"); + }) as unknown as WizardPrompter["select"]; + const prompter = createWizardPrompter({ select }); + let outcome: Awaited>> | undefined; + + await runWizardWithPromptNavigation(prompter, async (outerPrompter) => { + outerPrompter.disableBackNavigation?.(); + outcome = await runWizardWithPromptNavigationScope(outerPrompter, async (scopedPrompter) => { + await scopedPrompter.select({ + message: "First channel prompt", + options: selectOptions(["continue"]), + }); + return "completed"; + }); + }); + + expect(outcome).toEqual({ status: "back" }); + expect(selectParamsAt(select as ReturnType, 0).navigation).toEqual({ + canGoBack: true, + canGoForward: false, + }); + }); + + it("preserves optional client actions inside a navigation scope", async () => { + const deviceCode = vi.fn(async () => undefined); + const openUrl = vi.fn(async () => undefined); + const prompter = createWizardPrompter({ deviceCode, openUrl }); + + const outcome = await runWizardWithPromptNavigationScope(prompter, async (scopedPrompter) => { + await scopedPrompter.deviceCode?.({ title: "Link device", code: "ABCD" }); + await scopedPrompter.openUrl?.("https://example.com/link"); + return "completed"; + }); + + expect(outcome).toEqual({ status: "completed", value: "completed" }); + expect(deviceCode).toHaveBeenCalledWith({ title: "Link device", code: "ABCD" }); + expect(openUrl).toHaveBeenCalledWith("https://example.com/link"); + }); +}); diff --git a/src/wizard/navigation-prompter.ts b/src/wizard/navigation-prompter.ts index 1c0115c48e6..c07b491423c 100644 --- a/src/wizard/navigation-prompter.ts +++ b/src/wizard/navigation-prompter.ts @@ -27,6 +27,18 @@ type PromptRequest = { call: (params: Params) => Promise; }; +const basePrompterByNavigationPrompter = new WeakMap(); + +function unwrapNavigationPrompter(prompter: WizardPrompter): WizardPrompter { + let current = prompter; + let base = basePrompterByNavigationPrompter.get(current); + while (base) { + current = base; + base = basePrompterByNavigationPrompter.get(current); + } + return current; +} + function inertProgress(): WizardProgress { return { update: () => {}, @@ -75,10 +87,16 @@ class WizardPromptNavigator { private cursor = 0; private targetIndex: number | undefined; private restartRequested = false; + private boundaryBackRequested = false; private backNavigationDisabled = false; private records: Array = []; - constructor(private readonly base: WizardPrompter) {} + constructor( + private readonly base: WizardPrompter, + private readonly options: { allowBackFromStart?: boolean } = {}, + ) { + basePrompterByNavigationPrompter.set(this.prompter, unwrapNavigationPrompter(base)); + } readonly prompter: WizardPrompter = { intro: async (title) => { @@ -96,6 +114,15 @@ class WizardPromptNavigator { await this.base.note(message, title); } }, + ...(this.base.deviceCode + ? { + deviceCode: async (params) => { + if (!this.shouldSuppressOutput()) { + await this.base.deviceCode?.(params); + } + }, + } + : {}), plain: async (message) => { if (!this.shouldSuppressOutput()) { await this.base.plain?.(message); @@ -151,6 +178,15 @@ class WizardPromptNavigator { }), progress: (label) => this.shouldSuppressOutput() ? inertProgress() : this.base.progress(label), + ...(this.base.openUrl + ? { + openUrl: async (url) => { + if (!this.shouldSuppressOutput()) { + await this.base.openUrl?.(url); + } + }, + } + : {}), disableBackNavigation: () => { this.backNavigationDisabled = true; this.targetIndex = undefined; @@ -160,12 +196,17 @@ class WizardPromptNavigator { beginPass() { this.cursor = 0; this.restartRequested = false; + this.boundaryBackRequested = false; } hasRestartRequest(): boolean { return this.restartRequested; } + hasBoundaryBackRequest(): boolean { + return this.boundaryBackRequested; + } + private shouldSuppressOutput(): boolean { return this.targetIndex !== undefined && this.cursor <= this.targetIndex; } @@ -220,7 +261,8 @@ class WizardPromptNavigator { ? request.withInitial(request.params, record.answer) : request.params; const paramsWithNavigation = applyNavigation(paramsWithInitial, { - canGoBack: !this.backNavigationDisabled && index > 0, + canGoBack: + !this.backNavigationDisabled && (index > 0 || this.options.allowBackFromStart === true), canGoForward: record !== undefined, }); @@ -239,6 +281,14 @@ class WizardPromptNavigator { this.targetIndex = undefined; return record.answer as T; } + if ( + error.direction === "back" && + !this.backNavigationDisabled && + index === 0 && + this.options.allowBackFromStart === true + ) { + this.boundaryBackRequested = true; + } if (error.direction === "back" && !this.backNavigationDisabled && index > 0) { this.targetIndex = index - 1; this.restartRequested = true; @@ -249,6 +299,34 @@ class WizardPromptNavigator { } } +type WizardPromptNavigationScopeOutcome = { status: "completed"; value: T } | { status: "back" }; + +export async function runWizardWithPromptNavigationScope( + basePrompter: WizardPrompter, + runner: (prompter: WizardPrompter) => Promise, +): Promise> { + const navigator = new WizardPromptNavigator(unwrapNavigationPrompter(basePrompter), { + allowBackFromStart: true, + }); + + while (true) { + navigator.beginPass(); + try { + return { status: "completed", value: await runner(navigator.prompter) }; + } catch (error) { + if (error instanceof WizardNavigationError && error.direction === "back") { + if (navigator.hasRestartRequest()) { + continue; + } + if (navigator.hasBoundaryBackRequest()) { + return { status: "back" }; + } + } + throw error; + } + } +} + export async function runWizardWithPromptNavigation( basePrompter: WizardPrompter, runner: (prompter: WizardPrompter) => Promise,