refactor(core): make Config.Info canonical Effect Schema (#23716)
This commit is contained in:
@@ -55,7 +55,7 @@ const configFile = process.argv[2]
|
|||||||
const tuiFile = process.argv[3]
|
const tuiFile = process.argv[3]
|
||||||
|
|
||||||
console.log(configFile)
|
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) {
|
if (tuiFile) {
|
||||||
console.log(tuiFile)
|
console.log(tuiFile)
|
||||||
|
|||||||
@@ -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 { EffectFlock } from "@opencode-ai/shared/util/effect-flock"
|
||||||
import { InstanceRef } from "@/effect/instance-ref"
|
import { InstanceRef } from "@/effect/instance-ref"
|
||||||
import { zod, ZodOverride } from "@/util/effect-zod"
|
import { zod, ZodOverride } from "@/util/effect-zod"
|
||||||
|
import { withStatics } from "@/util/schema"
|
||||||
import { ConfigAgent } from "./agent"
|
import { ConfigAgent } from "./agent"
|
||||||
import { ConfigCommand } from "./command"
|
import { ConfigCommand } from "./command"
|
||||||
import { ConfigFormatter } from "./formatter"
|
import { ConfigFormatter } from "./formatter"
|
||||||
@@ -91,7 +92,15 @@ const LogLevelRef = Schema.Any.annotate({ [ZodOverride]: Log.Level })
|
|||||||
const PositiveInt = Schema.Number.check(Schema.isInt()).check(Schema.isGreaterThan(0))
|
const PositiveInt = Schema.Number.check(Schema.isInt()).check(Schema.isGreaterThan(0))
|
||||||
const NonNegativeInt = Schema.Number.check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(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({
|
$schema: Schema.optional(Schema.String).annotate({
|
||||||
description: "JSON schema reference for configuration validation",
|
description: "JSON schema reference for configuration validation",
|
||||||
}),
|
}),
|
||||||
@@ -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
|
// Schema.Struct produces readonly types by default, but the service code
|
||||||
// below mutates Info objects directly (e.g. `config.mode = ...`). Strip the
|
// below mutates Info objects directly (e.g. `config.mode = ...`). Strip the
|
||||||
@@ -256,15 +273,7 @@ type DeepMutable<T> = T extends readonly [unknown, ...unknown[]]
|
|||||||
? { -readonly [K in keyof T]: DeepMutable<T[K]> }
|
? { -readonly [K in keyof T]: DeepMutable<T[K]> }
|
||||||
: T
|
: T
|
||||||
|
|
||||||
// The walker emits `z.object({...})` which is non-strict by default. Config
|
export type Info = DeepMutable<Schema.Schema.Type<typeof Info>> & {
|
||||||
// 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> & {
|
|
||||||
// plugin_origins is derived state, not a persisted config field. It keeps each winning plugin spec together
|
// 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.
|
// with the file and scope it came from so later runtime code can make location-sensitive decisions.
|
||||||
plugin_origins?: ConfigPlugin.Origin[]
|
plugin_origins?: ConfigPlugin.Origin[]
|
||||||
@@ -361,7 +370,7 @@ export const layer = Layer.effect(
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
const parsed = ConfigParse.jsonc(expanded, source)
|
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
|
if (!("path" in options)) return data
|
||||||
|
|
||||||
yield* Effect.promise(() => resolveLoadedPlugins(data, options.path))
|
yield* Effect.promise(() => resolveLoadedPlugins(data, options.path))
|
||||||
@@ -753,13 +762,13 @@ export const layer = Layer.effect(
|
|||||||
|
|
||||||
let next: Info
|
let next: Info
|
||||||
if (!file.endsWith(".jsonc")) {
|
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))
|
const merged = mergeDeep(writable(existing), writable(config))
|
||||||
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, writable(config))
|
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)
|
yield* fs.writeFileString(file, updated).pipe(Effect.orDie)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -147,7 +147,7 @@ export const GlobalRoutes = lazy(() =>
|
|||||||
description: "Get global config info",
|
description: "Get global config info",
|
||||||
content: {
|
content: {
|
||||||
"application/json": {
|
"application/json": {
|
||||||
schema: resolver(Config.Info),
|
schema: resolver(Config.Info.zod),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -168,14 +168,14 @@ export const GlobalRoutes = lazy(() =>
|
|||||||
description: "Successfully updated global config",
|
description: "Successfully updated global config",
|
||||||
content: {
|
content: {
|
||||||
"application/json": {
|
"application/json": {
|
||||||
schema: resolver(Config.Info),
|
schema: resolver(Config.Info.zod),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
...errors(400),
|
...errors(400),
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
validator("json", Config.Info),
|
validator("json", Config.Info.zod),
|
||||||
async (c) => {
|
async (c) => {
|
||||||
const config = c.req.valid("json")
|
const config = c.req.valid("json")
|
||||||
const next = await AppRuntime.runPromise(Config.Service.use((cfg) => cfg.updateGlobal(config)))
|
const next = await AppRuntime.runPromise(Config.Service.use((cfg) => cfg.updateGlobal(config)))
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ export const ConfigRoutes = lazy(() =>
|
|||||||
description: "Get config info",
|
description: "Get config info",
|
||||||
content: {
|
content: {
|
||||||
"application/json": {
|
"application/json": {
|
||||||
schema: resolver(Config.Info),
|
schema: resolver(Config.Info.zod),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -43,14 +43,14 @@ export const ConfigRoutes = lazy(() =>
|
|||||||
description: "Successfully updated config",
|
description: "Successfully updated config",
|
||||||
content: {
|
content: {
|
||||||
"application/json": {
|
"application/json": {
|
||||||
schema: resolver(Config.Info),
|
schema: resolver(Config.Info.zod),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
...errors(400),
|
...errors(400),
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
validator("json", Config.Info),
|
validator("json", Config.Info.zod),
|
||||||
async (c) =>
|
async (c) =>
|
||||||
jsonRequest("ConfigRoutes.update", c, function* () {
|
jsonRequest("ConfigRoutes.update", c, function* () {
|
||||||
const config = c.req.valid("json")
|
const config = c.req.valid("json")
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ export const ConfigApi = HttpApi.make("config")
|
|||||||
HttpApiGroup.make("config")
|
HttpApiGroup.make("config")
|
||||||
.add(
|
.add(
|
||||||
HttpApiEndpoint.get("get", root, {
|
HttpApiEndpoint.get("get", root, {
|
||||||
success: Config.InfoSchema,
|
success: Config.Info,
|
||||||
}).annotateMerge(
|
}).annotateMerge(
|
||||||
OpenApi.annotations({
|
OpenApi.annotations({
|
||||||
identifier: "config.get",
|
identifier: "config.get",
|
||||||
|
|||||||
@@ -2221,7 +2221,7 @@ describe("OPENCODE_CONFIG_CONTENT token substitution", () => {
|
|||||||
|
|
||||||
test("parseManagedPlist strips MDM metadata keys", async () => {
|
test("parseManagedPlist strips MDM metadata keys", async () => {
|
||||||
const config = ConfigParse.schema(
|
const config = ConfigParse.schema(
|
||||||
Config.Info,
|
Config.Info.zod,
|
||||||
ConfigParse.jsonc(
|
ConfigParse.jsonc(
|
||||||
await ConfigManaged.parseManagedPlist(
|
await ConfigManaged.parseManagedPlist(
|
||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
@@ -2249,7 +2249,7 @@ test("parseManagedPlist strips MDM metadata keys", async () => {
|
|||||||
|
|
||||||
test("parseManagedPlist parses server settings", async () => {
|
test("parseManagedPlist parses server settings", async () => {
|
||||||
const config = ConfigParse.schema(
|
const config = ConfigParse.schema(
|
||||||
Config.Info,
|
Config.Info.zod,
|
||||||
ConfigParse.jsonc(
|
ConfigParse.jsonc(
|
||||||
await ConfigManaged.parseManagedPlist(
|
await ConfigManaged.parseManagedPlist(
|
||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
@@ -2269,7 +2269,7 @@ test("parseManagedPlist parses server settings", async () => {
|
|||||||
|
|
||||||
test("parseManagedPlist parses permission rules", async () => {
|
test("parseManagedPlist parses permission rules", async () => {
|
||||||
const config = ConfigParse.schema(
|
const config = ConfigParse.schema(
|
||||||
Config.Info,
|
Config.Info.zod,
|
||||||
ConfigParse.jsonc(
|
ConfigParse.jsonc(
|
||||||
await ConfigManaged.parseManagedPlist(
|
await ConfigManaged.parseManagedPlist(
|
||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
@@ -2299,7 +2299,7 @@ test("parseManagedPlist parses permission rules", async () => {
|
|||||||
|
|
||||||
test("parseManagedPlist parses enabled_providers", async () => {
|
test("parseManagedPlist parses enabled_providers", async () => {
|
||||||
const config = ConfigParse.schema(
|
const config = ConfigParse.schema(
|
||||||
Config.Info,
|
Config.Info.zod,
|
||||||
ConfigParse.jsonc(
|
ConfigParse.jsonc(
|
||||||
await ConfigManaged.parseManagedPlist(
|
await ConfigManaged.parseManagedPlist(
|
||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
@@ -2316,7 +2316,7 @@ test("parseManagedPlist parses enabled_providers", async () => {
|
|||||||
|
|
||||||
test("parseManagedPlist handles empty config", async () => {
|
test("parseManagedPlist handles empty config", async () => {
|
||||||
const config = ConfigParse.schema(
|
const config = ConfigParse.schema(
|
||||||
Config.Info,
|
Config.Info.zod,
|
||||||
ConfigParse.jsonc(
|
ConfigParse.jsonc(
|
||||||
await ConfigManaged.parseManagedPlist(JSON.stringify({ $schema: "https://opencode.ai/config.json" })),
|
await ConfigManaged.parseManagedPlist(JSON.stringify({ $schema: "https://opencode.ai/config.json" })),
|
||||||
"test:mobileconfig",
|
"test:mobileconfig",
|
||||||
|
|||||||
@@ -168,7 +168,7 @@ function layer(result: "continue" | "compact") {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function cfg(compaction?: Config.Info["compaction"]) {
|
function cfg(compaction?: Config.Info["compaction"]) {
|
||||||
const base = Config.Info.parse({})
|
const base = Config.Info.zod.parse({})
|
||||||
return Layer.mock(Config.Service)({
|
return Layer.mock(Config.Service)({
|
||||||
get: () => Effect.succeed({ ...base, compaction }),
|
get: () => Effect.succeed({ ...base, compaction }),
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user