diff --git a/packages/opencode/src/cli/cmd/tui/config/tui.ts b/packages/opencode/src/cli/cmd/tui/config/tui.ts index 6f2c161fb..e8eb9ff5d 100644 --- a/packages/opencode/src/cli/cmd/tui/config/tui.ts +++ b/packages/opencode/src/cli/cmd/tui/config/tui.ts @@ -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 { - 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 {} + }) } } diff --git a/packages/opencode/src/config/agent.ts b/packages/opencode/src/config/agent.ts index 3819368e8..f754f009d 100644 --- a/packages/opencode/src/config/agent.ts +++ b/packages/opencode/src/config/agent.ts @@ -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" }) diff --git a/packages/opencode/src/config/command.ts b/packages/opencode/src/config/command.ts index 5606bdd4c..979925056 100644 --- a/packages/opencode/src/config/command.ts +++ b/packages/opencode/src/config/command.ts @@ -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" }) diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index ed3be8808..6b6d74ed8 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -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(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) } - 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) } diff --git a/packages/opencode/src/config/error.ts b/packages/opencode/src/config/error.ts new file mode 100644 index 000000000..06f549fd8 --- /dev/null +++ b/packages/opencode/src/config/error.ts @@ -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().optional(), + message: z.string().optional(), + }), +) diff --git a/packages/opencode/src/config/index.ts b/packages/opencode/src/config/index.ts index 37665d8c6..c4a1c608b 100644 --- a/packages/opencode/src/config/index.ts +++ b/packages/opencode/src/config/index.ts @@ -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" diff --git a/packages/opencode/src/config/managed.ts b/packages/opencode/src/config/managed.ts index 61c535185..19b048ffc 100644 --- a/packages/opencode/src/config/managed.ts +++ b/packages/opencode/src/config/managed.ts @@ -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 { - 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 { 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 = { diff --git a/packages/opencode/src/config/parse.ts b/packages/opencode/src/config/parse.ts new file mode 100644 index 000000000..65cc48385 --- /dev/null +++ b/packages/opencode/src/config/parse.ts @@ -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 = z.ZodType +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(schema: Schema, 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(schema: Schema, text: string, options: LoadOptions): Promise { + 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 +} diff --git a/packages/opencode/src/config/paths.ts b/packages/opencode/src/config/paths.ts index fabd3fd5f..faf585d9b 100644 --- a/packages/opencode/src/config/paths.ts +++ b/packages/opencode/src/config/paths.ts @@ -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().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 -} diff --git a/packages/opencode/src/config/variable.ts b/packages/opencode/src/config/variable.ts new file mode 100644 index 000000000..e016e33a2 --- /dev/null +++ b/packages/opencode/src/config/variable.ts @@ -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 +} diff --git a/packages/opencode/test/config/config.test.ts b/packages/opencode/test/config/config.test.ts index 21d6e3e93..c41f395e5 100644 --- a/packages/opencode/test/config/config.test.ts +++ b/packages/opencode/test/config/config.test.ts @@ -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")