effectify Config service (#19139)

pull/19322/head
Kit Langton 2026-03-26 13:07:01 -04:00 committed by GitHub
parent 38450443b1
commit 28f5176ffd
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 461 additions and 358 deletions

View File

@ -49,6 +49,10 @@ See `specs/effect-migration.md` for the compact pattern reference and examples.
- Prefer `Path.Path`, `Config`, `Clock`, and `DateTime` when those concerns are already inside Effect code.
- For background loops or scheduled tasks, use `Effect.repeat` or `Effect.schedule` with `Effect.forkScoped` in the layer definition.
## Effect.cached for deduplication
Use `Effect.cached` when multiple concurrent callers should share a single in-flight computation rather than storing `Fiber | undefined` or `Promise | undefined` manually. See `specs/effect-migration.md` for the full pattern.
## Instance.bind — ALS for native callbacks
`Instance.bind(fn)` captures the current Instance AsyncLocalStorage context and restores it synchronously when called.

View File

@ -121,6 +121,31 @@ yield *
The key insight: don't split init into a separate method with a `started` flag. Put everything in the `InstanceState.make` closure and let `ScopedCache` handle the run-once semantics.
## Effect.cached for deduplication
Use `Effect.cached` when multiple concurrent callers should share a single in-flight computation. It memoizes the result and deduplicates concurrent fibers — second caller joins the first caller's fiber instead of starting a new one.
```ts
// Inside the layer — yield* to initialize the memo
let cached = yield* Effect.cached(loadExpensive())
const get = Effect.fn("Foo.get")(function* () {
return yield* cached // concurrent callers share the same fiber
})
// To invalidate: swap in a fresh memo
const invalidate = Effect.fn("Foo.invalidate")(function* () {
cached = yield* Effect.cached(loadExpensive())
})
```
Prefer `Effect.cached` over these patterns:
- Storing a `Fiber.Fiber | undefined` with manual check-and-fork (e.g. `file/index.ts` `ensure`)
- Storing a `Promise<void>` task for deduplication (e.g. `skill/index.ts` `ensure`)
- `let cached: X | undefined` with check-and-load (races when two callers see `undefined` before either resolves)
`Effect.cached` handles the run-once + concurrent-join semantics automatically. For invalidatable caches, reassign with `yield* Effect.cached(...)` — the old memo is discarded.
## Scheduled Tasks
For loops or periodic work, use `Effect.repeat` or `Effect.schedule` with `Effect.forkScoped` in the layer definition.
@ -179,7 +204,7 @@ Still open and likely worth migrating:
- [x] `Worktree`
- [x] `Bus`
- [x] `Command`
- [ ] `Config`
- [x] `Config`
- [ ] `Session`
- [ ] `SessionProcessor`
- [ ] `SessionPrompt`

View File

@ -149,8 +149,7 @@ export const rpc = {
})
},
async reload() {
Config.global.reset()
await Instance.disposeAll()
await Config.invalidate(true)
},
async setWorkspace(input: { workspaceID?: string }) {
startEventStream({ directory: process.cwd(), workspaceID: input.workspaceID })

View File

@ -37,7 +37,7 @@ export function withNetworkOptions<T>(yargs: Argv<T>) {
}
export async function resolveNetworkOptions(args: NetworkOptions) {
const config = await Config.global()
const config = await Config.getGlobal()
const portExplicitlySet = process.argv.includes("--port")
const hostnameExplicitlySet = process.argv.includes("--hostname")
const mdnsExplicitlySet = process.argv.includes("--mdns")

View File

@ -4,7 +4,7 @@ import { Flag } from "@/flag/flag"
import { Installation } from "@/installation"
export async function upgrade() {
const config = await Config.global()
const config = await Config.getGlobal()
const method = await Installation.method()
const latest = await Installation.latest(method).catch(() => {})
if (!latest) return

View File

@ -1,14 +1,13 @@
import { Log } from "../util/log"
import path from "path"
import { pathToFileURL, fileURLToPath } from "url"
import { pathToFileURL } from "url"
import { createRequire } from "module"
import os from "os"
import z from "zod"
import { ModelsDev } from "../provider/models"
import { mergeDeep, pipe, unique } from "remeda"
import { Global } from "../global"
import fs from "fs/promises"
import { lazy } from "../util/lazy"
import fsNode from "fs/promises"
import { NamedError } from "@opencode-ai/util/error"
import { Flag } from "../flag/flag"
import { Auth } from "../auth"
@ -20,7 +19,7 @@ import {
parse as parseJsonc,
printParseErrorCode,
} from "jsonc-parser"
import { Instance } from "../project/instance"
import { Instance, type InstanceContext } from "../project/instance"
import { LSPServer } from "../lsp/server"
import { BunProc } from "@/bun"
import { Installation } from "@/installation"
@ -38,6 +37,10 @@ import { ConfigPaths } from "./paths"
import { Filesystem } from "@/util/filesystem"
import { Process } from "@/util/process"
import { Lock } from "@/util/lock"
import { AppFileSystem } from "@/filesystem"
import { InstanceState } from "@/effect/instance-state"
import { makeRuntime } from "@/effect/run-service"
import { Effect, Layer, ServiceMap } from "effect"
export namespace Config {
const ModelId = z.string().meta({ $ref: "https://models.dev/model-schema.json#/$defs/Model" })
@ -75,201 +78,6 @@ export namespace Config {
return merged
}
export const state = Instance.state(async () => {
const auth = await Auth.all()
// Config loading order (low -> high precedence): https://opencode.ai/docs/config#precedence-order
// 1) Remote .well-known/opencode (org defaults)
// 2) Global config (~/.config/opencode/opencode.json{,c})
// 3) Custom config (OPENCODE_CONFIG)
// 4) Project config (opencode.json{,c})
// 5) .opencode directories (.opencode/agents/, .opencode/commands/, .opencode/plugins/, .opencode/opencode.json{,c})
// 6) Inline config (OPENCODE_CONFIG_CONTENT)
// Managed config directory is enterprise-only and always overrides everything above.
let result: Info = {}
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 = await fetch(`${url}/.well-known/opencode`)
if (!response.ok) {
throw new Error(`failed to fetch remote config from ${url}: ${response.status}`)
}
const wellknown = (await response.json()) as any
const remoteConfig = wellknown.config ?? {}
// Add $schema to prevent load() from trying to write back to a non-existent file
if (!remoteConfig.$schema) remoteConfig.$schema = "https://opencode.ai/config.json"
result = mergeConfigConcatArrays(
result,
await load(JSON.stringify(remoteConfig), {
dir: path.dirname(`${url}/.well-known/opencode`),
source: `${url}/.well-known/opencode`,
}),
)
log.debug("loaded remote config from well-known", { url })
}
}
// Global user config overrides remote config.
result = mergeConfigConcatArrays(result, await global())
// Custom config path overrides global config.
if (Flag.OPENCODE_CONFIG) {
result = mergeConfigConcatArrays(result, await loadFile(Flag.OPENCODE_CONFIG))
log.debug("loaded custom config", { path: Flag.OPENCODE_CONFIG })
}
// Project config overrides global and remote config.
if (!Flag.OPENCODE_DISABLE_PROJECT_CONFIG) {
for (const file of await ConfigPaths.projectFiles("opencode", Instance.directory, Instance.worktree)) {
result = mergeConfigConcatArrays(result, await loadFile(file))
}
}
result.agent = result.agent || {}
result.mode = result.mode || {}
result.plugin = result.plugin || []
const directories = await ConfigPaths.directories(Instance.directory, Instance.worktree)
// .opencode directory config overrides (project and global) config sources.
if (Flag.OPENCODE_CONFIG_DIR) {
log.debug("loading config from OPENCODE_CONFIG_DIR", { path: Flag.OPENCODE_CONFIG_DIR })
}
const deps = []
for (const dir of unique(directories)) {
if (dir.endsWith(".opencode") || dir === Flag.OPENCODE_CONFIG_DIR) {
for (const file of ["opencode.jsonc", "opencode.json"]) {
log.debug(`loading config from ${path.join(dir, file)}`)
result = mergeConfigConcatArrays(result, await loadFile(path.join(dir, file)))
// to satisfy the type checker
result.agent ??= {}
result.mode ??= {}
result.plugin ??= []
}
}
deps.push(
iife(async () => {
const shouldInstall = await needsInstall(dir)
if (shouldInstall) await installDependencies(dir)
}),
)
result.command = mergeDeep(result.command ?? {}, await loadCommand(dir))
result.agent = mergeDeep(result.agent, await loadAgent(dir))
result.agent = mergeDeep(result.agent, await loadMode(dir))
result.plugin.push(...(await loadPlugin(dir)))
}
// Inline config content overrides all non-managed config sources.
if (process.env.OPENCODE_CONFIG_CONTENT) {
result = mergeConfigConcatArrays(
result,
await load(process.env.OPENCODE_CONFIG_CONTENT, {
dir: Instance.directory,
source: "OPENCODE_CONFIG_CONTENT",
}),
)
log.debug("loaded custom config from OPENCODE_CONFIG_CONTENT")
}
const active = await Account.active()
if (active?.active_org_id) {
try {
const [config, token] = await Promise.all([
Account.config(active.id, active.active_org_id),
Account.token(active.id),
])
if (token) {
process.env["OPENCODE_CONSOLE_TOKEN"] = token
Env.set("OPENCODE_CONSOLE_TOKEN", token)
}
if (config) {
result = mergeConfigConcatArrays(
result,
await load(JSON.stringify(config), {
dir: path.dirname(`${active.url}/api/config`),
source: `${active.url}/api/config`,
}),
)
}
} catch (err: any) {
log.debug("failed to fetch remote account config", { error: err?.message ?? err })
}
}
// Load managed config files last (highest priority) - enterprise admin-controlled
// Kept separate from directories array to avoid write operations when installing plugins
// which would fail on system directories requiring elevated permissions
// This way it only loads config file and not skills/plugins/commands
if (existsSync(managedDir)) {
for (const file of ["opencode.jsonc", "opencode.json"]) {
result = mergeConfigConcatArrays(result, await loadFile(path.join(managedDir, file)))
}
}
// Migrate deprecated mode field to agent field
for (const [name, mode] of Object.entries(result.mode ?? {})) {
result.agent = mergeDeep(result.agent ?? {}, {
[name]: {
...mode,
mode: "primary" as const,
},
})
}
if (Flag.OPENCODE_PERMISSION) {
result.permission = mergeDeep(result.permission ?? {}, JSON.parse(Flag.OPENCODE_PERMISSION))
}
// Backwards compatibility: legacy top-level `tools` config
if (result.tools) {
const perms: Record<string, Config.PermissionAction> = {}
for (const [tool, enabled] of Object.entries(result.tools)) {
const action: Config.PermissionAction = enabled ? "allow" : "deny"
if (tool === "write" || tool === "edit" || tool === "patch" || tool === "multiedit") {
perms.edit = action
continue
}
perms[tool] = action
}
result.permission = mergeDeep(perms, result.permission ?? {})
}
if (!result.username) result.username = os.userInfo().username
// Handle migration from autoshare to share field
if (result.autoshare === true && !result.share) {
result.share = "auto"
}
// Apply flag overrides for compaction settings
if (Flag.OPENCODE_DISABLE_AUTOCOMPACT) {
result.compaction = { ...result.compaction, auto: false }
}
if (Flag.OPENCODE_DISABLE_PRUNE) {
result.compaction = { ...result.compaction, prune: false }
}
result.plugin = deduplicatePlugins(result.plugin ?? [])
return {
config: result,
directories,
deps,
}
})
export async function waitForDependencies() {
const deps = await state().then((x) => x.deps)
await Promise.all(deps)
}
export async function installDependencies(dir: string) {
const pkg = path.join(dir, "package.json")
const targetVersion = Installation.isLocal() ? "*" : Installation.VERSION
@ -325,7 +133,7 @@ export namespace Config {
async function isWritable(dir: string) {
try {
await fs.access(dir, constants.W_OK)
await fsNode.access(dir, constants.W_OK)
return true
} catch {
return false
@ -1234,123 +1042,23 @@ export namespace Config {
export type Info = z.output<typeof Info>
export const global = lazy(async () => {
let result: Info = pipe(
{},
mergeDeep(await loadFile(path.join(Global.Path.config, "config.json"))),
mergeDeep(await loadFile(path.join(Global.Path.config, "opencode.json"))),
mergeDeep(await loadFile(path.join(Global.Path.config, "opencode.jsonc"))),
)
const legacy = path.join(Global.Path.config, "config")
if (existsSync(legacy)) {
await import(pathToFileURL(legacy).href, {
with: {
type: "toml",
},
})
.then(async (mod) => {
const { provider, model, ...rest } = mod.default
if (provider && model) result.model = `${provider}/${model}`
result["$schema"] = "https://opencode.ai/config.json"
result = mergeDeep(result, rest)
await Filesystem.writeJson(path.join(Global.Path.config, "config.json"), result)
await fs.unlink(legacy)
})
.catch(() => {})
}
return result
})
export const { readFile } = ConfigPaths
async function loadFile(filepath: string): Promise<Info> {
log.info("loading", { path: filepath })
const text = await readFile(filepath)
if (!text) return {}
return load(text, { path: filepath })
type State = {
config: Info
directories: string[]
deps: Promise<void>[]
}
async function load(text: string, options: { path: string } | { dir: string; source: string }) {
const original = text
const source = "path" in options ? options.path : options.source
const isFile = "path" in options
const data = await ConfigPaths.parseText(
text,
"path" in options ? options.path : { source: options.source, dir: options.dir },
)
const normalized = (() => {
if (!data || typeof data !== "object" || Array.isArray(data)) return data
const copy = { ...(data as Record<string, unknown>) }
const hadLegacy = "theme" in copy || "keybinds" in copy || "tui" in copy
if (!hadLegacy) return copy
delete copy.theme
delete copy.keybinds
delete copy.tui
log.warn("tui keys in opencode config are deprecated; move them to tui.json", { path: source })
return copy
})()
const parsed = Info.safeParse(normalized)
if (parsed.success) {
if (!parsed.data.$schema && isFile) {
parsed.data.$schema = "https://opencode.ai/config.json"
const updated = original.replace(/^\s*\{/, '{\n "$schema": "https://opencode.ai/config.json",')
await Filesystem.write(options.path, updated).catch(() => {})
}
const data = parsed.data
if (data.plugin && isFile) {
for (let i = 0; i < data.plugin.length; i++) {
const plugin = data.plugin[i]
try {
data.plugin[i] = import.meta.resolve!(plugin, options.path)
} catch (e) {
try {
// import.meta.resolve sometimes fails with newly created node_modules
const require = createRequire(options.path)
const resolvedPath = require.resolve(plugin)
data.plugin[i] = pathToFileURL(resolvedPath).href
} catch {
// Ignore, plugin might be a generic string identifier like "mcp-server"
}
}
}
}
return data
}
throw new InvalidError({
path: source,
issues: parsed.error.issues,
})
}
export const { JsonError, InvalidError } = ConfigPaths
export const ConfigDirectoryTypoError = NamedError.create(
"ConfigDirectoryTypoError",
z.object({
path: z.string(),
dir: z.string(),
suggestion: z.string(),
}),
)
export async function get() {
return state().then((x) => x.config)
export interface Interface {
readonly get: () => Effect.Effect<Info>
readonly getGlobal: () => Effect.Effect<Info>
readonly update: (config: Info) => Effect.Effect<void>
readonly updateGlobal: (config: Info) => Effect.Effect<Info>
readonly invalidate: (wait?: boolean) => Effect.Effect<void>
readonly directories: () => Effect.Effect<string[]>
readonly waitForDependencies: () => Effect.Effect<void>
}
export async function getGlobal() {
return global()
}
export async function update(config: Info) {
const filepath = path.join(Instance.directory, "config.json")
const existing = await loadFile(filepath)
await Filesystem.writeJson(filepath, mergeDeep(existing, config))
await Instance.dispose()
}
export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/Config") {}
function globalConfigFile() {
const candidates = ["opencode.jsonc", "opencode.json", "config.json"].map((file) =>
@ -1417,47 +1125,413 @@ export namespace Config {
})
}
export async function updateGlobal(config: Info) {
const filepath = globalConfigFile()
const before = await Filesystem.readText(filepath).catch((err: any) => {
if (err.code === "ENOENT") return "{}"
throw new JsonError({ path: filepath }, { cause: err })
})
export const { JsonError, InvalidError } = ConfigPaths
const next = await (async () => {
if (!filepath.endsWith(".jsonc")) {
const existing = parseConfig(before, filepath)
const merged = mergeDeep(existing, config)
await Filesystem.writeJson(filepath, merged)
return merged
}
export const ConfigDirectoryTypoError = NamedError.create(
"ConfigDirectoryTypoError",
z.object({
path: z.string(),
dir: z.string(),
suggestion: z.string(),
}),
)
const updated = patchJsonc(before, config)
const merged = parseConfig(updated, filepath)
await Filesystem.write(filepath, updated)
return merged
})()
export const layer: Layer.Layer<Service, never, AppFileSystem.Service> = Layer.effect(
Service,
Effect.gen(function* () {
const fs = yield* AppFileSystem.Service
global.reset()
const readConfigFile = Effect.fnUntraced(function* (filepath: string) {
return yield* fs.readFileString(filepath).pipe(
Effect.catchIf(
(e) => e.reason._tag === "NotFound",
() => Effect.succeed(undefined),
),
Effect.orDie,
)
})
void Instance.disposeAll()
.catch(() => undefined)
.finally(() => {
GlobalBus.emit("event", {
directory: "global",
payload: {
type: Event.Disposed.type,
properties: {},
},
const loadConfig = Effect.fnUntraced(function* (
text: string,
options: { path: string } | { dir: string; source: string },
) {
const original = text
const source = "path" in options ? options.path : options.source
const isFile = "path" in options
const data = yield* Effect.promise(() =>
ConfigPaths.parseText(text, "path" in options ? options.path : { source: options.source, dir: options.dir }),
)
const normalized = (() => {
if (!data || typeof data !== "object" || Array.isArray(data)) return data
const copy = { ...(data as Record<string, unknown>) }
const hadLegacy = "theme" in copy || "keybinds" in copy || "tui" in copy
if (!hadLegacy) return copy
delete copy.theme
delete copy.keybinds
delete copy.tui
log.warn("tui keys in opencode config are deprecated; move them to tui.json", { path: source })
return copy
})()
const parsed = Info.safeParse(normalized)
if (parsed.success) {
if (!parsed.data.$schema && isFile) {
parsed.data.$schema = "https://opencode.ai/config.json"
const updated = original.replace(/^\s*\{/, '{\n "$schema": "https://opencode.ai/config.json",')
yield* fs.writeFileString(options.path, updated).pipe(Effect.catch(() => Effect.void))
}
const data = parsed.data
if (data.plugin && isFile) {
for (let i = 0; i < data.plugin.length; i++) {
const plugin = data.plugin[i]
try {
data.plugin[i] = import.meta.resolve!(plugin, options.path)
} catch (e) {
try {
const require = createRequire(options.path)
const resolvedPath = require.resolve(plugin)
data.plugin[i] = pathToFileURL(resolvedPath).href
} catch {
// Ignore, plugin might be a generic string identifier like "mcp-server"
}
}
}
}
return data
}
throw new InvalidError({
path: source,
issues: parsed.error.issues,
})
})
return next
const loadFile = Effect.fnUntraced(function* (filepath: string) {
log.info("loading", { path: filepath })
const text = yield* readConfigFile(filepath)
if (!text) return {} as Info
return yield* loadConfig(text, { path: filepath })
})
const loadGlobal = Effect.fnUntraced(function* () {
let result: Info = pipe(
{},
mergeDeep(yield* loadFile(path.join(Global.Path.config, "config.json"))),
mergeDeep(yield* loadFile(path.join(Global.Path.config, "opencode.json"))),
mergeDeep(yield* loadFile(path.join(Global.Path.config, "opencode.jsonc"))),
)
const legacy = path.join(Global.Path.config, "config")
if (existsSync(legacy)) {
yield* Effect.promise(() =>
import(pathToFileURL(legacy).href, { with: { type: "toml" } })
.then(async (mod) => {
const { provider, model, ...rest } = mod.default
if (provider && model) result.model = `${provider}/${model}`
result["$schema"] = "https://opencode.ai/config.json"
result = mergeDeep(result, rest)
await fsNode.writeFile(
path.join(Global.Path.config, "config.json"),
JSON.stringify(result, null, 2),
)
await fsNode.unlink(legacy)
})
.catch(() => {}),
)
}
return result
})
let cachedGlobal = yield* Effect.cached(
loadGlobal().pipe(Effect.orElseSucceed(() => ({}) as Info)),
)
const getGlobal = Effect.fn("Config.getGlobal")(function* () {
return yield* cachedGlobal
})
const loadInstanceState = Effect.fnUntraced(function* (ctx: InstanceContext) {
const auth = yield* Effect.promise(() => Auth.all())
let result: Info = {}
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 any
const remoteConfig = wellknown.config ?? {}
if (!remoteConfig.$schema) remoteConfig.$schema = "https://opencode.ai/config.json"
result = mergeConfigConcatArrays(
result,
yield* loadConfig(JSON.stringify(remoteConfig), {
dir: path.dirname(`${url}/.well-known/opencode`),
source: `${url}/.well-known/opencode`,
}),
)
log.debug("loaded remote config from well-known", { url })
}
}
result = mergeConfigConcatArrays(result, yield* getGlobal())
if (Flag.OPENCODE_CONFIG) {
result = mergeConfigConcatArrays(result, yield* loadFile(Flag.OPENCODE_CONFIG))
log.debug("loaded custom config", { path: Flag.OPENCODE_CONFIG })
}
if (!Flag.OPENCODE_DISABLE_PROJECT_CONFIG) {
for (const file of yield* Effect.promise(() =>
ConfigPaths.projectFiles("opencode", ctx.directory, ctx.worktree),
)) {
result = mergeConfigConcatArrays(result, yield* loadFile(file))
}
}
result.agent = result.agent || {}
result.mode = result.mode || {}
result.plugin = result.plugin || []
const directories = yield* Effect.promise(() => ConfigPaths.directories(ctx.directory, ctx.worktree))
if (Flag.OPENCODE_CONFIG_DIR) {
log.debug("loading config from OPENCODE_CONFIG_DIR", { path: Flag.OPENCODE_CONFIG_DIR })
}
const deps: Promise<void>[] = []
for (const dir of unique(directories)) {
if (dir.endsWith(".opencode") || dir === Flag.OPENCODE_CONFIG_DIR) {
for (const file of ["opencode.jsonc", "opencode.json"]) {
log.debug(`loading config from ${path.join(dir, file)}`)
result = mergeConfigConcatArrays(result, yield* loadFile(path.join(dir, file)))
result.agent ??= {}
result.mode ??= {}
result.plugin ??= []
}
}
deps.push(
iife(async () => {
const shouldInstall = await needsInstall(dir)
if (shouldInstall) await installDependencies(dir)
}),
)
result.command = mergeDeep(result.command ?? {}, yield* Effect.promise(() => loadCommand(dir)))
result.agent = mergeDeep(result.agent, yield* Effect.promise(() => loadAgent(dir)))
result.agent = mergeDeep(result.agent, yield* Effect.promise(() => loadMode(dir)))
result.plugin.push(...(yield* Effect.promise(() => loadPlugin(dir))))
}
if (process.env.OPENCODE_CONFIG_CONTENT) {
result = mergeConfigConcatArrays(
result,
yield* loadConfig(process.env.OPENCODE_CONFIG_CONTENT, {
dir: ctx.directory,
source: "OPENCODE_CONFIG_CONTENT",
}),
)
log.debug("loaded custom config from OPENCODE_CONFIG_CONTENT")
}
const active = yield* Effect.promise(() => Account.active())
if (active?.active_org_id) {
yield* Effect.gen(function* () {
const [config, token] = yield* Effect.promise(() =>
Promise.all([Account.config(active.id, active.active_org_id!), Account.token(active.id)]),
)
if (token) {
process.env["OPENCODE_CONSOLE_TOKEN"] = token
Env.set("OPENCODE_CONSOLE_TOKEN", token)
}
if (config) {
result = mergeConfigConcatArrays(
result,
yield* loadConfig(JSON.stringify(config), {
dir: path.dirname(`${active.url}/api/config`),
source: `${active.url}/api/config`,
}),
)
}
}).pipe(
Effect.catchDefect((err) => {
log.debug("failed to fetch remote account config", {
error: err instanceof Error ? err.message : String(err),
})
return Effect.void
}),
)
}
if (existsSync(managedDir)) {
for (const file of ["opencode.jsonc", "opencode.json"]) {
result = mergeConfigConcatArrays(result, yield* loadFile(path.join(managedDir, file)))
}
}
for (const [name, mode] of Object.entries(result.mode ?? {})) {
result.agent = mergeDeep(result.agent ?? {}, {
[name]: {
...mode,
mode: "primary" as const,
},
})
}
if (Flag.OPENCODE_PERMISSION) {
result.permission = mergeDeep(result.permission ?? {}, JSON.parse(Flag.OPENCODE_PERMISSION))
}
if (result.tools) {
const perms: Record<string, Config.PermissionAction> = {}
for (const [tool, enabled] of Object.entries(result.tools)) {
const action: Config.PermissionAction = enabled ? "allow" : "deny"
if (tool === "write" || tool === "edit" || tool === "patch" || tool === "multiedit") {
perms.edit = action
continue
}
perms[tool] = action
}
result.permission = mergeDeep(perms, result.permission ?? {})
}
if (!result.username) result.username = os.userInfo().username
if (result.autoshare === true && !result.share) {
result.share = "auto"
}
if (Flag.OPENCODE_DISABLE_AUTOCOMPACT) {
result.compaction = { ...result.compaction, auto: false }
}
if (Flag.OPENCODE_DISABLE_PRUNE) {
result.compaction = { ...result.compaction, prune: false }
}
result.plugin = deduplicatePlugins(result.plugin ?? [])
return {
config: result,
directories,
deps,
}
})
const state = yield* InstanceState.make<State>(
Effect.fn("Config.state")(function* (ctx) {
return yield* loadInstanceState(ctx)
}),
)
const get = Effect.fn("Config.get")(function* () {
return yield* InstanceState.use(state, (s) => s.config)
})
const directories = Effect.fn("Config.directories")(function* () {
return yield* InstanceState.use(state, (s) => s.directories)
})
const waitForDependencies = Effect.fn("Config.waitForDependencies")(function* () {
yield* InstanceState.useEffect(state, (s) =>
Effect.promise(() => Promise.all(s.deps).then(() => undefined)),
)
})
const update = Effect.fn("Config.update")(function* (config: Info) {
const file = path.join(Instance.directory, "config.json")
const existing = yield* loadFile(file)
yield* fs.writeFileString(file, JSON.stringify(mergeDeep(existing, config), null, 2)).pipe(Effect.orDie)
yield* Effect.promise(() => Instance.dispose())
})
const invalidate = Effect.fn("Config.invalidate")(function* (wait?: boolean) {
cachedGlobal = yield* Effect.cached(
loadGlobal().pipe(Effect.orElseSucceed(() => ({}) as Info)),
)
const task = Instance.disposeAll()
.catch(() => undefined)
.finally(() =>
GlobalBus.emit("event", {
directory: "global",
payload: {
type: Event.Disposed.type,
properties: {},
},
}),
)
if (wait) yield* Effect.promise(() => task)
else void task
})
const updateGlobal = Effect.fn("Config.updateGlobal")(function* (config: Info) {
const file = globalConfigFile()
const before = (yield* readConfigFile(file)) ?? "{}"
let next: Info
if (!file.endsWith(".jsonc")) {
const existing = parseConfig(before, file)
const merged = mergeDeep(existing, config)
yield* fs.writeFileString(file, JSON.stringify(merged, null, 2)).pipe(Effect.orDie)
next = merged
} else {
const updated = patchJsonc(before, config)
next = parseConfig(updated, file)
yield* fs.writeFileString(file, updated).pipe(Effect.orDie)
}
yield* invalidate()
return next
})
return Service.of({
get,
getGlobal,
update,
updateGlobal,
invalidate,
directories,
waitForDependencies,
})
}),
)
export const defaultLayer = layer.pipe(Layer.provide(AppFileSystem.defaultLayer))
const { runPromise } = makeRuntime(Service, defaultLayer)
export async function get() {
return runPromise((svc) => svc.get())
}
export async function getGlobal() {
return runPromise((svc) => svc.getGlobal())
}
export async function update(config: Info) {
return runPromise((svc) => svc.update(config))
}
export async function updateGlobal(config: Info) {
return runPromise((svc) => svc.updateGlobal(config))
}
export async function invalidate(wait = false) {
return runPromise((svc) => svc.invalidate(wait))
}
export async function directories() {
return state().then((x) => x.directories)
return runPromise((svc) => svc.directories())
}
export async function waitForDependencies() {
return runPromise((svc) => svc.waitForDependencies())
}
}
Filesystem.write
Filesystem.write

View File

@ -1,5 +1,5 @@
import { Effect, ScopedCache, Scope } from "effect"
import { Instance, type Shape } from "@/project/instance"
import { Instance, type InstanceContext } from "@/project/instance"
import { registerDisposer } from "./instance-registry"
const TypeId = "~opencode/InstanceState"
@ -11,7 +11,7 @@ export interface InstanceState<A, E = never, R = never> {
export namespace InstanceState {
export const make = <A, E = never, R = never>(
init: (ctx: Shape) => Effect.Effect<A, E, R | Scope.Scope>,
init: (ctx: InstanceContext) => Effect.Effect<A, E, R | Scope.Scope>,
): Effect.Effect<InstanceState<A, E, Exclude<R, Scope.Scope>>, never, R | Scope.Scope> =>
Effect.gen(function* () {
const cache = yield* ScopedCache.make<string, A, E, R>({

View File

@ -7,13 +7,14 @@ import { Context } from "../util/context"
import { Project } from "./project"
import { State } from "./state"
export interface Shape {
export interface InstanceContext {
directory: string
worktree: string
project: Project.Info
}
const context = Context.create<Shape>("instance")
const cache = new Map<string, Promise<Shape>>()
const context = Context.create<InstanceContext>("instance")
const cache = new Map<string, Promise<InstanceContext>>()
const disposal = {
all: undefined as Promise<void> | undefined,
@ -52,7 +53,7 @@ function boot(input: { directory: string; init?: () => Promise<any>; project?: P
})
}
function track(directory: string, next: Promise<Shape>) {
function track(directory: string, next: Promise<InstanceContext>) {
const task = next.catch((error) => {
if (cache.get(directory) === task) cache.delete(directory)
throw error

View File

@ -34,7 +34,7 @@ async function check(map: (dir: string) => string) {
await using tmp = await tmpdir({ git: true, config: { snapshot: true } })
const prev = Global.Path.config
;(Global.Path as { config: string }).config = globalTmp.path
Config.global.reset()
await Config.invalidate()
try {
await writeConfig(globalTmp.path, {
$schema: "https://opencode.ai/config.json",
@ -52,7 +52,7 @@ async function check(map: (dir: string) => string) {
} finally {
await Instance.disposeAll()
;(Global.Path as { config: string }).config = prev
Config.global.reset()
await Config.invalidate()
}
}