refactor(opencode): fetch remote config with http client (#28661)
This commit is contained in:
@@ -19,6 +19,7 @@ import type { ConsoleState } from "./console-state"
|
||||
import { AppFileSystem } from "@opencode-ai/core/filesystem"
|
||||
import { InstanceState } from "@/effect/instance-state"
|
||||
import { Context, Duration, Effect, Exit, Fiber, Layer, Option, Schema } from "effect"
|
||||
import { FetchHttpClient, HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
|
||||
import { EffectFlock } from "@opencode-ai/core/util/effect-flock"
|
||||
import { containsPath, type InstanceContext } from "../project/instance-context"
|
||||
import { NonNegativeInt, PositiveInt, type DeepMutable } from "@opencode-ai/core/schema"
|
||||
@@ -41,6 +42,7 @@ import { ConfigServer } from "./server"
|
||||
import { ConfigSkills } from "./skills"
|
||||
import { ConfigVariable } from "./variable"
|
||||
import { Npm } from "@opencode-ai/core/npm"
|
||||
import { withTransientReadRetry } from "@/util/effect-http-client"
|
||||
|
||||
const log = Log.create({ service: "config" })
|
||||
|
||||
@@ -70,14 +72,20 @@ function normalizeLoadedConfig(data: unknown, source: string) {
|
||||
return copy
|
||||
}
|
||||
|
||||
async function substituteWellKnownRemoteConfig(input: { value: unknown; dir: string; source: string }) {
|
||||
if (!isRecord(input.value) || typeof input.value.url !== "string") return
|
||||
async function substituteWellKnownRemoteConfig(input: {
|
||||
value: unknown
|
||||
dir: string
|
||||
source: string
|
||||
env: Record<string, string>
|
||||
}) {
|
||||
if (!isRecord(input.value) || typeof input.value.url !== "string") return undefined
|
||||
|
||||
const url = await ConfigVariable.substitute({
|
||||
text: input.value.url,
|
||||
type: "virtual",
|
||||
dir: input.dir,
|
||||
source: input.source,
|
||||
env: input.env,
|
||||
})
|
||||
const headers = isRecord(input.value.headers)
|
||||
? Object.fromEntries(
|
||||
@@ -91,6 +99,7 @@ async function substituteWellKnownRemoteConfig(input: { value: unknown; dir: str
|
||||
type: "virtual",
|
||||
dir: input.dir,
|
||||
source: input.source,
|
||||
env: input.env,
|
||||
}),
|
||||
]),
|
||||
),
|
||||
@@ -100,6 +109,11 @@ async function substituteWellKnownRemoteConfig(input: { value: unknown; dir: str
|
||||
return { url, headers }
|
||||
}
|
||||
|
||||
const WellKnownConfig = Schema.Struct({
|
||||
config: Schema.optional(Schema.Json),
|
||||
remote_config: Schema.optional(Schema.Json),
|
||||
})
|
||||
|
||||
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++) {
|
||||
@@ -303,7 +317,7 @@ export type Info = DeepMutable<Schema.Schema.Type<typeof Info>> & {
|
||||
type State = {
|
||||
config: Info
|
||||
directories: string[]
|
||||
deps: Fiber.Fiber<void, never>[]
|
||||
deps: Fiber.Fiber<void>[]
|
||||
consoleState: ConsoleState
|
||||
}
|
||||
|
||||
@@ -372,17 +386,38 @@ export const layer = Layer.effect(
|
||||
const accountSvc = yield* Account.Service
|
||||
const env = yield* Env.Service
|
||||
const npmSvc = yield* Npm.Service
|
||||
const http = yield* HttpClient.HttpClient
|
||||
|
||||
const readConfigFile = (filepath: string) => fs.readFileStringSafe(filepath).pipe(Effect.orDie)
|
||||
|
||||
const fetchRemoteJson = Effect.fnUntraced(function* <S extends Schema.Top>(
|
||||
url: string,
|
||||
headers: Record<string, string> | undefined,
|
||||
schema: S,
|
||||
) {
|
||||
const response = yield* HttpClient.filterStatusOk(withTransientReadRetry(http))
|
||||
.execute(
|
||||
HttpClientRequest.get(url).pipe(HttpClientRequest.acceptJson, HttpClientRequest.setHeaders(headers ?? {})),
|
||||
)
|
||||
.pipe(
|
||||
Effect.catch((error) => Effect.die(new Error(`failed to fetch remote config from ${url}: ${String(error)}`))),
|
||||
)
|
||||
return yield* HttpClientResponse.schemaBodyJson(schema)(response).pipe(
|
||||
Effect.catch((error) => Effect.die(new Error(`failed to decode remote config from ${url}: ${String(error)}`))),
|
||||
)
|
||||
})
|
||||
|
||||
const loadConfig = Effect.fnUntraced(function* (
|
||||
text: string,
|
||||
options: { path: string } | { dir: string; source: string },
|
||||
env?: Record<string, string>,
|
||||
) {
|
||||
const source = "path" in options ? options.path : options.source
|
||||
const expanded = yield* Effect.promise(() =>
|
||||
ConfigVariable.substitute(
|
||||
"path" in options ? { text, type: "path", path: options.path } : { text, type: "virtual", ...options },
|
||||
"path" in options
|
||||
? { text, type: "path", path: options.path, env }
|
||||
: { text, type: "virtual", ...options, env },
|
||||
),
|
||||
)
|
||||
const parsed = ConfigParse.jsonc(expanded, source)
|
||||
@@ -398,14 +433,14 @@ export const layer = Layer.effect(
|
||||
return data
|
||||
})
|
||||
|
||||
const loadFile = Effect.fnUntraced(function* (filepath: string) {
|
||||
const loadFile = Effect.fnUntraced(function* (filepath: string, env?: Record<string, string>) {
|
||||
log.info("loading", { path: filepath })
|
||||
const text = yield* readConfigFile(filepath)
|
||||
if (!text) return {} as Info
|
||||
return yield* loadConfig(text, { path: filepath })
|
||||
return yield* loadConfig(text, { path: filepath }, env)
|
||||
})
|
||||
|
||||
const loadGlobal = Effect.fnUntraced(function* () {
|
||||
const loadGlobal = Effect.fnUntraced(function* (env?: Record<string, string>) {
|
||||
let result: Info = {}
|
||||
// Seed the default global config with the schema for editor completion, but avoid writing when the user
|
||||
// explicitly routes config through env-provided paths or content.
|
||||
@@ -417,9 +452,9 @@ export const layer = Layer.effect(
|
||||
.pipe(Effect.catch(() => Effect.void))
|
||||
}
|
||||
}
|
||||
result = mergeConfig(result, yield* loadFile(path.join(Global.Path.config, "config.json")))
|
||||
result = mergeConfig(result, yield* loadFile(path.join(Global.Path.config, "opencode.json")))
|
||||
result = mergeConfig(result, yield* loadFile(path.join(Global.Path.config, "opencode.jsonc")))
|
||||
result = mergeConfig(result, yield* loadFile(path.join(Global.Path.config, "config.json"), env))
|
||||
result = mergeConfig(result, yield* loadFile(path.join(Global.Path.config, "opencode.json"), env))
|
||||
result = mergeConfig(result, yield* loadFile(path.join(Global.Path.config, "opencode.jsonc"), env))
|
||||
|
||||
const legacy = path.join(Global.Path.config, "config")
|
||||
if (existsSync(legacy)) {
|
||||
@@ -477,6 +512,7 @@ export const layer = Layer.effect(
|
||||
const auth = yield* authSvc.all().pipe(Effect.orDie)
|
||||
|
||||
let result: Info = {}
|
||||
const authEnv: Record<string, string> = {}
|
||||
const consoleManagedProviders = new Set<string>()
|
||||
let activeOrgName: string | undefined
|
||||
|
||||
@@ -516,56 +552,56 @@ export const layer = Layer.effect(
|
||||
for (const [key, value] of Object.entries(auth)) {
|
||||
if (value.type === "wellknown") {
|
||||
const url = key.replace(/\/+$/, "")
|
||||
process.env[value.key] = value.token
|
||||
log.debug("fetching remote config", { url: `${url}/.well-known/opencode` })
|
||||
const response = yield* Effect.promise(() => fetch(`${url}/.well-known/opencode`))
|
||||
if (!response.ok) {
|
||||
throw new Error(`failed to fetch remote config from ${url}: ${response.status}`)
|
||||
}
|
||||
const wellknown = (yield* Effect.promise(() => response.json())) as {
|
||||
config?: Record<string, unknown>
|
||||
remote_config?: unknown
|
||||
}
|
||||
authEnv[value.key] = value.token
|
||||
const wellknownURL = `${url}/.well-known/opencode`
|
||||
log.debug("fetching remote config", { url: wellknownURL })
|
||||
const wellknown = yield* fetchRemoteJson(wellknownURL, undefined, WellKnownConfig)
|
||||
const remote = yield* Effect.promise(() =>
|
||||
substituteWellKnownRemoteConfig({
|
||||
value: wellknown.remote_config,
|
||||
dir: url,
|
||||
source: `${url}/.well-known/opencode`,
|
||||
source: wellknownURL,
|
||||
env: authEnv,
|
||||
}),
|
||||
)
|
||||
const fetchedConfig = remote
|
||||
? ((yield* Effect.promise(async () => {
|
||||
? yield* Effect.gen(function* () {
|
||||
log.debug("fetching remote config", { url: remote.url })
|
||||
const response = await fetch(remote.url, { headers: remote.headers })
|
||||
if (!response.ok)
|
||||
throw new Error(`failed to fetch remote config from ${remote.url}: ${response.status}`)
|
||||
const data = await response.json()
|
||||
return isRecord(data) && isRecord(data.config) ? data.config : data
|
||||
})) as Record<string, unknown>)
|
||||
const data = yield* fetchRemoteJson(remote.url, remote.headers, Schema.Json)
|
||||
if (isRecord(data) && isRecord(data.config)) return data.config
|
||||
if (isRecord(data)) return data
|
||||
return yield* Effect.die(
|
||||
new Error(`failed to decode remote config from ${remote.url}: expected object`),
|
||||
)
|
||||
})
|
||||
: {}
|
||||
const remoteConfig = mergeConfig(wellknown.config ?? {}, fetchedConfig as Info)
|
||||
const remoteConfig = mergeConfig(isRecord(wellknown.config) ? wellknown.config : {}, fetchedConfig)
|
||||
if (!remoteConfig.$schema) remoteConfig.$schema = "https://opencode.ai/config.json"
|
||||
const source = `${url}/.well-known/opencode`
|
||||
const next = yield* loadConfig(JSON.stringify(remoteConfig), {
|
||||
dir: path.dirname(source),
|
||||
source,
|
||||
})
|
||||
const source = wellknownURL
|
||||
const next = yield* loadConfig(
|
||||
JSON.stringify(remoteConfig),
|
||||
{
|
||||
dir: path.dirname(source),
|
||||
source,
|
||||
},
|
||||
authEnv,
|
||||
)
|
||||
yield* merge(source, next, "global")
|
||||
log.debug("loaded remote config from well-known", { url })
|
||||
}
|
||||
}
|
||||
|
||||
const global = yield* getGlobal()
|
||||
const global = Object.keys(authEnv).length ? yield* loadGlobal(authEnv) : yield* getGlobal()
|
||||
yield* merge(Global.Path.config, global, "global")
|
||||
|
||||
if (Flag.OPENCODE_CONFIG) {
|
||||
yield* merge(Flag.OPENCODE_CONFIG, yield* loadFile(Flag.OPENCODE_CONFIG))
|
||||
yield* merge(Flag.OPENCODE_CONFIG, yield* loadFile(Flag.OPENCODE_CONFIG, authEnv))
|
||||
log.debug("loaded custom config", { path: Flag.OPENCODE_CONFIG })
|
||||
}
|
||||
|
||||
if (!Flag.OPENCODE_DISABLE_PROJECT_CONFIG) {
|
||||
for (const file of yield* ConfigPaths.files("opencode", ctx.directory, ctx.worktree).pipe(Effect.orDie)) {
|
||||
yield* merge(file, yield* loadFile(file), "local")
|
||||
yield* merge(file, yield* loadFile(file, authEnv), "local")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -579,14 +615,14 @@ export const layer = Layer.effect(
|
||||
log.debug("loading config from OPENCODE_CONFIG_DIR", { path: Flag.OPENCODE_CONFIG_DIR })
|
||||
}
|
||||
|
||||
const deps: Fiber.Fiber<void, never>[] = []
|
||||
const deps: Fiber.Fiber<void>[] = []
|
||||
|
||||
for (const dir of directories) {
|
||||
if (dir.endsWith(".opencode") || dir === Flag.OPENCODE_CONFIG_DIR) {
|
||||
for (const file of ["opencode.json", "opencode.jsonc"]) {
|
||||
const source = path.join(dir, file)
|
||||
log.debug(`loading config from ${source}`)
|
||||
yield* merge(source, yield* loadFile(source))
|
||||
yield* merge(source, yield* loadFile(source, authEnv))
|
||||
result.agent ??= {}
|
||||
result.mode ??= {}
|
||||
result.plugin ??= []
|
||||
@@ -835,6 +871,7 @@ export const defaultLayer = layer.pipe(
|
||||
Layer.provide(Auth.defaultLayer),
|
||||
Layer.provide(Account.defaultLayer),
|
||||
Layer.provide(Npm.defaultLayer),
|
||||
Layer.provide(FetchHttpClient.layer),
|
||||
)
|
||||
|
||||
export * as Config from "./config"
|
||||
|
||||
@@ -19,6 +19,7 @@ type ParseSource =
|
||||
type SubstituteInput = ParseSource & {
|
||||
text: string
|
||||
missing?: "error" | "empty"
|
||||
env?: Record<string, string>
|
||||
}
|
||||
|
||||
function source(input: ParseSource) {
|
||||
@@ -33,7 +34,7 @@ function dir(input: ParseSource) {
|
||||
export async function substitute(input: SubstituteInput) {
|
||||
const missing = input.missing ?? "error"
|
||||
let text = input.text.replace(/\{env:([^}]+)\}/g, (_, varName) => {
|
||||
return process.env[varName] || ""
|
||||
return (input.env?.[varName] ?? process.env[varName]) || ""
|
||||
})
|
||||
|
||||
const fileMatches = Array.from(text.matchAll(/\{file:[^}]+\}/g))
|
||||
@@ -46,7 +47,7 @@ export async function substitute(input: SubstituteInput) {
|
||||
|
||||
for (const match of fileMatches) {
|
||||
const token = match[0]
|
||||
const index = match.index!
|
||||
const index = match.index
|
||||
out += text.slice(cursor, index)
|
||||
|
||||
const lineStart = text.lastIndexOf("\n", index - 1) + 1
|
||||
|
||||
Reference in New Issue
Block a user