mirror of
https://github.com/openclaw/openclaw.git
synced 2026-04-18 10:17:26 +00:00
config: dedupe validate formatting and add io regression coverage
This commit is contained in:
@@ -198,7 +198,7 @@ describe("config cli", () => {
|
||||
|
||||
it("prints issues and exits 1 when config is invalid", async () => {
|
||||
setSnapshotOnce({
|
||||
path: "/tmp/openclaw.json",
|
||||
path: "/tmp/custom-openclaw.json",
|
||||
exists: true,
|
||||
raw: "{}",
|
||||
parsed: {},
|
||||
@@ -226,7 +226,7 @@ describe("config cli", () => {
|
||||
|
||||
it("returns machine-readable JSON with --json for invalid config", async () => {
|
||||
setSnapshotOnce({
|
||||
path: "/tmp/openclaw.json",
|
||||
path: "/tmp/custom-openclaw.json",
|
||||
exists: true,
|
||||
raw: "{}",
|
||||
parsed: {},
|
||||
@@ -250,7 +250,7 @@ describe("config cli", () => {
|
||||
issues: Array<{ path: string; message: string }>;
|
||||
};
|
||||
expect(payload.valid).toBe(false);
|
||||
expect(payload.path).toContain("openclaw.json");
|
||||
expect(payload.path).toBe("/tmp/custom-openclaw.json");
|
||||
expect(payload.issues).toEqual([{ path: "gateway.bind", message: "Invalid enum value" }]);
|
||||
expect(mockError).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -16,6 +16,10 @@ type PathSegment = string;
|
||||
type ConfigSetParseOpts = {
|
||||
strictJson?: boolean;
|
||||
};
|
||||
type ConfigIssue = {
|
||||
path: string;
|
||||
message: string;
|
||||
};
|
||||
|
||||
const OLLAMA_API_KEY_PATH: PathSegment[] = ["models", "providers", "ollama", "apiKey"];
|
||||
const OLLAMA_PROVIDER_PATH: PathSegment[] = ["models", "providers", "ollama"];
|
||||
@@ -98,6 +102,21 @@ function hasOwnPathKey(value: Record<string, unknown>, key: string): boolean {
|
||||
return Object.prototype.hasOwnProperty.call(value, key);
|
||||
}
|
||||
|
||||
function normalizeConfigIssues(issues: ReadonlyArray<ConfigIssue>): ConfigIssue[] {
|
||||
return issues.map((issue) => ({
|
||||
path: issue.path || "<root>",
|
||||
message: issue.message,
|
||||
}));
|
||||
}
|
||||
|
||||
function formatConfigIssueLines(issues: ReadonlyArray<ConfigIssue>, marker: string): string[] {
|
||||
return normalizeConfigIssues(issues).map((issue) => `${marker} ${issue.path}: ${issue.message}`);
|
||||
}
|
||||
|
||||
function formatDoctorHint(message: string): string {
|
||||
return `Run \`${formatCliCommand("openclaw doctor")}\` ${message}`;
|
||||
}
|
||||
|
||||
function validatePathSegments(path: PathSegment[]): void {
|
||||
for (const segment of path) {
|
||||
if (!isIndexSegment(segment) && isBlockedObjectKey(segment)) {
|
||||
@@ -230,10 +249,10 @@ async function loadValidConfig(runtime: RuntimeEnv = defaultRuntime) {
|
||||
return snapshot;
|
||||
}
|
||||
runtime.error(`Config invalid at ${shortenHomePath(snapshot.path)}.`);
|
||||
for (const issue of snapshot.issues) {
|
||||
runtime.error(`- ${issue.path || "<root>"}: ${issue.message}`);
|
||||
for (const line of formatConfigIssueLines(snapshot.issues, "-")) {
|
||||
runtime.error(line);
|
||||
}
|
||||
runtime.error(`Run \`${formatCliCommand("openclaw doctor")}\` to repair, then retry.`);
|
||||
runtime.error(formatDoctorHint("to repair, then retry."));
|
||||
runtime.exit(1);
|
||||
return snapshot;
|
||||
}
|
||||
@@ -338,15 +357,16 @@ export async function runConfigFile(opts: { runtime?: RuntimeEnv }) {
|
||||
|
||||
export async function runConfigValidate(opts: { json?: boolean; runtime?: RuntimeEnv } = {}) {
|
||||
const runtime = opts.runtime ?? defaultRuntime;
|
||||
const configPath = CONFIG_PATH ?? "openclaw.json";
|
||||
const shortPath = shortenHomePath(configPath);
|
||||
let outputPath = CONFIG_PATH ?? "openclaw.json";
|
||||
|
||||
try {
|
||||
const snapshot = await readConfigFileSnapshot();
|
||||
outputPath = snapshot.path;
|
||||
const shortPath = shortenHomePath(outputPath);
|
||||
|
||||
if (!snapshot.exists) {
|
||||
if (opts.json) {
|
||||
runtime.log(JSON.stringify({ valid: false, path: configPath, error: "file not found" }));
|
||||
runtime.log(JSON.stringify({ valid: false, path: outputPath, error: "file not found" }));
|
||||
} else {
|
||||
runtime.error(danger(`Config file not found: ${shortPath}`));
|
||||
}
|
||||
@@ -355,35 +375,30 @@ export async function runConfigValidate(opts: { json?: boolean; runtime?: Runtim
|
||||
}
|
||||
|
||||
if (!snapshot.valid) {
|
||||
const issues = snapshot.issues.map((iss) => ({
|
||||
path: iss.path || "<root>",
|
||||
message: iss.message,
|
||||
}));
|
||||
const issues = normalizeConfigIssues(snapshot.issues);
|
||||
|
||||
if (opts.json) {
|
||||
runtime.log(JSON.stringify({ valid: false, path: configPath, issues }, null, 2));
|
||||
runtime.log(JSON.stringify({ valid: false, path: outputPath, issues }, null, 2));
|
||||
} else {
|
||||
runtime.error(danger(`Config invalid at ${shortPath}:`));
|
||||
for (const issue of issues) {
|
||||
runtime.error(` ${danger("×")} ${issue.path}: ${issue.message}`);
|
||||
for (const line of formatConfigIssueLines(issues, danger("×"))) {
|
||||
runtime.error(` ${line}`);
|
||||
}
|
||||
runtime.error("");
|
||||
runtime.error(
|
||||
`Run \`${formatCliCommand("openclaw doctor")}\` to repair, or fix the keys above manually.`,
|
||||
);
|
||||
runtime.error(formatDoctorHint("to repair, or fix the keys above manually."));
|
||||
}
|
||||
runtime.exit(1);
|
||||
return;
|
||||
}
|
||||
|
||||
if (opts.json) {
|
||||
runtime.log(JSON.stringify({ valid: true, path: configPath }));
|
||||
runtime.log(JSON.stringify({ valid: true, path: outputPath }));
|
||||
} else {
|
||||
runtime.log(success(`Config valid: ${shortPath}`));
|
||||
}
|
||||
} catch (err) {
|
||||
if (opts.json) {
|
||||
runtime.log(JSON.stringify({ valid: false, path: configPath, error: String(err) }));
|
||||
runtime.log(JSON.stringify({ valid: false, path: outputPath, error: String(err) }));
|
||||
} else {
|
||||
runtime.error(danger(`Config validation error: ${String(err)}`));
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { createConfigIO } from "./io.js";
|
||||
|
||||
async function withTempHome(run: (home: string) => Promise<void>): Promise<void> {
|
||||
@@ -137,4 +137,33 @@ describe("config io paths", () => {
|
||||
expect(cfg.agents?.list?.[0]?.tools?.exec?.safeBinTrustedDirs).toEqual(["/ops/bin"]);
|
||||
});
|
||||
});
|
||||
|
||||
it("logs invalid config path details and returns empty config", async () => {
|
||||
await withTempHome(async (home) => {
|
||||
const configDir = path.join(home, ".openclaw");
|
||||
await fs.mkdir(configDir, { recursive: true });
|
||||
const configPath = path.join(configDir, "openclaw.json");
|
||||
await fs.writeFile(
|
||||
configPath,
|
||||
JSON.stringify({ gateway: { port: "not-a-number" } }, null, 2),
|
||||
);
|
||||
|
||||
const logger = {
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
};
|
||||
|
||||
const io = createConfigIO({
|
||||
env: {} as NodeJS.ProcessEnv,
|
||||
homedir: () => home,
|
||||
logger,
|
||||
});
|
||||
|
||||
expect(io.loadConfig()).toEqual({});
|
||||
expect(logger.error).toHaveBeenCalledWith(
|
||||
expect.stringContaining(`Invalid config at ${configPath}:\\n`),
|
||||
);
|
||||
expect(logger.error).toHaveBeenCalledWith(expect.stringContaining("- gateway.port:"));
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user