mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-21 02:06:43 +00:00
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
This commit is contained in:
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<void> };
|
||||
}) => {
|
||||
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;
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<T> = {
|
||||
prompter: WizardPrompter;
|
||||
options?: SetupChannelsOptions;
|
||||
runner: (prompter: WizardPrompter, options: SetupChannelsOptions) => Promise<T>;
|
||||
onPersistentEffect?: () => void;
|
||||
};
|
||||
|
||||
export async function runScopedChannelStep<T>(params: ScopedChannelStepParams<T>) {
|
||||
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<typeof ensureChannelSetupPluginInstalled>[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 };
|
||||
}
|
||||
@@ -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<Parameters<EnsureChannelSetupPluginInstalled>[0]>(ensureChannelSetupPluginInstalled)
|
||||
.beforePersistentEffect,
|
||||
).toBe(beforePersistentEffect);
|
||||
expect(beforePersistentEffect).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
|
||||
+123
-54
@@ -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<ChannelChoice, ChannelSetupPlugin>();
|
||||
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 <T>(
|
||||
runner: (prompter: WizardPrompter, options: SetupChannelsOptions) => Promise<T>,
|
||||
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<typeof runPluginInstallWithNavigation>[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";
|
||||
};
|
||||
|
||||
|
||||
@@ -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<typeof vi.fn>, 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<ReturnType<typeof runWizardWithPromptNavigationScope<string>>> | 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<typeof vi.fn>, 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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -27,6 +27,18 @@ type PromptRequest<T, Params> = {
|
||||
call: (params: Params) => Promise<T>;
|
||||
};
|
||||
|
||||
const basePrompterByNavigationPrompter = new WeakMap<WizardPrompter, WizardPrompter>();
|
||||
|
||||
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<PromptRecord | undefined> = [];
|
||||
|
||||
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<T> = { status: "completed"; value: T } | { status: "back" };
|
||||
|
||||
export async function runWizardWithPromptNavigationScope<T>(
|
||||
basePrompter: WizardPrompter,
|
||||
runner: (prompter: WizardPrompter) => Promise<T>,
|
||||
): Promise<WizardPromptNavigationScopeOutcome<T>> {
|
||||
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<void>,
|
||||
|
||||
Reference in New Issue
Block a user