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:
@@ -1,6 +1,7 @@
|
|||||||
import z from "zod"
|
import z from "zod"
|
||||||
import { mergeDeep, unique } from "remeda"
|
import { mergeDeep, unique } from "remeda"
|
||||||
import { Context, Effect, Fiber, Layer } from "effect"
|
import { Context, Effect, Fiber, Layer } from "effect"
|
||||||
|
import { ConfigParse } from "@/config/parse"
|
||||||
import * as ConfigPaths from "@/config/paths"
|
import * as ConfigPaths from "@/config/paths"
|
||||||
import { migrateTuiConfig } from "./tui-migrate"
|
import { migrateTuiConfig } from "./tui-migrate"
|
||||||
import { TuiInfo } from "./tui-schema"
|
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 }) {
|
async function mergeFile(acc: Acc, file: string, ctx: { directory: string }) {
|
||||||
const data = await loadFile(file)
|
const data = await loadFile(file)
|
||||||
acc.result = mergeDeep(acc.result, data)
|
acc.result = mergeDeep(acc.result, data)
|
||||||
@@ -183,26 +192,22 @@ export namespace TuiConfig {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function load(text: string, configFilepath: string): Promise<Info> {
|
async function load(text: string, configFilepath: string): Promise<Info> {
|
||||||
const raw = await ConfigPaths.parseText(text, configFilepath, "empty")
|
return ConfigParse.load(Info, text, {
|
||||||
if (!isRecord(raw)) return {}
|
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
|
// Flatten a nested "tui" key so users who wrote `{ "tui": { ... } }` inside tui.json
|
||||||
// (mirroring the old opencode.json shape) still get their settings applied.
|
// (mirroring the old opencode.json shape) still get their settings applied.
|
||||||
const normalized = normalize(raw)
|
return normalize(data)
|
||||||
|
},
|
||||||
const parsed = Info.safeParse(normalized)
|
})
|
||||||
if (!parsed.success) {
|
.then((data) => resolvePlugins(data, configFilepath))
|
||||||
log.warn("invalid tui config", { path: configFilepath, issues: parsed.error.issues })
|
.catch((error) => {
|
||||||
return {}
|
log.warn("invalid tui config", { path: configFilepath, error })
|
||||||
}
|
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
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,9 +6,9 @@ import { NamedError } from "@opencode-ai/shared/util/error"
|
|||||||
import { Glob } from "@opencode-ai/shared/util/glob"
|
import { Glob } from "@opencode-ai/shared/util/glob"
|
||||||
import { Bus } from "@/bus"
|
import { Bus } from "@/bus"
|
||||||
import { configEntryNameFromPath } from "./entry-name"
|
import { configEntryNameFromPath } from "./entry-name"
|
||||||
|
import { InvalidError } from "./error"
|
||||||
import * as ConfigMarkdown from "./markdown"
|
import * as ConfigMarkdown from "./markdown"
|
||||||
import { ConfigModelID } from "./model-id"
|
import { ConfigModelID } from "./model-id"
|
||||||
import { InvalidError } from "./paths"
|
|
||||||
import { ConfigPermission } from "./permission"
|
import { ConfigPermission } from "./permission"
|
||||||
|
|
||||||
const log = Log.create({ service: "config" })
|
const log = Log.create({ service: "config" })
|
||||||
|
|||||||
@@ -6,9 +6,9 @@ import { NamedError } from "@opencode-ai/shared/util/error"
|
|||||||
import { Glob } from "@opencode-ai/shared/util/glob"
|
import { Glob } from "@opencode-ai/shared/util/glob"
|
||||||
import { Bus } from "@/bus"
|
import { Bus } from "@/bus"
|
||||||
import { configEntryNameFromPath } from "./entry-name"
|
import { configEntryNameFromPath } from "./entry-name"
|
||||||
|
import { InvalidError } from "./error"
|
||||||
import * as ConfigMarkdown from "./markdown"
|
import * as ConfigMarkdown from "./markdown"
|
||||||
import { ConfigModelID } from "./model-id"
|
import { ConfigModelID } from "./model-id"
|
||||||
import { InvalidError } from "./paths"
|
|
||||||
|
|
||||||
const log = Log.create({ service: "config" })
|
const log = Log.create({ service: "config" })
|
||||||
|
|
||||||
|
|||||||
@@ -10,13 +10,7 @@ import { NamedError } from "@opencode-ai/shared/util/error"
|
|||||||
import { Flag } from "../flag/flag"
|
import { Flag } from "../flag/flag"
|
||||||
import { Auth } from "../auth"
|
import { Auth } from "../auth"
|
||||||
import { Env } from "../env"
|
import { Env } from "../env"
|
||||||
import {
|
import { applyEdits, modify } from "jsonc-parser"
|
||||||
type ParseError as JsoncParseError,
|
|
||||||
applyEdits,
|
|
||||||
modify,
|
|
||||||
parse as parseJsonc,
|
|
||||||
printParseErrorCode,
|
|
||||||
} from "jsonc-parser"
|
|
||||||
import { Instance, type InstanceContext } from "../project/instance"
|
import { Instance, type InstanceContext } from "../project/instance"
|
||||||
import * as LSPServer from "../lsp/server"
|
import * as LSPServer from "../lsp/server"
|
||||||
import { InstallationLocal, InstallationVersion } from "@/installation/version"
|
import { InstallationLocal, InstallationVersion } from "@/installation/version"
|
||||||
@@ -25,6 +19,7 @@ import { GlobalBus } from "@/bus/global"
|
|||||||
import { Event } from "../server/event"
|
import { Event } from "../server/event"
|
||||||
import { Account } from "@/account"
|
import { Account } from "@/account"
|
||||||
import { isRecord } from "@/util/record"
|
import { isRecord } from "@/util/record"
|
||||||
|
import { InvalidError, JsonError } from "./error"
|
||||||
import * as ConfigPaths from "./paths"
|
import * as ConfigPaths from "./paths"
|
||||||
import type { ConsoleState } from "./console-state"
|
import type { ConsoleState } from "./console-state"
|
||||||
import { AppFileSystem } from "@opencode-ai/shared/filesystem"
|
import { AppFileSystem } from "@opencode-ai/shared/filesystem"
|
||||||
@@ -39,6 +34,7 @@ import { ConfigModelID } from "./model-id"
|
|||||||
import { ConfigPlugin } from "./plugin"
|
import { ConfigPlugin } from "./plugin"
|
||||||
import { ConfigManaged } from "./managed"
|
import { ConfigManaged } from "./managed"
|
||||||
import { ConfigCommand } from "./command"
|
import { ConfigCommand } from "./command"
|
||||||
|
import { ConfigParse } from "./parse"
|
||||||
import { ConfigPermission } from "./permission"
|
import { ConfigPermission } from "./permission"
|
||||||
import { ConfigProvider } from "./provider"
|
import { ConfigProvider } from "./provider"
|
||||||
import { ConfigSkills } from "./skills"
|
import { ConfigSkills } from "./skills"
|
||||||
@@ -54,6 +50,28 @@ function mergeConfigConcatArrays(target: Info, source: Info): Info {
|
|||||||
return merged
|
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
|
export const Server = z
|
||||||
.object({
|
.object({
|
||||||
port: z.number().int().positive().optional().describe("Port to listen on"),
|
port: z.number().int().positive().optional().describe("Port to listen on"),
|
||||||
@@ -325,42 +343,6 @@ function writable(info: Info) {
|
|||||||
return next
|
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(
|
export const ConfigDirectoryTypoError = NamedError.create(
|
||||||
"ConfigDirectoryTypoError",
|
"ConfigDirectoryTypoError",
|
||||||
z.object({
|
z.object({
|
||||||
@@ -393,48 +375,31 @@ export const layer = Layer.effect(
|
|||||||
text: string,
|
text: string,
|
||||||
options: { path: string } | { dir: string; source: string },
|
options: { path: string } | { dir: string; source: string },
|
||||||
) {
|
) {
|
||||||
const original = text
|
if (!("path" in options)) {
|
||||||
const source = "path" in options ? options.path : options.source
|
return yield* Effect.promise(() =>
|
||||||
const isFile = "path" in options
|
ConfigParse.load(Info, text, {
|
||||||
const data = yield* Effect.promise(() =>
|
type: "virtual",
|
||||||
ConfigPaths.parseText(text, "path" in options ? options.path : { source: options.source, dir: options.dir }),
|
dir: options.dir,
|
||||||
)
|
source: options.source,
|
||||||
|
normalize: normalizeLoadedConfig,
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
throw new InvalidError({
|
const data = yield* Effect.promise(() =>
|
||||||
path: source,
|
ConfigParse.load(Info, text, {
|
||||||
issues: parsed.error.issues,
|
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) {
|
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
|
// 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 ?? {})) {
|
for (const [name, mode] of Object.entries(result.mode ?? {})) {
|
||||||
result.agent = mergeDeep(result.agent ?? {}, {
|
result.agent = mergeDeep(result.agent ?? {}, {
|
||||||
@@ -803,13 +777,13 @@ export const layer = Layer.effect(
|
|||||||
|
|
||||||
let next: Info
|
let next: Info
|
||||||
if (!file.endsWith(".jsonc")) {
|
if (!file.endsWith(".jsonc")) {
|
||||||
const existing = parseConfig(before, file)
|
const existing = ConfigParse.parse(Info, before, file)
|
||||||
const merged = mergeDeep(writable(existing), input)
|
const merged = mergeDeep(writable(existing), input)
|
||||||
yield* fs.writeFileString(file, JSON.stringify(merged, null, 2)).pipe(Effect.orDie)
|
yield* fs.writeFileString(file, JSON.stringify(merged, null, 2)).pipe(Effect.orDie)
|
||||||
next = merged
|
next = merged
|
||||||
} else {
|
} else {
|
||||||
const updated = patchJsonc(before, input)
|
const updated = patchJsonc(before, input)
|
||||||
next = parseConfig(updated, file)
|
next = ConfigParse.parse(Info, updated, file)
|
||||||
yield* fs.writeFileString(file, updated).pipe(Effect.orDie)
|
yield* fs.writeFileString(file, updated).pipe(Effect.orDie)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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(),
|
||||||
|
}),
|
||||||
|
)
|
||||||
@@ -1,10 +1,13 @@
|
|||||||
export * as Config from "./config"
|
export * as Config from "./config"
|
||||||
export * as ConfigAgent from "./agent"
|
export * as ConfigAgent from "./agent"
|
||||||
export * as ConfigCommand from "./command"
|
export * as ConfigCommand from "./command"
|
||||||
|
export * as ConfigError from "./error"
|
||||||
|
export * as ConfigVariable from "./variable"
|
||||||
export { ConfigManaged } from "./managed"
|
export { ConfigManaged } from "./managed"
|
||||||
export * as ConfigMarkdown from "./markdown"
|
export * as ConfigMarkdown from "./markdown"
|
||||||
export * as ConfigMCP from "./mcp"
|
export * as ConfigMCP from "./mcp"
|
||||||
export { ConfigModelID } from "./model-id"
|
export { ConfigModelID } from "./model-id"
|
||||||
|
export * as ConfigParse from "./parse"
|
||||||
export * as ConfigPermission from "./permission"
|
export * as ConfigPermission from "./permission"
|
||||||
export * as ConfigPaths from "./paths"
|
export * as ConfigPaths from "./paths"
|
||||||
export * as ConfigProvider from "./provider"
|
export * as ConfigProvider from "./provider"
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import { existsSync } from "fs"
|
import { existsSync } from "fs"
|
||||||
import os from "os"
|
import os from "os"
|
||||||
import path from "path"
|
import path from "path"
|
||||||
import { type Info, parseConfig } from "./config"
|
|
||||||
import { Log, Process } from "../util"
|
import { Log, Process } from "../util"
|
||||||
|
|
||||||
const log = Log.create({ service: "config" })
|
const log = Log.create({ service: "config" })
|
||||||
@@ -33,16 +32,16 @@ function managedConfigDir() {
|
|||||||
return process.env.OPENCODE_TEST_MANAGED_CONFIG_DIR || systemManagedConfigDir()
|
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)
|
const raw = JSON.parse(json)
|
||||||
for (const key of Object.keys(raw)) {
|
for (const key of Object.keys(raw)) {
|
||||||
if (PLIST_META.has(key)) delete raw[key]
|
if (PLIST_META.has(key)) delete raw[key]
|
||||||
}
|
}
|
||||||
return parseConfig(JSON.stringify(raw), source)
|
return JSON.stringify(raw)
|
||||||
}
|
}
|
||||||
|
|
||||||
async function readManagedPreferences(): Promise<Info> {
|
async function readManagedPreferences() {
|
||||||
if (process.platform !== "darwin") return {}
|
if (process.platform !== "darwin") return
|
||||||
|
|
||||||
const user = os.userInfo().username
|
const user = os.userInfo().username
|
||||||
const paths = [
|
const paths = [
|
||||||
@@ -58,10 +57,13 @@ async function readManagedPreferences(): Promise<Info> {
|
|||||||
log.warn("failed to convert managed preferences plist", { path: plist })
|
log.warn("failed to convert managed preferences plist", { path: plist })
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
return parseManagedPlist(result.stdout.toString(), `mobileconfig:${plist}`)
|
return {
|
||||||
|
source: `mobileconfig:${plist}`,
|
||||||
|
text: parseManagedPlist(result.stdout.toString()),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return {}
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
export const ConfigManaged = {
|
export const ConfigManaged = {
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -1,12 +1,9 @@
|
|||||||
import path from "path"
|
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 { Filesystem } from "@/util"
|
||||||
import { Flag } from "@/flag/flag"
|
import { Flag } from "@/flag/flag"
|
||||||
import { Global } from "@/global"
|
import { Global } from "@/global"
|
||||||
import { unique } from "remeda"
|
import { unique } from "remeda"
|
||||||
|
import { JsonError } from "./error"
|
||||||
|
|
||||||
export async function projectFiles(name: string, directory: string, worktree?: string) {
|
export async function projectFiles(name: string, directory: string, worktree?: string) {
|
||||||
return Filesystem.findUp([`${name}.json`, `${name}.jsonc`], directory, worktree, { rootFirst: true })
|
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`)]
|
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. */
|
/** Read a config file, returning undefined for missing files and throwing JsonError for other failures. */
|
||||||
export async function readFile(filepath: string) {
|
export async function readFile(filepath: string) {
|
||||||
return Filesystem.readText(filepath).catch((err: NodeJS.ErrnoException) => {
|
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 })
|
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
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ import { test, expect, describe, mock, afterEach, beforeEach } from "bun:test"
|
|||||||
import { Effect, Layer, Option } from "effect"
|
import { Effect, Layer, Option } from "effect"
|
||||||
import { NodeFileSystem, NodePath } from "@effect/platform-node"
|
import { NodeFileSystem, NodePath } from "@effect/platform-node"
|
||||||
import { Config, ConfigManaged } from "../../src/config"
|
import { Config, ConfigManaged } from "../../src/config"
|
||||||
|
import { ConfigParse } from "../../src/config/parse"
|
||||||
import { EffectFlock } from "@opencode-ai/shared/util/effect-flock"
|
import { EffectFlock } from "@opencode-ai/shared/util/effect-flock"
|
||||||
|
|
||||||
import { Instance } from "../../src/project/instance"
|
import { Instance } from "../../src/project/instance"
|
||||||
@@ -2211,17 +2212,20 @@ describe("OPENCODE_CONFIG_CONTENT token substitution", () => {
|
|||||||
// parseManagedPlist unit tests — pure function, no OS interaction
|
// parseManagedPlist unit tests — pure function, no OS interaction
|
||||||
|
|
||||||
test("parseManagedPlist strips MDM metadata keys", async () => {
|
test("parseManagedPlist strips MDM metadata keys", async () => {
|
||||||
const config = await ConfigManaged.parseManagedPlist(
|
const config = ConfigParse.parse(
|
||||||
JSON.stringify({
|
Config.Info,
|
||||||
PayloadDisplayName: "OpenCode Managed",
|
await ConfigManaged.parseManagedPlist(
|
||||||
PayloadIdentifier: "ai.opencode.managed.test",
|
JSON.stringify({
|
||||||
PayloadType: "ai.opencode.managed",
|
PayloadDisplayName: "OpenCode Managed",
|
||||||
PayloadUUID: "AAAA-BBBB-CCCC",
|
PayloadIdentifier: "ai.opencode.managed.test",
|
||||||
PayloadVersion: 1,
|
PayloadType: "ai.opencode.managed",
|
||||||
_manualProfile: true,
|
PayloadUUID: "AAAA-BBBB-CCCC",
|
||||||
share: "disabled",
|
PayloadVersion: 1,
|
||||||
model: "mdm/model",
|
_manualProfile: true,
|
||||||
}),
|
share: "disabled",
|
||||||
|
model: "mdm/model",
|
||||||
|
}),
|
||||||
|
),
|
||||||
"test:mobileconfig",
|
"test:mobileconfig",
|
||||||
)
|
)
|
||||||
expect(config.share).toBe("disabled")
|
expect(config.share).toBe("disabled")
|
||||||
@@ -2233,12 +2237,15 @@ test("parseManagedPlist strips MDM metadata keys", async () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
test("parseManagedPlist parses server settings", async () => {
|
test("parseManagedPlist parses server settings", async () => {
|
||||||
const config = await ConfigManaged.parseManagedPlist(
|
const config = ConfigParse.parse(
|
||||||
JSON.stringify({
|
Config.Info,
|
||||||
$schema: "https://opencode.ai/config.json",
|
await ConfigManaged.parseManagedPlist(
|
||||||
server: { hostname: "127.0.0.1", mdns: false },
|
JSON.stringify({
|
||||||
autoupdate: true,
|
$schema: "https://opencode.ai/config.json",
|
||||||
}),
|
server: { hostname: "127.0.0.1", mdns: false },
|
||||||
|
autoupdate: true,
|
||||||
|
}),
|
||||||
|
),
|
||||||
"test:mobileconfig",
|
"test:mobileconfig",
|
||||||
)
|
)
|
||||||
expect(config.server?.hostname).toBe("127.0.0.1")
|
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 () => {
|
test("parseManagedPlist parses permission rules", async () => {
|
||||||
const config = await ConfigManaged.parseManagedPlist(
|
const config = ConfigParse.parse(
|
||||||
JSON.stringify({
|
Config.Info,
|
||||||
$schema: "https://opencode.ai/config.json",
|
await ConfigManaged.parseManagedPlist(
|
||||||
permission: {
|
JSON.stringify({
|
||||||
"*": "ask",
|
$schema: "https://opencode.ai/config.json",
|
||||||
bash: { "*": "ask", "rm -rf *": "deny", "curl *": "deny" },
|
permission: {
|
||||||
grep: "allow",
|
"*": "ask",
|
||||||
glob: "allow",
|
bash: { "*": "ask", "rm -rf *": "deny", "curl *": "deny" },
|
||||||
webfetch: "ask",
|
grep: "allow",
|
||||||
"~/.ssh/*": "deny",
|
glob: "allow",
|
||||||
},
|
webfetch: "ask",
|
||||||
}),
|
"~/.ssh/*": "deny",
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
),
|
||||||
"test:mobileconfig",
|
"test:mobileconfig",
|
||||||
)
|
)
|
||||||
expect(config.permission?.["*"]).toBe("ask")
|
expect(config.permission?.["*"]).toBe("ask")
|
||||||
@@ -2271,19 +2281,23 @@ test("parseManagedPlist parses permission rules", async () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
test("parseManagedPlist parses enabled_providers", async () => {
|
test("parseManagedPlist parses enabled_providers", async () => {
|
||||||
const config = await ConfigManaged.parseManagedPlist(
|
const config = ConfigParse.parse(
|
||||||
JSON.stringify({
|
Config.Info,
|
||||||
$schema: "https://opencode.ai/config.json",
|
await ConfigManaged.parseManagedPlist(
|
||||||
enabled_providers: ["anthropic", "google"],
|
JSON.stringify({
|
||||||
}),
|
$schema: "https://opencode.ai/config.json",
|
||||||
|
enabled_providers: ["anthropic", "google"],
|
||||||
|
}),
|
||||||
|
),
|
||||||
"test:mobileconfig",
|
"test:mobileconfig",
|
||||||
)
|
)
|
||||||
expect(config.enabled_providers).toEqual(["anthropic", "google"])
|
expect(config.enabled_providers).toEqual(["anthropic", "google"])
|
||||||
})
|
})
|
||||||
|
|
||||||
test("parseManagedPlist handles empty config", async () => {
|
test("parseManagedPlist handles empty config", async () => {
|
||||||
const config = await ConfigManaged.parseManagedPlist(
|
const config = ConfigParse.parse(
|
||||||
JSON.stringify({ $schema: "https://opencode.ai/config.json" }),
|
Config.Info,
|
||||||
|
await ConfigManaged.parseManagedPlist(JSON.stringify({ $schema: "https://opencode.ai/config.json" })),
|
||||||
"test:mobileconfig",
|
"test:mobileconfig",
|
||||||
)
|
)
|
||||||
expect(config.$schema).toBe("https://opencode.ai/config.json")
|
expect(config.$schema).toBe("https://opencode.ai/config.json")
|
||||||
|
|||||||
Reference in New Issue
Block a user