diff --git a/docs/cli/configure.md b/docs/cli/configure.md index 518cc20c1c81..6bb782f18f59 100644 --- a/docs/cli/configure.md +++ b/docs/cli/configure.md @@ -55,6 +55,7 @@ When configure starts from a provider auth choice, the default-model and model-p - After local config writes, configure installs selected downloadable plugins when the chosen setup path requires them. Remote gateway config does not install local plugin packages. - Channel-oriented services (Slack/Discord/Matrix/Microsoft Teams) prompt for channel/room allowlists during setup. You can enter names or IDs; the wizard resolves names to IDs when possible. +- Choosing **Reinstall** keeps the existing Gateway service in place while you select its runtime and configure validates authentication and prepares the replacement. Cancelling or failing during preparation leaves the existing service installed. - If you run the daemon install step, token auth requires a token. If `gateway.auth.token` is SecretRef-managed, configure validates the SecretRef but does not persist resolved plaintext token values into supervisor service environment metadata; if the SecretRef is unresolved, configure blocks daemon install with actionable remediation guidance. - If both `gateway.auth.token` and `gateway.auth.password` are configured and `gateway.auth.mode` is unset, configure blocks daemon install until you set the mode explicitly. diff --git a/src/commands/configure.daemon.test.ts b/src/commands/configure.daemon.test.ts index 0d927e6c24a3..d50d034d8961 100644 --- a/src/commands/configure.daemon.test.ts +++ b/src/commands/configure.daemon.test.ts @@ -1,5 +1,7 @@ // Configure daemon tests cover daemon install prompts, progress labels, and runtime install calls. +import { PassThrough } from "node:stream"; +import { select as clackSelect } from "@clack/prompts"; import { expectDefined } from "@openclaw/normalization-core"; import { beforeEach, describe, expect, it, vi } from "vitest"; import { maybeInstallDaemon } from "./configure.daemon.js"; @@ -14,13 +16,14 @@ const buildGatewayInstallPlan = vi.hoisted(() => vi.fn()); const note = vi.hoisted(() => vi.fn()); const serviceIsLoaded = vi.hoisted(() => vi.fn(async () => false)); const serviceInstall = vi.hoisted(() => vi.fn(async () => {})); +const serviceUninstall = vi.hoisted(() => vi.fn(async () => {})); const serviceRestart = vi.hoisted(() => vi.fn<() => Promise<{ outcome: "completed" } | { outcome: "scheduled" }>>(async () => ({ outcome: "completed", })), ); const ensureSystemdUserLingerInteractive = vi.hoisted(() => vi.fn(async () => {})); -const select = vi.hoisted(() => vi.fn(async () => "node")); +const select = vi.hoisted(() => vi.fn<() => Promise>(async () => "node")); vi.mock("../cli/progress.js", () => ({ withProgress, @@ -49,11 +52,6 @@ vi.mock("./configure.shared.js", () => ({ select, })); -vi.mock("./daemon-runtime.js", () => ({ - DEFAULT_GATEWAY_DAEMON_RUNTIME: "node", - GATEWAY_DAEMON_RUNTIME_OPTIONS: [{ value: "node", label: "Node" }], -})); - vi.mock("../daemon/service.js", async () => { const actual = await vi.importActual("../daemon/service.js"); @@ -62,15 +60,12 @@ vi.mock("../daemon/service.js", async () => { resolveGatewayService: vi.fn(() => ({ isLoaded: serviceIsLoaded, install: serviceInstall, + uninstall: serviceUninstall, restart: serviceRestart, })), }; }); -vi.mock("./onboard-helpers.js", () => ({ - guardCancel: (value: unknown) => value, -})); - vi.mock("./systemd-linger.js", () => ({ ensureSystemdUserLingerInteractive, })); @@ -81,6 +76,9 @@ describe("maybeInstallDaemon", () => { progressSetLabel.mockReset(); serviceIsLoaded.mockResolvedValue(false); serviceInstall.mockResolvedValue(undefined); + serviceUninstall.mockReset(); + select.mockReset(); + select.mockResolvedValue("node"); serviceRestart.mockResolvedValue({ outcome: "completed" }); loadConfig.mockReturnValue({}); resolveGatewayInstallToken.mockResolvedValue({ @@ -113,7 +111,11 @@ describe("maybeInstallDaemon", () => { expect(serviceInstall).toHaveBeenCalledTimes(1); }); - it("blocks install when token SecretRef is unresolved", async () => { + it.each([false, true])("blocks install with unresolved auth (reinstall=%s)", async (loaded) => { + serviceIsLoaded.mockResolvedValue(loaded); + if (loaded) { + select.mockResolvedValueOnce("reinstall"); + } resolveGatewayInstallToken.mockResolvedValue({ token: undefined, tokenRefConfigured: true, @@ -133,6 +135,64 @@ describe("maybeInstallDaemon", () => { ); expect(buildGatewayInstallPlan).not.toHaveBeenCalled(); expect(serviceInstall).not.toHaveBeenCalled(); + expect(serviceUninstall).not.toHaveBeenCalled(); + }); + + it("keeps the installed service when runtime selection is cancelled", async () => { + serviceIsLoaded.mockResolvedValue(true); + const cancelled = await clackSelect({ + message: "Runtime", + options: [{ value: "node", label: "Node" }], + signal: AbortSignal.abort(), + input: new PassThrough(), + output: new PassThrough(), + }); + select.mockResolvedValueOnce("reinstall").mockResolvedValueOnce(cancelled); + const runtime = { + log: vi.fn(), + error: vi.fn(), + exit: vi.fn(() => { + throw new Error("setup cancelled"); + }), + }; + + await expect(maybeInstallDaemon({ runtime, port: 18789 })).rejects.toThrow("setup cancelled"); + + expect(runtime.exit).toHaveBeenCalledWith(1); + expect(serviceUninstall).not.toHaveBeenCalled(); + expect(resolveGatewayInstallToken).not.toHaveBeenCalled(); + expect(serviceInstall).not.toHaveBeenCalled(); + }); + + it("keeps the installed service when replacement planning fails", async () => { + serviceIsLoaded.mockResolvedValue(true); + select.mockResolvedValueOnce("reinstall"); + buildGatewayInstallPlan.mockRejectedValueOnce(new Error("replacement plan failed")); + + await expect( + maybeInstallDaemon({ + runtime: { log: vi.fn(), error: vi.fn(), exit: vi.fn() }, + port: 18789, + }), + ).rejects.toThrow("replacement plan failed"); + + expect(serviceUninstall).not.toHaveBeenCalled(); + expect(serviceInstall).not.toHaveBeenCalled(); + }); + + it("hands the existing service to the replacement installer", async () => { + serviceIsLoaded.mockResolvedValue(true); + select.mockResolvedValueOnce("reinstall"); + + const outcome = await maybeInstallDaemon({ + runtime: { log: vi.fn(), error: vi.fn(), exit: vi.fn() }, + port: 18789, + }); + + expect(outcome).toBe("succeeded"); + expect(serviceInstall).toHaveBeenCalledOnce(); + expect(serviceUninstall).not.toHaveBeenCalled(); + expect(serviceRestart).not.toHaveBeenCalled(); }); it("continues daemon install flow when service status probe throws", async () => { diff --git a/src/commands/configure.daemon.ts b/src/commands/configure.daemon.ts index ea469e73b190..dfb171604afe 100644 --- a/src/commands/configure.daemon.ts +++ b/src/commands/configure.daemon.ts @@ -71,19 +71,10 @@ export async function maybeInstallDaemon(params: { if (action === "skip") { return "skipped"; } - if (action === "reinstall") { - await withProgress( - { label: "Gateway service", indeterminate: true, delayMs: 0 }, - async (progress) => { - progress.setLabel("Uninstalling Gateway service…"); - await service.uninstall({ env: process.env, stdout: process.stdout }); - progress.setLabel("Gateway service uninstalled."); - }, - ); - } } if (shouldInstall) { + // Keep the old service until preparation succeeds; install owns replacement. let installError: string | null = null; if (!params.daemonRuntime) { if (GATEWAY_DAEMON_RUNTIME_OPTIONS.length === 1) { diff --git a/src/wizard/i18n/locales/en.ts b/src/wizard/i18n/locales/en.ts index 42c6b6784570..814f107376aa 100644 --- a/src/wizard/i18n/locales/en.ts +++ b/src/wizard/i18n/locales/en.ts @@ -1140,8 +1140,6 @@ export const en = { gatewayServiceRestarted: "Gateway service restarted.", gatewayServiceRestarting: "Restarting Gateway service...", gatewayServiceRestartScheduled: "Gateway service restart scheduled.", - gatewayServiceUninstalled: "Gateway service uninstalled.", - gatewayServiceUninstalling: "Uninstalling Gateway service...", gatewayTokenGenerate: "Generate token: {command}", gatewayTokenShared: "Gateway token: shared auth for the Gateway + Control UI.", gatewayTokenStored: diff --git a/src/wizard/i18n/locales/zh-CN.ts b/src/wizard/i18n/locales/zh-CN.ts index 78e4804c9b20..056113d54c19 100644 --- a/src/wizard/i18n/locales/zh-CN.ts +++ b/src/wizard/i18n/locales/zh-CN.ts @@ -1103,8 +1103,6 @@ export const zh_CN = { gatewayServiceRestarted: "Gateway 服务已重启。", gatewayServiceRestarting: "正在重启 Gateway 服务...", gatewayServiceRestartScheduled: "Gateway 服务重启已排队。", - gatewayServiceUninstalled: "Gateway 服务已卸载。", - gatewayServiceUninstalling: "正在卸载 Gateway 服务...", gatewayTokenGenerate: "生成令牌:{command}", gatewayTokenShared: "Gateway 令牌:Gateway 和 Control UI 的共享认证。", gatewayTokenStored: diff --git a/src/wizard/i18n/locales/zh-TW.ts b/src/wizard/i18n/locales/zh-TW.ts index 2713ab4dace2..a4aaf12fe11c 100644 --- a/src/wizard/i18n/locales/zh-TW.ts +++ b/src/wizard/i18n/locales/zh-TW.ts @@ -1104,8 +1104,6 @@ export const zh_TW = { gatewayServiceRestarted: "Gateway 服務已重新啟動。", gatewayServiceRestarting: "正在重新啟動 Gateway 服務...", gatewayServiceRestartScheduled: "Gateway 服務重新啟動已排程。", - gatewayServiceUninstalled: "Gateway 服務已解除安裝。", - gatewayServiceUninstalling: "正在解除安裝 Gateway 服務...", gatewayTokenGenerate: "產生權杖:{command}", gatewayTokenShared: "Gateway 權杖:Gateway 和 Control UI 的共享認證。", gatewayTokenStored: diff --git a/src/wizard/setup.finalize.test.ts b/src/wizard/setup.finalize.test.ts index 43bbce034fdf..319448f4a86b 100644 --- a/src/wizard/setup.finalize.test.ts +++ b/src/wizard/setup.finalize.test.ts @@ -1111,6 +1111,37 @@ describe("finalizeSetupWizard", () => { expect(gatewayServiceInstall).not.toHaveBeenCalled(); }); + it.each([false, true])( + "detects the surviving gateway after failed reinstall (skipHealth=%s)", + async (skipHealth) => { + gatewayServiceIsLoaded.mockResolvedValue(true); + buildGatewayInstallPlan.mockRejectedValueOnce(new Error("replacement plan failed")); + probeGatewayReachable.mockResolvedValue({ ok: true }); + const prompter = buildWizardPrompter({ + select: vi.fn().mockResolvedValueOnce("reinstall").mockResolvedValueOnce("tui"), + }); + + await finalizeSetupWizard( + createFinalizeArgs("quickstart", { + opts: { installDaemon: true, skipHealth }, + prompter, + }), + ); + + expect(gatewayServiceUninstall).not.toHaveBeenCalled(); + expect(gatewayServiceInstall).not.toHaveBeenCalled(); + expect(waitForGatewayReachable).not.toHaveBeenCalled(); + expect(probeGatewayReachable).toHaveBeenCalledOnce(); + expect(healthCommand).toHaveBeenCalledTimes(skipHealth ? 0 : 1); + expect(runTui).toHaveBeenCalledWith( + expect.objectContaining({ boundGateway: { url: "ws://127.0.0.1:18789" } }), + ); + expectNoteContains(prompter, "replacement plan failed", "Gateway"); + expectNoteNotContains(prompter, "Gateway: not detected"); + expect(prompter.outro).toHaveBeenCalledWith(expect.stringContaining("setup failed")); + }, + ); + it("reports gateway installation failure without waiting for impossible health", async () => { gatewayServiceInstall.mockRejectedValueOnce(new Error("service install exploded")); const prompter = createLaterPrompter(); @@ -1124,10 +1155,10 @@ describe("finalizeSetupWizard", () => { ); expect(waitForGatewayReachable).not.toHaveBeenCalled(); - expect(probeGatewayReachable).not.toHaveBeenCalled(); + expect(probeGatewayReachable).toHaveBeenCalledOnce(); expect(runtime.error).toHaveBeenCalledWith("health failed"); expectNoteContains(prompter, "service install exploded", "Gateway"); - expectNoteContains(prompter, "Gateway: not detected (service install exploded)", "Control UI"); + expectNoteContains(prompter, "Gateway: not detected (offline)", "Control UI"); expect(prompter.outro).toHaveBeenCalledWith( expect.stringContaining("managed Mock Platform Service setup failed"), ); @@ -1442,6 +1473,73 @@ describe("finalizeSetupWizard", () => { expect(progressStop).toHaveBeenCalledWith("Gateway service restart scheduled."); }); + it.each(["auth", "planning"])( + "preserves the installed service when reinstall %s fails", + async (failure) => { + let installed = true; + gatewayServiceIsLoaded.mockImplementation(async () => installed); + gatewayServiceUninstall.mockImplementationOnce(async () => { + installed = false; + }); + if (failure === "auth") { + resolveGatewayInstallToken.mockImplementationOnce(async () => ({ + token: undefined, + tokenRefConfigured: true, + warnings: [], + unavailableReason: "replacement auth unavailable", + })); + } else { + buildGatewayInstallPlan.mockRejectedValueOnce(new Error("replacement plan failed")); + } + const prompter = buildWizardPrompter({ select: vi.fn(async () => "reinstall") as never }); + + const result = await ensureGatewayServiceForOnboarding( + createFinalizeArgs("quickstart", { opts: { installDaemon: true }, prompter }), + ); + + expect(result.gateway.status).toBe("failed"); + expect(installed).toBe(true); + expect(gatewayServiceInstall).not.toHaveBeenCalled(); + }, + ); + + it("passes the existing service intact to the reinstall owner", async () => { + let installed = true; + gatewayServiceIsLoaded.mockImplementation(async () => installed); + gatewayServiceUninstall.mockImplementationOnce(async () => { + installed = false; + }); + gatewayServiceInstall.mockImplementationOnce(async () => { + expect(installed).toBe(true); + }); + const prompter = buildWizardPrompter({ select: vi.fn(async () => "reinstall") as never }); + + const result = await ensureGatewayServiceForOnboarding( + createFinalizeArgs("quickstart", { opts: { installDaemon: true }, prompter }), + ); + + expect(result.gateway).toEqual({ status: "ready", action: "installed" }); + expect(gatewayServiceInstall).toHaveBeenCalledOnce(); + expect(gatewayServiceUninstall).not.toHaveBeenCalled(); + }); + + it.each(["skip", "restart"])("does not turn %s into an implicit reinstall", async (action) => { + gatewayServiceIsLoaded.mockResolvedValueOnce(true).mockResolvedValue(false); + const prompter = buildWizardPrompter({ select: vi.fn(async () => action) as never }); + + const result = await ensureGatewayServiceForOnboarding( + createFinalizeArgs("quickstart", { opts: { installDaemon: true }, prompter }), + ); + + expect(result.gateway).toEqual({ + status: "ready", + action: action === "restart" ? "restarted" : "reused", + }); + expect(gatewayServiceInstall).not.toHaveBeenCalled(); + expect(gatewayServiceUninstall).not.toHaveBeenCalled(); + expect(gatewayServiceRestart).toHaveBeenCalledTimes(action === "restart" ? 1 : 0); + }); + it("localizes finalize non-prompt notes", async () => { const previousLocale = process.env.OPENCLAW_LOCALE; process.env.OPENCLAW_LOCALE = "zh-CN"; diff --git a/src/wizard/setup.finalize.ts b/src/wizard/setup.finalize.ts index 055416c48145..d9c23a5c8542 100644 --- a/src/wizard/setup.finalize.ts +++ b/src/wizard/setup.finalize.ts @@ -182,7 +182,7 @@ function buildGatewayRecoveryProjection(params: { } { const { gateway } = params; const notDetected = t("wizard.finalize.gatewayNotDetected"); - if (params.reachable) { + if (params.reachable && gateway.status !== "failed") { return { detail: t("wizard.finalize.gatewayReachable"), summary: t("wizard.guided.complete") }; } if (gateway.status === "ready") { @@ -202,7 +202,10 @@ function buildGatewayRecoveryProjection(params: { statusCommand: formatCliCommand("openclaw gateway status --deep"), recoveryCommand: formatCliCommand("openclaw gateway install --force"), }); - return { detail, summary: `${notDetected} ${detail.replaceAll("\n", " ")}` }; + return { + detail, + summary: `${params.reachable ? "" : `${notDetected} `}${detail.replaceAll("\n", " ")}`, + }; } const startGuidance = @@ -388,7 +391,7 @@ export async function ensureGatewayServiceForOnboarding(params: { } } const loaded = await service.isLoaded({ env: process.env }); - let restartWasScheduled = false; + let shouldInstall = !loaded; if (loaded) { const action = (params.loadedAction === "restart" ? params.loadedAction : undefined) ?? @@ -415,7 +418,6 @@ export async function ensureGatewayServiceForOnboarding(params: { restartDoneMessage = restartStatus.scheduled ? t("wizard.finalize.gatewayServiceRestartScheduled") : t("wizard.finalize.gatewayServiceRestarted"); - restartWasScheduled = restartStatus.scheduled; gateway = { status: "ready", action: restartStatus.scheduled ? "restart-scheduled" : "restarted", @@ -423,21 +425,12 @@ export async function ensureGatewayServiceForOnboarding(params: { }, ); } else if (action === "reinstall") { - await withWizardProgress( - t("wizard.finalize.gatewayService"), - { doneMessage: t("wizard.finalize.gatewayServiceUninstalled") }, - async (progress) => { - progress.update(t("wizard.finalize.gatewayServiceUninstalling")); - await service.uninstall({ env: process.env, stdout: process.stdout }); - }, - ); + // Preserve the old definition so the install owner can replace or restore it. + shouldInstall = true; } } - if ( - !loaded || - (!restartWasScheduled && loaded && !(await service.isLoaded({ env: process.env }))) - ) { + if (shouldInstall) { const progress = prompter.progress(t("wizard.finalize.gatewayService")); let installError: string | null = null; const installWarnings: Array<{ message: string; title?: string }> = []; @@ -531,9 +524,6 @@ export async function finalizeSetupWizard( prompter, runtime, }); - if (gateway.status === "failed") { - gatewayProbe = { ok: false, detail: gateway.error }; - } if (settings.authMode === "password") { try { @@ -573,17 +563,17 @@ export async function finalizeSetupWizard( basePath: undefined, tlsEnabled: nextConfig.gateway?.tls?.enabled === true, }); - // A failed installation cannot become healthy; preserve its authoritative - // error instead of masking it behind a slow, guaranteed-to-fail probe. - if (gateway.status !== "failed") { - // Install/restart can briefly flap the WS; wait before checking health. - gatewayProbe = await waitForGatewayReachable({ - url: probeLinks.wsUrl, - token: settings.authMode === "token" ? settings.gatewayToken : undefined, - password: settings.authMode === "password" ? resolvedGatewayPassword : undefined, - deadlineMs: 15_000, - }); - } + const probeOptions = { + url: probeLinks.wsUrl, + token: settings.authMode === "token" ? settings.gatewayToken : undefined, + password: settings.authMode === "password" ? resolvedGatewayPassword : undefined, + }; + // A failed replacement may leave the old Gateway alive. Observe it once; + // only successful install/restart needs the startup grace period. + gatewayProbe = + gateway.status === "failed" + ? await probeGatewayReachable(probeOptions) + : await waitForGatewayReachable({ ...probeOptions, deadlineMs: 15_000 }); if (gatewayProbe.ok) { try { const healthConfig: OpenClawConfig = @@ -684,7 +674,7 @@ export async function finalizeSetupWizard( basePath: controlUiBasePath, tlsEnabled: nextConfig.gateway?.tls?.enabled === true, }); - if (gateway.status !== "failed" && (opts.skipHealth || !gatewayProbe.ok)) { + if (opts.skipHealth || (!gatewayProbe.ok && gateway.status !== "failed")) { gatewayProbe = await probeGatewayReachable({ url: probeLinks.wsUrl, token: settings.authMode === "token" ? settings.gatewayToken : undefined, @@ -993,12 +983,17 @@ export async function finalizeSetupWizard( await prompter.note(t("wizard.finalize.whatNow"), t("wizard.finalize.whatNowTitle")); await prompter.outro( - gatewayProbe.ok && gatewayHealthCheckFailed - ? t("wizard.finalize.outroHealthCheckFailed", { - command: formatCliCommand("openclaw health"), - }) - : gatewayProbe.ok - ? dashboardReady + !gatewayProbe.ok || gateway.status === "failed" + ? buildGatewayRecoveryProjection({ + gateway, + reachable: gatewayProbe.ok, + serviceLabel: gateway.status === "skipped" ? undefined : resolveGatewayService().label, + }).summary + : gatewayHealthCheckFailed + ? t("wizard.finalize.outroHealthCheckFailed", { + command: formatCliCommand("openclaw health"), + }) + : dashboardReady ? t("wizard.finalize.outroDashboardLink") : controlUiEnabled ? [ @@ -1007,13 +1002,7 @@ export async function finalizeSetupWizard( command: formatCliCommand("openclaw dashboard"), }), ].join(" ") - : t("wizard.guided.complete") - : buildGatewayRecoveryProjection({ - gateway, - reachable: false, - serviceLabel: - gateway.status === "skipped" ? undefined : resolveGatewayService().label, - }).summary, + : t("wizard.guided.complete"), ); if (shouldLaunchTui) {