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>
This commit is contained in:
giulio-leone 2026-03-23 23:15:52 +01:00 committed by Peter Steinberger
parent 6e970010f7
commit 3359dcfdcf
3 changed files with 186 additions and 22 deletions

View File

@ -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,

View File

@ -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();
});
});

View File

@ -36,12 +36,12 @@ export function createDoctorPrompter(params: {
const canPrompt = isTty && !yes && !nonInteractive;
const confirmDefault = async (p: Parameters<typeof confirm>[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 <T>(p: Parameters<typeof select>[0], fallback: T) => {
if (!canPrompt || shouldRepair) {
return fallback;