Skip to content

Commit ecc06a3

Browse files
authored
refactor(core): make Config.Info canonical Effect Schema (anomalyco#23716)
1 parent 3205f12 commit ecc06a3

7 files changed

Lines changed: 36 additions & 27 deletions

File tree

packages/opencode/script/schema.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,7 @@ const configFile = process.argv[2]
5555
const tuiFile = process.argv[3]
5656

5757
console.log(configFile)
58-
await Bun.write(configFile, JSON.stringify(generate(Config.Info), null, 2))
58+
await Bun.write(configFile, JSON.stringify(generate(Config.Info.zod), null, 2))
5959

6060
if (tuiFile) {
6161
console.log(tuiFile)

packages/opencode/src/config/config.ts

Lines changed: 22 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ import { Context, Duration, Effect, Exit, Fiber, Layer, Option, Schema } from "e
2525
import { EffectFlock } from "@opencode-ai/shared/util/effect-flock"
2626
import { InstanceRef } from "@/effect/instance-ref"
2727
import { zod, ZodOverride } from "@/util/effect-zod"
28+
import { withStatics } from "@/util/schema"
2829
import { ConfigAgent } from "./agent"
2930
import { ConfigCommand } from "./command"
3031
import { ConfigFormatter } from "./formatter"
@@ -91,7 +92,15 @@ const LogLevelRef = Schema.Any.annotate({ [ZodOverride]: Log.Level })
9192
const PositiveInt = Schema.Number.check(Schema.isInt()).check(Schema.isGreaterThan(0))
9293
const NonNegativeInt = Schema.Number.check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0))
9394

94-
export const InfoSchema = Schema.Struct({
95+
// The Effect Schema is the canonical source of truth. The `.zod` compatibility
96+
// surface is derived so existing Hono validators keep working without a parallel
97+
// Zod definition.
98+
//
99+
// The walker emits `z.object({...})` which is non-strict by default. Config
100+
// historically uses `.strict()` (additionalProperties: false in openapi.json),
101+
// so layer that on after derivation. Re-apply the Config ref afterward
102+
// since `.strict()` strips the walker's meta annotation.
103+
export const Info = Schema.Struct({
95104
$schema: Schema.optional(Schema.String).annotate({
96105
description: "JSON schema reference for configuration validation",
97106
}),
@@ -235,6 +244,14 @@ export const InfoSchema = Schema.Struct({
235244
}),
236245
),
237246
})
247+
.annotate({ identifier: "Config" })
248+
.pipe(
249+
withStatics((s) => ({
250+
zod: (zod(s) as unknown as z.ZodObject<any>)
251+
.strict()
252+
.meta({ ref: "Config" }) as unknown as z.ZodType<DeepMutable<Schema.Schema.Type<typeof s>>>,
253+
})),
254+
)
238255

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

259-
// The walker emits `z.object({...})` which is non-strict by default. Config
260-
// historically uses `.strict()` (additionalProperties: false in openapi.json),
261-
// so layer that on after derivation. Re-apply the Config ref afterward
262-
// since `.strict()` strips the walker's meta annotation.
263-
export const Info = (zod(InfoSchema) as unknown as z.ZodObject<any>)
264-
.strict()
265-
.meta({ ref: "Config" }) as unknown as z.ZodType<DeepMutable<Schema.Schema.Type<typeof InfoSchema>>>
266-
267-
export type Info = z.output<typeof Info> & {
276+
export type Info = DeepMutable<Schema.Schema.Type<typeof Info>> & {
268277
// plugin_origins is derived state, not a persisted config field. It keeps each winning plugin spec together
269278
// with the file and scope it came from so later runtime code can make location-sensitive decisions.
270279
plugin_origins?: ConfigPlugin.Origin[]
@@ -361,7 +370,7 @@ export const layer = Layer.effect(
361370
),
362371
)
363372
const parsed = ConfigParse.jsonc(expanded, source)
364-
const data = ConfigParse.schema(Info, normalizeLoadedConfig(parsed, source), source)
373+
const data = ConfigParse.schema(Info.zod, normalizeLoadedConfig(parsed, source), source)
365374
if (!("path" in options)) return data
366375

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

754763
let next: Info
755764
if (!file.endsWith(".jsonc")) {
756-
const existing = ConfigParse.schema(Info, ConfigParse.jsonc(before, file), file)
765+
const existing = ConfigParse.schema(Info.zod, ConfigParse.jsonc(before, file), file)
757766
const merged = mergeDeep(writable(existing), writable(config))
758767
yield* fs.writeFileString(file, JSON.stringify(merged, null, 2)).pipe(Effect.orDie)
759768
next = merged
760769
} else {
761770
const updated = patchJsonc(before, writable(config))
762-
next = ConfigParse.schema(Info, ConfigParse.jsonc(updated, file), file)
771+
next = ConfigParse.schema(Info.zod, ConfigParse.jsonc(updated, file), file)
763772
yield* fs.writeFileString(file, updated).pipe(Effect.orDie)
764773
}
765774

packages/opencode/src/server/routes/global.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -147,7 +147,7 @@ export const GlobalRoutes = lazy(() =>
147147
description: "Get global config info",
148148
content: {
149149
"application/json": {
150-
schema: resolver(Config.Info),
150+
schema: resolver(Config.Info.zod),
151151
},
152152
},
153153
},
@@ -168,14 +168,14 @@ export const GlobalRoutes = lazy(() =>
168168
description: "Successfully updated global config",
169169
content: {
170170
"application/json": {
171-
schema: resolver(Config.Info),
171+
schema: resolver(Config.Info.zod),
172172
},
173173
},
174174
},
175175
...errors(400),
176176
},
177177
}),
178-
validator("json", Config.Info),
178+
validator("json", Config.Info.zod),
179179
async (c) => {
180180
const config = c.req.valid("json")
181181
const next = await AppRuntime.runPromise(Config.Service.use((cfg) => cfg.updateGlobal(config)))

packages/opencode/src/server/routes/instance/config.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ export const ConfigRoutes = lazy(() =>
2020
description: "Get config info",
2121
content: {
2222
"application/json": {
23-
schema: resolver(Config.Info),
23+
schema: resolver(Config.Info.zod),
2424
},
2525
},
2626
},
@@ -43,14 +43,14 @@ export const ConfigRoutes = lazy(() =>
4343
description: "Successfully updated config",
4444
content: {
4545
"application/json": {
46-
schema: resolver(Config.Info),
46+
schema: resolver(Config.Info.zod),
4747
},
4848
},
4949
},
5050
...errors(400),
5151
},
5252
}),
53-
validator("json", Config.Info),
53+
validator("json", Config.Info.zod),
5454
async (c) =>
5555
jsonRequest("ConfigRoutes.update", c, function* () {
5656
const config = c.req.valid("json")

packages/opencode/src/server/routes/instance/httpapi/config.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ export const ConfigApi = HttpApi.make("config")
1010
HttpApiGroup.make("config")
1111
.add(
1212
HttpApiEndpoint.get("get", root, {
13-
success: Config.InfoSchema,
13+
success: Config.Info,
1414
}).annotateMerge(
1515
OpenApi.annotations({
1616
identifier: "config.get",

packages/opencode/test/config/config.test.ts

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2221,7 +2221,7 @@ describe("OPENCODE_CONFIG_CONTENT token substitution", () => {
22212221

22222222
test("parseManagedPlist strips MDM metadata keys", async () => {
22232223
const config = ConfigParse.schema(
2224-
Config.Info,
2224+
Config.Info.zod,
22252225
ConfigParse.jsonc(
22262226
await ConfigManaged.parseManagedPlist(
22272227
JSON.stringify({
@@ -2249,7 +2249,7 @@ test("parseManagedPlist strips MDM metadata keys", async () => {
22492249

22502250
test("parseManagedPlist parses server settings", async () => {
22512251
const config = ConfigParse.schema(
2252-
Config.Info,
2252+
Config.Info.zod,
22532253
ConfigParse.jsonc(
22542254
await ConfigManaged.parseManagedPlist(
22552255
JSON.stringify({
@@ -2269,7 +2269,7 @@ test("parseManagedPlist parses server settings", async () => {
22692269

22702270
test("parseManagedPlist parses permission rules", async () => {
22712271
const config = ConfigParse.schema(
2272-
Config.Info,
2272+
Config.Info.zod,
22732273
ConfigParse.jsonc(
22742274
await ConfigManaged.parseManagedPlist(
22752275
JSON.stringify({
@@ -2299,7 +2299,7 @@ test("parseManagedPlist parses permission rules", async () => {
22992299

23002300
test("parseManagedPlist parses enabled_providers", async () => {
23012301
const config = ConfigParse.schema(
2302-
Config.Info,
2302+
Config.Info.zod,
23032303
ConfigParse.jsonc(
23042304
await ConfigManaged.parseManagedPlist(
23052305
JSON.stringify({
@@ -2316,7 +2316,7 @@ test("parseManagedPlist parses enabled_providers", async () => {
23162316

23172317
test("parseManagedPlist handles empty config", async () => {
23182318
const config = ConfigParse.schema(
2319-
Config.Info,
2319+
Config.Info.zod,
23202320
ConfigParse.jsonc(
23212321
await ConfigManaged.parseManagedPlist(JSON.stringify({ $schema: "https://opencode.ai/config.json" })),
23222322
"test:mobileconfig",

packages/opencode/test/session/compaction.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -168,7 +168,7 @@ function layer(result: "continue" | "compact") {
168168
}
169169

170170
function cfg(compaction?: Config.Info["compaction"]) {
171-
const base = Config.Info.parse({})
171+
const base = Config.Info.zod.parse({})
172172
return Layer.mock(Config.Service)({
173173
get: () => Effect.succeed({ ...base, compaction }),
174174
})

0 commit comments

Comments
 (0)