diff --git a/src/commands/onboard.test.ts b/src/commands/onboard.test.ts index ec574039deac..07adc2505a23 100644 --- a/src/commands/onboard.test.ts +++ b/src/commands/onboard.test.ts @@ -22,6 +22,7 @@ const mocks = vi.hoisted(() => ({ runInteractiveSetup: vi.fn(async () => {}), runGuidedOnboarding: vi.fn(async () => {}), runNonInteractiveSetup: vi.fn(async () => {}), + hasInteractiveOnboardingTty: vi.fn(() => true), resolvePluginProviders: vi.fn((): ProviderPlugin[] => [ { id: "anthropic", @@ -90,6 +91,10 @@ vi.mock("./onboard-non-interactive.js", () => ({ runNonInteractiveSetup: mocks.runNonInteractiveSetup, })); +vi.mock("./onboard-interactive-runner.js", () => ({ + hasInteractiveOnboardingTty: mocks.hasInteractiveOnboardingTty, +})); + vi.mock("../config/config.js", () => ({ readConfigFileSnapshot: mocks.readConfigFileSnapshot, resolveGatewayPort: () => 18_789, @@ -173,6 +178,7 @@ describe("setupWizardCommand", () => { afterEach(() => { vi.unstubAllEnvs(); vi.clearAllMocks(); + mocks.hasInteractiveOnboardingTty.mockReturnValue(true); mocks.readConfigFileSnapshot.mockResolvedValue({ exists: false, valid: false, config: {} }); }); @@ -228,6 +234,42 @@ describe("setupWizardCommand", () => { expectResetCall({ scope: "config+creds+sessions", runtime }); }); + it.each([ + ["guided", { reset: true }], + ["classic", { reset: true, classic: true }], + ] as const)("rejects headless %s onboarding before reset", async (_label, options) => { + const runtime = makeRuntime(); + mocks.hasInteractiveOnboardingTty.mockReturnValue(false); + + await setupWizardCommand(options, runtime); + + expect(runtime.error).toHaveBeenCalledWith( + "Onboarding needs an interactive TTY. Use `openclaw onboard --non-interactive --accept-risk ...` for automation.", + ); + expect(runtime.exit).toHaveBeenCalledWith(1); + expect(mocks.readConfigFileSnapshot).not.toHaveBeenCalled(); + expect(mocks.handleReset).not.toHaveBeenCalled(); + expect(mocks.runGuidedOnboarding).not.toHaveBeenCalled(); + expect(mocks.runInteractiveSetup).not.toHaveBeenCalled(); + expect(mocks.runNonInteractiveSetup).not.toHaveBeenCalled(); + }); + + it("keeps non-interactive reset ordering without a TTY", async () => { + const runtime = makeRuntime(); + mocks.hasInteractiveOnboardingTty.mockReturnValue(false); + + await setupWizardCommand({ reset: true, nonInteractive: true, acceptRisk: true }, runtime); + + expect(mocks.handleReset).toHaveBeenCalledOnce(); + expect(mocks.runNonInteractiveSetup).toHaveBeenCalledOnce(); + const resetOrder = mocks.handleReset.mock.invocationCallOrder[0]; + const setupOrder = mocks.runNonInteractiveSetup.mock.invocationCallOrder[0]; + if (resetOrder === undefined || setupOrder === undefined) { + throw new Error("expected reset and non-interactive setup calls"); + } + expect(resetOrder).toBeLessThan(setupOrder); + }); + it("uses configured default workspace for --reset when --workspace is not provided", async () => { const runtime = makeRuntime(); mocks.readConfigFileSnapshot.mockResolvedValue({ diff --git a/src/commands/onboard.ts b/src/commands/onboard.ts index 4c135d78b603..0c2755a00266 100644 --- a/src/commands/onboard.ts +++ b/src/commands/onboard.ts @@ -22,6 +22,7 @@ import { resolveProviderInstallCatalogEntries } from "../plugins/provider-instal import type { RuntimeEnv } from "../runtime.js"; import { defaultRuntime } from "../runtime.js"; import { resolveUserPath } from "../utils.js"; +import { t } from "../wizard/i18n/index.js"; import { formatDeprecatedNonInteractiveAuthChoiceError, isDeprecatedAuthChoice, @@ -38,6 +39,7 @@ import { } from "./onboard-custom-config.js"; import { runGuidedOnboarding } from "./onboard-guided.js"; import { DEFAULT_WORKSPACE, handleReset } from "./onboard-helpers.js"; +import { hasInteractiveOnboardingTty } from "./onboard-interactive-runner.js"; import { runInteractiveSetup } from "./onboard-interactive.js"; import { runNonInteractiveSetup } from "./onboard-non-interactive.js"; import { resolveNonInteractiveApiKey as resolveNonInteractiveCredential } from "./onboard-non-interactive/api-keys.js"; @@ -536,6 +538,14 @@ export async function setupWizardCommand( return; } + if (!normalizedOpts.nonInteractive && !hasInteractiveOnboardingTty()) { + // Reset is destructive, so prove the selected interactive surface can run + // before reading or moving any operator state. + runtime.error(t("wizard.guided.ttyRequired")); + runtime.exit(1); + return; + } + if (process.platform === "win32") { runtime.log( [