core: modularize config parsing to improve maintainability

Extract error handling, parsing logic, and variable substitution into dedicated
modules. This reduces duplication between tui.json and opencode.json parsing
and makes the config system easier to extend for future config formats.
This commit is contained in:
Dax Raad 2026-04-16 13:29:00 -04:00
parent c5deeee8c7
commit 03e20e6ac1
11 changed files with 335 additions and 273 deletions

View File

@ -1,6 +1,7 @@
import z from "zod"
import { mergeDeep, unique } from "remeda"
import { Context, Effect, Fiber, Layer } from "effect"
import { ConfigParse } from "@/config/parse"
import * as ConfigPaths from "@/config/paths"
import { migrateTuiConfig } from "./tui-migrate"
import { TuiInfo } from "./tui-schema"
@ -68,6 +69,14 @@ export namespace TuiConfig {
}
}
async function resolvePlugins(config: Info, configFilepath: string) {
if (!config.plugin) return config
for (let i = 0; i < config.plugin.length; i++) {
config.plugin[i] = await ConfigPlugin.resolvePluginSpec(config.plugin[i], configFilepath)
}
return config
}
async function mergeFile(acc: Acc, file: string, ctx: { directory: string }) {
const data = await loadFile(file)
acc.result = mergeDeep(acc.result, data)
@ -183,26 +192,22 @@ export namespace TuiConfig {
}
async function load(text: string, configFilepath: string): Promise<Info> {
const raw = await ConfigPaths.parseText(text, configFilepath, "empty")
if (!isRecord(raw)) return {}
return ConfigParse.load(Info, text, {
type: "path",
path: configFilepath,
missing: "empty",
normalize: (data) => {
if (!isRecord(data)) return {}
// Flatten a nested "tui" key so users who wrote `{ "tui": { ... } }` inside tui.json
// (mirroring the old opencode.json shape) still get their settings applied.
const normalized = normalize(raw)
const parsed = Info.safeParse(normalized)
if (!parsed.success) {
log.warn("invalid tui config", { path: configFilepath, issues: parsed.error.issues })
return {}
}
const data = parsed.data
if (data.plugin) {
for (let i = 0; i < data.plugin.length; i++) {
data.plugin[i] = await ConfigPlugin.resolvePluginSpec(data.plugin[i], configFilepath)
}
}
return data
// Flatten a nested "tui" key so users who wrote `{ "tui": { ... } }` inside tui.json
// (mirroring the old opencode.json shape) still get their settings applied.
return normalize(data)
},
})
.then((data) => resolvePlugins(data, configFilepath))
.catch((error) => {
log.warn("invalid tui config", { path: configFilepath, error })
return {}
})
}
}

View File

@ -6,9 +6,9 @@ import { NamedError } from "@opencode-ai/shared/util/error"
import { Glob } from "@opencode-ai/shared/util/glob"
import { Bus } from "@/bus"
import { configEntryNameFromPath } from "./entry-name"
import { InvalidError } from "./error"
import * as ConfigMarkdown from "./markdown"
import { ConfigModelID } from "./model-id"
import { InvalidError } from "./paths"
import { ConfigPermission } from "./permission"
const log = Log.create({ service: "config" })

View File

@ -6,9 +6,9 @@ import { NamedError } from "@opencode-ai/shared/util/error"
import { Glob } from "@opencode-ai/shared/util/glob"
import { Bus } from "@/bus"
import { configEntryNameFromPath } from "./entry-name"
import { InvalidError } from "./error"
import * as ConfigMarkdown from "./markdown"
import { ConfigModelID } from "./model-id"
import { InvalidError } from "./paths"
const log = Log.create({ service: "config" })

View File

@ -10,13 +10,7 @@ import { NamedError } from "@opencode-ai/shared/util/error"
import { Flag } from "../flag/flag"
import { Auth } from "../auth"
import { Env } from "../env"
import {
type ParseError as JsoncParseError,
applyEdits,
modify,
parse as parseJsonc,
printParseErrorCode,
} from "jsonc-parser"
import { applyEdits, modify } from "jsonc-parser"
import { Instance, type InstanceContext } from "../project/instance"
import * as LSPServer from "../lsp/server"
import { InstallationLocal, InstallationVersion } from "@/installation/version"
@ -25,6 +19,7 @@ import { GlobalBus } from "@/bus/global"
import { Event } from "../server/event"
import { Account } from "@/account"
import { isRecord } from "@/util/record"
import { InvalidError, JsonError } from "./error"
import * as ConfigPaths from "./paths"
import type { ConsoleState } from "./console-state"
import { AppFileSystem } from "@opencode-ai/shared/filesystem"
@ -39,6 +34,7 @@ import { ConfigModelID } from "./model-id"
import { ConfigPlugin } from "./plugin"
import { ConfigManaged } from "./managed"
import { ConfigCommand } from "./command"
import { ConfigParse } from "./parse"
import { ConfigPermission } from "./permission"
import { ConfigProvider } from "./provider"
import { ConfigSkills } from "./skills"
@ -54,6 +50,28 @@ function mergeConfigConcatArrays(target: Info, source: Info): Info {
return merged
}
function normalizeLoadedConfig(data: unknown, source: string) {
if (!isRecord(data)) return data
const copy = { ...data }
const hadLegacy = "theme" in copy || "keybinds" in copy || "tui" in copy
if (!hadLegacy) return copy
delete copy.theme
delete copy.keybinds
delete copy.tui
log.warn("tui keys in opencode config are deprecated; move them to tui.json", { path: source })
return copy
}
async function resolveLoadedPlugins<T extends { plugin?: ConfigPlugin.Spec[] }>(config: T, filepath: string) {
if (!config.plugin) return config
for (let i = 0; i < config.plugin.length; i++) {
// Normalize path-like plugin specs while we still know which config file declared them.
// This prevents `./plugin.ts` from being reinterpreted relative to some later merge location.
config.plugin[i] = await ConfigPlugin.resolvePluginSpec(config.plugin[i], filepath)
}
return config
}
export const Server = z
.object({
port: z.number().int().positive().optional().describe("Port to listen on"),
@ -325,42 +343,6 @@ function writable(info: Info) {
return next
}
export function parseConfig(text: string, filepath: string): Info {
const errors: JsoncParseError[] = []
const data = parseJsonc(text, errors, { allowTrailingComma: true })
if (errors.length) {
const lines = text.split("\n")
const errorDetails = errors
.map((e) => {
const beforeOffset = text.substring(0, e.offset).split("\n")
const line = beforeOffset.length
const column = beforeOffset[beforeOffset.length - 1].length + 1
const problemLine = lines[line - 1]
const error = `${printParseErrorCode(e.error)} at line ${line}, column ${column}`
if (!problemLine) return error
return `${error}\n Line ${line}: ${problemLine}\n${"".padStart(column + 9)}^`
})
.join("\n")
throw new JsonError({
path: filepath,
message: `\n--- JSONC Input ---\n${text}\n--- Errors ---\n${errorDetails}\n--- End ---`,
})
}
const parsed = Info.safeParse(data)
if (parsed.success) return parsed.data
throw new InvalidError({
path: filepath,
issues: parsed.error.issues,
})
}
export const { JsonError, InvalidError } = ConfigPaths
export const ConfigDirectoryTypoError = NamedError.create(
"ConfigDirectoryTypoError",
z.object({
@ -393,48 +375,31 @@ export const layer = Layer.effect(
text: string,
options: { path: string } | { dir: string; source: string },
) {
const original = text
const source = "path" in options ? options.path : options.source
const isFile = "path" in options
const data = yield* Effect.promise(() =>
ConfigPaths.parseText(text, "path" in options ? options.path : { source: options.source, dir: options.dir }),
)
const normalized = (() => {
if (!data || typeof data !== "object" || Array.isArray(data)) return data
const copy = { ...(data as Record<string, unknown>) }
const hadLegacy = "theme" in copy || "keybinds" in copy || "tui" in copy
if (!hadLegacy) return copy
delete copy.theme
delete copy.keybinds
delete copy.tui
log.warn("tui keys in opencode config are deprecated; move them to tui.json", { path: source })
return copy
})()
const parsed = Info.safeParse(normalized)
if (parsed.success) {
if (!parsed.data.$schema && isFile) {
parsed.data.$schema = "https://opencode.ai/config.json"
const updated = original.replace(/^\s*\{/, '{\n "$schema": "https://opencode.ai/config.json",')
yield* fs.writeFileString(options.path, updated).pipe(Effect.catch(() => Effect.void))
}
const data = parsed.data
if (data.plugin && isFile) {
const list = data.plugin
for (let i = 0; i < list.length; i++) {
// Normalize path-like plugin specs while we still know which config file declared them.
// This prevents `./plugin.ts` from being reinterpreted relative to some later merge location.
list[i] = yield* Effect.promise(() => ConfigPlugin.resolvePluginSpec(list[i], options.path))
}
}
return data
if (!("path" in options)) {
return yield* Effect.promise(() =>
ConfigParse.load(Info, text, {
type: "virtual",
dir: options.dir,
source: options.source,
normalize: normalizeLoadedConfig,
}),
)
}
throw new InvalidError({
path: source,
issues: parsed.error.issues,
})
const data = yield* Effect.promise(() =>
ConfigParse.load(Info, text, {
type: "path",
path: options.path,
normalize: normalizeLoadedConfig,
}),
)
yield* Effect.promise(() => resolveLoadedPlugins(data, options.path))
if (!data.$schema) {
data.$schema = "https://opencode.ai/config.json"
const updated = text.replace(/^\s*\{/, '{\n "$schema": "https://opencode.ai/config.json",')
yield* fs.writeFileString(options.path, updated).pipe(Effect.catch(() => Effect.void))
}
return data
})
const loadFile = Effect.fnUntraced(function* (filepath: string) {
@ -692,7 +657,16 @@ export const layer = Layer.effect(
}
// macOS managed preferences (.mobileconfig deployed via MDM) override everything
result = mergeConfigConcatArrays(result, yield* Effect.promise(() => ConfigManaged.readManagedPreferences()))
const managed = yield* Effect.promise(() => ConfigManaged.readManagedPreferences())
if (managed) {
result = mergeConfigConcatArrays(
result,
yield* loadConfig(managed.text, {
dir: path.dirname(managed.source),
source: managed.source,
}),
)
}
for (const [name, mode] of Object.entries(result.mode ?? {})) {
result.agent = mergeDeep(result.agent ?? {}, {
@ -803,13 +777,13 @@ export const layer = Layer.effect(
let next: Info
if (!file.endsWith(".jsonc")) {
const existing = parseConfig(before, file)
const existing = ConfigParse.parse(Info, before, file)
const merged = mergeDeep(writable(existing), input)
yield* fs.writeFileString(file, JSON.stringify(merged, null, 2)).pipe(Effect.orDie)
next = merged
} else {
const updated = patchJsonc(before, input)
next = parseConfig(updated, file)
next = ConfigParse.parse(Info, updated, file)
yield* fs.writeFileString(file, updated).pipe(Effect.orDie)
}

View File

@ -0,0 +1,21 @@
export * as ConfigError from "./error"
import z from "zod"
import { NamedError } from "@opencode-ai/shared/util/error"
export const JsonError = NamedError.create(
"ConfigJsonError",
z.object({
path: z.string(),
message: z.string().optional(),
}),
)
export const InvalidError = NamedError.create(
"ConfigInvalidError",
z.object({
path: z.string(),
issues: z.custom<z.core.$ZodIssue[]>().optional(),
message: z.string().optional(),
}),
)

View File

@ -1,10 +1,13 @@
export * as Config from "./config"
export * as ConfigAgent from "./agent"
export * as ConfigCommand from "./command"
export * as ConfigError from "./error"
export * as ConfigVariable from "./variable"
export { ConfigManaged } from "./managed"
export * as ConfigMarkdown from "./markdown"
export * as ConfigMCP from "./mcp"
export { ConfigModelID } from "./model-id"
export * as ConfigParse from "./parse"
export * as ConfigPermission from "./permission"
export * as ConfigPaths from "./paths"
export * as ConfigProvider from "./provider"

View File

@ -1,7 +1,6 @@
import { existsSync } from "fs"
import os from "os"
import path from "path"
import { type Info, parseConfig } from "./config"
import { Log, Process } from "../util"
const log = Log.create({ service: "config" })
@ -33,16 +32,16 @@ function managedConfigDir() {
return process.env.OPENCODE_TEST_MANAGED_CONFIG_DIR || systemManagedConfigDir()
}
function parseManagedPlist(json: string, source: string): Info {
function parseManagedPlist(json: string): string {
const raw = JSON.parse(json)
for (const key of Object.keys(raw)) {
if (PLIST_META.has(key)) delete raw[key]
}
return parseConfig(JSON.stringify(raw), source)
return JSON.stringify(raw)
}
async function readManagedPreferences(): Promise<Info> {
if (process.platform !== "darwin") return {}
async function readManagedPreferences() {
if (process.platform !== "darwin") return
const user = os.userInfo().username
const paths = [
@ -58,10 +57,13 @@ async function readManagedPreferences(): Promise<Info> {
log.warn("failed to convert managed preferences plist", { path: plist })
continue
}
return parseManagedPlist(result.stdout.toString(), `mobileconfig:${plist}`)
return {
source: `mobileconfig:${plist}`,
text: parseManagedPlist(result.stdout.toString()),
}
}
return {}
return
}
export const ConfigManaged = {

View File

@ -0,0 +1,80 @@
export * as ConfigParse from "./parse"
import { type ParseError as JsoncParseError, parse as parseJsonc, printParseErrorCode } from "jsonc-parser"
import z from "zod"
import { ConfigVariable } from "./variable"
import { InvalidError, JsonError } from "./error"
type Schema<T> = z.ZodType<T>
type VariableMode = "error" | "empty"
export type LoadOptions =
| {
type: "path"
path: string
missing?: VariableMode
normalize?: (data: unknown, source: string) => unknown
}
| {
type: "virtual"
dir: string
source: string
missing?: VariableMode
normalize?: (data: unknown, source: string) => unknown
}
function issues(text: string, errors: JsoncParseError[]) {
const lines = text.split("\n")
return errors
.map((e) => {
const beforeOffset = text.substring(0, e.offset).split("\n")
const line = beforeOffset.length
const column = beforeOffset[beforeOffset.length - 1].length + 1
const problemLine = lines[line - 1]
const error = `${printParseErrorCode(e.error)} at line ${line}, column ${column}`
if (!problemLine) return error
return `${error}\n Line ${line}: ${problemLine}\n${"".padStart(column + 9)}^`
})
.join("\n")
}
export function parse<T>(schema: Schema<T>, text: string, filepath: string): T {
const errors: JsoncParseError[] = []
const data = parseJsonc(text, errors, { allowTrailingComma: true })
if (errors.length) {
throw new JsonError({
path: filepath,
message: `\n--- JSONC Input ---\n${text}\n--- Errors ---\n${issues(text, errors)}\n--- End ---`,
})
}
const parsed = schema.safeParse(data)
if (parsed.success) return parsed.data
throw new InvalidError({
path: filepath,
issues: parsed.error.issues,
})
}
export async function load<T>(schema: Schema<T>, text: string, options: LoadOptions): Promise<T> {
const source = options.type === "path" ? options.path : options.source
const expanded = await ConfigVariable.substitute(
text,
options.type === "path" ? { type: "path", path: options.path } : options,
options.missing,
)
const data = parse(z.unknown(), expanded, source)
const normalized = options.normalize ? options.normalize(data, source) : data
const parsed = schema.safeParse(normalized)
if (!parsed.success) {
throw new InvalidError({
path: source,
issues: parsed.error.issues,
})
}
return parsed.data
}

View File

@ -1,12 +1,9 @@
import path from "path"
import os from "os"
import z from "zod"
import { type ParseError as JsoncParseError, parse as parseJsonc, printParseErrorCode } from "jsonc-parser"
import { NamedError } from "@opencode-ai/shared/util/error"
import { Filesystem } from "@/util"
import { Flag } from "@/flag/flag"
import { Global } from "@/global"
import { unique } from "remeda"
import { JsonError } from "./error"
export async function projectFiles(name: string, directory: string, worktree?: string) {
return Filesystem.findUp([`${name}.json`, `${name}.jsonc`], directory, worktree, { rootFirst: true })
@ -39,23 +36,6 @@ export function fileInDirectory(dir: string, name: string) {
return [path.join(dir, `${name}.json`), path.join(dir, `${name}.jsonc`)]
}
export const JsonError = NamedError.create(
"ConfigJsonError",
z.object({
path: z.string(),
message: z.string().optional(),
}),
)
export const InvalidError = NamedError.create(
"ConfigInvalidError",
z.object({
path: z.string(),
issues: z.custom<z.core.$ZodIssue[]>().optional(),
message: z.string().optional(),
}),
)
/** Read a config file, returning undefined for missing files and throwing JsonError for other failures. */
export async function readFile(filepath: string) {
return Filesystem.readText(filepath).catch((err: NodeJS.ErrnoException) => {
@ -63,104 +43,3 @@ export async function readFile(filepath: string) {
throw new JsonError({ path: filepath }, { cause: err })
})
}
type ParseSource = string | { source: string; dir: string }
function source(input: ParseSource) {
return typeof input === "string" ? input : input.source
}
function dir(input: ParseSource) {
return typeof input === "string" ? path.dirname(input) : input.dir
}
/** Apply {env:VAR} and {file:path} substitutions to config text. */
async function substitute(text: string, input: ParseSource, missing: "error" | "empty" = "error") {
text = text.replace(/\{env:([^}]+)\}/g, (_, varName) => {
return process.env[varName] || ""
})
const fileMatches = Array.from(text.matchAll(/\{file:[^}]+\}/g))
if (!fileMatches.length) return text
const configDir = dir(input)
const configSource = source(input)
let out = ""
let cursor = 0
for (const match of fileMatches) {
const token = match[0]
const index = match.index!
out += text.slice(cursor, index)
const lineStart = text.lastIndexOf("\n", index - 1) + 1
const prefix = text.slice(lineStart, index).trimStart()
if (prefix.startsWith("//")) {
out += token
cursor = index + token.length
continue
}
let filePath = token.replace(/^\{file:/, "").replace(/\}$/, "")
if (filePath.startsWith("~/")) {
filePath = path.join(os.homedir(), filePath.slice(2))
}
const resolvedPath = path.isAbsolute(filePath) ? filePath : path.resolve(configDir, filePath)
const fileContent = (
await Filesystem.readText(resolvedPath).catch((error: NodeJS.ErrnoException) => {
if (missing === "empty") return ""
const errMsg = `bad file reference: "${token}"`
if (error.code === "ENOENT") {
throw new InvalidError(
{
path: configSource,
message: errMsg + ` ${resolvedPath} does not exist`,
},
{ cause: error },
)
}
throw new InvalidError({ path: configSource, message: errMsg }, { cause: error })
})
).trim()
out += JSON.stringify(fileContent).slice(1, -1)
cursor = index + token.length
}
out += text.slice(cursor)
return out
}
/** Substitute and parse JSONC text, throwing JsonError on syntax errors. */
export async function parseText(text: string, input: ParseSource, missing: "error" | "empty" = "error") {
const configSource = source(input)
text = await substitute(text, input, missing)
const errors: JsoncParseError[] = []
const data = parseJsonc(text, errors, { allowTrailingComma: true })
if (errors.length) {
const lines = text.split("\n")
const errorDetails = errors
.map((e) => {
const beforeOffset = text.substring(0, e.offset).split("\n")
const line = beforeOffset.length
const column = beforeOffset[beforeOffset.length - 1].length + 1
const problemLine = lines[line - 1]
const error = `${printParseErrorCode(e.error)} at line ${line}, column ${column}`
if (!problemLine) return error
return `${error}\n Line ${line}: ${problemLine}\n${"".padStart(column + 9)}^`
})
.join("\n")
throw new JsonError({
path: configSource,
message: `\n--- JSONC Input ---\n${text}\n--- Errors ---\n${errorDetails}\n--- End ---`,
})
}
return data
}

View File

@ -0,0 +1,84 @@
export * as ConfigVariable from "./variable"
import path from "path"
import os from "os"
import { Filesystem } from "@/util"
import { InvalidError } from "./error"
type ParseSource =
| {
type: "path"
path: string
}
| {
type: "virtual"
source: string
dir: string
}
function source(input: ParseSource) {
return input.type === "path" ? input.path : input.source
}
function dir(input: ParseSource) {
return input.type === "path" ? path.dirname(input.path) : input.dir
}
/** Apply {env:VAR} and {file:path} substitutions to config text. */
export async function substitute(text: string, input: ParseSource, missing: "error" | "empty" = "error") {
text = text.replace(/\{env:([^}]+)\}/g, (_, varName) => {
return process.env[varName] || ""
})
const fileMatches = Array.from(text.matchAll(/\{file:[^}]+\}/g))
if (!fileMatches.length) return text
const configDir = dir(input)
const configSource = source(input)
let out = ""
let cursor = 0
for (const match of fileMatches) {
const token = match[0]
const index = match.index!
out += text.slice(cursor, index)
const lineStart = text.lastIndexOf("\n", index - 1) + 1
const prefix = text.slice(lineStart, index).trimStart()
if (prefix.startsWith("//")) {
out += token
cursor = index + token.length
continue
}
let filePath = token.replace(/^\{file:/, "").replace(/\}$/, "")
if (filePath.startsWith("~/")) {
filePath = path.join(os.homedir(), filePath.slice(2))
}
const resolvedPath = path.isAbsolute(filePath) ? filePath : path.resolve(configDir, filePath)
const fileContent = (
await Filesystem.readText(resolvedPath).catch((error: NodeJS.ErrnoException) => {
if (missing === "empty") return ""
const errMsg = `bad file reference: "${token}"`
if (error.code === "ENOENT") {
throw new InvalidError(
{
path: configSource,
message: errMsg + ` ${resolvedPath} does not exist`,
},
{ cause: error },
)
}
throw new InvalidError({ path: configSource, message: errMsg }, { cause: error })
})
).trim()
out += JSON.stringify(fileContent).slice(1, -1)
cursor = index + token.length
}
out += text.slice(cursor)
return out
}

View File

@ -2,6 +2,7 @@ import { test, expect, describe, mock, afterEach, beforeEach } from "bun:test"
import { Effect, Layer, Option } from "effect"
import { NodeFileSystem, NodePath } from "@effect/platform-node"
import { Config, ConfigManaged } from "../../src/config"
import { ConfigParse } from "../../src/config/parse"
import { EffectFlock } from "@opencode-ai/shared/util/effect-flock"
import { Instance } from "../../src/project/instance"
@ -2211,17 +2212,20 @@ describe("OPENCODE_CONFIG_CONTENT token substitution", () => {
// parseManagedPlist unit tests — pure function, no OS interaction
test("parseManagedPlist strips MDM metadata keys", async () => {
const config = await ConfigManaged.parseManagedPlist(
JSON.stringify({
PayloadDisplayName: "OpenCode Managed",
PayloadIdentifier: "ai.opencode.managed.test",
PayloadType: "ai.opencode.managed",
PayloadUUID: "AAAA-BBBB-CCCC",
PayloadVersion: 1,
_manualProfile: true,
share: "disabled",
model: "mdm/model",
}),
const config = ConfigParse.parse(
Config.Info,
await ConfigManaged.parseManagedPlist(
JSON.stringify({
PayloadDisplayName: "OpenCode Managed",
PayloadIdentifier: "ai.opencode.managed.test",
PayloadType: "ai.opencode.managed",
PayloadUUID: "AAAA-BBBB-CCCC",
PayloadVersion: 1,
_manualProfile: true,
share: "disabled",
model: "mdm/model",
}),
),
"test:mobileconfig",
)
expect(config.share).toBe("disabled")
@ -2233,12 +2237,15 @@ test("parseManagedPlist strips MDM metadata keys", async () => {
})
test("parseManagedPlist parses server settings", async () => {
const config = await ConfigManaged.parseManagedPlist(
JSON.stringify({
$schema: "https://opencode.ai/config.json",
server: { hostname: "127.0.0.1", mdns: false },
autoupdate: true,
}),
const config = ConfigParse.parse(
Config.Info,
await ConfigManaged.parseManagedPlist(
JSON.stringify({
$schema: "https://opencode.ai/config.json",
server: { hostname: "127.0.0.1", mdns: false },
autoupdate: true,
}),
),
"test:mobileconfig",
)
expect(config.server?.hostname).toBe("127.0.0.1")
@ -2247,18 +2254,21 @@ test("parseManagedPlist parses server settings", async () => {
})
test("parseManagedPlist parses permission rules", async () => {
const config = await ConfigManaged.parseManagedPlist(
JSON.stringify({
$schema: "https://opencode.ai/config.json",
permission: {
"*": "ask",
bash: { "*": "ask", "rm -rf *": "deny", "curl *": "deny" },
grep: "allow",
glob: "allow",
webfetch: "ask",
"~/.ssh/*": "deny",
},
}),
const config = ConfigParse.parse(
Config.Info,
await ConfigManaged.parseManagedPlist(
JSON.stringify({
$schema: "https://opencode.ai/config.json",
permission: {
"*": "ask",
bash: { "*": "ask", "rm -rf *": "deny", "curl *": "deny" },
grep: "allow",
glob: "allow",
webfetch: "ask",
"~/.ssh/*": "deny",
},
}),
),
"test:mobileconfig",
)
expect(config.permission?.["*"]).toBe("ask")
@ -2271,19 +2281,23 @@ test("parseManagedPlist parses permission rules", async () => {
})
test("parseManagedPlist parses enabled_providers", async () => {
const config = await ConfigManaged.parseManagedPlist(
JSON.stringify({
$schema: "https://opencode.ai/config.json",
enabled_providers: ["anthropic", "google"],
}),
const config = ConfigParse.parse(
Config.Info,
await ConfigManaged.parseManagedPlist(
JSON.stringify({
$schema: "https://opencode.ai/config.json",
enabled_providers: ["anthropic", "google"],
}),
),
"test:mobileconfig",
)
expect(config.enabled_providers).toEqual(["anthropic", "google"])
})
test("parseManagedPlist handles empty config", async () => {
const config = await ConfigManaged.parseManagedPlist(
JSON.stringify({ $schema: "https://opencode.ai/config.json" }),
const config = ConfigParse.parse(
Config.Info,
await ConfigManaged.parseManagedPlist(JSON.stringify({ $schema: "https://opencode.ai/config.json" })),
"test:mobileconfig",
)
expect(config.$schema).toBe("https://opencode.ai/config.json")