From 8369913c7ae1637383c4adb4621983e2896cd2bd Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Wed, 18 Feb 2026 22:49:21 +0000 Subject: [PATCH] refactor(models): reuse validated config snapshot loader --- src/commands/models/auth.ts | 11 +----- src/commands/models/shared.test.ts | 63 ++++++++++++++++++++++++++++++ src/commands/models/shared.ts | 13 ++++-- 3 files changed, 74 insertions(+), 13 deletions(-) create mode 100644 src/commands/models/shared.test.ts diff --git a/src/commands/models/auth.ts b/src/commands/models/auth.ts index 04ac9c25587..60fd8ed58ab 100644 --- a/src/commands/models/auth.ts +++ b/src/commands/models/auth.ts @@ -10,7 +10,6 @@ import { normalizeProviderId } from "../../agents/model-selection.js"; import { resolveDefaultAgentWorkspaceDir } from "../../agents/workspace.js"; import { formatCliCommand } from "../../cli/command-format.js"; import { parseDurationMs } from "../../cli/parse-duration.js"; -import { readConfigFileSnapshot } from "../../config/config.js"; import { logConfigUpdated } from "../../config/logging.js"; import { resolvePluginProviders } from "../../plugins/providers.js"; import type { ProviderAuthResult, ProviderPlugin } from "../../plugins/types.js"; @@ -28,7 +27,7 @@ import { pickAuthMethod, resolveProviderMatch, } from "../provider-auth-helpers.js"; -import { updateConfig } from "./shared.js"; +import { loadValidConfigOrThrow, updateConfig } from "./shared.js"; const confirm = (params: Parameters[0]) => clackConfirm({ @@ -278,13 +277,7 @@ export async function modelsAuthLoginCommand(opts: LoginOptions, runtime: Runtim throw new Error("models auth login requires an interactive TTY."); } - const snapshot = await readConfigFileSnapshot(); - if (!snapshot.valid) { - const issues = snapshot.issues.map((issue) => `- ${issue.path}: ${issue.message}`).join("\n"); - throw new Error(`Invalid config at ${snapshot.path}\n${issues}`); - } - - const config = snapshot.config; + const config = await loadValidConfigOrThrow(); const defaultAgentId = resolveDefaultAgentId(config); const agentDir = resolveAgentDir(config, defaultAgentId); const workspaceDir = diff --git a/src/commands/models/shared.test.ts b/src/commands/models/shared.test.ts new file mode 100644 index 00000000000..becf29f390f --- /dev/null +++ b/src/commands/models/shared.test.ts @@ -0,0 +1,63 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { OpenClawConfig } from "../../config/config.js"; + +const mocks = vi.hoisted(() => ({ + readConfigFileSnapshot: vi.fn(), + writeConfigFile: vi.fn(), +})); + +vi.mock("../../config/config.js", () => ({ + readConfigFileSnapshot: (...args: unknown[]) => mocks.readConfigFileSnapshot(...args), + writeConfigFile: (...args: unknown[]) => mocks.writeConfigFile(...args), +})); + +import { loadValidConfigOrThrow, updateConfig } from "./shared.js"; + +describe("models/shared", () => { + beforeEach(() => { + mocks.readConfigFileSnapshot.mockReset(); + mocks.writeConfigFile.mockReset(); + }); + + it("returns config when snapshot is valid", async () => { + const cfg = { providers: {} } as unknown as OpenClawConfig; + mocks.readConfigFileSnapshot.mockResolvedValue({ + valid: true, + config: cfg, + }); + + await expect(loadValidConfigOrThrow()).resolves.toBe(cfg); + }); + + it("throws formatted issues when snapshot is invalid", async () => { + mocks.readConfigFileSnapshot.mockResolvedValue({ + valid: false, + path: "/tmp/openclaw.json", + issues: [{ path: "providers.openai.apiKey", message: "Required" }], + }); + + await expect(loadValidConfigOrThrow()).rejects.toThrowError( + "Invalid config at /tmp/openclaw.json\n- providers.openai.apiKey: Required", + ); + }); + + it("updateConfig writes mutated config", async () => { + const cfg = { update: { channel: "stable" } } as unknown as OpenClawConfig; + mocks.readConfigFileSnapshot.mockResolvedValue({ + valid: true, + config: cfg, + }); + mocks.writeConfigFile.mockResolvedValue(undefined); + + await updateConfig((current) => ({ + ...current, + update: { channel: "beta" }, + })); + + expect(mocks.writeConfigFile).toHaveBeenCalledWith( + expect.objectContaining({ + update: { channel: "beta" }, + }), + ); + }); +}); diff --git a/src/commands/models/shared.ts b/src/commands/models/shared.ts index 64439ef60c7..53836192e7d 100644 --- a/src/commands/models/shared.ts +++ b/src/commands/models/shared.ts @@ -59,15 +59,20 @@ export const isLocalBaseUrl = (baseUrl: string) => { } }; -export async function updateConfig( - mutator: (cfg: OpenClawConfig) => OpenClawConfig, -): Promise { +export async function loadValidConfigOrThrow(): Promise { const snapshot = await readConfigFileSnapshot(); if (!snapshot.valid) { const issues = snapshot.issues.map((issue) => `- ${issue.path}: ${issue.message}`).join("\n"); throw new Error(`Invalid config at ${snapshot.path}\n${issues}`); } - const next = mutator(snapshot.config); + return snapshot.config; +} + +export async function updateConfig( + mutator: (cfg: OpenClawConfig) => OpenClawConfig, +): Promise { + const config = await loadValidConfigOrThrow(); + const next = mutator(config); await writeConfigFile(next); return next; }