From d8aada9d4516dc865e2e6a5bec6a2010552882b6 Mon Sep 17 00:00:00 2001 From: giulio-leone Date: Mon, 23 Mar 2026 23:45:45 +0100 Subject: [PATCH] Preserve no-restart during update doctor fixes Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../doctor-gateway-daemon-flow.test.ts | 47 +++++++++++++++++ src/commands/doctor-prompter.test.ts | 40 ++++++++++++++ src/commands/doctor-prompter.ts | 9 +++- .../doctor.update-repair-no-restart.test.ts | 52 +++++++++++++++++++ 4 files changed, 147 insertions(+), 1 deletion(-) create mode 100644 src/commands/doctor.update-repair-no-restart.test.ts diff --git a/src/commands/doctor-gateway-daemon-flow.test.ts b/src/commands/doctor-gateway-daemon-flow.test.ts index 02c0b885bb0..23b4794aaaf 100644 --- a/src/commands/doctor-gateway-daemon-flow.test.ts +++ b/src/commands/doctor-gateway-daemon-flow.test.ts @@ -1,4 +1,5 @@ import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; +import { createDoctorPrompter } from "./doctor-prompter.js"; const service = vi.hoisted(() => ({ isLoaded: vi.fn(), @@ -99,6 +100,7 @@ vi.mock("./health.js", () => ({ describe("maybeRepairGatewayDaemon", () => { let maybeRepairGatewayDaemon: typeof import("./doctor-gateway-daemon-flow.js").maybeRepairGatewayDaemon; const originalPlatformDescriptor = Object.getOwnPropertyDescriptor(process, "platform"); + const originalUpdateInProgress = process.env.OPENCLAW_UPDATE_IN_PROGRESS; beforeAll(async () => { ({ maybeRepairGatewayDaemon } = await import("./doctor-gateway-daemon-flow.js")); @@ -121,6 +123,11 @@ describe("maybeRepairGatewayDaemon", () => { if (originalPlatformDescriptor) { Object.defineProperty(process, "platform", originalPlatformDescriptor); } + if (originalUpdateInProgress === undefined) { + delete process.env.OPENCLAW_UPDATE_IN_PROGRESS; + } else { + process.env.OPENCLAW_UPDATE_IN_PROGRESS = originalUpdateInProgress; + } }); function setPlatform(platform: NodeJS.Platform) { @@ -191,4 +198,44 @@ describe("maybeRepairGatewayDaemon", () => { expect(sleep).not.toHaveBeenCalled(); expect(healthCommand).not.toHaveBeenCalled(); }); + + it("skips gateway install during non-interactive update repairs", async () => { + setPlatform("linux"); + process.env.OPENCLAW_UPDATE_IN_PROGRESS = "1"; + service.isLoaded.mockResolvedValue(false); + + await maybeRepairGatewayDaemon({ + cfg: { gateway: {} }, + runtime: { log: vi.fn(), error: vi.fn(), exit: vi.fn() }, + prompter: createDoctorPrompter({ + runtime: { log: vi.fn(), error: vi.fn(), exit: vi.fn() }, + options: { repair: true, nonInteractive: true }, + }), + options: { deep: false, repair: true, nonInteractive: true }, + gatewayDetailsMessage: "details", + healthOk: false, + }); + + expect(service.install).not.toHaveBeenCalled(); + expect(service.restart).not.toHaveBeenCalled(); + }); + + it("skips gateway restart during non-interactive update repairs", async () => { + setPlatform("linux"); + process.env.OPENCLAW_UPDATE_IN_PROGRESS = "1"; + + await maybeRepairGatewayDaemon({ + cfg: { gateway: {} }, + runtime: { log: vi.fn(), error: vi.fn(), exit: vi.fn() }, + prompter: createDoctorPrompter({ + runtime: { log: vi.fn(), error: vi.fn(), exit: vi.fn() }, + options: { repair: true, nonInteractive: true }, + }), + options: { deep: false, repair: true, nonInteractive: true }, + gatewayDetailsMessage: "details", + healthOk: false, + }); + + expect(service.restart).not.toHaveBeenCalled(); + }); }); diff --git a/src/commands/doctor-prompter.test.ts b/src/commands/doctor-prompter.test.ts index 2833840a786..157693bb421 100644 --- a/src/commands/doctor-prompter.test.ts +++ b/src/commands/doctor-prompter.test.ts @@ -11,6 +11,7 @@ vi.mock("@clack/prompts", () => ({ describe("createDoctorPrompter", () => { const originalStdinIsTTY = process.stdin.isTTY; + const originalUpdateInProgress = process.env.OPENCLAW_UPDATE_IN_PROGRESS; afterEach(() => { vi.resetAllMocks(); @@ -18,6 +19,11 @@ describe("createDoctorPrompter", () => { value: originalStdinIsTTY, configurable: true, }); + if (originalUpdateInProgress === undefined) { + delete process.env.OPENCLAW_UPDATE_IN_PROGRESS; + } else { + process.env.OPENCLAW_UPDATE_IN_PROGRESS = originalUpdateInProgress; + } }); it("auto-accepts repairs in non-interactive fix mode", async () => { @@ -86,6 +92,40 @@ describe("createDoctorPrompter", () => { expect(confirmMock).not.toHaveBeenCalled(); }); + it("keeps skip-in-non-interactive prompts disabled during update-mode repairs", async () => { + Object.defineProperty(process.stdin, "isTTY", { + value: false, + configurable: true, + }); + process.env.OPENCLAW_UPDATE_IN_PROGRESS = "1"; + + const prompter = createDoctorPrompter({ + runtime: { + log: vi.fn(), + error: vi.fn(), + exit: vi.fn(), + }, + options: { + repair: true, + nonInteractive: true, + }, + }); + + await expect( + prompter.confirmRepair({ + message: "Repair gateway service config?", + initialValue: false, + }), + ).resolves.toBe(true); + await expect( + prompter.confirmSkipInNonInteractive({ + message: "Restart gateway service now?", + 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, diff --git a/src/commands/doctor-prompter.ts b/src/commands/doctor-prompter.ts index f604f3bb444..670c8ab595f 100644 --- a/src/commands/doctor-prompter.ts +++ b/src/commands/doctor-prompter.ts @@ -1,4 +1,5 @@ import { confirm, select } from "@clack/prompts"; +import { isTruthyEnvValue } from "../infra/env.js"; import type { RuntimeEnv } from "../runtime.js"; import { stylePromptHint, stylePromptMessage } from "../terminal/prompt-style.js"; import { guardCancel } from "./onboard-helpers.js"; @@ -33,6 +34,7 @@ export function createDoctorPrompter(params: { const shouldForce = params.options.force === true; const isTty = Boolean(process.stdin.isTTY); const nonInteractive = requestedNonInteractive || (!isTty && !yes); + const updateInProgress = isTruthyEnvValue(process.env.OPENCLAW_UPDATE_IN_PROGRESS); const canPrompt = isTty && !yes && !nonInteractive; const confirmDefault = async (p: Parameters[0]) => { @@ -78,7 +80,12 @@ export function createDoctorPrompter(params: { params.runtime, ); }, - confirmSkipInNonInteractive: confirmDefault, + confirmSkipInNonInteractive: async (p) => { + if (updateInProgress && nonInteractive) { + return false; + } + return confirmDefault(p); + }, select: async (p: Parameters[0], fallback: T) => { if (!canPrompt || shouldRepair) { return fallback; diff --git a/src/commands/doctor.update-repair-no-restart.test.ts b/src/commands/doctor.update-repair-no-restart.test.ts new file mode 100644 index 00000000000..832fe98e0d1 --- /dev/null +++ b/src/commands/doctor.update-repair-no-restart.test.ts @@ -0,0 +1,52 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { + confirm, + createDoctorRuntime, + mockDoctorConfigSnapshot, + serviceInstall, + serviceIsLoaded, + serviceRestart, +} from "./doctor.e2e-harness.js"; + +let doctorCommand: typeof import("./doctor.js").doctorCommand; +let healthCommand: typeof import("./health.js").healthCommand; + +describe("doctor command update-mode repairs", () => { + beforeEach(async () => { + vi.resetModules(); + ({ doctorCommand } = await import("./doctor.js")); + ({ healthCommand } = await import("./health.js")); + }); + + it("skips gateway installs during non-interactive update repairs", async () => { + mockDoctorConfigSnapshot(); + + vi.mocked(healthCommand).mockRejectedValueOnce(new Error("gateway closed")); + + serviceIsLoaded.mockResolvedValueOnce(false); + serviceInstall.mockClear(); + serviceRestart.mockClear(); + confirm.mockClear(); + + await doctorCommand(createDoctorRuntime(), { repair: true, nonInteractive: true }); + + expect(serviceInstall).not.toHaveBeenCalled(); + expect(serviceRestart).not.toHaveBeenCalled(); + expect(confirm).not.toHaveBeenCalled(); + }); + + it("skips gateway restarts during non-interactive update repairs", async () => { + mockDoctorConfigSnapshot(); + + vi.mocked(healthCommand).mockRejectedValueOnce(new Error("gateway closed")); + + serviceIsLoaded.mockResolvedValueOnce(true); + serviceRestart.mockClear(); + confirm.mockClear(); + + await doctorCommand(createDoctorRuntime(), { repair: true, nonInteractive: true }); + + expect(serviceRestart).not.toHaveBeenCalled(); + expect(confirm).not.toHaveBeenCalled(); + }); +});