From 93411b74a0265ed690ed36608d1cbd0eff8c326a Mon Sep 17 00:00:00 2001 From: 0xRain Date: Thu, 12 Feb 2026 02:07:30 +0800 Subject: [PATCH] fix(cli): exit with non-zero code when configure/agents-add wizards are cancelled (#14156) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(cli): exit with non-zero code when configure/agents-add wizards are cancelled Follow-up to the onboard cancel fix. The configure wizard and agents add wizard also caught WizardCancelledError and exited with code 0, which signals success to callers. Change to exit(1) for consistency — user cancellation is not a successful completion. This ensures scripts that chain these commands with set -e will correctly stop when the user cancels. * fix(cli): make wizard cancellations exit non-zero (#14156) (thanks @0xRaini) --------- Co-authored-by: Rain Co-authored-by: Sebastian <19554889+sebslight@users.noreply.github.com> --- CHANGELOG.md | 1 + src/commands/agents.add.test.ts | 26 ++++++++++ src/commands/agents.commands.add.ts | 2 +- src/commands/configure.wizard.test.ts | 25 ++++++++++ src/commands/configure.wizard.ts | 2 +- src/commands/onboard-interactive.test.ts | 61 ++++++++++++++++++++++++ src/commands/onboard-interactive.ts | 2 +- 7 files changed, 116 insertions(+), 3 deletions(-) create mode 100644 src/commands/onboard-interactive.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index b23befdbe0f..88dcd82aec0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,7 @@ Docs: https://docs.openclaw.ai - Ollama: use configured `models.providers.ollama.baseUrl` for model discovery and normalize `/v1` endpoints to the native Ollama API root. (#14131) Thanks @shtse8. - Slack: detect control commands when channel messages start with bot mention prefixes (for example, `@Bot /new`). (#14142) Thanks @beefiker. +- CLI/Wizard: exit with code 1 when `configure`, `agents add`, or interactive `onboard` wizards are canceled, so `set -e` automation stops correctly. (#14156) Thanks @0xRaini. ## 2026.2.9 diff --git a/src/commands/agents.add.test.ts b/src/commands/agents.add.test.ts index 3175cd3fd10..d78f3862e67 100644 --- a/src/commands/agents.add.test.ts +++ b/src/commands/agents.add.test.ts @@ -6,6 +6,10 @@ const configMocks = vi.hoisted(() => ({ writeConfigFile: vi.fn().mockResolvedValue(undefined), })); +const wizardMocks = vi.hoisted(() => ({ + createClackPrompter: vi.fn(), +})); + vi.mock("../config/config.js", async (importOriginal) => { const actual = await importOriginal(); return { @@ -15,6 +19,11 @@ vi.mock("../config/config.js", async (importOriginal) => { }; }); +vi.mock("../wizard/clack-prompter.js", () => ({ + createClackPrompter: wizardMocks.createClackPrompter, +})); + +import { WizardCancelledError } from "../wizard/prompts.js"; import { agentsAddCommand } from "./agents.js"; const runtime: RuntimeEnv = { @@ -38,6 +47,7 @@ describe("agents add command", () => { beforeEach(() => { configMocks.readConfigFileSnapshot.mockReset(); configMocks.writeConfigFile.mockClear(); + wizardMocks.createClackPrompter.mockReset(); runtime.log.mockClear(); runtime.error.mockClear(); runtime.exit.mockClear(); @@ -64,4 +74,20 @@ describe("agents add command", () => { expect(runtime.exit).toHaveBeenCalledWith(1); expect(configMocks.writeConfigFile).not.toHaveBeenCalled(); }); + + it("exits with code 1 when the interactive wizard is cancelled", async () => { + configMocks.readConfigFileSnapshot.mockResolvedValue({ ...baseSnapshot }); + wizardMocks.createClackPrompter.mockReturnValue({ + intro: vi.fn().mockRejectedValue(new WizardCancelledError()), + text: vi.fn(), + confirm: vi.fn(), + note: vi.fn(), + outro: vi.fn(), + }); + + await agentsAddCommand({}, runtime); + + expect(runtime.exit).toHaveBeenCalledWith(1); + expect(configMocks.writeConfigFile).not.toHaveBeenCalled(); + }); }); diff --git a/src/commands/agents.commands.add.ts b/src/commands/agents.commands.add.ts index 8a9fde8fb30..f090d77dcb3 100644 --- a/src/commands/agents.commands.add.ts +++ b/src/commands/agents.commands.add.ts @@ -359,7 +359,7 @@ export async function agentsAddCommand( await prompter.outro(`Agent "${agentId}" ready.`); } catch (err) { if (err instanceof WizardCancelledError) { - runtime.exit(0); + runtime.exit(1); return; } throw err; diff --git a/src/commands/configure.wizard.test.ts b/src/commands/configure.wizard.test.ts index a3f8ffd4d1e..034a3fdf505 100644 --- a/src/commands/configure.wizard.test.ts +++ b/src/commands/configure.wizard.test.ts @@ -95,6 +95,7 @@ vi.mock("./onboard-channels.js", () => ({ setupChannels: vi.fn(), })); +import { WizardCancelledError } from "../wizard/prompts.js"; import { runConfigureWizard } from "./configure.wizard.js"; describe("runConfigureWizard", () => { @@ -133,4 +134,28 @@ describe("runConfigureWizard", () => { }), ); }); + + it("exits with code 1 when configure wizard is cancelled", async () => { + const runtime = { + log: vi.fn(), + error: vi.fn(), + exit: vi.fn(), + }; + + mocks.readConfigFileSnapshot.mockResolvedValue({ + exists: false, + valid: true, + config: {}, + issues: [], + }); + mocks.probeGatewayReachable.mockResolvedValue({ ok: false }); + mocks.resolveControlUiLinks.mockReturnValue({ wsUrl: "ws://127.0.0.1:18789" }); + mocks.summarizeExistingConfig.mockReturnValue(""); + mocks.createClackPrompter.mockReturnValue({}); + mocks.clackSelect.mockRejectedValueOnce(new WizardCancelledError()); + + await runConfigureWizard({ command: "configure" }, runtime); + + expect(runtime.exit).toHaveBeenCalledWith(1); + }); }); diff --git a/src/commands/configure.wizard.ts b/src/commands/configure.wizard.ts index 8f9ff2fc9fb..390626ced17 100644 --- a/src/commands/configure.wizard.ts +++ b/src/commands/configure.wizard.ts @@ -587,7 +587,7 @@ export async function runConfigureWizard( outro("Configure complete."); } catch (err) { if (err instanceof WizardCancelledError) { - runtime.exit(0); + runtime.exit(1); return; } throw err; diff --git a/src/commands/onboard-interactive.test.ts b/src/commands/onboard-interactive.test.ts new file mode 100644 index 00000000000..654edd540aa --- /dev/null +++ b/src/commands/onboard-interactive.test.ts @@ -0,0 +1,61 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { RuntimeEnv } from "../runtime.js"; + +const mocks = vi.hoisted(() => ({ + createClackPrompter: vi.fn(), + runOnboardingWizard: vi.fn(), + restoreTerminalState: vi.fn(), +})); + +vi.mock("../wizard/clack-prompter.js", () => ({ + createClackPrompter: mocks.createClackPrompter, +})); + +vi.mock("../wizard/onboarding.js", () => ({ + runOnboardingWizard: mocks.runOnboardingWizard, +})); + +vi.mock("../terminal/restore.js", () => ({ + restoreTerminalState: mocks.restoreTerminalState, +})); + +import { WizardCancelledError } from "../wizard/prompts.js"; +import { runInteractiveOnboarding } from "./onboard-interactive.js"; + +const runtime: RuntimeEnv = { + log: vi.fn(), + error: vi.fn(), + exit: vi.fn(), +}; + +describe("runInteractiveOnboarding", () => { + beforeEach(() => { + mocks.createClackPrompter.mockReset(); + mocks.runOnboardingWizard.mockReset(); + mocks.restoreTerminalState.mockReset(); + runtime.log.mockClear(); + runtime.error.mockClear(); + runtime.exit.mockClear(); + + mocks.createClackPrompter.mockReturnValue({}); + }); + + it("exits with code 1 when the wizard is cancelled", async () => { + mocks.runOnboardingWizard.mockRejectedValue(new WizardCancelledError()); + + await runInteractiveOnboarding({} as never, runtime); + + expect(runtime.exit).toHaveBeenCalledWith(1); + expect(mocks.restoreTerminalState).toHaveBeenCalledWith("onboarding finish"); + }); + + it("rethrows non-cancel errors", async () => { + const err = new Error("boom"); + mocks.runOnboardingWizard.mockRejectedValue(err); + + await expect(runInteractiveOnboarding({} as never, runtime)).rejects.toThrow("boom"); + + expect(runtime.exit).not.toHaveBeenCalled(); + expect(mocks.restoreTerminalState).toHaveBeenCalledWith("onboarding finish"); + }); +}); diff --git a/src/commands/onboard-interactive.ts b/src/commands/onboard-interactive.ts index d0e147dc2b2..a02d066b9d5 100644 --- a/src/commands/onboard-interactive.ts +++ b/src/commands/onboard-interactive.ts @@ -15,7 +15,7 @@ export async function runInteractiveOnboarding( await runOnboardingWizard(opts, runtime, prompter); } catch (err) { if (err instanceof WizardCancelledError) { - runtime.exit(0); + runtime.exit(1); return; } throw err;