summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
-rw-r--r--packages/opencode/src/config/config.ts839
-rw-r--r--packages/opencode/test/config/config.test.ts26
-rw-r--r--packages/shared/src/npm.ts8
-rw-r--r--packages/shared/src/util/effect-flock.ts2
4 files changed, 433 insertions, 442 deletions
diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts
index 58d9343ad..43ec8d709 100644
--- a/packages/opencode/src/config/config.ts
+++ b/packages/opencode/src/config/config.ts
@@ -34,7 +34,8 @@ import type { ConsoleState } from "./console-state"
import { AppFileSystem } from "@opencode-ai/shared/filesystem"
import { InstanceState } from "@/effect/instance-state"
import { Context, Duration, Effect, Exit, Fiber, Layer, Option } from "effect"
-import { Flock } from "@opencode-ai/shared/util/flock"
+import { EffectFlock } from "@opencode-ai/shared/util/effect-flock"
+
import { isPathPluginSpec, parsePluginSpecifier, resolvePathPluginTarget } from "@/plugin/shared"
import { Npm } from "../npm"
import { InstanceRef } from "@/effect/instance-ref"
@@ -1144,497 +1145,483 @@ export const ConfigDirectoryTypoError = NamedError.create(
}),
)
-export const layer: Layer.Layer<Service, never, AppFileSystem.Service | Auth.Service | Account.Service | Env.Service> =
- Layer.effect(
- Service,
- Effect.gen(function* () {
- const fs = yield* AppFileSystem.Service
- const authSvc = yield* Auth.Service
- const accountSvc = yield* Account.Service
- const env = yield* Env.Service
-
- 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,
- )
- })
+export const layer: Layer.Layer<
+ Service,
+ never,
+ AppFileSystem.Service | Auth.Service | Account.Service | Env.Service | EffectFlock.Service
+> = Layer.effect(
+ Service,
+ Effect.gen(function* () {
+ const fs = yield* AppFileSystem.Service
+ const authSvc = yield* Auth.Service
+ const accountSvc = yield* Account.Service
+ const env = yield* Env.Service
+ const flock = yield* EffectFlock.Service
+
+ 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,
+ )
+ })
- 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 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) {
- const list = data.plugin
- for (let i = 0; i < list.length; i++) {
- list[i] = yield* Effect.promise(() => resolvePluginSpec(list[i], options.path))
- }
+ 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) {
+ const list = data.plugin
+ for (let i = 0; i < list.length; i++) {
+ list[i] = yield* Effect.promise(() => resolvePluginSpec(list[i], options.path))
}
- return data
}
+ return data
+ }
- throw new InvalidError({
- path: source,
- issues: parsed.error.issues,
- })
+ throw new InvalidError({
+ path: source,
+ issues: parsed.error.issues,
})
+ })
- 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 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 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(() => {}),
- )
- }
+ 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
- })
+ return result
+ })
- const [cachedGlobal, invalidateGlobal] = yield* Effect.cachedInvalidateWithTTL(
- loadGlobal().pipe(
- Effect.tapError((error) =>
- Effect.sync(() => log.error("failed to load global config, using defaults", { error: String(error) })),
- ),
- Effect.orElseSucceed((): Info => ({})),
+ const [cachedGlobal, invalidateGlobal] = yield* Effect.cachedInvalidateWithTTL(
+ loadGlobal().pipe(
+ Effect.tapError((error) =>
+ Effect.sync(() => log.error("failed to load global config, using defaults", { error: String(error) })),
),
- Duration.infinity,
- )
-
- const getGlobal = Effect.fn("Config.getGlobal")(function* () {
- return yield* cachedGlobal
- })
+ Effect.orElseSucceed((): Info => ({})),
+ ),
+ Duration.infinity,
+ )
- const install = Effect.fn("Config.install")(function* (dir: string) {
- const pkg = path.join(dir, "package.json")
- const gitignore = path.join(dir, ".gitignore")
- const plugin = path.join(dir, "node_modules", "@opencode-ai", "plugin", "package.json")
- const target = Installation.isLocal() ? "*" : Installation.VERSION
- const json = yield* fs.readJson(pkg).pipe(
- Effect.catch(() => Effect.succeed({} satisfies Package)),
- Effect.map((x): Package => (isRecord(x) ? (x as Package) : {})),
- )
- const hasDep = json.dependencies?.["@opencode-ai/plugin"] === target
- const hasIgnore = yield* fs.existsSafe(gitignore)
- const hasPkg = yield* fs.existsSafe(plugin)
-
- if (!hasDep) {
- yield* fs.writeJson(pkg, {
- ...json,
- dependencies: {
- ...json.dependencies,
- "@opencode-ai/plugin": target,
- },
- })
- }
+ const getGlobal = Effect.fn("Config.getGlobal")(function* () {
+ return yield* cachedGlobal
+ })
- if (!hasIgnore) {
- yield* fs.writeFileString(
- gitignore,
- ["node_modules", "package.json", "package-lock.json", "bun.lock", ".gitignore"].join("\n"),
- )
- }
+ const install = Effect.fn("Config.install")(function* (dir: string) {
+ const pkg = path.join(dir, "package.json")
+ const gitignore = path.join(dir, ".gitignore")
+ const plugin = path.join(dir, "node_modules", "@opencode-ai", "plugin", "package.json")
+ const target = Installation.isLocal() ? "*" : Installation.VERSION
+ const json = yield* fs.readJson(pkg).pipe(
+ Effect.catch(() => Effect.succeed({} satisfies Package)),
+ Effect.map((x): Package => (isRecord(x) ? (x as Package) : {})),
+ )
+ const hasDep = json.dependencies?.["@opencode-ai/plugin"] === target
+ const hasIgnore = yield* fs.existsSafe(gitignore)
+ const hasPkg = yield* fs.existsSafe(plugin)
+
+ if (!hasDep) {
+ yield* fs.writeJson(pkg, {
+ ...json,
+ dependencies: {
+ ...json.dependencies,
+ "@opencode-ai/plugin": target,
+ },
+ })
+ }
- if (hasDep && hasIgnore && hasPkg) return
+ if (!hasIgnore) {
+ yield* fs.writeFileString(
+ gitignore,
+ ["node_modules", "package.json", "package-lock.json", "bun.lock", ".gitignore"].join("\n"),
+ )
+ }
- yield* Effect.promise(() => Npm.install(dir))
- })
+ if (hasDep && hasIgnore && hasPkg) return
- const installDependencies = Effect.fn("Config.installDependencies")(function* (
- dir: string,
- input?: InstallInput,
- ) {
- if (
- !(yield* fs.access(dir, { writable: true }).pipe(
- Effect.as(true),
- Effect.orElseSucceed(() => false),
- ))
- )
- return
-
- const key =
- process.platform === "win32" ? "config-install:win32" : `config-install:${AppFileSystem.resolve(dir)}`
-
- yield* Effect.acquireUseRelease(
- Effect.promise((signal) =>
- Flock.acquire(key, {
- signal,
- onWait: (tick) =>
- input?.waitTick?.({
- dir,
- attempt: tick.attempt,
- delay: tick.delay,
- waited: tick.waited,
- }),
- }),
- ),
- () => install(dir),
- (lease) => Effect.promise(() => lease.release()),
- )
- })
+ yield* Effect.promise(() => Npm.install(dir))
+ })
- const loadInstanceState = Effect.fn("Config.loadInstanceState")(function* (ctx: InstanceContext) {
- const auth = yield* authSvc.all().pipe(Effect.orDie)
+ const installDependencies = Effect.fn("Config.installDependencies")(function* (dir: string, input?: InstallInput) {
+ if (
+ !(yield* fs.access(dir, { writable: true }).pipe(
+ Effect.as(true),
+ Effect.orElseSucceed(() => false),
+ ))
+ )
+ return
- let result: Info = {}
- const consoleManagedProviders = new Set<string>()
- let activeOrgName: string | undefined
+ const key = process.platform === "win32" ? "config-install:win32" : `config-install:${AppFileSystem.resolve(dir)}`
- const scope = Effect.fnUntraced(function* (source: string) {
- if (source.startsWith("http://") || source.startsWith("https://")) return "global"
- if (source === "OPENCODE_CONFIG_CONTENT") return "local"
- if (yield* InstanceRef.use((ctx) => Effect.succeed(Instance.containsPath(source, ctx)))) return "local"
- return "global"
- })
+ yield* flock.withLock(install(dir), key).pipe(Effect.orDie)
+ })
- const track = Effect.fnUntraced(function* (source: string, list: PluginSpec[] | undefined, kind?: PluginScope) {
- if (!list?.length) return
- const hit = kind ?? (yield* scope(source))
- const plugins = deduplicatePluginOrigins([
- ...(result.plugin_origins ?? []),
- ...list.map((spec) => ({ spec, source, scope: hit })),
- ])
- result.plugin = plugins.map((item) => item.spec)
- result.plugin_origins = plugins
- })
+ const loadInstanceState = Effect.fn("Config.loadInstanceState")(function* (ctx: InstanceContext) {
+ const auth = yield* authSvc.all().pipe(Effect.orDie)
- const merge = (source: string, next: Info, kind?: PluginScope) => {
- result = mergeConfigConcatArrays(result, next)
- return track(source, next.plugin, kind)
- }
+ let result: Info = {}
+ const consoleManagedProviders = new Set<string>()
+ let activeOrgName: string | undefined
- 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"
- const source = `${url}/.well-known/opencode`
- const next = yield* loadConfig(JSON.stringify(remoteConfig), {
- dir: path.dirname(source),
- source,
- })
- yield* merge(source, next, "global")
- log.debug("loaded remote config from well-known", { url })
- }
- }
+ const scope = Effect.fnUntraced(function* (source: string) {
+ if (source.startsWith("http://") || source.startsWith("https://")) return "global"
+ if (source === "OPENCODE_CONFIG_CONTENT") return "local"
+ if (yield* InstanceRef.use((ctx) => Effect.succeed(Instance.containsPath(source, ctx)))) return "local"
+ return "global"
+ })
- const global = yield* getGlobal()
- yield* merge(Global.Path.config, global, "global")
+ const track = Effect.fnUntraced(function* (source: string, list: PluginSpec[] | undefined, kind?: PluginScope) {
+ if (!list?.length) return
+ const hit = kind ?? (yield* scope(source))
+ const plugins = deduplicatePluginOrigins([
+ ...(result.plugin_origins ?? []),
+ ...list.map((spec) => ({ spec, source, scope: hit })),
+ ])
+ result.plugin = plugins.map((item) => item.spec)
+ result.plugin_origins = plugins
+ })
- if (Flag.OPENCODE_CONFIG) {
- yield* merge(Flag.OPENCODE_CONFIG, yield* loadFile(Flag.OPENCODE_CONFIG))
- log.debug("loaded custom config", { path: Flag.OPENCODE_CONFIG })
- }
+ const merge = (source: string, next: Info, kind?: PluginScope) => {
+ result = mergeConfigConcatArrays(result, next)
+ return track(source, next.plugin, kind)
+ }
- if (!Flag.OPENCODE_DISABLE_PROJECT_CONFIG) {
- for (const file of yield* Effect.promise(() =>
- ConfigPaths.projectFiles("opencode", ctx.directory, ctx.worktree),
- )) {
- yield* merge(file, yield* loadFile(file), "local")
+ 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"
+ const source = `${url}/.well-known/opencode`
+ const next = yield* loadConfig(JSON.stringify(remoteConfig), {
+ dir: path.dirname(source),
+ source,
+ })
+ yield* merge(source, next, "global")
+ log.debug("loaded remote config from well-known", { url })
}
+ }
- result.agent = result.agent || {}
- result.mode = result.mode || {}
- result.plugin = result.plugin || []
+ const global = yield* getGlobal()
+ yield* merge(Global.Path.config, global, "global")
- const directories = yield* Effect.promise(() => ConfigPaths.directories(ctx.directory, ctx.worktree))
+ if (Flag.OPENCODE_CONFIG) {
+ yield* merge(Flag.OPENCODE_CONFIG, yield* loadFile(Flag.OPENCODE_CONFIG))
+ log.debug("loaded custom config", { path: Flag.OPENCODE_CONFIG })
+ }
- if (Flag.OPENCODE_CONFIG_DIR) {
- log.debug("loading config from OPENCODE_CONFIG_DIR", { path: Flag.OPENCODE_CONFIG_DIR })
+ if (!Flag.OPENCODE_DISABLE_PROJECT_CONFIG) {
+ for (const file of yield* Effect.promise(() =>
+ ConfigPaths.projectFiles("opencode", ctx.directory, ctx.worktree),
+ )) {
+ yield* merge(file, yield* loadFile(file), "local")
}
+ }
- const deps: Fiber.Fiber<void, never>[] = []
-
- for (const dir of unique(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))
- result.agent ??= {}
- result.mode ??= {}
- result.plugin ??= []
- }
- }
+ result.agent = result.agent || {}
+ result.mode = result.mode || {}
+ result.plugin = result.plugin || []
- const dep = yield* installDependencies(dir).pipe(
- Effect.exit,
- Effect.tap((exit) =>
- Exit.isFailure(exit)
- ? Effect.sync(() => {
- log.warn("background dependency install failed", { dir, error: String(exit.cause) })
- })
- : Effect.void,
- ),
- Effect.asVoid,
- Effect.forkScoped,
- )
- deps.push(dep)
+ const directories = yield* Effect.promise(() => ConfigPaths.directories(ctx.directory, ctx.worktree))
- 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)))
- const list = yield* Effect.promise(() => loadPlugin(dir))
- yield* track(dir, list)
- }
+ if (Flag.OPENCODE_CONFIG_DIR) {
+ log.debug("loading config from OPENCODE_CONFIG_DIR", { path: Flag.OPENCODE_CONFIG_DIR })
+ }
- if (process.env.OPENCODE_CONFIG_CONTENT) {
- const source = "OPENCODE_CONFIG_CONTENT"
- const next = yield* loadConfig(process.env.OPENCODE_CONFIG_CONTENT, {
- dir: ctx.directory,
- source,
- })
- yield* merge(source, next, "local")
- log.debug("loaded custom config from OPENCODE_CONFIG_CONTENT")
+ const deps: Fiber.Fiber<void, never>[] = []
+
+ for (const dir of unique(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))
+ result.agent ??= {}
+ result.mode ??= {}
+ result.plugin ??= []
+ }
}
- const activeAccount = Option.getOrUndefined(
- yield* accountSvc.active().pipe(Effect.catch(() => Effect.succeed(Option.none()))),
+ const dep = yield* installDependencies(dir).pipe(
+ Effect.exit,
+ Effect.tap((exit) =>
+ Exit.isFailure(exit)
+ ? Effect.sync(() => {
+ log.warn("background dependency install failed", { dir, error: String(exit.cause) })
+ })
+ : Effect.void,
+ ),
+ Effect.asVoid,
+ Effect.forkScoped,
)
- 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)
- }
+ deps.push(dep)
- 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) => {
- log.debug("failed to fetch remote account config", {
- error: err instanceof Error ? err.message : String(err),
- })
- return Effect.void
- }),
+ 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)))
+ const list = yield* Effect.promise(() => loadPlugin(dir))
+ yield* track(dir, list)
+ }
+
+ if (process.env.OPENCODE_CONFIG_CONTENT) {
+ const source = "OPENCODE_CONFIG_CONTENT"
+ const next = yield* loadConfig(process.env.OPENCODE_CONFIG_CONTENT, {
+ dir: ctx.directory,
+ source,
+ })
+ yield* merge(source, next, "local")
+ log.debug("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 (existsSync(managedDir)) {
- for (const file of ["opencode.json", "opencode.jsonc"]) {
- const source = path.join(managedDir, file)
- yield* merge(source, yield* loadFile(source), "global")
+ 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) => {
+ 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.json", "opencode.jsonc"]) {
+ const source = path.join(managedDir, file)
+ yield* merge(source, yield* loadFile(source), "global")
}
+ }
- // macOS managed preferences (.mobileconfig deployed via MDM) override everything
- result = mergeConfigConcatArrays(result, yield* Effect.promise(() => readManagedPreferences()))
+ // macOS managed preferences (.mobileconfig deployed via MDM) override everything
+ result = mergeConfigConcatArrays(result, yield* Effect.promise(() => readManagedPreferences()))
- for (const [name, mode] of Object.entries(result.mode ?? {})) {
- result.agent = mergeDeep(result.agent ?? {}, {
- [name]: {
- ...mode,
- mode: "primary" as const,
- },
- })
- }
+ 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 (Flag.OPENCODE_PERMISSION) {
+ result.permission = mergeDeep(result.permission ?? {}, JSON.parse(Flag.OPENCODE_PERMISSION))
+ }
- if (result.tools) {
- const perms: Record<string, PermissionAction> = {}
- for (const [tool, enabled] of Object.entries(result.tools)) {
- const action: PermissionAction = enabled ? "allow" : "deny"
- if (tool === "write" || tool === "edit" || tool === "patch" || tool === "multiedit") {
- perms.edit = action
- continue
- }
- perms[tool] = action
+ if (result.tools) {
+ const perms: Record<string, PermissionAction> = {}
+ for (const [tool, enabled] of Object.entries(result.tools)) {
+ const action: PermissionAction = enabled ? "allow" : "deny"
+ if (tool === "write" || tool === "edit" || tool === "patch" || tool === "multiedit") {
+ perms.edit = action
+ continue
}
- result.permission = mergeDeep(perms, result.permission ?? {})
+ perms[tool] = action
}
+ result.permission = mergeDeep(perms, result.permission ?? {})
+ }
- if (!result.username) result.username = os.userInfo().username
+ if (!result.username) result.username = os.userInfo().username
- if (result.autoshare === true && !result.share) {
- result.share = "auto"
- }
+ 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 }
- }
+ if (Flag.OPENCODE_DISABLE_AUTOCOMPACT) {
+ result.compaction = { ...result.compaction, auto: false }
+ }
+ if (Flag.OPENCODE_DISABLE_PRUNE) {
+ result.compaction = { ...result.compaction, prune: false }
+ }
- return {
- config: result,
- directories,
- deps,
- consoleState: {
- consoleManagedProviders: Array.from(consoleManagedProviders),
- activeOrgName,
- switchableOrgCount: 0,
- },
- }
- })
+ return {
+ config: result,
+ directories,
+ deps,
+ consoleState: {
+ consoleManagedProviders: Array.from(consoleManagedProviders),
+ activeOrgName,
+ switchableOrgCount: 0,
+ },
+ }
+ })
- const state = yield* InstanceState.make<State>(
- Effect.fn("Config.state")(function* (ctx) {
- return yield* loadInstanceState(ctx)
- }),
- )
+ 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 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 directories = Effect.fn("Config.directories")(function* () {
+ return yield* InstanceState.use(state, (s) => s.directories)
+ })
- const getConsoleState = Effect.fn("Config.getConsoleState")(function* () {
- return yield* InstanceState.use(state, (s) => s.consoleState)
- })
+ const getConsoleState = Effect.fn("Config.getConsoleState")(function* () {
+ return yield* InstanceState.use(state, (s) => s.consoleState)
+ })
- const waitForDependencies = Effect.fn("Config.waitForDependencies")(function* () {
- yield* InstanceState.useEffect(state, (s) =>
- Effect.forEach(s.deps, Fiber.join, { concurrency: "unbounded" }).pipe(Effect.asVoid),
- )
- })
+ const waitForDependencies = Effect.fn("Config.waitForDependencies")(function* () {
+ yield* InstanceState.useEffect(state, (s) =>
+ Effect.forEach(s.deps, Fiber.join, { concurrency: "unbounded" }).pipe(Effect.asVoid),
+ )
+ })
- const update = Effect.fn("Config.update")(function* (config: Info) {
- const dir = yield* InstanceState.directory
- const file = path.join(dir, "config.json")
- const existing = yield* loadFile(file)
- yield* fs
- .writeFileString(file, JSON.stringify(mergeDeep(writable(existing), writable(config)), null, 2))
- .pipe(Effect.orDie)
- yield* Effect.promise(() => Instance.dispose())
- })
+ const update = Effect.fn("Config.update")(function* (config: Info) {
+ const dir = yield* InstanceState.directory
+ const file = path.join(dir, "config.json")
+ const existing = yield* loadFile(file)
+ yield* fs
+ .writeFileString(file, JSON.stringify(mergeDeep(writable(existing), writable(config)), null, 2))
+ .pipe(Effect.orDie)
+ yield* Effect.promise(() => Instance.dispose())
+ })
- const invalidate = Effect.fn("Config.invalidate")(function* (wait?: boolean) {
- yield* invalidateGlobal
- 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 invalidate = Effect.fn("Config.invalidate")(function* (wait?: boolean) {
+ yield* invalidateGlobal
+ 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)) ?? "{}"
- const input = writable(config)
-
- let next: Info
- if (!file.endsWith(".jsonc")) {
- const existing = parseConfig(before, file)
- const merged = mergeDeep(writable(existing), input)
- yield* fs.writeFileString(file, JSON.stringify(merged, null, 2)).pipe(Effect.orDie)
- next = merged
- } else {
- const updated = patchJsonc(before, input)
- next = parseConfig(updated, file)
- yield* fs.writeFileString(file, updated).pipe(Effect.orDie)
- }
+ const updateGlobal = Effect.fn("Config.updateGlobal")(function* (config: Info) {
+ const file = globalConfigFile()
+ const before = (yield* readConfigFile(file)) ?? "{}"
+ const input = writable(config)
+
+ let next: Info
+ if (!file.endsWith(".jsonc")) {
+ const existing = parseConfig(before, file)
+ const merged = mergeDeep(writable(existing), input)
+ yield* fs.writeFileString(file, JSON.stringify(merged, null, 2)).pipe(Effect.orDie)
+ next = merged
+ } else {
+ const updated = patchJsonc(before, input)
+ next = parseConfig(updated, file)
+ yield* fs.writeFileString(file, updated).pipe(Effect.orDie)
+ }
- yield* invalidate()
- return next
- })
+ yield* invalidate()
+ return next
+ })
- return Service.of({
- get,
- getGlobal,
- getConsoleState,
- installDependencies,
- update,
- updateGlobal,
- invalidate,
- directories,
- waitForDependencies,
- })
- }),
- )
+ return Service.of({
+ get,
+ getGlobal,
+ getConsoleState,
+ installDependencies,
+ update,
+ updateGlobal,
+ invalidate,
+ directories,
+ waitForDependencies,
+ })
+ }),
+)
export const defaultLayer = layer.pipe(
+ Layer.provide(EffectFlock.defaultLayer),
Layer.provide(AppFileSystem.defaultLayer),
Layer.provide(Env.defaultLayer),
Layer.provide(Auth.defaultLayer),
diff --git a/packages/opencode/test/config/config.test.ts b/packages/opencode/test/config/config.test.ts
index 88957c614..8cf410c3d 100644
--- a/packages/opencode/test/config/config.test.ts
+++ b/packages/opencode/test/config/config.test.ts
@@ -2,6 +2,8 @@ import { test, expect, describe, mock, afterEach, beforeEach, spyOn } from "bun:
import { Deferred, Effect, Fiber, Layer, Option } from "effect"
import { NodeFileSystem, NodePath } from "@effect/platform-node"
import { Config } from "../../src/config"
+import { EffectFlock } from "@opencode-ai/shared/util/effect-flock"
+
import { Instance } from "../../src/project/instance"
import { Auth } from "../../src/auth"
import { AccessToken, Account, AccountID, OrgID } from "../../src/account"
@@ -34,7 +36,10 @@ const emptyAuth = Layer.mock(Auth.Service)({
all: () => Effect.succeed({}),
})
+const testFlock = EffectFlock.defaultLayer
+
const layer = Config.layer.pipe(
+ Layer.provide(testFlock),
Layer.provide(AppFileSystem.defaultLayer),
Layer.provide(Env.defaultLayer),
Layer.provide(emptyAuth),
@@ -333,6 +338,7 @@ test("resolves env templates in account config with account token", async () =>
})
const layer = Config.layer.pipe(
+ Layer.provide(testFlock),
Layer.provide(AppFileSystem.defaultLayer),
Layer.provide(Env.defaultLayer),
Layer.provide(emptyAuth),
@@ -879,11 +885,7 @@ it.live("dedupes concurrent config dependency installs for the same dir", () =>
yield* Deferred.await(ready)
let done = false
- const second = yield* installDeps(dir, {
- waitTick: () => {
- Deferred.doneUnsafe(blocked, Effect.void)
- },
- }).pipe(
+ const second = yield* installDeps(dir).pipe(
Effect.tap(() =>
Effect.sync(() => {
done = true
@@ -892,7 +894,8 @@ it.live("dedupes concurrent config dependency installs for the same dir", () =>
Effect.forkScoped,
)
- yield* Deferred.await(blocked)
+ // Give the second fiber time to hit the lock retry loop
+ yield* Effect.sleep(500)
expect(done).toBe(false)
yield* Deferred.succeed(hold, void 0)
@@ -955,12 +958,9 @@ it.live("serializes config dependency installs across dirs", () =>
const first = yield* installDeps(a).pipe(Effect.forkScoped)
yield* Deferred.await(ready)
- const second = yield* installDeps(b, {
- waitTick: () => {
- Deferred.doneUnsafe(blocked, Effect.void)
- },
- }).pipe(Effect.forkScoped)
- yield* Deferred.await(blocked)
+ const second = yield* installDeps(b).pipe(Effect.forkScoped)
+ // Give the second fiber time to hit the lock retry loop
+ yield* Effect.sleep(500)
expect(peak).toBe(1)
yield* Deferred.succeed(hold, void 0)
@@ -1826,6 +1826,7 @@ test("project config overrides remote well-known config", async () => {
})
const layer = Config.layer.pipe(
+ Layer.provide(testFlock),
Layer.provide(AppFileSystem.defaultLayer),
Layer.provide(Env.defaultLayer),
Layer.provide(fakeAuth),
@@ -1882,6 +1883,7 @@ test("wellknown URL with trailing slash is normalized", async () => {
})
const layer = Config.layer.pipe(
+ Layer.provide(testFlock),
Layer.provide(AppFileSystem.defaultLayer),
Layer.provide(Env.defaultLayer),
Layer.provide(fakeAuth),
diff --git a/packages/shared/src/npm.ts b/packages/shared/src/npm.ts
index 994ec04da..8bd0cc468 100644
--- a/packages/shared/src/npm.ts
+++ b/packages/shared/src/npm.ts
@@ -5,7 +5,7 @@ import { Effect, Schema, Context, Layer, Option, FileSystem } from "effect"
import { NodeFileSystem } from "@effect/platform-node"
import { AppFileSystem } from "./filesystem"
import { Global } from "./global"
-import { Flock } from "./util/flock"
+import { EffectFlock } from "./util/effect-flock"
export namespace Npm {
export class InstallFailedError extends Schema.TaggedErrorClass<InstallFailedError>()("NpmInstallFailedError", {
@@ -62,6 +62,7 @@ export namespace Npm {
const afs = yield* AppFileSystem.Service
const global = yield* Global.Service
const fs = yield* FileSystem.FileSystem
+ const flock = yield* EffectFlock.Service
const directory = (pkg: string) => path.join(global.cache, "packages", sanitize(pkg))
const outdated = Effect.fn("Npm.outdated")(function* (pkg: string, cachedVersion: string) {
@@ -92,7 +93,7 @@ export namespace Npm {
const add = Effect.fn("Npm.add")(function* (pkg: string) {
const dir = directory(pkg)
- yield* Flock.effect(`npm-install:${dir}`)
+ yield* flock.acquire(`npm-install:${dir}`)
const arborist = new Arborist({
path: dir,
@@ -133,7 +134,7 @@ export namespace Npm {
}, Effect.scoped)
const install = Effect.fn("Npm.install")(function* (dir: string) {
- yield* Flock.effect(`npm-install:${dir}`)
+ yield* flock.acquire(`npm-install:${dir}`)
const reify = Effect.fnUntraced(function* () {
const arb = new Arborist({
@@ -240,6 +241,7 @@ export namespace Npm {
)
export const defaultLayer = layer.pipe(
+ Layer.provide(EffectFlock.layer),
Layer.provide(AppFileSystem.layer),
Layer.provide(Global.layer),
Layer.provide(NodeFileSystem.layer),
diff --git a/packages/shared/src/util/effect-flock.ts b/packages/shared/src/util/effect-flock.ts
index d728c0ef1..3e00afc9e 100644
--- a/packages/shared/src/util/effect-flock.ts
+++ b/packages/shared/src/util/effect-flock.ts
@@ -274,5 +274,5 @@ export namespace EffectFlock {
}),
)
- export const live = layer.pipe(Layer.provide(AppFileSystem.defaultLayer))
+ export const defaultLayer = layer.pipe(Layer.provide(AppFileSystem.defaultLayer), Layer.provide(Global.layer))
}