From 3359dcfdcf174a67551e248ea3805e9f3087f2fa Mon Sep 17 00:00:00 2001 From: giulio-leone Date: Mon, 23 Mar 2026 23:15:52 +0100 Subject: [PATCH] fix(doctor): honor --fix in non-interactive mode Ensure repair-mode doctor prompts auto-accept recommended fixes even when running non-interactively, while still requiring --force for aggressive rewrites. This restores the expected behavior for upgrade/doctor flows that rely on 'openclaw doctor --fix --non-interactive' to repair stale gateway service configuration such as entrypoint drift after global updates. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/commands/doctor-gateway-services.test.ts | 63 +++++++++- src/commands/doctor-prompter.test.ts | 116 +++++++++++++++++++ src/commands/doctor-prompter.ts | 29 ++--- 3 files changed, 186 insertions(+), 22 deletions(-) create mode 100644 src/commands/doctor-prompter.test.ts diff --git a/src/commands/doctor-gateway-services.test.ts b/src/commands/doctor-gateway-services.test.ts index 68ae2f89ff3..0fad5998564 100644 --- a/src/commands/doctor-gateway-services.test.ts +++ b/src/commands/doctor-gateway-services.test.ts @@ -1,6 +1,7 @@ -import { beforeEach, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type { OpenClawConfig } from "../config/config.js"; import { withEnvAsync } from "../test-utils/env.js"; +import { createDoctorPrompter } from "./doctor-prompter.js"; const fsMocks = vi.hoisted(() => ({ realpath: vi.fn(), @@ -97,6 +98,8 @@ import { maybeScanExtraGatewayServices, } from "./doctor-gateway-services.js"; +const originalStdinIsTTY = process.stdin.isTTY; + function makeDoctorIo() { return { log: vi.fn(), error: vi.fn(), exit: vi.fn() }; } @@ -162,6 +165,13 @@ describe("maybeRepairGatewayServiceConfig", () => { }); }); + afterEach(() => { + Object.defineProperty(process.stdin, "isTTY", { + value: originalStdinIsTTY, + configurable: true, + }); + }); + it("treats gateway.auth.token as source of truth for service token repairs", async () => { setupGatewayTokenRepairScenario(); @@ -350,6 +360,57 @@ describe("maybeRepairGatewayServiceConfig", () => { expect(mocks.install).toHaveBeenCalledTimes(1); }); + it("repairs entrypoint mismatch in non-interactive fix mode", async () => { + Object.defineProperty(process.stdin, "isTTY", { + value: false, + configurable: true, + }); + mocks.readCommand.mockResolvedValue({ + programArguments: [ + "/usr/bin/node", + "/Users/test/Library/npm/node_modules/openclaw/dist/entry.js", + "gateway", + "--port", + "18789", + ], + environment: {}, + }); + mocks.auditGatewayServiceConfig.mockResolvedValue({ + ok: true, + issues: [], + }); + mocks.buildGatewayInstallPlan.mockResolvedValue({ + programArguments: [ + "/usr/bin/node", + "/Users/test/Library/npm/node_modules/openclaw/dist/index.js", + "gateway", + "--port", + "18789", + ], + workingDirectory: "/tmp", + environment: {}, + }); + + await maybeRepairGatewayServiceConfig( + { gateway: {} }, + "local", + makeDoctorIo(), + createDoctorPrompter({ + runtime: makeDoctorIo(), + options: { + repair: true, + nonInteractive: true, + }, + }), + ); + + expect(mocks.note).toHaveBeenCalledWith( + expect.stringContaining("Gateway service entrypoint does not match the current install."), + "Gateway service config", + ); + expect(mocks.install).toHaveBeenCalledTimes(1); + }); + it("treats SecretRef-managed gateway token as non-persisted service state", async () => { mocks.readCommand.mockResolvedValue({ programArguments: gatewayProgramArguments, diff --git a/src/commands/doctor-prompter.test.ts b/src/commands/doctor-prompter.test.ts new file mode 100644 index 00000000000..2833840a786 --- /dev/null +++ b/src/commands/doctor-prompter.test.ts @@ -0,0 +1,116 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { createDoctorPrompter } from "./doctor-prompter.js"; + +const confirmMock = vi.fn(); +const selectMock = vi.fn(); + +vi.mock("@clack/prompts", () => ({ + confirm: (options: unknown) => confirmMock(options), + select: (options: unknown) => selectMock(options), +})); + +describe("createDoctorPrompter", () => { + const originalStdinIsTTY = process.stdin.isTTY; + + afterEach(() => { + vi.resetAllMocks(); + Object.defineProperty(process.stdin, "isTTY", { + value: originalStdinIsTTY, + configurable: true, + }); + }); + + it("auto-accepts repairs in non-interactive fix mode", async () => { + Object.defineProperty(process.stdin, "isTTY", { + value: false, + configurable: true, + }); + + const prompter = createDoctorPrompter({ + runtime: { + log: vi.fn(), + error: vi.fn(), + exit: vi.fn(), + }, + options: { + repair: true, + nonInteractive: true, + }, + }); + + await expect( + prompter.confirm({ + message: "Apply general repair?", + initialValue: false, + }), + ).resolves.toBe(true); + await expect( + prompter.confirmRepair({ + message: "Repair gateway service config?", + initialValue: false, + }), + ).resolves.toBe(true); + await expect( + prompter.confirmSkipInNonInteractive({ + message: "Repair launch agent bootstrap?", + initialValue: false, + }), + ).resolves.toBe(true); + expect(confirmMock).not.toHaveBeenCalled(); + }); + + it("requires --force for aggressive repairs in non-interactive fix mode", async () => { + Object.defineProperty(process.stdin, "isTTY", { + value: false, + configurable: true, + }); + + const prompter = createDoctorPrompter({ + runtime: { + log: vi.fn(), + error: vi.fn(), + exit: vi.fn(), + }, + options: { + repair: true, + nonInteractive: true, + }, + }); + + await expect( + prompter.confirmAggressive({ + message: "Overwrite gateway service config?", + initialValue: true, + }), + ).resolves.toBe(false); + expect(confirmMock).not.toHaveBeenCalled(); + }); + + it("auto-accepts aggressive repairs only with --force in non-interactive fix mode", async () => { + Object.defineProperty(process.stdin, "isTTY", { + value: false, + configurable: true, + }); + + const prompter = createDoctorPrompter({ + runtime: { + log: vi.fn(), + error: vi.fn(), + exit: vi.fn(), + }, + options: { + repair: true, + force: true, + nonInteractive: true, + }, + }); + + await expect( + prompter.confirmAggressive({ + message: "Overwrite gateway service config?", + initialValue: false, + }), + ).resolves.toBe(true); + expect(confirmMock).not.toHaveBeenCalled(); + }); +}); diff --git a/src/commands/doctor-prompter.ts b/src/commands/doctor-prompter.ts index 0755fe8e3b8..f604f3bb444 100644 --- a/src/commands/doctor-prompter.ts +++ b/src/commands/doctor-prompter.ts @@ -36,12 +36,12 @@ export function createDoctorPrompter(params: { const canPrompt = isTty && !yes && !nonInteractive; const confirmDefault = async (p: Parameters[0]) => { - if (nonInteractive) { - return false; - } if (shouldRepair) { return true; } + if (nonInteractive) { + return false; + } if (!canPrompt) { return Boolean(p.initialValue ?? false); } @@ -56,19 +56,14 @@ export function createDoctorPrompter(params: { return { confirm: confirmDefault, - confirmRepair: async (p) => { - if (nonInteractive) { - return false; - } - return confirmDefault(p); - }, + confirmRepair: confirmDefault, confirmAggressive: async (p) => { - if (nonInteractive) { - return false; - } if (shouldRepair && shouldForce) { return true; } + if (nonInteractive) { + return false; + } if (shouldRepair && !shouldForce) { return false; } @@ -83,15 +78,7 @@ export function createDoctorPrompter(params: { params.runtime, ); }, - confirmSkipInNonInteractive: async (p) => { - if (nonInteractive) { - return false; - } - if (shouldRepair) { - return true; - } - return confirmDefault(p); - }, + confirmSkipInNonInteractive: confirmDefault, select: async (p: Parameters[0], fallback: T) => { if (!canPrompt || shouldRepair) { return fallback;