mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 05:30:42 +00:00
fix(config): preserve empty patch objects
This commit is contained in:
@@ -81,6 +81,15 @@ function setSnapshotOnce(snapshot: ConfigFileSnapshot) {
|
||||
mockReadConfigFileSnapshot.mockResolvedValueOnce(snapshot);
|
||||
}
|
||||
|
||||
function writeTempJson5File(prefix: string, value: unknown): string {
|
||||
const pathname = path.join(
|
||||
os.tmpdir(),
|
||||
`${prefix}-${Date.now()}-${Math.random().toString(16).slice(2)}.json5`,
|
||||
);
|
||||
fs.writeFileSync(pathname, JSON.stringify(value), "utf8");
|
||||
return pathname;
|
||||
}
|
||||
|
||||
function withRuntimeDefaults(resolved: OpenClawConfig): OpenClawConfig {
|
||||
return {
|
||||
...resolved,
|
||||
@@ -733,7 +742,10 @@ describe("config cli", () => {
|
||||
const configCommand = program.commands.find((command) => command.name() === "config");
|
||||
const setCommand = configCommand?.commands.find((command) => command.name() === "set");
|
||||
const helpText = setCommand?.helpInformation() ?? "";
|
||||
const configHelpText = configCommand?.helpInformation() ?? "";
|
||||
|
||||
expect(configHelpText).toContain("get/set/patch/unset/file/schema/validate");
|
||||
expect(configHelpText).not.toContain("get/set/apply/unset/file/schema/validate");
|
||||
expect(helpText).toContain("--strict-json");
|
||||
expect(helpText).toContain("--json");
|
||||
expect(helpText).toContain("Legacy alias for --strict-json");
|
||||
@@ -1462,6 +1474,71 @@ describe("config cli", () => {
|
||||
).toEqual({ source: "env", provider: "default", id: "DISCORD_BOT_TOKEN" });
|
||||
});
|
||||
|
||||
it("preserves empty object values in config patch", async () => {
|
||||
const resolved = {
|
||||
agents: {
|
||||
defaults: {
|
||||
models: {
|
||||
"openai/gpt-5.4": { alias: "GPT 5.4" },
|
||||
},
|
||||
},
|
||||
},
|
||||
} as unknown as OpenClawConfig;
|
||||
setSnapshot(resolved, resolved);
|
||||
|
||||
const pathname = writeTempJson5File("openclaw-config-patch-empty-object", {
|
||||
agents: {
|
||||
defaults: {
|
||||
models: {
|
||||
"openai/gpt-5.5": {},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
try {
|
||||
await runConfigCommand(["config", "patch", "--file", pathname]);
|
||||
} finally {
|
||||
fs.rmSync(pathname, { force: true });
|
||||
}
|
||||
|
||||
const written = mockWriteConfigFile.mock.calls[0]?.[0] as Record<string, unknown>;
|
||||
expect(
|
||||
((written.agents as Record<string, unknown>).defaults as Record<string, unknown>).models,
|
||||
).toEqual({
|
||||
"openai/gpt-5.4": { alias: "GPT 5.4" },
|
||||
"openai/gpt-5.5": {},
|
||||
});
|
||||
});
|
||||
|
||||
it("treats empty object config patches as recursive merges", async () => {
|
||||
const resolved = {
|
||||
channels: {
|
||||
slack: {
|
||||
enabled: true,
|
||||
mode: "socket",
|
||||
},
|
||||
},
|
||||
} as unknown as OpenClawConfig;
|
||||
setSnapshot(resolved, resolved);
|
||||
|
||||
const pathname = writeTempJson5File("openclaw-config-patch-empty-merge", {
|
||||
channels: {
|
||||
slack: {},
|
||||
},
|
||||
});
|
||||
try {
|
||||
await runConfigCommand(["config", "patch", "--file", pathname]);
|
||||
} finally {
|
||||
fs.rmSync(pathname, { force: true });
|
||||
}
|
||||
|
||||
const written = mockWriteConfigFile.mock.calls[0]?.[0] as Record<string, unknown>;
|
||||
expect((written.channels as Record<string, unknown>).slack).toEqual({
|
||||
enabled: true,
|
||||
mode: "socket",
|
||||
});
|
||||
});
|
||||
|
||||
it("dry-runs config patch and resolves changed SecretRefs", async () => {
|
||||
const resolved = {
|
||||
secrets: {
|
||||
|
||||
@@ -915,7 +915,7 @@ function configPatchModeError(message: string): Error {
|
||||
}
|
||||
|
||||
async function readStdinText(): Promise<string> {
|
||||
let raw = "";
|
||||
const chunks: string[] = [];
|
||||
let bytes = 0;
|
||||
if (process.stdin.isTTY) {
|
||||
throw configPatchModeError(
|
||||
@@ -931,9 +931,9 @@ async function readStdinText(): Promise<string> {
|
||||
`--stdin input exceeds ${CONFIG_PATCH_STDIN_MAX_BYTES} bytes; use --file <path> for larger patches.`,
|
||||
);
|
||||
}
|
||||
raw += text;
|
||||
chunks.push(text);
|
||||
}
|
||||
return raw;
|
||||
return chunks.join("");
|
||||
}
|
||||
|
||||
async function readConfigPatchInput(opts: ConfigPatchOptions): Promise<unknown> {
|
||||
@@ -972,7 +972,7 @@ function buildDeleteOperation(path: PathSegment[]): ConfigSetOperation {
|
||||
function buildApplyValueOperation(params: {
|
||||
path: PathSegment[];
|
||||
value: unknown;
|
||||
mutation?: "set" | "replace";
|
||||
mutation?: ConfigSetOperation["mutation"];
|
||||
}): ConfigSetOperation {
|
||||
const ref = isPlainRecord(params.value) ? coerceSecretRef(params.value) : null;
|
||||
if (ref) {
|
||||
@@ -1026,6 +1026,10 @@ function buildConfigPatchOperations(params: {
|
||||
return;
|
||||
}
|
||||
if (isPlainRecord(value)) {
|
||||
if (path.length > 0 && Object.keys(value).length === 0) {
|
||||
operations.push(buildApplyValueOperation({ path, value, mutation: "merge" }));
|
||||
return;
|
||||
}
|
||||
for (const [key, child] of Object.entries(value)) {
|
||||
visit(child, [...path, key]);
|
||||
}
|
||||
@@ -1373,7 +1377,7 @@ async function runConfigOperations(params: {
|
||||
runtime: RuntimeEnv;
|
||||
operations: ConfigSetOperation[];
|
||||
options: ConfigMutationOptions;
|
||||
successMode: "set" | "apply";
|
||||
successMode: "set" | "patch";
|
||||
}) {
|
||||
const { runtime, operations, options } = params;
|
||||
if (
|
||||
@@ -1640,7 +1644,7 @@ export async function runConfigPatch(opts: {
|
||||
allowExec: opts.cliOptions.allowExec,
|
||||
json: opts.cliOptions.json,
|
||||
},
|
||||
successMode: "apply",
|
||||
successMode: "patch",
|
||||
});
|
||||
} catch (err) {
|
||||
handleConfigMutationError({ err, runtime, options: opts.cliOptions });
|
||||
@@ -1795,7 +1799,7 @@ export function registerConfigCli(program: Command) {
|
||||
const cmd = program
|
||||
.command("config")
|
||||
.description(
|
||||
"Non-interactive config helpers (get/set/apply/unset/file/schema/validate). Run without subcommand for guided setup.",
|
||||
"Non-interactive config helpers (get/set/patch/unset/file/schema/validate). Run without subcommand for guided setup.",
|
||||
)
|
||||
.addHelpText(
|
||||
"after",
|
||||
|
||||
Reference in New Issue
Block a user