|
|
@@ -0,0 +1,796 @@
|
|
|
+export * as ConfigNormalize from "./normalize"
|
|
|
+
|
|
|
+import { isDeepStrictEqual } from "node:util"
|
|
|
+import { Option, Schema } from "effect"
|
|
|
+import { Info } from "@opencode-ai/schema/config"
|
|
|
+import { ConfigAgent } from "@opencode-ai/schema/config/agent"
|
|
|
+import { ConfigCommand } from "@opencode-ai/schema/config/command"
|
|
|
+import { ConfigCompaction } from "@opencode-ai/schema/config/compaction"
|
|
|
+import { ConfigFormatter } from "@opencode-ai/schema/config/formatter"
|
|
|
+import { ConfigLSP } from "@opencode-ai/schema/config/lsp"
|
|
|
+import { ConfigMedia } from "@opencode-ai/schema/config/media"
|
|
|
+import { ConfigMCP } from "@opencode-ai/schema/config/mcp"
|
|
|
+import { ConfigPlugin } from "@opencode-ai/schema/config/plugin"
|
|
|
+import { ConfigPolicy } from "@opencode-ai/schema/config/policy"
|
|
|
+import { ConfigProvider } from "@opencode-ai/schema/config/provider"
|
|
|
+import { ConfigReference } from "@opencode-ai/schema/config/reference"
|
|
|
+import { ConfigExperimental } from "@opencode-ai/schema/config/experimental"
|
|
|
+import { Permission } from "@opencode-ai/schema/permission"
|
|
|
+import { ConfigAgentV1 } from "../v1/config/agent"
|
|
|
+import { ConfigAttachmentV1 } from "../v1/config/attachment"
|
|
|
+import { ConfigCommandV1 } from "../v1/config/command"
|
|
|
+import { ConfigMCPV1 } from "../v1/config/mcp"
|
|
|
+import { ConfigPermissionV1 } from "../v1/config/permission"
|
|
|
+import { ConfigPluginV1 } from "../v1/config/plugin"
|
|
|
+import { ConfigProviderV1 } from "../v1/config/provider"
|
|
|
+import { ConfigMigrateV1 } from "../v1/config/migrate"
|
|
|
+import { PositiveInt } from "../schema"
|
|
|
+
|
|
|
+export interface Diagnostic {
|
|
|
+ readonly kind: "conflict" | "invalid" | "unsupported"
|
|
|
+ readonly path: readonly string[]
|
|
|
+ readonly message: string
|
|
|
+}
|
|
|
+
|
|
|
+export type Result =
|
|
|
+ | {
|
|
|
+ readonly type: "normalized"
|
|
|
+ readonly encoded: Readonly<Record<string, unknown>>
|
|
|
+ readonly diagnostics: readonly Diagnostic[]
|
|
|
+ }
|
|
|
+ | { readonly type: "rejected"; readonly diagnostics: readonly Diagnostic[] }
|
|
|
+
|
|
|
+const options = { errors: "all", onExcessProperty: "ignore", propertyOrder: "original" } as const
|
|
|
+const unsupportedTopLevel = ["logLevel", "server", "small_model", "subagent_depth", "layout"] as const
|
|
|
+const unsupportedExperimental = [
|
|
|
+ "disable_paste_summary",
|
|
|
+ "batch_tool",
|
|
|
+ "openTelemetry",
|
|
|
+ "primary_tools",
|
|
|
+ "continue_loop_on_deny",
|
|
|
+] as const
|
|
|
+const unsupportedProvider = ["id", "whitelist", "blacklist"] as const
|
|
|
+const unsupportedModel = ["release_date", "attachment", "reasoning", "temperature", "experimental"] as const
|
|
|
+
|
|
|
+export function normalize(input: unknown): Result {
|
|
|
+ if (!isRecord(input))
|
|
|
+ return {
|
|
|
+ type: "rejected",
|
|
|
+ diagnostics: [
|
|
|
+ { kind: "invalid", path: ["$"], message: "rejected configuration because its root is not an object" },
|
|
|
+ ],
|
|
|
+ }
|
|
|
+
|
|
|
+ const diagnostics: Diagnostic[] = []
|
|
|
+ const encoded: Record<string, unknown> = {}
|
|
|
+ unsupportedTopLevel.forEach((key) => unsupportedIfPresent(input, key, [key], diagnostics))
|
|
|
+
|
|
|
+ const legacySnapshots = own(input, "snapshot")
|
|
|
+ ? decodeEncoded(Schema.Boolean, input.snapshot, ["snapshot"], diagnostics)
|
|
|
+ : undefined
|
|
|
+ const legacyShare = own(input, "autoshare")
|
|
|
+ ? decodeValue(Schema.Boolean, input.autoshare, ["autoshare"], diagnostics) === true
|
|
|
+ ? "auto"
|
|
|
+ : undefined
|
|
|
+ : undefined
|
|
|
+ const legacyMedia = own(input, "attachment")
|
|
|
+ ? decodeValue(ConfigAttachmentV1.Info, input.attachment, ["attachment"], diagnostics)
|
|
|
+ : undefined
|
|
|
+ if (legacyMedia !== undefined) {
|
|
|
+ const migrated = ConfigMigrateV1.migrate({ attachment: legacyMedia }).media
|
|
|
+ if (migrated !== undefined) encoded.media = canonical(ConfigMedia.Info, migrated)
|
|
|
+ }
|
|
|
+ if (legacySnapshots !== undefined) encoded.snapshots = legacySnapshots
|
|
|
+ if (legacyShare !== undefined) encoded.share = legacyShare
|
|
|
+
|
|
|
+ const legacyReferences = decodeEncodedMap(input.reference, ConfigReference.Entry, ["reference"], diagnostics)
|
|
|
+ const nativeReferences = decodeEncodedMap(input.references, ConfigReference.Entry, ["references"], diagnostics)
|
|
|
+ mergeMap(
|
|
|
+ encoded,
|
|
|
+ "references",
|
|
|
+ legacyReferences,
|
|
|
+ nativeReferences,
|
|
|
+ isRecord(input.reference) || isRecord(input.references),
|
|
|
+ diagnostics,
|
|
|
+ )
|
|
|
+
|
|
|
+ const legacyCommands = decodeMap(input.command, ConfigCommandV1.Info, ["command"], diagnostics)
|
|
|
+ diagnoseSelectionMap(input.command, ["command"], diagnostics)
|
|
|
+ const migratedCommands = mapValues(legacyCommands, (value) => {
|
|
|
+ const migrated = ConfigMigrateV1.commands({ value })?.value
|
|
|
+ return migrated === undefined ? undefined : canonical(ConfigCommand.Info, migrated)
|
|
|
+ })
|
|
|
+ const nativeCommands = decodeEncodedMap(input.commands, ConfigCommand.Info, ["commands"], diagnostics)
|
|
|
+ mergeMap(
|
|
|
+ encoded,
|
|
|
+ "commands",
|
|
|
+ migratedCommands,
|
|
|
+ nativeCommands,
|
|
|
+ isRecord(input.command) || isRecord(input.commands),
|
|
|
+ diagnostics,
|
|
|
+ )
|
|
|
+
|
|
|
+ const legacyAgents = mapValues(decodeMap(input.agent, ConfigAgentV1.Info, ["agent"], diagnostics), (value) =>
|
|
|
+ canonical(ConfigAgent.Info, ConfigMigrateV1.migrateAgent(value)),
|
|
|
+ )
|
|
|
+ const modeAgents = mapValues(decodeMap(input.mode, ConfigAgentV1.Info, ["mode"], diagnostics), (value) =>
|
|
|
+ canonical(ConfigAgent.Info, ConfigMigrateV1.migrateAgent({ ...value, mode: "primary" })),
|
|
|
+ )
|
|
|
+ const migratedAgents = mergeMaps(legacyAgents, modeAgents, ["agents"], diagnostics)
|
|
|
+ const nativeAgents = decodeEncodedMap(input.agents, ConfigAgent.Info, ["agents"], diagnostics)
|
|
|
+ diagnoseAgentUnsupported(input.agent, ["agent"], diagnostics)
|
|
|
+ diagnoseAgentUnsupported(input.mode, ["mode"], diagnostics)
|
|
|
+ mergeMap(
|
|
|
+ encoded,
|
|
|
+ "agents",
|
|
|
+ migratedAgents,
|
|
|
+ nativeAgents,
|
|
|
+ isRecord(input.agent) || isRecord(input.mode) || isRecord(input.agents),
|
|
|
+ diagnostics,
|
|
|
+ )
|
|
|
+
|
|
|
+ const legacyProviders = migrateProviders(input.provider, diagnostics)
|
|
|
+ const nativeProviders = decodeEncodedMap(input.providers, ConfigProvider.Info, ["providers"], diagnostics)
|
|
|
+ mergeMap(
|
|
|
+ encoded,
|
|
|
+ "providers",
|
|
|
+ legacyProviders,
|
|
|
+ nativeProviders,
|
|
|
+ isRecord(input.provider) || isRecord(input.providers),
|
|
|
+ diagnostics,
|
|
|
+ )
|
|
|
+
|
|
|
+ const toolRules = migrateTools(input.tools, diagnostics)
|
|
|
+ const permissionRules = migratePermissions(input.permission, diagnostics)
|
|
|
+ const nativePermissions = decodeEncodedList(input.permissions, Permission.Rule, ["permissions"], diagnostics)
|
|
|
+ const permissions = [...toolRules, ...permissionRules, ...nativePermissions]
|
|
|
+ if (permissions.length || Array.isArray(input.permissions)) encoded.permissions = permissions
|
|
|
+
|
|
|
+ const legacyPlugins = decodeList(input.plugin, ConfigPluginV1.Spec, ["plugin"], diagnostics).map((plugin) =>
|
|
|
+ typeof plugin === "string" ? plugin : { package: plugin[0], options: plugin[1] },
|
|
|
+ )
|
|
|
+ const nativePlugins = decodeEncodedList(input.plugins, ConfigPlugin.Plugin, ["plugins"], diagnostics)
|
|
|
+ if (legacyPlugins.length || nativePlugins.length || Array.isArray(input.plugin) || Array.isArray(input.plugins))
|
|
|
+ encoded.plugins = [...legacyPlugins, ...nativePlugins]
|
|
|
+
|
|
|
+ normalizeSkills(input, encoded, diagnostics)
|
|
|
+ normalizeMcp(input, encoded, diagnostics)
|
|
|
+ normalizeCompaction(input, encoded, diagnostics)
|
|
|
+ normalizeExperimental(input, encoded, diagnostics)
|
|
|
+ normalizeWatcher(input, encoded, diagnostics)
|
|
|
+ normalizeFormatter(input, encoded, diagnostics)
|
|
|
+ normalizeLsp(input, encoded, diagnostics)
|
|
|
+
|
|
|
+ const nativeAtomic = {
|
|
|
+ $schema: Info.fields.$schema,
|
|
|
+ shell: Info.fields.shell,
|
|
|
+ model: Info.fields.model,
|
|
|
+ default_agent: Info.fields.default_agent,
|
|
|
+ autoupdate: Info.fields.autoupdate,
|
|
|
+ share: Info.fields.share,
|
|
|
+ enterprise: Info.fields.enterprise,
|
|
|
+ username: Info.fields.username,
|
|
|
+ snapshots: Info.fields.snapshots,
|
|
|
+ media: Info.fields.media,
|
|
|
+ tool_output: Info.fields.tool_output,
|
|
|
+ websearch: Info.fields.websearch,
|
|
|
+ warming: Info.fields.warming,
|
|
|
+ }
|
|
|
+ Object.entries(nativeAtomic).forEach(([key, schema]) => {
|
|
|
+ if (!own(input, key)) return
|
|
|
+ const value = decodeEncoded(schema, input[key], [key], diagnostics)
|
|
|
+ if (value === undefined) return
|
|
|
+ overlay(encoded, key, value, [key], diagnostics)
|
|
|
+ })
|
|
|
+
|
|
|
+ const instructions = decodeEncodedList(input.instructions, Schema.String, ["instructions"], diagnostics)
|
|
|
+ if (instructions.length || Array.isArray(input.instructions)) encoded.instructions = instructions
|
|
|
+
|
|
|
+ return { type: "normalized", encoded, diagnostics }
|
|
|
+}
|
|
|
+
|
|
|
+function normalizeSkills(input: Record<string, unknown>, encoded: Record<string, unknown>, diagnostics: Diagnostic[]) {
|
|
|
+ if (!own(input, "skills")) return
|
|
|
+ if (Array.isArray(input.skills)) {
|
|
|
+ encoded.skills = decodeEncodedList(input.skills, Schema.String, ["skills"], diagnostics)
|
|
|
+ return
|
|
|
+ }
|
|
|
+ if (!isRecord(input.skills)) {
|
|
|
+ invalid(["skills"], diagnostics)
|
|
|
+ return
|
|
|
+ }
|
|
|
+ encoded.skills = [
|
|
|
+ ...decodeEncodedList(input.skills.paths, Schema.String, ["skills", "paths"], diagnostics),
|
|
|
+ ...decodeEncodedList(input.skills.urls, Schema.String, ["skills", "urls"], diagnostics),
|
|
|
+ ]
|
|
|
+}
|
|
|
+
|
|
|
+function normalizeMcp(input: Record<string, unknown>, encoded: Record<string, unknown>, diagnostics: Diagnostic[]) {
|
|
|
+ const legacyServers: Record<string, unknown> = {}
|
|
|
+ const nativeServers: Record<string, unknown> = {}
|
|
|
+ const timeout: Record<string, unknown> = {}
|
|
|
+ if (isRecord(input.experimental) && own(input.experimental, "mcp_timeout")) {
|
|
|
+ const value = decodeEncoded(
|
|
|
+ PositiveInt,
|
|
|
+ input.experimental.mcp_timeout,
|
|
|
+ ["experimental", "mcp_timeout"],
|
|
|
+ diagnostics,
|
|
|
+ )
|
|
|
+ if (value !== undefined) {
|
|
|
+ timeout.catalog = value
|
|
|
+ timeout.execution = value
|
|
|
+ }
|
|
|
+ }
|
|
|
+ if (own(input, "mcp")) {
|
|
|
+ if (!isRecord(input.mcp)) invalid(["mcp"], diagnostics)
|
|
|
+ if (isRecord(input.mcp)) {
|
|
|
+ Object.entries(input.mcp).forEach(([name, value]) => {
|
|
|
+ const path = ["mcp", name]
|
|
|
+ if (isEnabledOnlyMcp(value)) {
|
|
|
+ diagnostics.push({ kind: "unsupported", path, message: "omitted enabled-only legacy MCP entry" })
|
|
|
+ return
|
|
|
+ }
|
|
|
+ if (name === "servers" && !isDirectLegacyMcp(value)) {
|
|
|
+ Object.entries(decodeEncodedMap(value, ConfigMCP.Server, path, diagnostics)).forEach(([key, server]) =>
|
|
|
+ setOwn(nativeServers, key, server),
|
|
|
+ )
|
|
|
+ return
|
|
|
+ }
|
|
|
+ if (name === "timeout" && !isDirectLegacyMcp(value)) {
|
|
|
+ normalizeMcpTimeout(value, timeout, path, diagnostics)
|
|
|
+ return
|
|
|
+ }
|
|
|
+ const server = decodeValue(ConfigMCPV1.Info, value, path, diagnostics)
|
|
|
+ if (server !== undefined)
|
|
|
+ setOwn(legacyServers, name, canonical(ConfigMCP.Server, ConfigMigrateV1.migrateMcp(server)))
|
|
|
+ })
|
|
|
+ }
|
|
|
+ }
|
|
|
+ const servers = mergeMaps(legacyServers, nativeServers, ["mcp", "servers"], diagnostics)
|
|
|
+ if (!Object.keys(servers).length && !Object.keys(timeout).length) {
|
|
|
+ if (isRecord(input.mcp) && !Object.keys(input.mcp).length) encoded.mcp = {}
|
|
|
+ return
|
|
|
+ }
|
|
|
+ encoded.mcp = {
|
|
|
+ ...(Object.keys(timeout).length ? { timeout } : {}),
|
|
|
+ ...(Object.keys(servers).length ? { servers } : {}),
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+function normalizeMcpTimeout(
|
|
|
+ value: unknown,
|
|
|
+ timeout: Record<string, unknown>,
|
|
|
+ path: string[],
|
|
|
+ diagnostics: Diagnostic[],
|
|
|
+) {
|
|
|
+ if (!isRecord(value)) {
|
|
|
+ invalid(path, diagnostics)
|
|
|
+ return
|
|
|
+ }
|
|
|
+ const recognized = ["startup", "catalog", "execution"].filter((key) => own(value, key))
|
|
|
+ if (Object.keys(value).length && !recognized.length) {
|
|
|
+ invalid(path, diagnostics)
|
|
|
+ return
|
|
|
+ }
|
|
|
+ recognized.forEach((key) => {
|
|
|
+ const leaf = decodeEncoded(
|
|
|
+ ConfigMCP.Timeout.fields[key as keyof typeof ConfigMCP.Timeout.fields],
|
|
|
+ value[key],
|
|
|
+ [...path, key],
|
|
|
+ diagnostics,
|
|
|
+ )
|
|
|
+ if (leaf === undefined) return
|
|
|
+ overlay(timeout, key, leaf, [...path, key], diagnostics)
|
|
|
+ })
|
|
|
+}
|
|
|
+
|
|
|
+function normalizeCompaction(
|
|
|
+ input: Record<string, unknown>,
|
|
|
+ encoded: Record<string, unknown>,
|
|
|
+ diagnostics: Diagnostic[],
|
|
|
+) {
|
|
|
+ if (!own(input, "compaction")) return
|
|
|
+ if (!isRecord(input.compaction)) {
|
|
|
+ invalid(["compaction"], diagnostics)
|
|
|
+ return
|
|
|
+ }
|
|
|
+ unsupportedIfPresent(input.compaction, "tail_turns", ["compaction", "tail_turns"], diagnostics)
|
|
|
+ unsupportedIfPresent(input.compaction, "prune", ["compaction", "prune"], diagnostics)
|
|
|
+ const result: Record<string, unknown> = {}
|
|
|
+ if (own(input.compaction, "auto")) {
|
|
|
+ const value = decodeEncoded(
|
|
|
+ ConfigCompaction.Info.fields.auto,
|
|
|
+ input.compaction.auto,
|
|
|
+ ["compaction", "auto"],
|
|
|
+ diagnostics,
|
|
|
+ )
|
|
|
+ if (value !== undefined) result.auto = value
|
|
|
+ }
|
|
|
+ const legacyTokens = own(input.compaction, "preserve_recent_tokens")
|
|
|
+ ? decodeEncoded(
|
|
|
+ ConfigCompaction.Keep.fields.tokens,
|
|
|
+ input.compaction.preserve_recent_tokens,
|
|
|
+ ["compaction", "preserve_recent_tokens"],
|
|
|
+ diagnostics,
|
|
|
+ )
|
|
|
+ : undefined
|
|
|
+ const nativeKeep = isRecord(input.compaction.keep) ? input.compaction.keep : undefined
|
|
|
+ if (own(input.compaction, "keep") && !nativeKeep) invalid(["compaction", "keep"], diagnostics)
|
|
|
+ const nativeTokens =
|
|
|
+ nativeKeep && own(nativeKeep, "tokens")
|
|
|
+ ? decodeEncoded(
|
|
|
+ ConfigCompaction.Keep.fields.tokens,
|
|
|
+ nativeKeep.tokens,
|
|
|
+ ["compaction", "keep", "tokens"],
|
|
|
+ diagnostics,
|
|
|
+ )
|
|
|
+ : undefined
|
|
|
+ const tokens = prefer(legacyTokens, nativeTokens, ["compaction", "keep", "tokens"], diagnostics)
|
|
|
+ if (tokens !== undefined) result.keep = { tokens }
|
|
|
+ const legacyBuffer = own(input.compaction, "reserved")
|
|
|
+ ? decodeEncoded(
|
|
|
+ ConfigCompaction.Info.fields.buffer,
|
|
|
+ input.compaction.reserved,
|
|
|
+ ["compaction", "reserved"],
|
|
|
+ diagnostics,
|
|
|
+ )
|
|
|
+ : undefined
|
|
|
+ const nativeBuffer = own(input.compaction, "buffer")
|
|
|
+ ? decodeEncoded(ConfigCompaction.Info.fields.buffer, input.compaction.buffer, ["compaction", "buffer"], diagnostics)
|
|
|
+ : undefined
|
|
|
+ const buffer = prefer(legacyBuffer, nativeBuffer, ["compaction", "buffer"], diagnostics)
|
|
|
+ if (buffer !== undefined) result.buffer = buffer
|
|
|
+ if (Object.keys(result).length || !Object.keys(input.compaction).length) encoded.compaction = result
|
|
|
+}
|
|
|
+
|
|
|
+function normalizeExperimental(
|
|
|
+ input: Record<string, unknown>,
|
|
|
+ encoded: Record<string, unknown>,
|
|
|
+ diagnostics: Diagnostic[],
|
|
|
+) {
|
|
|
+ const result: Record<string, unknown> = {}
|
|
|
+ const generated: unknown[] = []
|
|
|
+ const enabled = decodeProviderList(input, "enabled_providers", diagnostics)
|
|
|
+ if (enabled.present && (!enabled.nonEmpty || enabled.values.length)) {
|
|
|
+ generated.push({ action: "provider.use", resource: "*", effect: "deny" })
|
|
|
+ generated.push(
|
|
|
+ ...enabled.values.map((resource) => ({
|
|
|
+ action: "provider.use",
|
|
|
+ resource: ConfigMigrateV1.providerID(resource),
|
|
|
+ effect: "allow",
|
|
|
+ })),
|
|
|
+ )
|
|
|
+ }
|
|
|
+ const disabled = decodeProviderList(input, "disabled_providers", diagnostics)
|
|
|
+ generated.push(
|
|
|
+ ...disabled.values.map((resource) => ({
|
|
|
+ action: "provider.use",
|
|
|
+ resource: ConfigMigrateV1.providerID(resource),
|
|
|
+ effect: "deny",
|
|
|
+ })),
|
|
|
+ )
|
|
|
+ const native: unknown[] = []
|
|
|
+ if (own(input, "experimental")) {
|
|
|
+ if (!isRecord(input.experimental)) invalid(["experimental"], diagnostics)
|
|
|
+ if (isRecord(input.experimental)) {
|
|
|
+ const experimental = input.experimental
|
|
|
+ unsupportedExperimental.forEach((key) =>
|
|
|
+ unsupportedIfPresent(experimental, key, ["experimental", key], diagnostics),
|
|
|
+ )
|
|
|
+ if (own(experimental, "subagent_depth")) {
|
|
|
+ const value = decodeEncoded(
|
|
|
+ ConfigExperimental.Info.fields.subagent_depth,
|
|
|
+ experimental.subagent_depth,
|
|
|
+ ["experimental", "subagent_depth"],
|
|
|
+ diagnostics,
|
|
|
+ )
|
|
|
+ if (value !== undefined) result.subagent_depth = value
|
|
|
+ }
|
|
|
+ native.push(
|
|
|
+ ...decodeEncodedList(experimental.policies, ConfigPolicy.Info, ["experimental", "policies"], diagnostics),
|
|
|
+ )
|
|
|
+ }
|
|
|
+ }
|
|
|
+ if (generated.length || native.length || (isRecord(input.experimental) && Array.isArray(input.experimental.policies)))
|
|
|
+ result.policies = [...generated, ...native]
|
|
|
+ if (Object.keys(result).length || (isRecord(input.experimental) && !Object.keys(input.experimental).length))
|
|
|
+ encoded.experimental = result
|
|
|
+}
|
|
|
+
|
|
|
+function normalizeWatcher(input: Record<string, unknown>, encoded: Record<string, unknown>, diagnostics: Diagnostic[]) {
|
|
|
+ if (!own(input, "watcher")) return
|
|
|
+ if (!isRecord(input.watcher)) {
|
|
|
+ invalid(["watcher"], diagnostics)
|
|
|
+ return
|
|
|
+ }
|
|
|
+ const ignore = decodeEncodedList(input.watcher.ignore, Schema.String, ["watcher", "ignore"], diagnostics)
|
|
|
+ encoded.watcher = ignore.length || Array.isArray(input.watcher.ignore) ? { ignore } : {}
|
|
|
+}
|
|
|
+
|
|
|
+function normalizeFormatter(
|
|
|
+ input: Record<string, unknown>,
|
|
|
+ encoded: Record<string, unknown>,
|
|
|
+ diagnostics: Diagnostic[],
|
|
|
+) {
|
|
|
+ if (!own(input, "formatter")) return
|
|
|
+ if (typeof input.formatter === "boolean") {
|
|
|
+ const value = decodeEncoded(ConfigFormatter.Info, input.formatter, ["formatter"], diagnostics)
|
|
|
+ if (value !== undefined) encoded.formatter = value
|
|
|
+ return
|
|
|
+ }
|
|
|
+ const entries = decodeEncodedMap(input.formatter, ConfigFormatter.Entry, ["formatter"], diagnostics)
|
|
|
+ if (isRecord(input.formatter) && (!Object.keys(input.formatter).length || Object.keys(entries).length))
|
|
|
+ encoded.formatter = entries
|
|
|
+}
|
|
|
+
|
|
|
+function normalizeLsp(input: Record<string, unknown>, encoded: Record<string, unknown>, diagnostics: Diagnostic[]) {
|
|
|
+ if (!own(input, "lsp")) return
|
|
|
+ if (typeof input.lsp === "boolean") {
|
|
|
+ const value = decodeEncoded(ConfigLSP.Info, input.lsp, ["lsp"], diagnostics)
|
|
|
+ if (value !== undefined) encoded.lsp = value
|
|
|
+ return
|
|
|
+ }
|
|
|
+ const entries = decodeEncodedMap(input.lsp, ConfigLSP.Entry, ["lsp"], diagnostics)
|
|
|
+ if (isRecord(input.lsp) && (!Object.keys(input.lsp).length || Object.keys(entries).length)) encoded.lsp = entries
|
|
|
+}
|
|
|
+
|
|
|
+function migrateTools(value: unknown, diagnostics: Diagnostic[]) {
|
|
|
+ if (value === undefined) return []
|
|
|
+ if (!isRecord(value)) {
|
|
|
+ invalid(["tools"], diagnostics)
|
|
|
+ return []
|
|
|
+ }
|
|
|
+ return Object.entries(value).flatMap(([action, raw]) => {
|
|
|
+ const enabled = decodeValue(Schema.Boolean, raw, ["tools", action], diagnostics)
|
|
|
+ if (enabled === undefined) return []
|
|
|
+ return [{ action: ConfigMigrateV1.normalizeAction(action), resource: "*", effect: enabled ? "allow" : "deny" }]
|
|
|
+ })
|
|
|
+}
|
|
|
+
|
|
|
+function migratePermissions(value: unknown, diagnostics: Diagnostic[]) {
|
|
|
+ if (value === undefined) return []
|
|
|
+ if (typeof value === "string") {
|
|
|
+ const effect = decodeValue(ConfigPermissionV1.Action, value, ["permission"], diagnostics)
|
|
|
+ return effect === undefined ? [] : [{ action: "*", resource: "*", effect }]
|
|
|
+ }
|
|
|
+ if (!isRecord(value)) {
|
|
|
+ invalid(["permission"], diagnostics)
|
|
|
+ return []
|
|
|
+ }
|
|
|
+ return Object.entries(value).flatMap(([action, raw]) => {
|
|
|
+ if (typeof raw === "string") {
|
|
|
+ const effect = decodeValue(ConfigPermissionV1.Action, raw, ["permission", action], diagnostics)
|
|
|
+ return effect === undefined ? [] : [{ action: ConfigMigrateV1.normalizeAction(action), resource: "*", effect }]
|
|
|
+ }
|
|
|
+ if (!isRecord(raw)) {
|
|
|
+ invalid(["permission", action], diagnostics)
|
|
|
+ return []
|
|
|
+ }
|
|
|
+ return Object.entries(raw).flatMap(([resource, effect], index) => {
|
|
|
+ const decoded = decodeValue(ConfigPermissionV1.Action, effect, ["permission", action, String(index)], diagnostics)
|
|
|
+ return decoded === undefined
|
|
|
+ ? []
|
|
|
+ : [{ action: ConfigMigrateV1.normalizeAction(action), resource, effect: decoded }]
|
|
|
+ })
|
|
|
+ })
|
|
|
+}
|
|
|
+
|
|
|
+function migrateProviders(value: unknown, diagnostics: Diagnostic[]) {
|
|
|
+ if (value === undefined) return {}
|
|
|
+ if (!isRecord(value)) {
|
|
|
+ invalid(["provider"], diagnostics)
|
|
|
+ return {}
|
|
|
+ }
|
|
|
+ const candidates = Object.entries(value).flatMap(([name, raw]) => {
|
|
|
+ const path = ["provider", name]
|
|
|
+ diagnoseProviderUnsupported(raw, path, diagnostics)
|
|
|
+ if (invalidProviderOverlays(raw, path, diagnostics)) return []
|
|
|
+ const provider = decodeValue(ConfigProviderV1.Info, raw, path, diagnostics)
|
|
|
+ if (provider === undefined) return []
|
|
|
+ const destination = ConfigMigrateV1.providerID(name)
|
|
|
+ return [
|
|
|
+ {
|
|
|
+ name,
|
|
|
+ destination,
|
|
|
+ provider: canonical(ConfigProvider.Info, ConfigMigrateV1.migrateProvider(name, provider)),
|
|
|
+ },
|
|
|
+ ]
|
|
|
+ })
|
|
|
+ const current = new Set(candidates.filter((item) => item.name === item.destination).map((item) => item.destination))
|
|
|
+ const result: Record<string, unknown> = {}
|
|
|
+ candidates.forEach((item) => {
|
|
|
+ if (item.name !== item.destination && current.has(item.destination)) return
|
|
|
+ setOwn(result, item.destination, item.provider)
|
|
|
+ })
|
|
|
+ return result
|
|
|
+}
|
|
|
+
|
|
|
+function invalidProviderOverlays(value: unknown, path: string[], diagnostics: Diagnostic[]) {
|
|
|
+ if (!isRecord(value) || !isRecord(value.options)) return false
|
|
|
+ const headersInvalid =
|
|
|
+ own(value.options, "headers") &&
|
|
|
+ (!isPlainRecord(value.options.headers) ||
|
|
|
+ Object.values(value.options.headers).some((item) => typeof item !== "string"))
|
|
|
+ const bodyInvalid = own(value.options, "body") && !isPlainRecord(value.options.body)
|
|
|
+ if (headersInvalid) invalid([...path, "options", "headers"], diagnostics)
|
|
|
+ if (bodyInvalid) invalid([...path, "options", "body"], diagnostics)
|
|
|
+ return headersInvalid || bodyInvalid
|
|
|
+}
|
|
|
+
|
|
|
+function diagnoseProviderUnsupported(value: unknown, path: string[], diagnostics: Diagnostic[]) {
|
|
|
+ if (!isRecord(value)) return
|
|
|
+ unsupportedProvider.forEach((key) => unsupportedIfPresent(value, key, [...path, key], diagnostics))
|
|
|
+ if (!isRecord(value.models)) return
|
|
|
+ Object.entries(value.models).forEach(([name, model]) => {
|
|
|
+ if (!isRecord(model)) return
|
|
|
+ unsupportedModel.forEach((key) => unsupportedIfPresent(model, key, [...path, "models", name, key], diagnostics))
|
|
|
+ if (own(model, "status") && model.status !== "deprecated")
|
|
|
+ unsupportedIfPresent(model, "status", [...path, "models", name, "status"], diagnostics)
|
|
|
+ if (own(model, "interleaved") && typeof model.interleaved === "boolean")
|
|
|
+ unsupportedIfPresent(model, "interleaved", [...path, "models", name, "interleaved"], diagnostics)
|
|
|
+ })
|
|
|
+}
|
|
|
+
|
|
|
+function diagnoseAgentUnsupported(value: unknown, path: string[], diagnostics: Diagnostic[]) {
|
|
|
+ if (!isRecord(value)) return
|
|
|
+ Object.entries(value).forEach(([name, agent]) => {
|
|
|
+ if (!isRecord(agent)) return
|
|
|
+ unsupportedIfPresent(agent, "name", [...path, name, "name"], diagnostics)
|
|
|
+ diagnoseSelection(agent, [...path, name], diagnostics)
|
|
|
+ })
|
|
|
+}
|
|
|
+
|
|
|
+function diagnoseSelectionMap(value: unknown, path: string[], diagnostics: Diagnostic[]) {
|
|
|
+ if (!isRecord(value)) return
|
|
|
+ Object.entries(value).forEach(([name, entry]) => {
|
|
|
+ if (isRecord(entry)) diagnoseSelection(entry, [...path, name], diagnostics)
|
|
|
+ })
|
|
|
+}
|
|
|
+
|
|
|
+function diagnoseSelection(value: Record<string, unknown>, path: string[], diagnostics: Diagnostic[]) {
|
|
|
+ const modelValid = typeof value.model === "string" && /^[^/#]+\/[^#]+$/.test(value.model)
|
|
|
+ if (own(value, "model") && typeof value.model === "string" && !modelValid)
|
|
|
+ diagnostics.push({
|
|
|
+ kind: "unsupported",
|
|
|
+ path: [...path, "model"],
|
|
|
+ message: "omitted unsupported legacy model reference",
|
|
|
+ })
|
|
|
+ if (
|
|
|
+ own(value, "variant") &&
|
|
|
+ typeof value.variant === "string" &&
|
|
|
+ (!modelValid || value.variant.length === 0 || value.variant.includes("#"))
|
|
|
+ )
|
|
|
+ diagnostics.push({
|
|
|
+ kind: "unsupported",
|
|
|
+ path: [...path, "variant"],
|
|
|
+ message: "omitted unsupported legacy model variant",
|
|
|
+ })
|
|
|
+}
|
|
|
+
|
|
|
+function decodeProviderList(
|
|
|
+ input: Record<string, unknown>,
|
|
|
+ key: "enabled_providers" | "disabled_providers",
|
|
|
+ diagnostics: Diagnostic[],
|
|
|
+) {
|
|
|
+ if (!own(input, key)) return { present: false, nonEmpty: false, values: [] as string[] }
|
|
|
+ if (!Array.isArray(input[key])) {
|
|
|
+ invalid([key], diagnostics)
|
|
|
+ return { present: true, nonEmpty: true, values: [] as string[] }
|
|
|
+ }
|
|
|
+ return {
|
|
|
+ present: true,
|
|
|
+ nonEmpty: input[key].length > 0,
|
|
|
+ values: decodeList(input[key], Schema.String, [key], diagnostics),
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+function decodeEncodedMap<S extends Schema.Codec<unknown, unknown, never, never>>(
|
|
|
+ value: unknown,
|
|
|
+ schema: S,
|
|
|
+ path: string[],
|
|
|
+ diagnostics: Diagnostic[],
|
|
|
+) {
|
|
|
+ if (value === undefined) return {}
|
|
|
+ if (!isRecord(value)) {
|
|
|
+ invalid(path, diagnostics)
|
|
|
+ return {}
|
|
|
+ }
|
|
|
+ return Object.fromEntries(
|
|
|
+ Object.entries(value).flatMap(([name, raw]) => {
|
|
|
+ const decoded = decodeEncoded(schema, raw, [...path, name], diagnostics)
|
|
|
+ return decoded === undefined ? [] : [[name, decoded]]
|
|
|
+ }),
|
|
|
+ )
|
|
|
+}
|
|
|
+
|
|
|
+function decodeMap<S extends Schema.Codec<unknown, unknown, never, never>>(
|
|
|
+ value: unknown,
|
|
|
+ schema: S,
|
|
|
+ path: string[],
|
|
|
+ diagnostics: Diagnostic[],
|
|
|
+) {
|
|
|
+ if (value === undefined) return {} as Record<string, S["Type"]>
|
|
|
+ if (!isRecord(value)) {
|
|
|
+ invalid(path, diagnostics)
|
|
|
+ return {} as Record<string, S["Type"]>
|
|
|
+ }
|
|
|
+ return Object.fromEntries(
|
|
|
+ Object.entries(value).flatMap(([name, raw]) => {
|
|
|
+ const decoded = decodeValue(schema, raw, [...path, name], diagnostics)
|
|
|
+ return decoded === undefined ? [] : [[name, decoded]]
|
|
|
+ }),
|
|
|
+ ) as Record<string, S["Type"]>
|
|
|
+}
|
|
|
+
|
|
|
+function decodeEncodedList<S extends Schema.Codec<unknown, unknown, never, never>>(
|
|
|
+ value: unknown,
|
|
|
+ schema: S,
|
|
|
+ path: string[],
|
|
|
+ diagnostics: Diagnostic[],
|
|
|
+) {
|
|
|
+ if (value === undefined) return [] as S["Encoded"][]
|
|
|
+ if (!Array.isArray(value)) {
|
|
|
+ invalid(path, diagnostics)
|
|
|
+ return [] as S["Encoded"][]
|
|
|
+ }
|
|
|
+ return value.flatMap((item, index) => {
|
|
|
+ const decoded = decodeEncoded(schema, item, [...path, String(index)], diagnostics)
|
|
|
+ return decoded === undefined ? [] : [decoded]
|
|
|
+ })
|
|
|
+}
|
|
|
+
|
|
|
+function decodeList<S extends Schema.Codec<unknown, unknown, never, never>>(
|
|
|
+ value: unknown,
|
|
|
+ schema: S,
|
|
|
+ path: string[],
|
|
|
+ diagnostics: Diagnostic[],
|
|
|
+) {
|
|
|
+ if (value === undefined) return [] as S["Type"][]
|
|
|
+ if (!Array.isArray(value)) {
|
|
|
+ invalid(path, diagnostics)
|
|
|
+ return [] as S["Type"][]
|
|
|
+ }
|
|
|
+ return value.flatMap((item, index) => {
|
|
|
+ const decoded = decodeValue(schema, item, [...path, String(index)], diagnostics)
|
|
|
+ return decoded === undefined ? [] : [decoded]
|
|
|
+ })
|
|
|
+}
|
|
|
+
|
|
|
+function decodeValue<S extends Schema.Codec<unknown, unknown, never, never>>(
|
|
|
+ schema: S,
|
|
|
+ value: unknown,
|
|
|
+ path: string[],
|
|
|
+ diagnostics: Diagnostic[],
|
|
|
+) {
|
|
|
+ const decoded = Schema.decodeUnknownOption(schema, options)(value)
|
|
|
+ if (Option.isSome(decoded)) return decoded.value
|
|
|
+ invalid(path, diagnostics)
|
|
|
+ return undefined
|
|
|
+}
|
|
|
+
|
|
|
+function decodeEncoded<S extends Schema.Codec<unknown, unknown, never, never>>(
|
|
|
+ schema: S,
|
|
|
+ value: unknown,
|
|
|
+ path: string[],
|
|
|
+ diagnostics: Diagnostic[],
|
|
|
+) {
|
|
|
+ const decoded = Schema.decodeUnknownOption(schema, options)(value)
|
|
|
+ if (Option.isNone(decoded)) {
|
|
|
+ invalid(path, diagnostics)
|
|
|
+ return undefined
|
|
|
+ }
|
|
|
+ const encoded = Schema.encodeUnknownOption(schema, options)(decoded.value)
|
|
|
+ if (Option.isSome(encoded)) return plain(encoded.value)
|
|
|
+ invalid(path, diagnostics)
|
|
|
+ return undefined
|
|
|
+}
|
|
|
+
|
|
|
+function canonical<S extends Schema.Codec<unknown, unknown, never, never>>(schema: S, value: unknown) {
|
|
|
+ return plain(
|
|
|
+ Option.getOrThrow(
|
|
|
+ Schema.decodeUnknownOption(
|
|
|
+ schema,
|
|
|
+ options,
|
|
|
+ )(plain(value)).pipe(Option.flatMap((decoded) => Schema.encodeUnknownOption(schema, options)(decoded))),
|
|
|
+ ),
|
|
|
+ )
|
|
|
+}
|
|
|
+
|
|
|
+function plain(value: unknown): unknown {
|
|
|
+ if (Array.isArray(value)) return value.map(plain)
|
|
|
+ if (!isRecord(value)) return value
|
|
|
+ return Object.fromEntries(
|
|
|
+ Object.entries(value).flatMap(([key, item]) => (item === undefined ? [] : [[key, plain(item)]])),
|
|
|
+ )
|
|
|
+}
|
|
|
+
|
|
|
+function mergeMap(
|
|
|
+ target: Record<string, unknown>,
|
|
|
+ key: string,
|
|
|
+ legacy: Readonly<Record<string, unknown>>,
|
|
|
+ native: Readonly<Record<string, unknown>>,
|
|
|
+ present: boolean,
|
|
|
+ diagnostics: Diagnostic[],
|
|
|
+) {
|
|
|
+ const merged = mergeMaps(legacy, native, [key], diagnostics)
|
|
|
+ if (present) target[key] = merged
|
|
|
+}
|
|
|
+
|
|
|
+function mergeMaps(
|
|
|
+ legacy: Readonly<Record<string, unknown>>,
|
|
|
+ native: Readonly<Record<string, unknown>>,
|
|
|
+ path: string[],
|
|
|
+ diagnostics: Diagnostic[],
|
|
|
+) {
|
|
|
+ const result = Object.fromEntries(Object.entries(legacy))
|
|
|
+ Object.entries(native).forEach(([name, value]) => {
|
|
|
+ if (own(result, name) && !isDeepStrictEqual(result[name], value)) conflict([...path, name], diagnostics)
|
|
|
+ setOwn(result, name, value)
|
|
|
+ })
|
|
|
+ return result
|
|
|
+}
|
|
|
+
|
|
|
+function mapValues<A>(input: Readonly<Record<string, A>>, map: (value: A) => unknown) {
|
|
|
+ return Object.fromEntries(
|
|
|
+ Object.entries(input).flatMap(([key, value]) => {
|
|
|
+ const mapped = map(value)
|
|
|
+ return mapped === undefined ? [] : [[key, mapped]]
|
|
|
+ }),
|
|
|
+ )
|
|
|
+}
|
|
|
+
|
|
|
+function overlay(
|
|
|
+ target: Record<string, unknown>,
|
|
|
+ key: string,
|
|
|
+ value: unknown,
|
|
|
+ path: string[],
|
|
|
+ diagnostics: Diagnostic[],
|
|
|
+) {
|
|
|
+ if (own(target, key) && !isDeepStrictEqual(target[key], value)) conflict(path, diagnostics)
|
|
|
+ target[key] = value
|
|
|
+}
|
|
|
+
|
|
|
+function prefer(legacy: unknown, native: unknown, path: string[], diagnostics: Diagnostic[]) {
|
|
|
+ if (native === undefined) return legacy
|
|
|
+ if (legacy !== undefined && !isDeepStrictEqual(legacy, native)) conflict(path, diagnostics)
|
|
|
+ return native
|
|
|
+}
|
|
|
+
|
|
|
+function unsupportedIfPresent(value: Record<string, unknown>, key: string, path: string[], diagnostics: Diagnostic[]) {
|
|
|
+ if (!own(value, key)) return
|
|
|
+ diagnostics.push({ kind: "unsupported", path, message: "omitted unsupported legacy setting" })
|
|
|
+}
|
|
|
+
|
|
|
+function invalid(path: string[], diagnostics: Diagnostic[]) {
|
|
|
+ diagnostics.push({ kind: "invalid", path, message: "skipped malformed recognized value" })
|
|
|
+}
|
|
|
+
|
|
|
+function conflict(path: string[], diagnostics: Diagnostic[]) {
|
|
|
+ diagnostics.push({ kind: "conflict", path, message: "retained native value over legacy value" })
|
|
|
+}
|
|
|
+
|
|
|
+function isDirectLegacyMcp(value: unknown) {
|
|
|
+ return isRecord(value) && (value.type === "local" || value.type === "remote")
|
|
|
+}
|
|
|
+
|
|
|
+function isEnabledOnlyMcp(value: unknown) {
|
|
|
+ return isRecord(value) && !own(value, "type") && typeof value.enabled === "boolean"
|
|
|
+}
|
|
|
+
|
|
|
+function isRecord(value: unknown): value is Record<string, unknown> {
|
|
|
+ return typeof value === "object" && value !== null && !Array.isArray(value)
|
|
|
+}
|
|
|
+
|
|
|
+function isPlainRecord(value: unknown): value is Record<string, unknown> {
|
|
|
+ if (!isRecord(value)) return false
|
|
|
+ const prototype = Object.getPrototypeOf(value)
|
|
|
+ return prototype === Object.prototype || prototype === null
|
|
|
+}
|
|
|
+
|
|
|
+function own(value: Record<string, unknown>, key: string) {
|
|
|
+ return Object.prototype.hasOwnProperty.call(value, key)
|
|
|
+}
|
|
|
+
|
|
|
+function setOwn(value: Record<string, unknown>, key: string, item: unknown) {
|
|
|
+ Object.defineProperty(value, key, { value: item, enumerable: true, configurable: true, writable: true })
|
|
|
+}
|