refactor(opencode): fetch remote config with http client (#28661)
This commit is contained in:
parent
562d299a41
commit
b99787e95b
6 changed files with 360 additions and 251 deletions
|
|
@ -19,6 +19,7 @@ import type { ConsoleState } from "./console-state"
|
||||||
import { AppFileSystem } from "@opencode-ai/core/filesystem"
|
import { AppFileSystem } from "@opencode-ai/core/filesystem"
|
||||||
import { InstanceState } from "@/effect/instance-state"
|
import { InstanceState } from "@/effect/instance-state"
|
||||||
import { Context, Duration, Effect, Exit, Fiber, Layer, Option, Schema } from "effect"
|
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 { EffectFlock } from "@opencode-ai/core/util/effect-flock"
|
||||||
import { containsPath, type InstanceContext } from "../project/instance-context"
|
import { containsPath, type InstanceContext } from "../project/instance-context"
|
||||||
import { NonNegativeInt, PositiveInt, type DeepMutable } from "@opencode-ai/core/schema"
|
import { NonNegativeInt, PositiveInt, type DeepMutable } from "@opencode-ai/core/schema"
|
||||||
|
|
@ -41,6 +42,7 @@ import { ConfigServer } from "./server"
|
||||||
import { ConfigSkills } from "./skills"
|
import { ConfigSkills } from "./skills"
|
||||||
import { ConfigVariable } from "./variable"
|
import { ConfigVariable } from "./variable"
|
||||||
import { Npm } from "@opencode-ai/core/npm"
|
import { Npm } from "@opencode-ai/core/npm"
|
||||||
|
import { withTransientReadRetry } from "@/util/effect-http-client"
|
||||||
|
|
||||||
const log = Log.create({ service: "config" })
|
const log = Log.create({ service: "config" })
|
||||||
|
|
||||||
|
|
@ -70,14 +72,20 @@ function normalizeLoadedConfig(data: unknown, source: string) {
|
||||||
return copy
|
return copy
|
||||||
}
|
}
|
||||||
|
|
||||||
async function substituteWellKnownRemoteConfig(input: { value: unknown; dir: string; source: string }) {
|
async function substituteWellKnownRemoteConfig(input: {
|
||||||
if (!isRecord(input.value) || typeof input.value.url !== "string") return
|
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({
|
const url = await ConfigVariable.substitute({
|
||||||
text: input.value.url,
|
text: input.value.url,
|
||||||
type: "virtual",
|
type: "virtual",
|
||||||
dir: input.dir,
|
dir: input.dir,
|
||||||
source: input.source,
|
source: input.source,
|
||||||
|
env: input.env,
|
||||||
})
|
})
|
||||||
const headers = isRecord(input.value.headers)
|
const headers = isRecord(input.value.headers)
|
||||||
? Object.fromEntries(
|
? Object.fromEntries(
|
||||||
|
|
@ -91,6 +99,7 @@ async function substituteWellKnownRemoteConfig(input: { value: unknown; dir: str
|
||||||
type: "virtual",
|
type: "virtual",
|
||||||
dir: input.dir,
|
dir: input.dir,
|
||||||
source: input.source,
|
source: input.source,
|
||||||
|
env: input.env,
|
||||||
}),
|
}),
|
||||||
]),
|
]),
|
||||||
),
|
),
|
||||||
|
|
@ -100,6 +109,11 @@ async function substituteWellKnownRemoteConfig(input: { value: unknown; dir: str
|
||||||
return { url, headers }
|
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) {
|
async function resolveLoadedPlugins<T extends { plugin?: ConfigPlugin.Spec[] }>(config: T, filepath: string) {
|
||||||
if (!config.plugin) return config
|
if (!config.plugin) return config
|
||||||
for (let i = 0; i < config.plugin.length; i++) {
|
for (let i = 0; i < config.plugin.length; i++) {
|
||||||
|
|
@ -303,7 +317,7 @@ export type Info = DeepMutable<Schema.Schema.Type<typeof Info>> & {
|
||||||
type State = {
|
type State = {
|
||||||
config: Info
|
config: Info
|
||||||
directories: string[]
|
directories: string[]
|
||||||
deps: Fiber.Fiber<void, never>[]
|
deps: Fiber.Fiber<void>[]
|
||||||
consoleState: ConsoleState
|
consoleState: ConsoleState
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -372,17 +386,38 @@ export const layer = Layer.effect(
|
||||||
const accountSvc = yield* Account.Service
|
const accountSvc = yield* Account.Service
|
||||||
const env = yield* Env.Service
|
const env = yield* Env.Service
|
||||||
const npmSvc = yield* Npm.Service
|
const npmSvc = yield* Npm.Service
|
||||||
|
const http = yield* HttpClient.HttpClient
|
||||||
|
|
||||||
const readConfigFile = (filepath: string) => fs.readFileStringSafe(filepath).pipe(Effect.orDie)
|
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* (
|
const loadConfig = Effect.fnUntraced(function* (
|
||||||
text: string,
|
text: string,
|
||||||
options: { path: string } | { dir: string; source: string },
|
options: { path: string } | { dir: string; source: string },
|
||||||
|
env?: Record<string, string>,
|
||||||
) {
|
) {
|
||||||
const source = "path" in options ? options.path : options.source
|
const source = "path" in options ? options.path : options.source
|
||||||
const expanded = yield* Effect.promise(() =>
|
const expanded = yield* Effect.promise(() =>
|
||||||
ConfigVariable.substitute(
|
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)
|
const parsed = ConfigParse.jsonc(expanded, source)
|
||||||
|
|
@ -398,14 +433,14 @@ export const layer = Layer.effect(
|
||||||
return data
|
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 })
|
log.info("loading", { path: filepath })
|
||||||
const text = yield* readConfigFile(filepath)
|
const text = yield* readConfigFile(filepath)
|
||||||
if (!text) return {} as Info
|
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 = {}
|
let result: Info = {}
|
||||||
// Seed the default global config with the schema for editor completion, but avoid writing when the user
|
// 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.
|
// explicitly routes config through env-provided paths or content.
|
||||||
|
|
@ -417,9 +452,9 @@ export const layer = Layer.effect(
|
||||||
.pipe(Effect.catch(() => Effect.void))
|
.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, "config.json"), env))
|
||||||
result = mergeConfig(result, yield* loadFile(path.join(Global.Path.config, "opencode.json")))
|
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")))
|
result = mergeConfig(result, yield* loadFile(path.join(Global.Path.config, "opencode.jsonc"), env))
|
||||||
|
|
||||||
const legacy = path.join(Global.Path.config, "config")
|
const legacy = path.join(Global.Path.config, "config")
|
||||||
if (existsSync(legacy)) {
|
if (existsSync(legacy)) {
|
||||||
|
|
@ -477,6 +512,7 @@ export const layer = Layer.effect(
|
||||||
const auth = yield* authSvc.all().pipe(Effect.orDie)
|
const auth = yield* authSvc.all().pipe(Effect.orDie)
|
||||||
|
|
||||||
let result: Info = {}
|
let result: Info = {}
|
||||||
|
const authEnv: Record<string, string> = {}
|
||||||
const consoleManagedProviders = new Set<string>()
|
const consoleManagedProviders = new Set<string>()
|
||||||
let activeOrgName: string | undefined
|
let activeOrgName: string | undefined
|
||||||
|
|
||||||
|
|
@ -516,56 +552,56 @@ export const layer = Layer.effect(
|
||||||
for (const [key, value] of Object.entries(auth)) {
|
for (const [key, value] of Object.entries(auth)) {
|
||||||
if (value.type === "wellknown") {
|
if (value.type === "wellknown") {
|
||||||
const url = key.replace(/\/+$/, "")
|
const url = key.replace(/\/+$/, "")
|
||||||
process.env[value.key] = value.token
|
authEnv[value.key] = value.token
|
||||||
log.debug("fetching remote config", { url: `${url}/.well-known/opencode` })
|
const wellknownURL = `${url}/.well-known/opencode`
|
||||||
const response = yield* Effect.promise(() => fetch(`${url}/.well-known/opencode`))
|
log.debug("fetching remote config", { url: wellknownURL })
|
||||||
if (!response.ok) {
|
const wellknown = yield* fetchRemoteJson(wellknownURL, undefined, WellKnownConfig)
|
||||||
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
|
|
||||||
}
|
|
||||||
const remote = yield* Effect.promise(() =>
|
const remote = yield* Effect.promise(() =>
|
||||||
substituteWellKnownRemoteConfig({
|
substituteWellKnownRemoteConfig({
|
||||||
value: wellknown.remote_config,
|
value: wellknown.remote_config,
|
||||||
dir: url,
|
dir: url,
|
||||||
source: `${url}/.well-known/opencode`,
|
source: wellknownURL,
|
||||||
|
env: authEnv,
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
const fetchedConfig = remote
|
const fetchedConfig = remote
|
||||||
? ((yield* Effect.promise(async () => {
|
? yield* Effect.gen(function* () {
|
||||||
log.debug("fetching remote config", { url: remote.url })
|
log.debug("fetching remote config", { url: remote.url })
|
||||||
const response = await fetch(remote.url, { headers: remote.headers })
|
const data = yield* fetchRemoteJson(remote.url, remote.headers, Schema.Json)
|
||||||
if (!response.ok)
|
if (isRecord(data) && isRecord(data.config)) return data.config
|
||||||
throw new Error(`failed to fetch remote config from ${remote.url}: ${response.status}`)
|
if (isRecord(data)) return data
|
||||||
const data = await response.json()
|
return yield* Effect.die(
|
||||||
return isRecord(data) && isRecord(data.config) ? data.config : data
|
new Error(`failed to decode remote config from ${remote.url}: expected object`),
|
||||||
})) as Record<string, unknown>)
|
)
|
||||||
|
})
|
||||||
: {}
|
: {}
|
||||||
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"
|
if (!remoteConfig.$schema) remoteConfig.$schema = "https://opencode.ai/config.json"
|
||||||
const source = `${url}/.well-known/opencode`
|
const source = wellknownURL
|
||||||
const next = yield* loadConfig(JSON.stringify(remoteConfig), {
|
const next = yield* loadConfig(
|
||||||
dir: path.dirname(source),
|
JSON.stringify(remoteConfig),
|
||||||
source,
|
{
|
||||||
})
|
dir: path.dirname(source),
|
||||||
|
source,
|
||||||
|
},
|
||||||
|
authEnv,
|
||||||
|
)
|
||||||
yield* merge(source, next, "global")
|
yield* merge(source, next, "global")
|
||||||
log.debug("loaded remote config from well-known", { url })
|
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")
|
yield* merge(Global.Path.config, global, "global")
|
||||||
|
|
||||||
if (Flag.OPENCODE_CONFIG) {
|
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 })
|
log.debug("loaded custom config", { path: Flag.OPENCODE_CONFIG })
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!Flag.OPENCODE_DISABLE_PROJECT_CONFIG) {
|
if (!Flag.OPENCODE_DISABLE_PROJECT_CONFIG) {
|
||||||
for (const file of yield* ConfigPaths.files("opencode", ctx.directory, ctx.worktree).pipe(Effect.orDie)) {
|
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 })
|
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) {
|
for (const dir of directories) {
|
||||||
if (dir.endsWith(".opencode") || dir === Flag.OPENCODE_CONFIG_DIR) {
|
if (dir.endsWith(".opencode") || dir === Flag.OPENCODE_CONFIG_DIR) {
|
||||||
for (const file of ["opencode.json", "opencode.jsonc"]) {
|
for (const file of ["opencode.json", "opencode.jsonc"]) {
|
||||||
const source = path.join(dir, file)
|
const source = path.join(dir, file)
|
||||||
log.debug(`loading config from ${source}`)
|
log.debug(`loading config from ${source}`)
|
||||||
yield* merge(source, yield* loadFile(source))
|
yield* merge(source, yield* loadFile(source, authEnv))
|
||||||
result.agent ??= {}
|
result.agent ??= {}
|
||||||
result.mode ??= {}
|
result.mode ??= {}
|
||||||
result.plugin ??= []
|
result.plugin ??= []
|
||||||
|
|
@ -835,6 +871,7 @@ export const defaultLayer = layer.pipe(
|
||||||
Layer.provide(Auth.defaultLayer),
|
Layer.provide(Auth.defaultLayer),
|
||||||
Layer.provide(Account.defaultLayer),
|
Layer.provide(Account.defaultLayer),
|
||||||
Layer.provide(Npm.defaultLayer),
|
Layer.provide(Npm.defaultLayer),
|
||||||
|
Layer.provide(FetchHttpClient.layer),
|
||||||
)
|
)
|
||||||
|
|
||||||
export * as Config from "./config"
|
export * as Config from "./config"
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,7 @@ type ParseSource =
|
||||||
type SubstituteInput = ParseSource & {
|
type SubstituteInput = ParseSource & {
|
||||||
text: string
|
text: string
|
||||||
missing?: "error" | "empty"
|
missing?: "error" | "empty"
|
||||||
|
env?: Record<string, string>
|
||||||
}
|
}
|
||||||
|
|
||||||
function source(input: ParseSource) {
|
function source(input: ParseSource) {
|
||||||
|
|
@ -33,7 +34,7 @@ function dir(input: ParseSource) {
|
||||||
export async function substitute(input: SubstituteInput) {
|
export async function substitute(input: SubstituteInput) {
|
||||||
const missing = input.missing ?? "error"
|
const missing = input.missing ?? "error"
|
||||||
let text = input.text.replace(/\{env:([^}]+)\}/g, (_, varName) => {
|
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))
|
const fileMatches = Array.from(text.matchAll(/\{file:[^}]+\}/g))
|
||||||
|
|
@ -46,7 +47,7 @@ export async function substitute(input: SubstituteInput) {
|
||||||
|
|
||||||
for (const match of fileMatches) {
|
for (const match of fileMatches) {
|
||||||
const token = match[0]
|
const token = match[0]
|
||||||
const index = match.index!
|
const index = match.index
|
||||||
out += text.slice(cursor, index)
|
out += text.slice(cursor, index)
|
||||||
|
|
||||||
const lineStart = text.lastIndexOf("\n", index - 1) + 1
|
const lineStart = text.lastIndexOf("\n", index - 1) + 1
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import { expect } from "bun:test"
|
import { expect } from "bun:test"
|
||||||
import { AppFileSystem } from "@opencode-ai/core/filesystem"
|
import { AppFileSystem } from "@opencode-ai/core/filesystem"
|
||||||
import { Effect, Layer } from "effect"
|
import { Effect, Layer } from "effect"
|
||||||
|
import { FetchHttpClient } from "effect/unstable/http"
|
||||||
import path from "path"
|
import path from "path"
|
||||||
import { pathToFileURL } from "url"
|
import { pathToFileURL } from "url"
|
||||||
import { Agent } from "../../src/agent/agent"
|
import { Agent } from "../../src/agent/agent"
|
||||||
|
|
@ -29,6 +30,7 @@ const configLayer = Config.layer.pipe(
|
||||||
Layer.provide(AuthTest.empty),
|
Layer.provide(AuthTest.empty),
|
||||||
Layer.provide(AccountTest.empty),
|
Layer.provide(AccountTest.empty),
|
||||||
Layer.provide(NpmTest.noop),
|
Layer.provide(NpmTest.noop),
|
||||||
|
Layer.provide(FetchHttpClient.layer),
|
||||||
)
|
)
|
||||||
const pluginLayer = Plugin.layer.pipe(
|
const pluginLayer = Plugin.layer.pipe(
|
||||||
Layer.provide(Bus.layer),
|
Layer.provide(Bus.layer),
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import { test, expect, describe, mock, afterEach, beforeEach } from "bun:test"
|
import { test, expect, describe, afterEach, beforeEach } from "bun:test"
|
||||||
import { Effect, Exit, Layer, Option } from "effect"
|
import { Effect, Exit, Layer, Option } from "effect"
|
||||||
|
import { FetchHttpClient, HttpClient, HttpClientResponse } from "effect/unstable/http"
|
||||||
import { NodeFileSystem, NodePath } from "@effect/platform-node"
|
import { NodeFileSystem, NodePath } from "@effect/platform-node"
|
||||||
import { Config } from "@/config/config"
|
import { Config } from "@/config/config"
|
||||||
import { ConfigManaged } from "@/config/managed"
|
import { ConfigManaged } from "@/config/managed"
|
||||||
|
|
@ -36,35 +37,72 @@ import { Global } from "@opencode-ai/core/global"
|
||||||
import { ProjectID } from "../../src/project/schema"
|
import { ProjectID } from "../../src/project/schema"
|
||||||
import { Filesystem } from "@/util/filesystem"
|
import { Filesystem } from "@/util/filesystem"
|
||||||
import { ConfigPlugin } from "@/config/plugin"
|
import { ConfigPlugin } from "@/config/plugin"
|
||||||
import { Npm } from "@opencode-ai/core/npm"
|
import { AccountTest } from "../fake/account"
|
||||||
|
import { AuthTest } from "../fake/auth"
|
||||||
const emptyAccount = Layer.mock(Account.Service)({
|
import { NpmTest } from "../fake/npm"
|
||||||
active: () => Effect.succeed(Option.none()),
|
|
||||||
activeOrg: () => Effect.succeed(Option.none()),
|
|
||||||
})
|
|
||||||
|
|
||||||
const emptyAuth = Layer.mock(Auth.Service)({
|
|
||||||
all: () => Effect.succeed({}),
|
|
||||||
})
|
|
||||||
|
|
||||||
const testFlock = EffectFlock.defaultLayer
|
const testFlock = EffectFlock.defaultLayer
|
||||||
|
|
||||||
const noopNpm = Layer.mock(Npm.Service)({
|
const unexpectedHttp = HttpClient.make((request) =>
|
||||||
install: () => Effect.void,
|
Effect.die(`unexpected http request: ${request.method} ${request.url}`),
|
||||||
add: () => Effect.die("not implemented"),
|
|
||||||
which: () => Effect.succeed(Option.none()),
|
|
||||||
})
|
|
||||||
|
|
||||||
const layer = Config.layer.pipe(
|
|
||||||
Layer.provide(testFlock),
|
|
||||||
Layer.provide(AppFileSystem.defaultLayer),
|
|
||||||
Layer.provide(Env.defaultLayer),
|
|
||||||
Layer.provide(emptyAuth),
|
|
||||||
Layer.provide(emptyAccount),
|
|
||||||
Layer.provideMerge(infra),
|
|
||||||
Layer.provide(noopNpm),
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const json = (request: Parameters<typeof HttpClientResponse.fromWeb>[0], body: unknown, status = 200) =>
|
||||||
|
HttpClientResponse.fromWeb(
|
||||||
|
request,
|
||||||
|
new Response(JSON.stringify(body), {
|
||||||
|
status,
|
||||||
|
headers: { "content-type": "application/json" },
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
const wellKnownAuth = (url: string) =>
|
||||||
|
Layer.mock(Auth.Service)({
|
||||||
|
all: () =>
|
||||||
|
Effect.succeed({
|
||||||
|
[url]: new Auth.WellKnown({ type: "wellknown", key: "TEST_TOKEN", token: "test-token" }),
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
|
||||||
|
function remoteConfigClient(input: {
|
||||||
|
wellKnown: unknown
|
||||||
|
remote?: unknown
|
||||||
|
seen: { wellKnown?: string; remote?: string; authorization?: string }
|
||||||
|
}) {
|
||||||
|
return HttpClient.make((request) => {
|
||||||
|
if (request.url.includes(".well-known/opencode")) {
|
||||||
|
input.seen.wellKnown = request.url
|
||||||
|
return Effect.succeed(json(request, input.wellKnown))
|
||||||
|
}
|
||||||
|
if (input.remote !== undefined && request.url.includes("config.example.com")) {
|
||||||
|
input.seen.remote = request.url
|
||||||
|
input.seen.authorization = request.headers.authorization
|
||||||
|
return Effect.succeed(json(request, input.remote))
|
||||||
|
}
|
||||||
|
return Effect.succeed(json(request, {}, 404))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const configLayer = (
|
||||||
|
options: {
|
||||||
|
auth?: Layer.Layer<Auth.Service>
|
||||||
|
account?: Layer.Layer<Account.Service>
|
||||||
|
client?: HttpClient.HttpClient
|
||||||
|
} = {},
|
||||||
|
) =>
|
||||||
|
Config.layer.pipe(
|
||||||
|
Layer.provide(testFlock),
|
||||||
|
Layer.provide(AppFileSystem.defaultLayer),
|
||||||
|
Layer.provide(Env.defaultLayer),
|
||||||
|
Layer.provide(options.auth ?? AuthTest.empty),
|
||||||
|
Layer.provide(options.account ?? AccountTest.empty),
|
||||||
|
Layer.provideMerge(infra),
|
||||||
|
Layer.provide(NpmTest.noop),
|
||||||
|
Layer.provide(Layer.succeed(HttpClient.HttpClient, options.client ?? unexpectedHttp)),
|
||||||
|
)
|
||||||
|
|
||||||
|
const layer = configLayer()
|
||||||
|
|
||||||
const it = testEffect(layer)
|
const it = testEffect(layer)
|
||||||
|
|
||||||
const provideCurrentInstance = <A, E, R>(effect: Effect.Effect<A, E, R>, ctx: InstanceContext) =>
|
const provideCurrentInstance = <A, E, R>(effect: Effect.Effect<A, E, R>, ctx: InstanceContext) =>
|
||||||
|
|
@ -95,6 +133,7 @@ const listDirs = (ctx: InstanceContext) =>
|
||||||
)
|
)
|
||||||
// Get managed config directory from environment (set in preload.ts)
|
// Get managed config directory from environment (set in preload.ts)
|
||||||
const managedConfigDir = process.env.OPENCODE_TEST_MANAGED_CONFIG_DIR!
|
const managedConfigDir = process.env.OPENCODE_TEST_MANAGED_CONFIG_DIR!
|
||||||
|
const originalTestToken = process.env.TEST_TOKEN
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
await clear(true)
|
await clear(true)
|
||||||
|
|
@ -102,6 +141,8 @@ beforeEach(async () => {
|
||||||
|
|
||||||
afterEach(async () => {
|
afterEach(async () => {
|
||||||
await fs.rm(managedConfigDir, { force: true, recursive: true }).catch(() => {})
|
await fs.rm(managedConfigDir, { force: true, recursive: true }).catch(() => {})
|
||||||
|
if (originalTestToken === undefined) delete process.env.TEST_TOKEN
|
||||||
|
else process.env.TEST_TOKEN = originalTestToken
|
||||||
await clear(true)
|
await clear(true)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
@ -528,15 +569,7 @@ test("resolves env templates in account config with account token", async () =>
|
||||||
token: () => Effect.succeed(Option.some(AccessToken.make("st_test_token"))),
|
token: () => Effect.succeed(Option.some(AccessToken.make("st_test_token"))),
|
||||||
})
|
})
|
||||||
|
|
||||||
const layer = Config.layer.pipe(
|
const layer = configLayer({ account: fakeAccount })
|
||||||
Layer.provide(testFlock),
|
|
||||||
Layer.provide(AppFileSystem.defaultLayer),
|
|
||||||
Layer.provide(Env.defaultLayer),
|
|
||||||
Layer.provide(emptyAuth),
|
|
||||||
Layer.provide(fakeAccount),
|
|
||||||
Layer.provideMerge(infra),
|
|
||||||
Layer.provide(noopNpm),
|
|
||||||
)
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await provideTmpdirInstance(() =>
|
await provideTmpdirInstance(() =>
|
||||||
|
|
@ -900,15 +933,7 @@ test("installs dependencies in writable OPENCODE_CONFIG_DIR", async () => {
|
||||||
const prev = process.env.OPENCODE_CONFIG_DIR
|
const prev = process.env.OPENCODE_CONFIG_DIR
|
||||||
process.env.OPENCODE_CONFIG_DIR = tmp.extra
|
process.env.OPENCODE_CONFIG_DIR = tmp.extra
|
||||||
|
|
||||||
const testLayer = Config.layer.pipe(
|
const testLayer = configLayer()
|
||||||
Layer.provide(testFlock),
|
|
||||||
Layer.provide(AppFileSystem.defaultLayer),
|
|
||||||
Layer.provide(Env.defaultLayer),
|
|
||||||
Layer.provide(emptyAuth),
|
|
||||||
Layer.provide(emptyAccount),
|
|
||||||
Layer.provideMerge(infra),
|
|
||||||
Layer.provide(noopNpm),
|
|
||||||
)
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await withTestInstance({
|
await withTestInstance({
|
||||||
|
|
@ -1559,42 +1584,79 @@ it.instance("local .opencode config can override MCP from project config", () =>
|
||||||
)
|
)
|
||||||
|
|
||||||
test("project config overrides remote well-known config", async () => {
|
test("project config overrides remote well-known config", async () => {
|
||||||
const originalFetch = globalThis.fetch
|
const seen: { wellKnown?: string } = {}
|
||||||
let fetchedUrl: string | undefined
|
const client = remoteConfigClient({
|
||||||
globalThis.fetch = mock((url: string | URL | Request) => {
|
seen,
|
||||||
const urlStr = url instanceof Request ? url.url : url instanceof URL ? url.href : url
|
wellKnown: {
|
||||||
if (urlStr.includes(".well-known/opencode")) {
|
config: {
|
||||||
fetchedUrl = urlStr
|
mcp: { jira: { type: "remote", url: "https://jira.example.com/mcp", enabled: false } },
|
||||||
return Promise.resolve(
|
},
|
||||||
new Response(
|
},
|
||||||
JSON.stringify({
|
|
||||||
config: {
|
|
||||||
mcp: { jira: { type: "remote", url: "https://jira.example.com/mcp", enabled: false } },
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
{ status: 200 },
|
|
||||||
),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
return originalFetch(url)
|
|
||||||
}) as unknown as typeof fetch
|
|
||||||
|
|
||||||
const fakeAuth = Layer.mock(Auth.Service)({
|
|
||||||
all: () =>
|
|
||||||
Effect.succeed({
|
|
||||||
"https://example.com": new Auth.WellKnown({ type: "wellknown", key: "TEST_TOKEN", token: "test-token" }),
|
|
||||||
}),
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const layer = Config.layer.pipe(
|
await provideTmpdirInstance(
|
||||||
Layer.provide(testFlock),
|
() =>
|
||||||
Layer.provide(AppFileSystem.defaultLayer),
|
Config.Service.use((svc) =>
|
||||||
Layer.provide(Env.defaultLayer),
|
Effect.gen(function* () {
|
||||||
Layer.provide(fakeAuth),
|
const config = yield* svc.get()
|
||||||
Layer.provide(emptyAccount),
|
expect(seen.wellKnown).toBe("https://example.com/.well-known/opencode")
|
||||||
Layer.provideMerge(infra),
|
expect(config.mcp?.jira?.enabled).toBe(true)
|
||||||
Layer.provide(noopNpm),
|
}),
|
||||||
|
),
|
||||||
|
{
|
||||||
|
git: true,
|
||||||
|
config: { mcp: { jira: { type: "remote", url: "https://jira.example.com/mcp", enabled: true } } },
|
||||||
|
},
|
||||||
|
).pipe(
|
||||||
|
Effect.scoped,
|
||||||
|
Effect.provide(configLayer({ auth: wellKnownAuth("https://example.com"), client })),
|
||||||
|
Effect.runPromise,
|
||||||
)
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("wellknown URL with trailing slash is normalized", async () => {
|
||||||
|
const seen: { wellKnown?: string } = {}
|
||||||
|
const client = remoteConfigClient({
|
||||||
|
seen,
|
||||||
|
wellKnown: {
|
||||||
|
config: {
|
||||||
|
mcp: { slack: { type: "remote", url: "https://slack.example.com/mcp", enabled: true } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
await provideTmpdirInstance(
|
||||||
|
() =>
|
||||||
|
Config.Service.use((svc) =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
yield* svc.get()
|
||||||
|
expect(seen.wellKnown).toBe("https://example.com/.well-known/opencode")
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
{ git: true },
|
||||||
|
).pipe(
|
||||||
|
Effect.scoped,
|
||||||
|
Effect.provide(configLayer({ auth: wellKnownAuth("https://example.com/"), client })),
|
||||||
|
Effect.runPromise,
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("remote well-known config can use FetchHttpClient layer", async () => {
|
||||||
|
let fetchedUrl: string | undefined
|
||||||
|
const server = Bun.serve({
|
||||||
|
port: 0,
|
||||||
|
fetch: (request) => {
|
||||||
|
fetchedUrl = request.url
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({
|
||||||
|
config: {
|
||||||
|
mcp: { jira: { type: "remote", url: "https://jira.example.com/mcp", enabled: true } },
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
{ status: 200, headers: { "content-type": "application/json" } },
|
||||||
|
)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await provideTmpdirInstance(
|
await provideTmpdirInstance(
|
||||||
|
|
@ -1602,151 +1664,168 @@ test("project config overrides remote well-known config", async () => {
|
||||||
Config.Service.use((svc) =>
|
Config.Service.use((svc) =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const config = yield* svc.get()
|
const config = yield* svc.get()
|
||||||
expect(fetchedUrl).toBe("https://example.com/.well-known/opencode")
|
expect(fetchedUrl).toBe(`${server.url.origin}/.well-known/opencode`)
|
||||||
expect(config.mcp?.jira?.enabled).toBe(true)
|
expect(config.mcp?.jira?.enabled).toBe(true)
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
{
|
|
||||||
git: true,
|
|
||||||
config: { mcp: { jira: { type: "remote", url: "https://jira.example.com/mcp", enabled: true } } },
|
|
||||||
},
|
|
||||||
).pipe(Effect.scoped, Effect.provide(layer), Effect.runPromise)
|
|
||||||
} finally {
|
|
||||||
globalThis.fetch = originalFetch
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
test("wellknown URL with trailing slash is normalized", async () => {
|
|
||||||
const originalFetch = globalThis.fetch
|
|
||||||
let fetchedUrl: string | undefined
|
|
||||||
globalThis.fetch = mock((url: string | URL | Request) => {
|
|
||||||
const urlStr = url instanceof Request ? url.url : url instanceof URL ? url.href : url
|
|
||||||
if (urlStr.includes(".well-known/opencode")) {
|
|
||||||
fetchedUrl = urlStr
|
|
||||||
return Promise.resolve(
|
|
||||||
new Response(
|
|
||||||
JSON.stringify({
|
|
||||||
config: {
|
|
||||||
mcp: { slack: { type: "remote", url: "https://slack.example.com/mcp", enabled: true } },
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
{ status: 200 },
|
|
||||||
),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
return originalFetch(url)
|
|
||||||
}) as unknown as typeof fetch
|
|
||||||
|
|
||||||
const fakeAuth = Layer.mock(Auth.Service)({
|
|
||||||
all: () =>
|
|
||||||
Effect.succeed({
|
|
||||||
"https://example.com/": new Auth.WellKnown({ type: "wellknown", key: "TEST_TOKEN", token: "test-token" }),
|
|
||||||
}),
|
|
||||||
})
|
|
||||||
|
|
||||||
const layer = Config.layer.pipe(
|
|
||||||
Layer.provide(testFlock),
|
|
||||||
Layer.provide(AppFileSystem.defaultLayer),
|
|
||||||
Layer.provide(Env.defaultLayer),
|
|
||||||
Layer.provide(fakeAuth),
|
|
||||||
Layer.provide(emptyAccount),
|
|
||||||
Layer.provideMerge(infra),
|
|
||||||
Layer.provide(noopNpm),
|
|
||||||
)
|
|
||||||
|
|
||||||
try {
|
|
||||||
await provideTmpdirInstance(
|
|
||||||
() =>
|
|
||||||
Config.Service.use((svc) =>
|
|
||||||
Effect.gen(function* () {
|
|
||||||
yield* svc.get()
|
|
||||||
expect(fetchedUrl).toBe("https://example.com/.well-known/opencode")
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
{ git: true },
|
{ git: true },
|
||||||
).pipe(Effect.scoped, Effect.provide(layer), Effect.runPromise)
|
).pipe(
|
||||||
|
Effect.scoped,
|
||||||
|
Effect.provide(
|
||||||
|
Config.layer.pipe(
|
||||||
|
Layer.provide(testFlock),
|
||||||
|
Layer.provide(AppFileSystem.defaultLayer),
|
||||||
|
Layer.provide(Env.defaultLayer),
|
||||||
|
Layer.provide(wellKnownAuth(server.url.origin)),
|
||||||
|
Layer.provide(AccountTest.empty),
|
||||||
|
Layer.provideMerge(infra),
|
||||||
|
Layer.provide(NpmTest.noop),
|
||||||
|
Layer.provide(FetchHttpClient.layer),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Effect.runPromise,
|
||||||
|
)
|
||||||
} finally {
|
} finally {
|
||||||
globalThis.fetch = originalFetch
|
await server.stop(true)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
test("wellknown remote_config supports templated env vars in headers", async () => {
|
test("wellknown remote_config supports templated env vars in headers", async () => {
|
||||||
const originalFetch = globalThis.fetch
|
|
||||||
const originalToken = process.env.TEST_TOKEN
|
const originalToken = process.env.TEST_TOKEN
|
||||||
let wellknownFetchedUrl: string | undefined
|
const seen: { wellKnown?: string; remote?: string; authorization?: string } = {}
|
||||||
let remoteFetchedUrl: string | undefined
|
const client = remoteConfigClient({
|
||||||
let remoteHeaders: HeadersInit | undefined
|
seen,
|
||||||
globalThis.fetch = mock((url: string | URL | Request, init?: RequestInit) => {
|
wellKnown: {
|
||||||
const urlStr = url instanceof Request ? url.url : url instanceof URL ? url.href : url
|
remote_config: {
|
||||||
if (urlStr.includes(".well-known/opencode")) {
|
url: "https://config.example.com/opencode.json",
|
||||||
wellknownFetchedUrl = urlStr
|
headers: {
|
||||||
return Promise.resolve(
|
Authorization: "Bearer {env:TEST_TOKEN}",
|
||||||
new Response(
|
},
|
||||||
JSON.stringify({
|
},
|
||||||
remote_config: {
|
},
|
||||||
url: "https://config.example.com/opencode.json",
|
remote: {
|
||||||
headers: {
|
mcp: { confluence: { type: "remote", url: "https://confluence.example.com/mcp", enabled: true } },
|
||||||
Authorization: "Bearer {env:TEST_TOKEN}",
|
},
|
||||||
},
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
{ status: 200 },
|
|
||||||
),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
if (urlStr.includes("config.example.com")) {
|
|
||||||
remoteFetchedUrl = urlStr
|
|
||||||
remoteHeaders = init?.headers
|
|
||||||
return Promise.resolve(
|
|
||||||
new Response(
|
|
||||||
JSON.stringify({
|
|
||||||
mcp: { confluence: { type: "remote", url: "https://confluence.example.com/mcp", enabled: true } },
|
|
||||||
}),
|
|
||||||
{ status: 200 },
|
|
||||||
),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
return originalFetch(url, init)
|
|
||||||
}) as unknown as typeof fetch
|
|
||||||
|
|
||||||
const fakeAuth = Layer.mock(Auth.Service)({
|
|
||||||
all: () =>
|
|
||||||
Effect.succeed({
|
|
||||||
"https://example.com": new Auth.WellKnown({ type: "wellknown", key: "TEST_TOKEN", token: "test-token" }),
|
|
||||||
}),
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const layer = Config.layer.pipe(
|
|
||||||
Layer.provide(testFlock),
|
|
||||||
Layer.provide(AppFileSystem.defaultLayer),
|
|
||||||
Layer.provide(Env.defaultLayer),
|
|
||||||
Layer.provide(fakeAuth),
|
|
||||||
Layer.provide(emptyAccount),
|
|
||||||
Layer.provideMerge(infra),
|
|
||||||
Layer.provide(noopNpm),
|
|
||||||
)
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await provideTmpdirInstance(
|
await provideTmpdirInstance(
|
||||||
() =>
|
() =>
|
||||||
Config.Service.use((svc) =>
|
Config.Service.use((svc) =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const config = yield* svc.get()
|
const config = yield* svc.get()
|
||||||
expect(wellknownFetchedUrl).toBe("https://example.com/.well-known/opencode")
|
expect(seen.wellKnown).toBe("https://example.com/.well-known/opencode")
|
||||||
expect(remoteFetchedUrl).toBe("https://config.example.com/opencode.json")
|
expect(seen.remote).toBe("https://config.example.com/opencode.json")
|
||||||
expect(remoteHeaders).toEqual({ Authorization: "Bearer test-token" })
|
expect(seen.authorization).toBe("Bearer test-token")
|
||||||
expect(config.mcp?.confluence?.enabled).toBe(true)
|
expect(config.mcp?.confluence?.enabled).toBe(true)
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
{ git: true },
|
{ git: true },
|
||||||
).pipe(Effect.scoped, Effect.provide(layer), Effect.runPromise)
|
).pipe(
|
||||||
|
Effect.scoped,
|
||||||
|
Effect.provide(configLayer({ auth: wellKnownAuth("https://example.com"), client })),
|
||||||
|
Effect.runPromise,
|
||||||
|
)
|
||||||
} finally {
|
} finally {
|
||||||
globalThis.fetch = originalFetch
|
|
||||||
if (originalToken === undefined) delete process.env.TEST_TOKEN
|
if (originalToken === undefined) delete process.env.TEST_TOKEN
|
||||||
else process.env.TEST_TOKEN = originalToken
|
else process.env.TEST_TOKEN = originalToken
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test("wellknown token env substitution does not mutate process env", async () => {
|
||||||
|
const originalToken = process.env.TEST_TOKEN
|
||||||
|
process.env.TEST_TOKEN = "preexisting-token"
|
||||||
|
const seen: { wellKnown?: string; remote?: string; authorization?: string } = {}
|
||||||
|
const client = remoteConfigClient({
|
||||||
|
seen,
|
||||||
|
wellKnown: {
|
||||||
|
remote_config: {
|
||||||
|
url: "https://config.example.com/opencode.json",
|
||||||
|
headers: {
|
||||||
|
Authorization: "Bearer {env:TEST_TOKEN}",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
remote: {
|
||||||
|
mcp: { confluence: { type: "remote", url: "https://confluence.example.com/mcp", enabled: true } },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
try {
|
||||||
|
const config = await provideTmpdirInstance(() => Config.Service.use((svc) => svc.get()), {
|
||||||
|
git: true,
|
||||||
|
config: { username: "{env:TEST_TOKEN}" },
|
||||||
|
}).pipe(
|
||||||
|
Effect.scoped,
|
||||||
|
Effect.provide(configLayer({ auth: wellKnownAuth("https://example.com"), client })),
|
||||||
|
Effect.runPromise,
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(seen.authorization).toBe("Bearer test-token")
|
||||||
|
expect(config.username).toBe("test-token")
|
||||||
|
expect(process.env.TEST_TOKEN).toBe("preexisting-token")
|
||||||
|
} finally {
|
||||||
|
if (originalToken === undefined) delete process.env.TEST_TOKEN
|
||||||
|
else process.env.TEST_TOKEN = originalToken
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test("wellknown config null is treated as absent", async () => {
|
||||||
|
const seen: { wellKnown?: string; remote?: string; authorization?: string } = {}
|
||||||
|
const client = remoteConfigClient({
|
||||||
|
seen,
|
||||||
|
wellKnown: {
|
||||||
|
config: null,
|
||||||
|
remote_config: {
|
||||||
|
url: "https://config.example.com/opencode.json",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
remote: {
|
||||||
|
mcp: { confluence: { type: "remote", url: "https://confluence.example.com/mcp", enabled: true } },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
await provideTmpdirInstance(
|
||||||
|
() =>
|
||||||
|
Config.Service.use((svc) =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const config = yield* svc.get()
|
||||||
|
expect(seen.remote).toBe("https://config.example.com/opencode.json")
|
||||||
|
expect(config.mcp?.confluence?.enabled).toBe(true)
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
{ git: true },
|
||||||
|
).pipe(
|
||||||
|
Effect.scoped,
|
||||||
|
Effect.provide(configLayer({ auth: wellKnownAuth("https://example.com"), client })),
|
||||||
|
Effect.runPromise,
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("wellknown remote_config rejects non-object config responses", async () => {
|
||||||
|
const seen: { wellKnown?: string; remote?: string; authorization?: string } = {}
|
||||||
|
const client = remoteConfigClient({
|
||||||
|
seen,
|
||||||
|
wellKnown: {
|
||||||
|
remote_config: {
|
||||||
|
url: "https://config.example.com/opencode.json",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
remote: "not an object",
|
||||||
|
})
|
||||||
|
|
||||||
|
const exit = await provideTmpdirInstance(() => Config.Service.use((svc) => svc.get()).pipe(Effect.exit), {
|
||||||
|
git: true,
|
||||||
|
}).pipe(
|
||||||
|
Effect.scoped,
|
||||||
|
Effect.provide(configLayer({ auth: wellKnownAuth("https://example.com"), client })),
|
||||||
|
Effect.runPromise,
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(seen.remote).toBe("https://config.example.com/opencode.json")
|
||||||
|
expect(Exit.isFailure(exit)).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
describe("resolvePluginSpec", () => {
|
describe("resolvePluginSpec", () => {
|
||||||
test("keeps package specs unchanged", async () => {
|
test("keeps package specs unchanged", async () => {
|
||||||
await using tmp = await tmpdir()
|
await using tmp = await tmpdir()
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,11 @@
|
||||||
import { describe, expect } from "bun:test"
|
import { describe, expect } from "bun:test"
|
||||||
import { Effect, Layer, Option } from "effect"
|
import { Effect, Layer } from "effect"
|
||||||
|
import { FetchHttpClient } from "effect/unstable/http"
|
||||||
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
|
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
|
||||||
import { AppFileSystem } from "@opencode-ai/core/filesystem"
|
import { AppFileSystem } from "@opencode-ai/core/filesystem"
|
||||||
import { EffectFlock } from "@opencode-ai/core/util/effect-flock"
|
import { EffectFlock } from "@opencode-ai/core/util/effect-flock"
|
||||||
import path from "path"
|
import path from "path"
|
||||||
import { pathToFileURL } from "url"
|
import { pathToFileURL } from "url"
|
||||||
import { Account } from "../../src/account/account"
|
|
||||||
import { Auth } from "../../src/auth"
|
|
||||||
import { Bus } from "../../src/bus"
|
import { Bus } from "../../src/bus"
|
||||||
import { Config } from "../../src/config/config"
|
import { Config } from "../../src/config/config"
|
||||||
import { Env } from "../../src/env"
|
import { Env } from "../../src/env"
|
||||||
|
|
@ -15,22 +14,18 @@ import { Plugin } from "../../src/plugin/index"
|
||||||
import { ModelID, ProviderID } from "../../src/provider/schema"
|
import { ModelID, ProviderID } from "../../src/provider/schema"
|
||||||
import { provideTmpdirInstance } from "../fixture/fixture"
|
import { provideTmpdirInstance } from "../fixture/fixture"
|
||||||
import { testEffect } from "../lib/effect"
|
import { testEffect } from "../lib/effect"
|
||||||
|
import { AccountTest } from "../fake/account"
|
||||||
|
import { AuthTest } from "../fake/auth"
|
||||||
import { NpmTest } from "../fake/npm"
|
import { NpmTest } from "../fake/npm"
|
||||||
|
|
||||||
const emptyAccount = Layer.mock(Account.Service)({
|
|
||||||
active: () => Effect.succeed(Option.none()),
|
|
||||||
activeOrg: () => Effect.succeed(Option.none()),
|
|
||||||
})
|
|
||||||
const emptyAuth = Layer.mock(Auth.Service)({
|
|
||||||
all: () => Effect.succeed({}),
|
|
||||||
})
|
|
||||||
const configLayer = Config.layer.pipe(
|
const configLayer = Config.layer.pipe(
|
||||||
Layer.provide(EffectFlock.defaultLayer),
|
Layer.provide(EffectFlock.defaultLayer),
|
||||||
Layer.provide(AppFileSystem.defaultLayer),
|
Layer.provide(AppFileSystem.defaultLayer),
|
||||||
Layer.provide(Env.defaultLayer),
|
Layer.provide(Env.defaultLayer),
|
||||||
Layer.provide(emptyAuth),
|
Layer.provide(AuthTest.empty),
|
||||||
Layer.provide(emptyAccount),
|
Layer.provide(AccountTest.empty),
|
||||||
Layer.provide(NpmTest.noop),
|
Layer.provide(NpmTest.noop),
|
||||||
|
Layer.provide(FetchHttpClient.layer),
|
||||||
)
|
)
|
||||||
const it = testEffect(
|
const it = testEffect(
|
||||||
Layer.mergeAll(
|
Layer.mergeAll(
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,11 @@
|
||||||
import { afterEach, describe, expect } from "bun:test"
|
import { afterEach, describe, expect } from "bun:test"
|
||||||
import { Effect, Layer, Option } from "effect"
|
import { Effect, Layer } from "effect"
|
||||||
import { FetchHttpClient } from "effect/unstable/http"
|
import { FetchHttpClient } from "effect/unstable/http"
|
||||||
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
|
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
|
||||||
import { AppFileSystem } from "@opencode-ai/core/filesystem"
|
import { AppFileSystem } from "@opencode-ai/core/filesystem"
|
||||||
import { EffectFlock } from "@opencode-ai/core/util/effect-flock"
|
import { EffectFlock } from "@opencode-ai/core/util/effect-flock"
|
||||||
import path from "path"
|
import path from "path"
|
||||||
import { pathToFileURL } from "url"
|
import { pathToFileURL } from "url"
|
||||||
import { Account } from "../../src/account/account"
|
|
||||||
import { Auth } from "../../src/auth"
|
import { Auth } from "../../src/auth"
|
||||||
import { Bus } from "../../src/bus"
|
import { Bus } from "../../src/bus"
|
||||||
import { Config } from "../../src/config/config"
|
import { Config } from "../../src/config/config"
|
||||||
|
|
@ -24,22 +23,18 @@ import { SessionPrompt } from "../../src/session/prompt"
|
||||||
import { SyncEvent } from "../../src/sync"
|
import { SyncEvent } from "../../src/sync"
|
||||||
import { disposeAllInstances, provideTmpdirInstance } from "../fixture/fixture"
|
import { disposeAllInstances, provideTmpdirInstance } from "../fixture/fixture"
|
||||||
import { testEffect } from "../lib/effect"
|
import { testEffect } from "../lib/effect"
|
||||||
|
import { AccountTest } from "../fake/account"
|
||||||
|
import { AuthTest } from "../fake/auth"
|
||||||
import { NpmTest } from "../fake/npm"
|
import { NpmTest } from "../fake/npm"
|
||||||
|
|
||||||
const emptyAccount = Layer.mock(Account.Service)({
|
|
||||||
active: () => Effect.succeed(Option.none()),
|
|
||||||
activeOrg: () => Effect.succeed(Option.none()),
|
|
||||||
})
|
|
||||||
const emptyAuth = Layer.mock(Auth.Service)({
|
|
||||||
all: () => Effect.succeed({}),
|
|
||||||
})
|
|
||||||
const configLayer = Config.layer.pipe(
|
const configLayer = Config.layer.pipe(
|
||||||
Layer.provide(EffectFlock.defaultLayer),
|
Layer.provide(EffectFlock.defaultLayer),
|
||||||
Layer.provide(AppFileSystem.defaultLayer),
|
Layer.provide(AppFileSystem.defaultLayer),
|
||||||
Layer.provide(Env.defaultLayer),
|
Layer.provide(Env.defaultLayer),
|
||||||
Layer.provide(emptyAuth),
|
Layer.provide(AuthTest.empty),
|
||||||
Layer.provide(emptyAccount),
|
Layer.provide(AccountTest.empty),
|
||||||
Layer.provide(NpmTest.noop),
|
Layer.provide(NpmTest.noop),
|
||||||
|
Layer.provide(FetchHttpClient.layer),
|
||||||
)
|
)
|
||||||
const pluginLayer = Plugin.layer.pipe(
|
const pluginLayer = Plugin.layer.pipe(
|
||||||
Layer.provide(Bus.layer),
|
Layer.provide(Bus.layer),
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue