From 3d841472bd0efebabfd0247a1509df396c1fa314 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 20 Jul 2026 18:22:57 -0700 Subject: [PATCH] fix(cli): align configure and channel wizard behavior (#111720) --- docs/cli/channels.md | 9 +++- docs/cli/index.md | 2 +- src/cli/channels-cli.test.ts | 72 +++++++++++++++++++++++++ src/cli/channels-cli.ts | 6 ++- src/commands/configure.channels.ts | 2 + src/commands/configure.commands.test.ts | 19 +++++-- src/commands/configure.commands.ts | 18 +++---- src/commands/configure.daemon.ts | 4 +- src/commands/configure.gateway.ts | 14 +++++ src/commands/configure.wizard.test.ts | 55 ++++++++++++++++++- src/commands/configure.wizard.ts | 12 ++++- 11 files changed, 195 insertions(+), 18 deletions(-) diff --git a/docs/cli/channels.md b/docs/cli/channels.md index 74b871a92a53..c3439badaaf4 100644 --- a/docs/cli/channels.md +++ b/docs/cli/channels.md @@ -100,7 +100,14 @@ Non-interactive add flags shared across channels: `--account `, `--name ` both preselect that channel without bypassing guidance: + +```bash +openclaw channels add telegram +openclaw channels add --channel telegram +``` + +The wizard can prompt for: - account ids per selected channel - optional display names for those accounts diff --git a/docs/cli/index.md b/docs/cli/index.md index e6ee212424b2..780e6418a490 100644 --- a/docs/cli/index.md +++ b/docs/cli/index.md @@ -15,7 +15,7 @@ Setup commands by intent: - `openclaw setup` and `openclaw onboard` verify inference first, then start OpenClaw for Gateway, workspace, channels, skills, and health setup. - `openclaw setup --baseline` creates the baseline config and workspace without walking the guided onboarding flow. - `openclaw configure` changes targeted parts of an existing setup: model auth, gateway, channels, plugins, or skills. -- `openclaw channels add` configures channel accounts after the baseline exists; run without flags for guided setup, or with channel-specific flags for scripts. +- `openclaw channels add` configures channel accounts after the baseline exists; a channel selection alone uses guided setup, while account, credential, or channel-config flags use the direct path for scripts. ## Command pages diff --git a/src/cli/channels-cli.test.ts b/src/cli/channels-cli.test.ts index 57aec5a09978..56ea96221d7a 100644 --- a/src/cli/channels-cli.test.ts +++ b/src/cli/channels-cli.test.ts @@ -8,11 +8,25 @@ import { registerChannelsCli } from "./channels-cli.js"; const listBundledPackageChannelMetadataMock = vi.hoisted(() => vi.fn<() => readonly PluginPackageChannel[]>(() => []), ); +const channelsAddCommandMock = vi.hoisted(() => vi.fn(async () => undefined)); +const runtimeMock = vi.hoisted(() => ({ + log: vi.fn(), + error: vi.fn(), + exit: vi.fn(), +})); vi.mock("../plugins/bundled-package-channel-metadata.js", () => ({ listBundledPackageChannelMetadata: listBundledPackageChannelMetadataMock, })); +vi.mock("../commands/channels.js", () => ({ + channelsAddCommand: channelsAddCommandMock, +})); + +vi.mock("../runtime.js", () => ({ + defaultRuntime: runtimeMock, +})); + function getChannelAddOptionFlags(program: Command): string[] { const channels = program.commands.find((command) => command.name() === "channels"); const add = channels?.commands.find((command) => command.name() === "add"); @@ -25,6 +39,12 @@ function getChannelSubcommandNames(program: Command, parentName: string): string return parent?.commands.map((command) => command.name()) ?? []; } +async function runChannelsAddCli(args: string[]) { + const program = new Command().name("openclaw"); + await registerChannelsCli(program, ["node", "openclaw", ...args]); + await program.parseAsync(args, { from: "user" }); +} + describe("registerChannelsCli", () => { const originalArgv = [...process.argv]; @@ -135,4 +155,56 @@ describe("registerChannelsCli", () => { expect(listBundledPackageChannelMetadataMock).toHaveBeenCalledTimes(1); expect(getChannelAddOptionFlags(program)).toContain("--homeserver "); }); + + it.each([ + ["positional", ["channels", "add", "telegram"]], + ["--channel", ["channels", "add", "--channel", "telegram"]], + ])("keeps selection-only %s channel adds on the guided path", async (_label, args) => { + await runChannelsAddCli(args); + + expect(channelsAddCommandMock).toHaveBeenCalledWith( + expect.objectContaining({ channel: "telegram" }), + runtimeMock, + { hasFlags: false }, + ); + }); + + it.each([ + ["token", ["--token", "test-token"]], + ["token file", ["--token-file", "/tmp/test-token"]], + ["environment", ["--use-env"]], + ["account", ["--account", "work"]], + ])("keeps explicit %s inputs on the direct path", async (_label, extraArgs) => { + await runChannelsAddCli(["channels", "add", "--channel", "telegram", ...extraArgs]); + + expect(channelsAddCommandMock).toHaveBeenCalledWith( + expect.objectContaining({ channel: "telegram" }), + runtimeMock, + { hasFlags: true }, + ); + }); + + it("treats plugin-provided config flags as direct automation inputs", async () => { + listBundledPackageChannelMetadataMock.mockReturnValueOnce([ + { + id: "matrix", + cliAddOptions: [{ flags: "--homeserver ", description: "Matrix homeserver URL" }], + }, + ]); + + await runChannelsAddCli([ + "channels", + "add", + "--channel", + "matrix", + "--homeserver", + "https://matrix.example.org", + ]); + + expect(channelsAddCommandMock).toHaveBeenCalledWith( + expect.objectContaining({ channel: "matrix", homeserver: "https://matrix.example.org" }), + runtimeMock, + { hasFlags: true }, + ); + }); }); diff --git a/src/cli/channels-cli.ts b/src/cli/channels-cli.ts index 4017f3e26828..d819bf8163d6 100644 --- a/src/cli/channels-cli.ts +++ b/src/cli/channels-cli.ts @@ -19,6 +19,7 @@ type BundledPackageChannelMetadataModule = typeof import("../plugins/bundled-package-channel-metadata.js"); const optionNamesRemove = ["channel", "account", "delete"] as const; +const CHANNEL_ADD_SELECTION_OPTION_NAMES = new Set(["channel"]); type RegisterChannelsCliOptions = { includeSetupOptions?: boolean; @@ -282,7 +283,10 @@ export async function registerChannelsCli( addCommand.action(async (channelArg: string | undefined, opts, command) => { await runChannelsCommand(async () => { const { channelsAddCommand } = await loadChannelsCommands(); - const hasFlags = hasExplicitOptions(command, getOptionNames(command)); + const hasFlags = hasExplicitOptions( + command, + getOptionNames(command).filter((name) => !CHANNEL_ADD_SELECTION_OPTION_NAMES.has(name)), + ); await channelsAddCommand(resolveChannelsAddOptions(channelArg, opts), defaultRuntime, { hasFlags, }); diff --git a/src/commands/configure.channels.ts b/src/commands/configure.channels.ts index 4470fbd0212a..1cd99dffbde9 100644 --- a/src/commands/configure.channels.ts +++ b/src/commands/configure.channels.ts @@ -100,6 +100,7 @@ export async function removeChannelConfigWizard( options, }), runtime, + 1, ); if (choice.kind === "done") { @@ -114,6 +115,7 @@ export async function removeChannelConfigWizard( initialValue: false, }), runtime, + 1, ); if (!confirmed) { continue; diff --git a/src/commands/configure.commands.test.ts b/src/commands/configure.commands.test.ts index 9f118ecceadb..fe221f9e5053 100644 --- a/src/commands/configure.commands.test.ts +++ b/src/commands/configure.commands.test.ts @@ -83,7 +83,21 @@ describe("configureCommandFromSectionsArg", () => { ); }); - it("fails closed for a mixed valid/invalid section list on a non-interactive terminal", async () => { + it("rejects a lone invalid section before unrestricted wizard dispatch", async () => { + const runtime = makeRuntime(); + + await expect( + configureCommandFromSectionsArg(["bogus"], runtime, { interactive: true }), + ).rejects.toThrow("exit 1"); + + expect(runtime.exit).toHaveBeenCalledWith(1); + expect(runtime.error.mock.calls[0]?.[0]).toBe( + "Invalid --section: bogus. Expected one of: workspace, model, web, gateway, daemon, channels, plugins, skills, health. Run openclaw configure without --section to use the full wizard.", + ); + expect(runConfigureWizardMock).not.toHaveBeenCalled(); + }); + + it("validates invalid sections before the interactive-terminal guard", async () => { const runtime = makeRuntime(); await expect( @@ -92,9 +106,8 @@ describe("configureCommandFromSectionsArg", () => { }), ).rejects.toThrow("exit 1"); - // Non-TTY guard fires before any section validation, so the wizard never runs. expect(runtime.exit).toHaveBeenCalledWith(1); - expect(runtime.error.mock.calls[0]?.[0]).toContain("interactive terminal (TTY)"); + expect(runtime.error.mock.calls[0]?.[0]).toContain("Invalid --section: bogus"); expect(runConfigureWizardMock).not.toHaveBeenCalled(); }); }); diff --git a/src/commands/configure.commands.ts b/src/commands/configure.commands.ts index 1758cd022a6a..b073fd15e0b6 100644 --- a/src/commands/configure.commands.ts +++ b/src/commands/configure.commands.ts @@ -60,6 +60,15 @@ export async function configureCommandFromSectionsArg( runtime: RuntimeEnv = defaultRuntime, options?: { interactive?: boolean }, ): Promise { + const { sections, invalid } = parseConfigureWizardSections(rawSections); + if (invalid.length > 0) { + runtime.error( + `Invalid --section: ${invalid.join(", ")}. Expected one of: ${CONFIGURE_WIZARD_SECTIONS.join(", ")}. Run ${formatCliCommand("openclaw configure")} without --section to use the full wizard.`, + ); + runtime.exit(1); + return; + } + // Fail closed once at the shared entry: both `openclaw configure` and the // no-subcommand `openclaw config` route here, so a single guard keeps them // consistent instead of partially entering the wizard on a non-TTY pipe. @@ -69,19 +78,10 @@ export async function configureCommandFromSectionsArg( return; } - const { sections, invalid } = parseConfigureWizardSections(rawSections); if (sections.length === 0) { await configureCommand(runtime); return; } - if (invalid.length > 0) { - runtime.error( - `Invalid --section: ${invalid.join(", ")}. Expected one of: ${CONFIGURE_WIZARD_SECTIONS.join(", ")}. Run ${formatCliCommand("openclaw configure")} without --section to use the full wizard.`, - ); - runtime.exit(1); - return; - } - await configureCommandWithSections(sections as never, runtime); } diff --git a/src/commands/configure.daemon.ts b/src/commands/configure.daemon.ts index bddbaf8f1757..b7d12d293312 100644 --- a/src/commands/configure.daemon.ts +++ b/src/commands/configure.daemon.ts @@ -47,6 +47,7 @@ export async function maybeInstallDaemon(params: { ], }), params.runtime, + 1, ); if (action === "restart") { await withProgress( @@ -93,6 +94,7 @@ export async function maybeInstallDaemon(params: { initialValue: DEFAULT_GATEWAY_DAEMON_RUNTIME, }), params.runtime, + 1, ) as GatewayDaemonRuntime; } } @@ -156,7 +158,7 @@ export async function maybeInstallDaemon(params: { await ensureSystemdUserLingerInteractive({ runtime: params.runtime, prompter: { - confirm: async (p) => guardCancel(await confirm(p), params.runtime), + confirm: async (p) => guardCancel(await confirm(p), params.runtime, 1), note, }, reason: diff --git a/src/commands/configure.gateway.ts b/src/commands/configure.gateway.ts index 8b7c88536e51..43d711cf4a16 100644 --- a/src/commands/configure.gateway.ts +++ b/src/commands/configure.gateway.ts @@ -55,6 +55,7 @@ export async function promptGatewayConfig( validate: validateGatewayPortInput, }), runtime, + 1, ); const port = parsePort(portRaw) ?? resolveGatewayPort(cfg); @@ -90,6 +91,7 @@ export async function promptGatewayConfig( ], }), runtime, + 1, ); let customBindHost: string | undefined; @@ -101,6 +103,7 @@ export async function promptGatewayConfig( validate: validateDottedDecimalIPv4Input, }), runtime, + 1, ); customBindHost = readStringValue(input); } @@ -120,6 +123,7 @@ export async function promptGatewayConfig( initialValue: "token", }), runtime, + 1, ) as GatewayAuthChoice; let tailscaleMode = guardCancel( @@ -128,6 +132,7 @@ export async function promptGatewayConfig( options: [...TAILSCALE_EXPOSURE_OPTIONS], }), runtime, + 1, ); // Detect Tailscale binary before proceeding with serve/funnel setup. @@ -149,6 +154,7 @@ export async function promptGatewayConfig( initialValue: false, }), runtime, + 1, ); } @@ -201,6 +207,7 @@ export async function promptGatewayConfig( initialValue: "plaintext", }), runtime, + 1, ); if (tokenInputMode === "ref") { const envVar = guardCancel( @@ -221,6 +228,7 @@ export async function promptGatewayConfig( }, }), runtime, + 1, ); const envVarName = normalizeOptionalString(envVar) ?? ""; gatewayToken = { @@ -237,6 +245,7 @@ export async function promptGatewayConfig( message: "Gateway token (blank to generate)", }), runtime, + 1, ); gatewayTokenForCalls = normalizeGatewayTokenInput(tokenInput) || randomToken(); gatewayToken = gatewayTokenForCalls; @@ -250,6 +259,7 @@ export async function promptGatewayConfig( validate: validateGatewayPasswordInput, }), runtime, + 1, ); gatewayPassword = normalizeOptionalString(passwordInput) ?? ""; } @@ -275,6 +285,7 @@ export async function promptGatewayConfig( validate: (value) => (value?.trim() ? undefined : "User header is required"), }), runtime, + 1, ); const requiredHeadersRaw = guardCancel( @@ -283,6 +294,7 @@ export async function promptGatewayConfig( placeholder: "x-forwarded-proto,x-forwarded-host", }), runtime, + 1, ); const requiredHeaders = requiredHeadersRaw ? normalizeStringEntries(requiredHeadersRaw.split(",")) @@ -294,6 +306,7 @@ export async function promptGatewayConfig( placeholder: "nick@example.com,admin@company.com", }), runtime, + 1, ); const allowUsers = allowUsersRaw ? normalizeStringEntries(allowUsersRaw.split(",")) : []; @@ -309,6 +322,7 @@ export async function promptGatewayConfig( }, }), runtime, + 1, ); trustedProxies = normalizeStringEntries(trustedProxiesRaw.split(",")); diff --git a/src/commands/configure.wizard.test.ts b/src/commands/configure.wizard.test.ts index a1cd57f4840a..5b5e9023a735 100644 --- a/src/commands/configure.wizard.test.ts +++ b/src/commands/configure.wizard.test.ts @@ -1,6 +1,7 @@ // Configure wizard tests cover guided setup routing across gateway, auth, channels, skills, and search. import { beforeEach, describe, expect, it, vi } from "vitest"; import type { OpenClawConfig } from "../config/config.js"; +import type { RuntimeEnv } from "../runtime.js"; const mocks = vi.hoisted(() => { const writeConfigFile = vi.fn(); @@ -48,6 +49,7 @@ const mocks = vi.hoisted(() => { Boolean(config.auth?.profiles?.["openai:default"]), ), setupChannels: vi.fn(async (cfg: OpenClawConfig) => cfg), + guardCancel: vi.fn((value: unknown, _runtime: RuntimeEnv, _exitCode?: number) => value), }; }); @@ -114,7 +116,7 @@ vi.mock("./onboard-helpers.js", () => ({ DEFAULT_WORKSPACE: "~/.openclaw/workspace", applyWizardMetadata: (cfg: OpenClawConfig) => cfg, ensureWorkspaceAndSessions: vi.fn(), - guardCancel: (value: T) => value, + guardCancel: mocks.guardCancel, printWizardHeader: mocks.printWizardHeader, probeGatewayReachable: mocks.probeGatewayReachable, resolveAdvertisedControlUiLinks: mocks.resolveAdvertisedControlUiLinks, @@ -346,6 +348,8 @@ describe("runConfigureWizard", () => { config: cfg, port: 18789, })); + mocks.guardCancel.mockReset(); + mocks.guardCancel.mockImplementation((value: unknown) => value); }); it("persists gateway.mode=local when only the run mode is selected", async () => { @@ -393,6 +397,37 @@ describe("runConfigureWizard", () => { expect(remoteProbe?.timeoutMs).toBe(300); }); + it("uses the resolved configured port for the local gateway startup hint", async () => { + setupBaseWizardState({ + gateway: { + mode: "local", + port: 18991, + }, + }); + mocks.resolveGatewayPort.mockReturnValue(18991); + mocks.probeGatewayReachable + .mockResolvedValueOnce({ ok: true }) + .mockResolvedValue({ ok: false }); + mocks.clackSelect.mockResolvedValue("local"); + + await runConfigureWizard({ command: "configure", sections: ["gateway"] }, createRuntime()); + + expect(mocks.probeGatewayReachable).toHaveBeenCalledWith( + expect.objectContaining({ url: "ws://127.0.0.1:18991", timeoutMs: 300 }), + ); + expect(mocks.clackSelect).toHaveBeenCalledWith( + expect.objectContaining({ + message: "Where will the Gateway run?", + options: expect.arrayContaining([ + expect.objectContaining({ + value: "local", + hint: "Gateway reachable (ws://127.0.0.1:18991)", + }), + ]), + }), + ); + }); + it("advertises LAN Control UI links while probing the local gateway", async () => { setupBaseWizardState({ gateway: { @@ -455,6 +490,24 @@ describe("runConfigureWizard", () => { expect(runtime.exit).toHaveBeenCalledWith(1); }); + it("uses nonzero exit semantics for cancellation at the first direct Clack prompt", async () => { + const runtime = createRuntime(); + setupBaseWizardState(); + mocks.guardCancel.mockImplementationOnce( + (_value: unknown, promptRuntime: RuntimeEnv, exitCode?: number) => { + promptRuntime.exit(exitCode ?? 0); + throw new Error("direct prompt cancelled"); + }, + ); + + await expect(runConfigureWizard({ command: "configure" }, runtime)).rejects.toThrow( + "direct prompt cancelled", + ); + + expect(runtime.exit).toHaveBeenCalledWith(1); + expect(mocks.writeConfigFile).not.toHaveBeenCalled(); + }); + it("does not gate model-only configure behind Gateway run-mode selection", async () => { setupBaseWizardState(); diff --git a/src/commands/configure.wizard.ts b/src/commands/configure.wizard.ts index c2784b38da9c..5c5ed083c57b 100644 --- a/src/commands/configure.wizard.ts +++ b/src/commands/configure.wizard.ts @@ -165,6 +165,7 @@ async function promptConfigureSection( initialValue: CONFIGURE_SECTION_OPTIONS[0]?.value, }), runtime, + 1, ); } @@ -187,6 +188,7 @@ async function promptChannelMode(runtime: RuntimeEnv): Promise => { - const localUrl = "ws://127.0.0.1:18789"; + const localUrl = `ws://127.0.0.1:${resolveGatewayPort(baseConfig)}`; const remoteUrl = normalizeOptionalString(baseConfig.gateway?.remote?.url) ?? ""; const localProbePromise = (async () => { const [baseLocalProbeToken, baseLocalProbePassword] = await Promise.all([ @@ -482,6 +489,7 @@ export async function runConfigureWizard( ], }), runtime, + 1, ); }; @@ -580,6 +588,7 @@ export async function runConfigureWizard( initialValue: workspaceDir, }), runtime, + 1, ); workspaceDir = resolveUserPath( normalizeOptionalString(workspaceInput ?? "") || DEFAULT_WORKSPACE, @@ -650,6 +659,7 @@ export async function runConfigureWizard( validate: validateGatewayPortInput, }), runtime, + 1, ); gatewayPort = parsePort(portInput) ?? gatewayPort; };