Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion packages/opencode/script/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@ const configFile = process.argv[2]
const tuiFile = process.argv[3]

console.log(configFile)
await Bun.write(configFile, JSON.stringify(generate(Config.Info), null, 2))
await Bun.write(configFile, JSON.stringify(generate(Config.Info.zod), null, 2))

if (tuiFile) {
console.log(tuiFile)
Expand Down
35 changes: 22 additions & 13 deletions packages/opencode/src/config/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import { Context, Duration, Effect, Exit, Fiber, Layer, Option, Schema } from "e
import { EffectFlock } from "@opencode-ai/shared/util/effect-flock"
import { InstanceRef } from "@/effect/instance-ref"
import { zod, ZodOverride } from "@/util/effect-zod"
import { withStatics } from "@/util/schema"
import { ConfigAgent } from "./agent"
import { ConfigCommand } from "./command"
import { ConfigFormatter } from "./formatter"
Expand Down Expand Up @@ -91,7 +92,15 @@ const LogLevelRef = Schema.Any.annotate({ [ZodOverride]: Log.Level })
const PositiveInt = Schema.Number.check(Schema.isInt()).check(Schema.isGreaterThan(0))
const NonNegativeInt = Schema.Number.check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0))

export const InfoSchema = Schema.Struct({
// The Effect Schema is the canonical source of truth. The `.zod` compatibility
// surface is derived so existing Hono validators keep working without a parallel
// Zod definition.
//
// The walker emits `z.object({...})` which is non-strict by default. Config
// historically uses `.strict()` (additionalProperties: false in openapi.json),
// so layer that on after derivation. Re-apply the Config ref afterward
// since `.strict()` strips the walker's meta annotation.
export const Info = Schema.Struct({
$schema: Schema.optional(Schema.String).annotate({
description: "JSON schema reference for configuration validation",
}),
Expand Down Expand Up @@ -235,6 +244,14 @@ export const InfoSchema = Schema.Struct({
}),
),
})
.annotate({ identifier: "Config" })
.pipe(
withStatics((s) => ({
zod: (zod(s) as unknown as z.ZodObject<any>)
.strict()
.meta({ ref: "Config" }) as unknown as z.ZodType<DeepMutable<Schema.Schema.Type<typeof s>>>,
})),
)

// Schema.Struct produces readonly types by default, but the service code
// below mutates Info objects directly (e.g. `config.mode = ...`). Strip the
Expand All @@ -256,15 +273,7 @@ type DeepMutable<T> = T extends readonly [unknown, ...unknown[]]
? { -readonly [K in keyof T]: DeepMutable<T[K]> }
: T

// The walker emits `z.object({...})` which is non-strict by default. Config
// historically uses `.strict()` (additionalProperties: false in openapi.json),
// so layer that on after derivation. Re-apply the Config ref afterward
// since `.strict()` strips the walker's meta annotation.
export const Info = (zod(InfoSchema) as unknown as z.ZodObject<any>)
.strict()
.meta({ ref: "Config" }) as unknown as z.ZodType<DeepMutable<Schema.Schema.Type<typeof InfoSchema>>>

export type Info = z.output<typeof Info> & {
export type Info = DeepMutable<Schema.Schema.Type<typeof Info>> & {
// plugin_origins is derived state, not a persisted config field. It keeps each winning plugin spec together
// with the file and scope it came from so later runtime code can make location-sensitive decisions.
plugin_origins?: ConfigPlugin.Origin[]
Expand Down Expand Up @@ -361,7 +370,7 @@ export const layer = Layer.effect(
),
)
const parsed = ConfigParse.jsonc(expanded, source)
const data = ConfigParse.schema(Info, normalizeLoadedConfig(parsed, source), source)
const data = ConfigParse.schema(Info.zod, normalizeLoadedConfig(parsed, source), source)
if (!("path" in options)) return data

yield* Effect.promise(() => resolveLoadedPlugins(data, options.path))
Expand Down Expand Up @@ -753,13 +762,13 @@ export const layer = Layer.effect(

let next: Info
if (!file.endsWith(".jsonc")) {
const existing = ConfigParse.schema(Info, ConfigParse.jsonc(before, file), file)
const existing = ConfigParse.schema(Info.zod, ConfigParse.jsonc(before, file), file)
const merged = mergeDeep(writable(existing), writable(config))
yield* fs.writeFileString(file, JSON.stringify(merged, null, 2)).pipe(Effect.orDie)
next = merged
} else {
const updated = patchJsonc(before, writable(config))
next = ConfigParse.schema(Info, ConfigParse.jsonc(updated, file), file)
next = ConfigParse.schema(Info.zod, ConfigParse.jsonc(updated, file), file)
yield* fs.writeFileString(file, updated).pipe(Effect.orDie)
}

Expand Down
6 changes: 3 additions & 3 deletions packages/opencode/src/server/routes/global.ts
Original file line number Diff line number Diff line change
Expand Up @@ -147,7 +147,7 @@ export const GlobalRoutes = lazy(() =>
description: "Get global config info",
content: {
"application/json": {
schema: resolver(Config.Info),
schema: resolver(Config.Info.zod),
},
},
},
Expand All @@ -168,14 +168,14 @@ export const GlobalRoutes = lazy(() =>
description: "Successfully updated global config",
content: {
"application/json": {
schema: resolver(Config.Info),
schema: resolver(Config.Info.zod),
},
},
},
...errors(400),
},
}),
validator("json", Config.Info),
validator("json", Config.Info.zod),
async (c) => {
const config = c.req.valid("json")
const next = await AppRuntime.runPromise(Config.Service.use((cfg) => cfg.updateGlobal(config)))
Expand Down
6 changes: 3 additions & 3 deletions packages/opencode/src/server/routes/instance/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ export const ConfigRoutes = lazy(() =>
description: "Get config info",
content: {
"application/json": {
schema: resolver(Config.Info),
schema: resolver(Config.Info.zod),
},
},
},
Expand All @@ -43,14 +43,14 @@ export const ConfigRoutes = lazy(() =>
description: "Successfully updated config",
content: {
"application/json": {
schema: resolver(Config.Info),
schema: resolver(Config.Info.zod),
},
},
},
...errors(400),
},
}),
validator("json", Config.Info),
validator("json", Config.Info.zod),
async (c) =>
jsonRequest("ConfigRoutes.update", c, function* () {
const config = c.req.valid("json")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ export const ConfigApi = HttpApi.make("config")
HttpApiGroup.make("config")
.add(
HttpApiEndpoint.get("get", root, {
success: Config.InfoSchema,
success: Config.Info,
}).annotateMerge(
OpenApi.annotations({
identifier: "config.get",
Expand Down
10 changes: 5 additions & 5 deletions packages/opencode/test/config/config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2221,7 +2221,7 @@ describe("OPENCODE_CONFIG_CONTENT token substitution", () => {

test("parseManagedPlist strips MDM metadata keys", async () => {
const config = ConfigParse.schema(
Config.Info,
Config.Info.zod,
ConfigParse.jsonc(
await ConfigManaged.parseManagedPlist(
JSON.stringify({
Expand Down Expand Up @@ -2249,7 +2249,7 @@ test("parseManagedPlist strips MDM metadata keys", async () => {

test("parseManagedPlist parses server settings", async () => {
const config = ConfigParse.schema(
Config.Info,
Config.Info.zod,
ConfigParse.jsonc(
await ConfigManaged.parseManagedPlist(
JSON.stringify({
Expand All @@ -2269,7 +2269,7 @@ test("parseManagedPlist parses server settings", async () => {

test("parseManagedPlist parses permission rules", async () => {
const config = ConfigParse.schema(
Config.Info,
Config.Info.zod,
ConfigParse.jsonc(
await ConfigManaged.parseManagedPlist(
JSON.stringify({
Expand Down Expand Up @@ -2299,7 +2299,7 @@ test("parseManagedPlist parses permission rules", async () => {

test("parseManagedPlist parses enabled_providers", async () => {
const config = ConfigParse.schema(
Config.Info,
Config.Info.zod,
ConfigParse.jsonc(
await ConfigManaged.parseManagedPlist(
JSON.stringify({
Expand All @@ -2316,7 +2316,7 @@ test("parseManagedPlist parses enabled_providers", async () => {

test("parseManagedPlist handles empty config", async () => {
const config = ConfigParse.schema(
Config.Info,
Config.Info.zod,
ConfigParse.jsonc(
await ConfigManaged.parseManagedPlist(JSON.stringify({ $schema: "https://opencode.ai/config.json" })),
"test:mobileconfig",
Expand Down
2 changes: 1 addition & 1 deletion packages/opencode/test/session/compaction.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -168,7 +168,7 @@ function layer(result: "continue" | "compact") {
}

function cfg(compaction?: Config.Info["compaction"]) {
const base = Config.Info.parse({})
const base = Config.Info.zod.parse({})
return Layer.mock(Config.Service)({
get: () => Effect.succeed({ ...base, compaction }),
})
Expand Down
Loading