feat: add /debug runtime overrides

This commit is contained in:
Peter Steinberger
2026-01-09 16:38:52 +01:00
parent 36bdec0f2c
commit c643ce2a7a
14 changed files with 412 additions and 2 deletions

View File

@@ -7,6 +7,7 @@ export {
} from "./io.js";
export { migrateLegacyConfig } from "./legacy-migrate.js";
export * from "./paths.js";
export * from "./runtime-overrides.js";
export * from "./types.js";
export { validateConfigObject } from "./validation.js";
export { ClawdbotSchema } from "./zod-schema.js";

View File

@@ -26,6 +26,7 @@ import {
resolveConfigPath,
resolveStateDir,
} from "./paths.js";
import { applyConfigOverrides } from "./runtime-overrides.js";
import type {
ClawdbotConfig,
ConfigFileSnapshot,
@@ -195,7 +196,7 @@ export function createConfigIO(overrides: ConfigIoDeps = {}) {
});
}
return cfg;
return applyConfigOverrides(cfg);
} catch (err) {
if (err instanceof DuplicateAgentDirError) {
deps.logger.error(err.message);

View File

@@ -0,0 +1,43 @@
import { describe, expect, it, beforeEach } from "vitest";
import type { ClawdbotConfig } from "./types.js";
import {
applyConfigOverrides,
getConfigOverrides,
resetConfigOverrides,
setConfigOverride,
unsetConfigOverride,
} from "./runtime-overrides.js";
describe("runtime overrides", () => {
beforeEach(() => {
resetConfigOverrides();
});
it("sets and applies nested overrides", () => {
const cfg = {
messages: { responsePrefix: "[clawdbot]" },
} as ClawdbotConfig;
setConfigOverride("messages.responsePrefix", "[debug]");
const next = applyConfigOverrides(cfg);
expect(next.messages?.responsePrefix).toBe("[debug]");
});
it("merges object overrides without clobbering siblings", () => {
const cfg = {
whatsapp: { dmPolicy: "pairing", allowFrom: ["+1"] },
} as ClawdbotConfig;
setConfigOverride("whatsapp.dmPolicy", "open");
const next = applyConfigOverrides(cfg);
expect(next.whatsapp?.dmPolicy).toBe("open");
expect(next.whatsapp?.allowFrom).toEqual(["+1"]);
});
it("unsets overrides and prunes empty branches", () => {
setConfigOverride("whatsapp.dmPolicy", "open");
const removed = unsetConfigOverride("whatsapp.dmPolicy");
expect(removed.ok).toBe(true);
expect(removed.removed).toBe(true);
expect(Object.keys(getConfigOverrides()).length).toBe(0);
});
});

View File

@@ -0,0 +1,112 @@
import type { ClawdbotConfig } from "./types.js";
type OverrideTree = Record<string, unknown>;
let overrides: OverrideTree = {};
function isPlainObject(value: unknown): value is Record<string, unknown> {
return (
typeof value === "object" &&
value !== null &&
!Array.isArray(value) &&
Object.prototype.toString.call(value) === "[object Object]"
);
}
function parsePath(raw: string): string[] | null {
const trimmed = raw.trim();
if (!trimmed) return null;
const parts = trimmed.split(".").map((part) => part.trim());
if (parts.some((part) => !part)) return null;
return parts;
}
function setOverrideAtPath(
root: OverrideTree,
path: string[],
value: unknown,
): void {
let cursor: OverrideTree = root;
for (let idx = 0; idx < path.length - 1; idx += 1) {
const key = path[idx];
const next = cursor[key];
if (!isPlainObject(next)) {
cursor[key] = {};
}
cursor = cursor[key] as OverrideTree;
}
cursor[path[path.length - 1]] = value;
}
function unsetOverrideAtPath(root: OverrideTree, path: string[]): boolean {
const stack: Array<{ node: OverrideTree; key: string }> = [];
let cursor: OverrideTree = root;
for (let idx = 0; idx < path.length - 1; idx += 1) {
const key = path[idx];
const next = cursor[key];
if (!isPlainObject(next)) return false;
stack.push({ node: cursor, key });
cursor = next;
}
const leafKey = path[path.length - 1];
if (!(leafKey in cursor)) return false;
delete cursor[leafKey];
for (let idx = stack.length - 1; idx >= 0; idx -= 1) {
const { node, key } = stack[idx];
const child = node[key];
if (isPlainObject(child) && Object.keys(child).length === 0) {
delete node[key];
} else {
break;
}
}
return true;
}
function mergeOverrides(base: unknown, override: unknown): unknown {
if (!isPlainObject(base) || !isPlainObject(override)) return override;
const next: OverrideTree = { ...base };
for (const [key, value] of Object.entries(override)) {
if (value === undefined) continue;
next[key] = mergeOverrides((base as OverrideTree)[key], value);
}
return next;
}
export function getConfigOverrides(): OverrideTree {
return overrides;
}
export function resetConfigOverrides(): void {
overrides = {};
}
export function setConfigOverride(pathRaw: string, value: unknown): {
ok: boolean;
error?: string;
} {
const path = parsePath(pathRaw);
if (!path) {
return { ok: false, error: "Invalid path. Use dot notation (e.g. foo.bar)." };
}
setOverrideAtPath(overrides, path, value);
return { ok: true };
}
export function unsetConfigOverride(pathRaw: string): {
ok: boolean;
removed: boolean;
error?: string;
} {
const path = parsePath(pathRaw);
if (!path) {
return { ok: false, removed: false, error: "Invalid path." };
}
const removed = unsetOverrideAtPath(overrides, path);
return { ok: true, removed };
}
export function applyConfigOverrides(cfg: ClawdbotConfig): ClawdbotConfig {
if (!overrides || Object.keys(overrides).length === 0) return cfg;
return mergeOverrides(cfg, overrides) as ClawdbotConfig;
}