fix: repair doctor config cleanup
This commit is contained in:
@@ -23,6 +23,7 @@ clawdbot doctor --deep
|
|||||||
|
|
||||||
Notes:
|
Notes:
|
||||||
- Interactive prompts (like keychain/OAuth fixes) only run when stdin is a TTY and `--non-interactive` is **not** set. Headless runs (cron, Telegram, no terminal) will skip prompts.
|
- Interactive prompts (like keychain/OAuth fixes) only run when stdin is a TTY and `--non-interactive` is **not** set. Headless runs (cron, Telegram, no terminal) will skip prompts.
|
||||||
|
- `--fix` (alias for `--repair`) writes a backup to `~/.clawdbot/clawdbot.json.bak` and drops unknown config keys, listing each removal.
|
||||||
|
|
||||||
## macOS: `launchctl` env overrides
|
## macOS: `launchctl` env overrides
|
||||||
|
|
||||||
|
|||||||
@@ -34,4 +34,36 @@ describe("doctor config flow", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("drops unknown keys on repair", async () => {
|
||||||
|
await withTempHome(async (home) => {
|
||||||
|
const configDir = path.join(home, ".clawdbot");
|
||||||
|
await fs.mkdir(configDir, { recursive: true });
|
||||||
|
await fs.writeFile(
|
||||||
|
path.join(configDir, "clawdbot.json"),
|
||||||
|
JSON.stringify(
|
||||||
|
{
|
||||||
|
bridge: { bind: "auto" },
|
||||||
|
gateway: { auth: { mode: "token", token: "ok", extra: true } },
|
||||||
|
agents: { list: [{ id: "pi" }] },
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
2,
|
||||||
|
),
|
||||||
|
"utf-8",
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = await loadAndMaybeMigrateDoctorConfig({
|
||||||
|
options: { nonInteractive: true, repair: true },
|
||||||
|
confirm: async () => false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const cfg = result.cfg as Record<string, unknown>;
|
||||||
|
expect(cfg.bridge).toBeUndefined();
|
||||||
|
expect((cfg.gateway as Record<string, unknown>)?.auth).toEqual({
|
||||||
|
mode: "token",
|
||||||
|
token: "ok",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
|
import type { ZodIssue } from "zod";
|
||||||
|
|
||||||
import type { ClawdbotConfig } from "../config/config.js";
|
import type { ClawdbotConfig } from "../config/config.js";
|
||||||
import {
|
import {
|
||||||
|
ClawdbotSchema,
|
||||||
CONFIG_PATH_CLAWDBOT,
|
CONFIG_PATH_CLAWDBOT,
|
||||||
migrateLegacyConfig,
|
migrateLegacyConfig,
|
||||||
readConfigFileSnapshot,
|
readConfigFileSnapshot,
|
||||||
@@ -13,6 +16,68 @@ function isRecord(value: unknown): value is Record<string, unknown> {
|
|||||||
return Boolean(value && typeof value === "object" && !Array.isArray(value));
|
return Boolean(value && typeof value === "object" && !Array.isArray(value));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type UnrecognizedKeysIssue = ZodIssue & {
|
||||||
|
code: "unrecognized_keys";
|
||||||
|
keys: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
function isUnrecognizedKeysIssue(issue: ZodIssue): issue is UnrecognizedKeysIssue {
|
||||||
|
return issue.code === "unrecognized_keys";
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatPath(parts: Array<string | number>): string {
|
||||||
|
if (parts.length === 0) return "<root>";
|
||||||
|
let out = "";
|
||||||
|
for (const part of parts) {
|
||||||
|
if (typeof part === "number") {
|
||||||
|
out += `[${part}]`;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
out = out ? `${out}.${part}` : part;
|
||||||
|
}
|
||||||
|
return out || "<root>";
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolvePathTarget(root: unknown, path: Array<string | number>): unknown {
|
||||||
|
let current: unknown = root;
|
||||||
|
for (const part of path) {
|
||||||
|
if (typeof part === "number") {
|
||||||
|
if (!Array.isArray(current)) return null;
|
||||||
|
if (part < 0 || part >= current.length) return null;
|
||||||
|
current = current[part];
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!current || typeof current !== "object" || Array.isArray(current)) return null;
|
||||||
|
const record = current as Record<string, unknown>;
|
||||||
|
if (!(part in record)) return null;
|
||||||
|
current = record[part];
|
||||||
|
}
|
||||||
|
return current;
|
||||||
|
}
|
||||||
|
|
||||||
|
function stripUnknownConfigKeys(config: ClawdbotConfig): { config: ClawdbotConfig; removed: string[] } {
|
||||||
|
const parsed = ClawdbotSchema.safeParse(config);
|
||||||
|
if (parsed.success) {
|
||||||
|
return { config, removed: [] };
|
||||||
|
}
|
||||||
|
|
||||||
|
const next = structuredClone(config) as ClawdbotConfig;
|
||||||
|
const removed: string[] = [];
|
||||||
|
for (const issue of parsed.error.issues) {
|
||||||
|
if (!isUnrecognizedKeysIssue(issue)) continue;
|
||||||
|
const target = resolvePathTarget(next, issue.path);
|
||||||
|
if (!target || typeof target !== "object" || Array.isArray(target)) continue;
|
||||||
|
const record = target as Record<string, unknown>;
|
||||||
|
for (const key of issue.keys) {
|
||||||
|
if (!(key in record)) continue;
|
||||||
|
delete record[key];
|
||||||
|
removed.push(formatPath([...issue.path, key]));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { config: next, removed };
|
||||||
|
}
|
||||||
|
|
||||||
function noteOpencodeProviderOverrides(cfg: ClawdbotConfig) {
|
function noteOpencodeProviderOverrides(cfg: ClawdbotConfig) {
|
||||||
const providers = cfg.models?.providers;
|
const providers = cfg.models?.providers;
|
||||||
if (!providers) return;
|
if (!providers) return;
|
||||||
@@ -89,6 +154,18 @@ export async function loadAndMaybeMigrateDoctorConfig(params: {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const unknown = stripUnknownConfigKeys(cfg);
|
||||||
|
if (unknown.removed.length > 0) {
|
||||||
|
const lines = unknown.removed.map((path) => `- ${path}`).join("\n");
|
||||||
|
if (shouldRepair) {
|
||||||
|
cfg = unknown.config;
|
||||||
|
note(lines, "Doctor changes");
|
||||||
|
} else {
|
||||||
|
note(lines, "Unknown config keys");
|
||||||
|
note('Run "clawdbot doctor --fix" to remove these keys.', "Doctor");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
noteOpencodeProviderOverrides(cfg);
|
noteOpencodeProviderOverrides(cfg);
|
||||||
|
|
||||||
return { cfg, path: snapshot.path ?? CONFIG_PATH_CLAWDBOT };
|
return { cfg, path: snapshot.path ?? CONFIG_PATH_CLAWDBOT };
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import fs from "node:fs";
|
||||||
|
|
||||||
import { intro as clackIntro, outro as clackOutro } from "@clack/prompts";
|
import { intro as clackIntro, outro as clackOutro } from "@clack/prompts";
|
||||||
import { resolveAgentWorkspaceDir, resolveDefaultAgentId } from "../agents/agent-scope.js";
|
import { resolveAgentWorkspaceDir, resolveDefaultAgentId } from "../agents/agent-scope.js";
|
||||||
import { DEFAULT_MODEL, DEFAULT_PROVIDER } from "../agents/defaults.js";
|
import { DEFAULT_MODEL, DEFAULT_PROVIDER } from "../agents/defaults.js";
|
||||||
@@ -251,6 +253,10 @@ export async function doctorCommand(
|
|||||||
cfg = applyWizardMetadata(cfg, { command: "doctor", mode: resolveMode(cfg) });
|
cfg = applyWizardMetadata(cfg, { command: "doctor", mode: resolveMode(cfg) });
|
||||||
await writeConfigFile(cfg);
|
await writeConfigFile(cfg);
|
||||||
runtime.log(`Updated ${CONFIG_PATH_CLAWDBOT}`);
|
runtime.log(`Updated ${CONFIG_PATH_CLAWDBOT}`);
|
||||||
|
const backupPath = `${CONFIG_PATH_CLAWDBOT}.bak`;
|
||||||
|
if (fs.existsSync(backupPath)) {
|
||||||
|
runtime.log(`Backup: ${backupPath}`);
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
runtime.log('Run "clawdbot doctor --fix" to apply changes.');
|
runtime.log('Run "clawdbot doctor --fix" to apply changes.');
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user