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
48 changes: 45 additions & 3 deletions packages/opencode/src/config/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,11 +13,12 @@ import { Env } from "../env"
import { applyEdits, modify } from "jsonc-parser"
import { InstallationLocal, InstallationVersion } from "@opencode-ai/core/installation/version"
import { existsSync } from "fs"
import { Account } from "@/account/account"
import { isRecord } from "@/util/record"
import type { ConsoleState } from "@opencode-ai/core/v1/config/console-state"
import { FSUtil } from "@opencode-ai/core/fs-util"
import { InstanceState } from "@/effect/instance-state"
import { Context, Duration, Effect, Exit, Fiber, Layer, Schema } from "effect"
import { Context, Duration, Effect, Exit, Fiber, Layer, Option, Schema } from "effect"
import { FetchHttpClient, HttpClient, HttpClientRequest } from "effect/unstable/http"
import { EffectFlock } from "@opencode-ai/core/util/effect-flock"
import { containsPath, type InstanceContext } from "../project/instance-context"
Expand Down Expand Up @@ -176,6 +177,7 @@ export const layer = Layer.effect(
Effect.gen(function* () {
const fs = yield* FSUtil.Service
const authSvc = yield* Auth.Service
const accountSvc = yield* Account.Service
const env = yield* Env.Service
const npmSvc = yield* Npm.Service
const http = yield* HttpClient.HttpClient
Expand Down Expand Up @@ -314,6 +316,7 @@ export const layer = Layer.effect(

let result: Info = {}
const authEnv: Record<string, string> = {}
const consoleManagedProviders = new Set<string>()
let activeOrgName: string | undefined

const pluginScopeForSource = Effect.fnUntraced(function* (source: string) {
Expand Down Expand Up @@ -471,6 +474,44 @@ export const layer = Layer.effect(
yield* Effect.logDebug("loaded custom config from OPENCODE_CONFIG_CONTENT")
}

const activeAccount = Option.getOrUndefined(
yield* accountSvc.active().pipe(Effect.catch(() => Effect.succeed(Option.none()))),
)
if (activeAccount?.active_org_id) {
const accountID = activeAccount.id
const orgID = activeAccount.active_org_id
const url = activeAccount.url
yield* Effect.gen(function* () {
const [configOpt, tokenOpt] = yield* Effect.all(
[accountSvc.config(accountID, orgID), accountSvc.token(accountID)],
{ concurrency: 2 },
)
if (Option.isSome(tokenOpt)) {
process.env["OPENCODE_CONSOLE_TOKEN"] = tokenOpt.value
yield* env.set("OPENCODE_CONSOLE_TOKEN", tokenOpt.value)
}

if (Option.isSome(configOpt)) {
const source = `${url}/api/config`
const next = yield* loadConfig(JSON.stringify(configOpt.value), {
dir: path.dirname(source),
source,
})
for (const providerID of Object.keys(next.provider ?? {})) {
consoleManagedProviders.add(providerID)
}
yield* merge(source, next, "global")
}
}).pipe(
Effect.withSpan("Config.loadActiveOrgConfig"),
Effect.catch((err) =>
Effect.logDebug("failed to fetch remote account config", {
error: err instanceof Error ? err.message : String(err),
}),
),
)
}

const managedDir = ConfigManaged.managedConfigDir()
if (existsSync(managedDir)) {
for (const file of ["opencode.json", "opencode.jsonc"]) {
Expand Down Expand Up @@ -546,7 +587,7 @@ export const layer = Layer.effect(
directories,
deps,
consoleState: {
consoleManagedProviders: [],
consoleManagedProviders: Array.from(consoleManagedProviders),
activeOrgName,
switchableOrgCount: 0,
},
Expand Down Expand Up @@ -635,10 +676,11 @@ export const defaultLayer = layer.pipe(
Layer.provide(FSUtil.defaultLayer),
Layer.provide(Env.defaultLayer),
Layer.provide(Auth.defaultLayer),
Layer.provide(Account.defaultLayer),
Layer.provide(Npm.defaultLayer),
Layer.provide(FetchHttpClient.layer),
)

export const node = LayerNode.make(layer, [FSUtil.node, Auth.node, Env.node, Npm.node, httpClient])
export const node = LayerNode.make(layer, [FSUtil.node, Auth.node, Account.node, Env.node, Npm.node, httpClient])

export * as Config from "./config"
49 changes: 48 additions & 1 deletion packages/opencode/test/config/config.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { test, expect, describe, afterEach, beforeEach, spyOn } from "bun:test"
import { ConfigV1 } from "@opencode-ai/core/v1/config/config"
import { Cause, Effect, Exit, Layer } from "effect"
import { Cause, Effect, Exit, Layer, Option } from "effect"
import { NamedError } from "@opencode-ai/core/util/error"
import { FetchHttpClient, HttpClient, HttpClientResponse } from "effect/unstable/http"
import { NodeFileSystem, NodePath } from "@effect/platform-node"
Expand All @@ -13,6 +13,7 @@ import { InstanceRef } from "../../src/effect/instance-ref"
import type { InstanceContext } from "../../src/project/instance-context"
import { Auth } from "../../src/auth"
import { Account } from "../../src/account/account"
import { AccessToken, AccountID, OrgID } from "../../src/account/schema"
import { FSUtil } from "@opencode-ai/core/fs-util"
import { Env } from "../../src/env"
import {
Expand Down Expand Up @@ -140,6 +141,7 @@ const clear = (wait = false) => Effect.runPromise(clearEffect(wait))
// Get managed config directory from environment (set in preload.ts)
const managedConfigDir = process.env.OPENCODE_TEST_MANAGED_CONFIG_DIR!
const originalTestToken = process.env.TEST_TOKEN
const originalConsoleToken = process.env.OPENCODE_CONSOLE_TOKEN

beforeEach(async () => {
await clear(true)
Expand All @@ -149,6 +151,8 @@ afterEach(async () => {
await fs.rm(managedConfigDir, { force: true, recursive: true }).catch(() => {})
if (originalTestToken === undefined) delete process.env.TEST_TOKEN
else process.env.TEST_TOKEN = originalTestToken
if (originalConsoleToken === undefined) delete process.env.OPENCODE_CONSOLE_TOKEN
else process.env.OPENCODE_CONSOLE_TOKEN = originalConsoleToken
await clear(true)
})

Expand Down Expand Up @@ -560,6 +564,49 @@ it.instance("handles file inclusion with replacement tokens", () =>
}),
)

const accountTokenIt = configIt({
account: Layer.mock(Account.Service)({
active: () =>
Effect.succeed(
Option.some({
id: AccountID.make("account-1"),
email: "user@example.com",
url: "https://control.example.com",
active_org_id: OrgID.make("org-1"),
}),
),
activeOrg: () =>
Effect.succeed(
Option.some({
account: {
id: AccountID.make("account-1"),
email: "user@example.com",
url: "https://control.example.com",
active_org_id: OrgID.make("org-1"),
},
org: {
id: OrgID.make("org-1"),
name: "Example Org",
},
}),
),
config: () =>
Effect.succeed(
Option.some({
provider: { opencode: { options: { apiKey: "{env:OPENCODE_CONSOLE_TOKEN}" } } },
}),
),
token: () => Effect.succeed(Option.some(AccessToken.make("st_test_token"))),
}),
})

accountTokenIt.instance("resolves env templates in account config with account token", () =>
Effect.gen(function* () {
const config = yield* Config.use.get()
expect(config.provider?.["opencode"]?.options?.apiKey).toBe("st_test_token")
}),
)

it.instance("validates config schema and throws on invalid fields", () =>
Effect.gen(function* () {
const test = yield* TestInstance
Expand Down
Loading