refactor: split doctor config analysis helpers
This commit is contained in:
34
src/commands/doctor-config-analysis.test.ts
Normal file
34
src/commands/doctor-config-analysis.test.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
formatConfigPath,
|
||||
resolveConfigPathTarget,
|
||||
stripUnknownConfigKeys,
|
||||
} from "./doctor-config-analysis.js";
|
||||
|
||||
describe("doctor config analysis helpers", () => {
|
||||
it("formats config paths predictably", () => {
|
||||
expect(formatConfigPath([])).toBe("<root>");
|
||||
expect(formatConfigPath(["channels", "slack", "accounts", 0, "token"])).toBe(
|
||||
"channels.slack.accounts[0].token",
|
||||
);
|
||||
});
|
||||
|
||||
it("resolves nested config targets without throwing", () => {
|
||||
const target = resolveConfigPathTarget(
|
||||
{ channels: { slack: { accounts: [{ token: "x" }] } } },
|
||||
["channels", "slack", "accounts", 0],
|
||||
);
|
||||
expect(target).toEqual({ token: "x" });
|
||||
expect(resolveConfigPathTarget({ channels: null }, ["channels", "slack"])).toBeNull();
|
||||
});
|
||||
|
||||
it("strips unknown config keys while keeping known values", () => {
|
||||
const result = stripUnknownConfigKeys({
|
||||
hooks: {},
|
||||
unexpected: true,
|
||||
} as never);
|
||||
expect(result.removed).toContain("unexpected");
|
||||
expect((result.config as Record<string, unknown>).unexpected).toBeUndefined();
|
||||
expect((result.config as Record<string, unknown>).hooks).toEqual({});
|
||||
});
|
||||
});
|
||||
152
src/commands/doctor-config-analysis.ts
Normal file
152
src/commands/doctor-config-analysis.ts
Normal file
@@ -0,0 +1,152 @@
|
||||
import path from "node:path";
|
||||
import type { ZodIssue } from "zod";
|
||||
import type { OpenClawConfig } from "../config/config.js";
|
||||
import { CONFIG_PATH } from "../config/config.js";
|
||||
import { OpenClawSchema } from "../config/zod-schema.js";
|
||||
import { note } from "../terminal/note.js";
|
||||
import { isRecord } from "../utils.js";
|
||||
|
||||
type UnrecognizedKeysIssue = ZodIssue & {
|
||||
code: "unrecognized_keys";
|
||||
keys: PropertyKey[];
|
||||
};
|
||||
|
||||
function normalizeIssuePath(path: PropertyKey[]): Array<string | number> {
|
||||
return path.filter((part): part is string | number => typeof part !== "symbol");
|
||||
}
|
||||
|
||||
function isUnrecognizedKeysIssue(issue: ZodIssue): issue is UnrecognizedKeysIssue {
|
||||
return issue.code === "unrecognized_keys";
|
||||
}
|
||||
|
||||
export function formatConfigPath(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>";
|
||||
}
|
||||
|
||||
export function resolveConfigPathTarget(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;
|
||||
}
|
||||
|
||||
export function stripUnknownConfigKeys(config: OpenClawConfig): {
|
||||
config: OpenClawConfig;
|
||||
removed: string[];
|
||||
} {
|
||||
const parsed = OpenClawSchema.safeParse(config);
|
||||
if (parsed.success) {
|
||||
return { config, removed: [] };
|
||||
}
|
||||
|
||||
const next = structuredClone(config);
|
||||
const removed: string[] = [];
|
||||
for (const issue of parsed.error.issues) {
|
||||
if (!isUnrecognizedKeysIssue(issue)) {
|
||||
continue;
|
||||
}
|
||||
const issuePath = normalizeIssuePath(issue.path);
|
||||
const target = resolveConfigPathTarget(next, issuePath);
|
||||
if (!target || typeof target !== "object" || Array.isArray(target)) {
|
||||
continue;
|
||||
}
|
||||
const record = target as Record<string, unknown>;
|
||||
for (const key of issue.keys) {
|
||||
if (typeof key !== "string" || !(key in record)) {
|
||||
continue;
|
||||
}
|
||||
delete record[key];
|
||||
removed.push(formatConfigPath([...issuePath, key]));
|
||||
}
|
||||
}
|
||||
|
||||
return { config: next, removed };
|
||||
}
|
||||
|
||||
export function noteOpencodeProviderOverrides(cfg: OpenClawConfig): void {
|
||||
const providers = cfg.models?.providers;
|
||||
if (!providers) {
|
||||
return;
|
||||
}
|
||||
|
||||
const overrides: string[] = [];
|
||||
if (providers.opencode) {
|
||||
overrides.push("opencode");
|
||||
}
|
||||
if (providers["opencode-zen"]) {
|
||||
overrides.push("opencode-zen");
|
||||
}
|
||||
if (overrides.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const lines = overrides.flatMap((id) => {
|
||||
const providerEntry = providers[id];
|
||||
const api =
|
||||
isRecord(providerEntry) && typeof providerEntry.api === "string"
|
||||
? providerEntry.api
|
||||
: undefined;
|
||||
return [
|
||||
`- models.providers.${id} is set; this overrides the built-in OpenCode Zen catalog.`,
|
||||
api ? `- models.providers.${id}.api=${api}` : null,
|
||||
].filter((line): line is string => Boolean(line));
|
||||
});
|
||||
|
||||
lines.push(
|
||||
"- Remove these entries to restore per-model API routing + costs (then re-run onboarding if needed).",
|
||||
);
|
||||
note(lines.join("\n"), "OpenCode Zen");
|
||||
}
|
||||
|
||||
export function noteIncludeConfinementWarning(snapshot: {
|
||||
path?: string | null;
|
||||
issues?: Array<{ message: string }>;
|
||||
}): void {
|
||||
const issues = snapshot.issues ?? [];
|
||||
const includeIssue = issues.find(
|
||||
(issue) =>
|
||||
issue.message.includes("Include path escapes config directory") ||
|
||||
issue.message.includes("Include path resolves outside config directory"),
|
||||
);
|
||||
if (!includeIssue) {
|
||||
return;
|
||||
}
|
||||
const configRoot = path.dirname(snapshot.path ?? CONFIG_PATH);
|
||||
note(
|
||||
[
|
||||
`- $include paths must stay under: ${configRoot}`,
|
||||
'- Move shared include files under that directory and update to relative paths like "./shared/common.json".',
|
||||
`- Error: ${includeIssue.message}`,
|
||||
].join("\n"),
|
||||
"Doctor warnings",
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,5 @@
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import type { ZodIssue } from "zod";
|
||||
import { normalizeChatChannelId } from "../channels/registry.js";
|
||||
import {
|
||||
isNumericTelegramUserId,
|
||||
@@ -17,7 +16,6 @@ import { collectProviderDangerousNameMatchingScopes } from "../config/dangerous-
|
||||
import { formatConfigIssueLines } from "../config/issue-format.js";
|
||||
import { applyPluginAutoEnable } from "../config/plugin-auto-enable.js";
|
||||
import { parseToolsBySenderTypedKey } from "../config/types.tools.js";
|
||||
import { OpenClawSchema } from "../config/zod-schema.js";
|
||||
import { resolveCommandResolutionFromArgv } from "../infra/exec-command-resolution.js";
|
||||
import {
|
||||
listInterpreterLikeSafeBins,
|
||||
@@ -50,161 +48,18 @@ import {
|
||||
import { inspectTelegramAccount } from "../telegram/account-inspect.js";
|
||||
import { listTelegramAccountIds, resolveTelegramAccount } from "../telegram/accounts.js";
|
||||
import { note } from "../terminal/note.js";
|
||||
import { isRecord, resolveHomeDir } from "../utils.js";
|
||||
import { resolveHomeDir } from "../utils.js";
|
||||
import {
|
||||
formatConfigPath,
|
||||
noteIncludeConfinementWarning,
|
||||
noteOpencodeProviderOverrides,
|
||||
resolveConfigPathTarget,
|
||||
stripUnknownConfigKeys,
|
||||
} from "./doctor-config-analysis.js";
|
||||
import { normalizeCompatibilityConfigValues } from "./doctor-legacy-config.js";
|
||||
import type { DoctorOptions } from "./doctor-prompter.js";
|
||||
import { autoMigrateLegacyStateDir } from "./doctor-state-migrations.js";
|
||||
|
||||
type UnrecognizedKeysIssue = ZodIssue & {
|
||||
code: "unrecognized_keys";
|
||||
keys: PropertyKey[];
|
||||
};
|
||||
|
||||
function normalizeIssuePath(path: PropertyKey[]): Array<string | number> {
|
||||
return path.filter((part): part is string | number => typeof part !== "symbol");
|
||||
}
|
||||
|
||||
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: OpenClawConfig): {
|
||||
config: OpenClawConfig;
|
||||
removed: string[];
|
||||
} {
|
||||
const parsed = OpenClawSchema.safeParse(config);
|
||||
if (parsed.success) {
|
||||
return { config, removed: [] };
|
||||
}
|
||||
|
||||
const next = structuredClone(config);
|
||||
const removed: string[] = [];
|
||||
for (const issue of parsed.error.issues) {
|
||||
if (!isUnrecognizedKeysIssue(issue)) {
|
||||
continue;
|
||||
}
|
||||
const path = normalizeIssuePath(issue.path);
|
||||
const target = resolvePathTarget(next, path);
|
||||
if (!target || typeof target !== "object" || Array.isArray(target)) {
|
||||
continue;
|
||||
}
|
||||
const record = target as Record<string, unknown>;
|
||||
for (const key of issue.keys) {
|
||||
if (typeof key !== "string") {
|
||||
continue;
|
||||
}
|
||||
if (!(key in record)) {
|
||||
continue;
|
||||
}
|
||||
delete record[key];
|
||||
removed.push(formatPath([...path, key]));
|
||||
}
|
||||
}
|
||||
|
||||
return { config: next, removed };
|
||||
}
|
||||
|
||||
function noteOpencodeProviderOverrides(cfg: OpenClawConfig) {
|
||||
const providers = cfg.models?.providers;
|
||||
if (!providers) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 2026-01-10: warn when OpenCode Zen overrides mask built-in routing/costs (8a194b4abc360c6098f157956bb9322576b44d51, 2d105d16f8a099276114173836d46b46cdfbdbae).
|
||||
const overrides: string[] = [];
|
||||
if (providers.opencode) {
|
||||
overrides.push("opencode");
|
||||
}
|
||||
if (providers["opencode-zen"]) {
|
||||
overrides.push("opencode-zen");
|
||||
}
|
||||
if (overrides.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const lines = overrides.flatMap((id) => {
|
||||
const providerEntry = providers[id];
|
||||
const api =
|
||||
isRecord(providerEntry) && typeof providerEntry.api === "string"
|
||||
? providerEntry.api
|
||||
: undefined;
|
||||
return [
|
||||
`- models.providers.${id} is set; this overrides the built-in OpenCode Zen catalog.`,
|
||||
api ? `- models.providers.${id}.api=${api}` : null,
|
||||
].filter((line): line is string => Boolean(line));
|
||||
});
|
||||
|
||||
lines.push(
|
||||
"- Remove these entries to restore per-model API routing + costs (then re-run onboarding if needed).",
|
||||
);
|
||||
|
||||
note(lines.join("\n"), "OpenCode Zen");
|
||||
}
|
||||
|
||||
function noteIncludeConfinementWarning(snapshot: {
|
||||
path?: string | null;
|
||||
issues?: Array<{ message: string }>;
|
||||
}): void {
|
||||
const issues = snapshot.issues ?? [];
|
||||
const includeIssue = issues.find(
|
||||
(issue) =>
|
||||
issue.message.includes("Include path escapes config directory") ||
|
||||
issue.message.includes("Include path resolves outside config directory"),
|
||||
);
|
||||
if (!includeIssue) {
|
||||
return;
|
||||
}
|
||||
const configRoot = path.dirname(snapshot.path ?? CONFIG_PATH);
|
||||
note(
|
||||
[
|
||||
`- $include paths must stay under: ${configRoot}`,
|
||||
'- Move shared include files under that directory and update to relative paths like "./shared/common.json".',
|
||||
`- Error: ${includeIssue.message}`,
|
||||
].join("\n"),
|
||||
"Doctor warnings",
|
||||
);
|
||||
}
|
||||
|
||||
type TelegramAllowFromUsernameHit = { path: string; entry: string };
|
||||
|
||||
type TelegramAllowFromListRef = {
|
||||
@@ -1659,7 +1514,7 @@ function collectLegacyToolsBySenderKeyHits(
|
||||
const toolsBySender = asObjectRecord(record.toolsBySender);
|
||||
if (toolsBySender) {
|
||||
const path = [...pathParts, "toolsBySender"];
|
||||
const pathLabel = formatPath(path);
|
||||
const pathLabel = formatConfigPath(path);
|
||||
for (const rawKey of Object.keys(toolsBySender)) {
|
||||
const trimmed = rawKey.trim();
|
||||
if (!trimmed || trimmed === "*" || parseToolsBySenderTypedKey(trimmed)) {
|
||||
@@ -1702,7 +1557,7 @@ function maybeRepairLegacyToolsBySenderKeys(cfg: OpenClawConfig): {
|
||||
let changed = false;
|
||||
|
||||
for (const hit of hits) {
|
||||
const toolsBySender = asObjectRecord(resolvePathTarget(next, hit.toolsBySenderPath));
|
||||
const toolsBySender = asObjectRecord(resolveConfigPathTarget(next, hit.toolsBySenderPath));
|
||||
if (!toolsBySender || !(hit.key in toolsBySender)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user