effectify Config service (#19139)
parent
38450443b1
commit
28f5176ffd
|
|
@ -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.
|
- 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.
|
- 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 — ALS for native callbacks
|
||||||
|
|
||||||
`Instance.bind(fn)` captures the current Instance AsyncLocalStorage context and restores it synchronously when called.
|
`Instance.bind(fn)` captures the current Instance AsyncLocalStorage context and restores it synchronously when called.
|
||||||
|
|
|
||||||
|
|
@ -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.
|
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
|
## Scheduled Tasks
|
||||||
|
|
||||||
For loops or periodic work, use `Effect.repeat` or `Effect.schedule` with `Effect.forkScoped` in the layer definition.
|
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] `Worktree`
|
||||||
- [x] `Bus`
|
- [x] `Bus`
|
||||||
- [x] `Command`
|
- [x] `Command`
|
||||||
- [ ] `Config`
|
- [x] `Config`
|
||||||
- [ ] `Session`
|
- [ ] `Session`
|
||||||
- [ ] `SessionProcessor`
|
- [ ] `SessionProcessor`
|
||||||
- [ ] `SessionPrompt`
|
- [ ] `SessionPrompt`
|
||||||
|
|
|
||||||
|
|
@ -149,8 +149,7 @@ export const rpc = {
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
async reload() {
|
async reload() {
|
||||||
Config.global.reset()
|
await Config.invalidate(true)
|
||||||
await Instance.disposeAll()
|
|
||||||
},
|
},
|
||||||
async setWorkspace(input: { workspaceID?: string }) {
|
async setWorkspace(input: { workspaceID?: string }) {
|
||||||
startEventStream({ directory: process.cwd(), workspaceID: input.workspaceID })
|
startEventStream({ directory: process.cwd(), workspaceID: input.workspaceID })
|
||||||
|
|
|
||||||
|
|
@ -37,7 +37,7 @@ export function withNetworkOptions<T>(yargs: Argv<T>) {
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function resolveNetworkOptions(args: NetworkOptions) {
|
export async function resolveNetworkOptions(args: NetworkOptions) {
|
||||||
const config = await Config.global()
|
const config = await Config.getGlobal()
|
||||||
const portExplicitlySet = process.argv.includes("--port")
|
const portExplicitlySet = process.argv.includes("--port")
|
||||||
const hostnameExplicitlySet = process.argv.includes("--hostname")
|
const hostnameExplicitlySet = process.argv.includes("--hostname")
|
||||||
const mdnsExplicitlySet = process.argv.includes("--mdns")
|
const mdnsExplicitlySet = process.argv.includes("--mdns")
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@ import { Flag } from "@/flag/flag"
|
||||||
import { Installation } from "@/installation"
|
import { Installation } from "@/installation"
|
||||||
|
|
||||||
export async function upgrade() {
|
export async function upgrade() {
|
||||||
const config = await Config.global()
|
const config = await Config.getGlobal()
|
||||||
const method = await Installation.method()
|
const method = await Installation.method()
|
||||||
const latest = await Installation.latest(method).catch(() => {})
|
const latest = await Installation.latest(method).catch(() => {})
|
||||||
if (!latest) return
|
if (!latest) return
|
||||||
|
|
|
||||||
|
|
@ -1,14 +1,13 @@
|
||||||
import { Log } from "../util/log"
|
import { Log } from "../util/log"
|
||||||
import path from "path"
|
import path from "path"
|
||||||
import { pathToFileURL, fileURLToPath } from "url"
|
import { pathToFileURL } from "url"
|
||||||
import { createRequire } from "module"
|
import { createRequire } from "module"
|
||||||
import os from "os"
|
import os from "os"
|
||||||
import z from "zod"
|
import z from "zod"
|
||||||
import { ModelsDev } from "../provider/models"
|
import { ModelsDev } from "../provider/models"
|
||||||
import { mergeDeep, pipe, unique } from "remeda"
|
import { mergeDeep, pipe, unique } from "remeda"
|
||||||
import { Global } from "../global"
|
import { Global } from "../global"
|
||||||
import fs from "fs/promises"
|
import fsNode from "fs/promises"
|
||||||
import { lazy } from "../util/lazy"
|
|
||||||
import { NamedError } from "@opencode-ai/util/error"
|
import { NamedError } from "@opencode-ai/util/error"
|
||||||
import { Flag } from "../flag/flag"
|
import { Flag } from "../flag/flag"
|
||||||
import { Auth } from "../auth"
|
import { Auth } from "../auth"
|
||||||
|
|
@ -20,7 +19,7 @@ import {
|
||||||
parse as parseJsonc,
|
parse as parseJsonc,
|
||||||
printParseErrorCode,
|
printParseErrorCode,
|
||||||
} from "jsonc-parser"
|
} from "jsonc-parser"
|
||||||
import { Instance } from "../project/instance"
|
import { Instance, type InstanceContext } from "../project/instance"
|
||||||
import { LSPServer } from "../lsp/server"
|
import { LSPServer } from "../lsp/server"
|
||||||
import { BunProc } from "@/bun"
|
import { BunProc } from "@/bun"
|
||||||
import { Installation } from "@/installation"
|
import { Installation } from "@/installation"
|
||||||
|
|
@ -38,6 +37,10 @@ import { ConfigPaths } from "./paths"
|
||||||
import { Filesystem } from "@/util/filesystem"
|
import { Filesystem } from "@/util/filesystem"
|
||||||
import { Process } from "@/util/process"
|
import { Process } from "@/util/process"
|
||||||
import { Lock } from "@/util/lock"
|
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 {
|
export namespace Config {
|
||||||
const ModelId = z.string().meta({ $ref: "https://models.dev/model-schema.json#/$defs/Model" })
|
const ModelId = z.string().meta({ $ref: "https://models.dev/model-schema.json#/$defs/Model" })
|
||||||
|
|
@ -75,201 +78,6 @@ export namespace Config {
|
||||||
return merged
|
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) {
|
export async function installDependencies(dir: string) {
|
||||||
const pkg = path.join(dir, "package.json")
|
const pkg = path.join(dir, "package.json")
|
||||||
const targetVersion = Installation.isLocal() ? "*" : Installation.VERSION
|
const targetVersion = Installation.isLocal() ? "*" : Installation.VERSION
|
||||||
|
|
@ -325,7 +133,7 @@ export namespace Config {
|
||||||
|
|
||||||
async function isWritable(dir: string) {
|
async function isWritable(dir: string) {
|
||||||
try {
|
try {
|
||||||
await fs.access(dir, constants.W_OK)
|
await fsNode.access(dir, constants.W_OK)
|
||||||
return true
|
return true
|
||||||
} catch {
|
} catch {
|
||||||
return false
|
return false
|
||||||
|
|
@ -1234,123 +1042,23 @@ export namespace Config {
|
||||||
|
|
||||||
export type Info = z.output<typeof Info>
|
export type Info = z.output<typeof Info>
|
||||||
|
|
||||||
export const global = lazy(async () => {
|
type State = {
|
||||||
let result: Info = pipe(
|
config: Info
|
||||||
{},
|
directories: string[]
|
||||||
mergeDeep(await loadFile(path.join(Global.Path.config, "config.json"))),
|
deps: Promise<void>[]
|
||||||
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 interface Interface {
|
||||||
})
|
readonly get: () => Effect.Effect<Info>
|
||||||
|
readonly getGlobal: () => Effect.Effect<Info>
|
||||||
export const { readFile } = ConfigPaths
|
readonly update: (config: Info) => Effect.Effect<void>
|
||||||
|
readonly updateGlobal: (config: Info) => Effect.Effect<Info>
|
||||||
async function loadFile(filepath: string): Promise<Info> {
|
readonly invalidate: (wait?: boolean) => Effect.Effect<void>
|
||||||
log.info("loading", { path: filepath })
|
readonly directories: () => Effect.Effect<string[]>
|
||||||
const text = await readFile(filepath)
|
readonly waitForDependencies: () => Effect.Effect<void>
|
||||||
if (!text) return {}
|
|
||||||
return load(text, { path: filepath })
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function load(text: string, options: { path: string } | { dir: string; source: string }) {
|
export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/Config") {}
|
||||||
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 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()
|
|
||||||
}
|
|
||||||
|
|
||||||
function globalConfigFile() {
|
function globalConfigFile() {
|
||||||
const candidates = ["opencode.jsonc", "opencode.json", "config.json"].map((file) =>
|
const candidates = ["opencode.jsonc", "opencode.json", "config.json"].map((file) =>
|
||||||
|
|
@ -1417,47 +1125,413 @@ export namespace Config {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function updateGlobal(config: Info) {
|
export const { JsonError, InvalidError } = ConfigPaths
|
||||||
const filepath = globalConfigFile()
|
|
||||||
const before = await Filesystem.readText(filepath).catch((err: any) => {
|
export const ConfigDirectoryTypoError = NamedError.create(
|
||||||
if (err.code === "ENOENT") return "{}"
|
"ConfigDirectoryTypoError",
|
||||||
throw new JsonError({ path: filepath }, { cause: err })
|
z.object({
|
||||||
|
path: z.string(),
|
||||||
|
dir: z.string(),
|
||||||
|
suggestion: z.string(),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
export const layer: Layer.Layer<Service, never, AppFileSystem.Service> = Layer.effect(
|
||||||
|
Service,
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const fs = yield* AppFileSystem.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 next = await (async () => {
|
const loadConfig = Effect.fnUntraced(function* (
|
||||||
if (!filepath.endsWith(".jsonc")) {
|
text: string,
|
||||||
const existing = parseConfig(before, filepath)
|
options: { path: string } | { dir: string; source: string },
|
||||||
const merged = mergeDeep(existing, config)
|
) {
|
||||||
await Filesystem.writeJson(filepath, merged)
|
const original = text
|
||||||
return merged
|
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 updated = patchJsonc(before, config)
|
const normalized = (() => {
|
||||||
const merged = parseConfig(updated, filepath)
|
if (!data || typeof data !== "object" || Array.isArray(data)) return data
|
||||||
await Filesystem.write(filepath, updated)
|
const copy = { ...(data as Record<string, unknown>) }
|
||||||
return merged
|
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
|
||||||
})()
|
})()
|
||||||
|
|
||||||
global.reset()
|
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
|
||||||
|
}
|
||||||
|
|
||||||
void Instance.disposeAll()
|
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 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)
|
.catch(() => undefined)
|
||||||
.finally(() => {
|
.finally(() =>
|
||||||
GlobalBus.emit("event", {
|
GlobalBus.emit("event", {
|
||||||
directory: "global",
|
directory: "global",
|
||||||
payload: {
|
payload: {
|
||||||
type: Event.Disposed.type,
|
type: Event.Disposed.type,
|
||||||
properties: {},
|
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 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() {
|
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
|
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { Effect, ScopedCache, Scope } from "effect"
|
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"
|
import { registerDisposer } from "./instance-registry"
|
||||||
|
|
||||||
const TypeId = "~opencode/InstanceState"
|
const TypeId = "~opencode/InstanceState"
|
||||||
|
|
@ -11,7 +11,7 @@ export interface InstanceState<A, E = never, R = never> {
|
||||||
|
|
||||||
export namespace InstanceState {
|
export namespace InstanceState {
|
||||||
export const make = <A, E = never, R = never>(
|
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.Effect<InstanceState<A, E, Exclude<R, Scope.Scope>>, never, R | Scope.Scope> =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const cache = yield* ScopedCache.make<string, A, E, R>({
|
const cache = yield* ScopedCache.make<string, A, E, R>({
|
||||||
|
|
|
||||||
|
|
@ -7,13 +7,14 @@ import { Context } from "../util/context"
|
||||||
import { Project } from "./project"
|
import { Project } from "./project"
|
||||||
import { State } from "./state"
|
import { State } from "./state"
|
||||||
|
|
||||||
export interface Shape {
|
export interface InstanceContext {
|
||||||
directory: string
|
directory: string
|
||||||
worktree: string
|
worktree: string
|
||||||
project: Project.Info
|
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 = {
|
const disposal = {
|
||||||
all: undefined as Promise<void> | undefined,
|
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) => {
|
const task = next.catch((error) => {
|
||||||
if (cache.get(directory) === task) cache.delete(directory)
|
if (cache.get(directory) === task) cache.delete(directory)
|
||||||
throw error
|
throw error
|
||||||
|
|
|
||||||
|
|
@ -34,7 +34,7 @@ async function check(map: (dir: string) => string) {
|
||||||
await using tmp = await tmpdir({ git: true, config: { snapshot: true } })
|
await using tmp = await tmpdir({ git: true, config: { snapshot: true } })
|
||||||
const prev = Global.Path.config
|
const prev = Global.Path.config
|
||||||
;(Global.Path as { config: string }).config = globalTmp.path
|
;(Global.Path as { config: string }).config = globalTmp.path
|
||||||
Config.global.reset()
|
await Config.invalidate()
|
||||||
try {
|
try {
|
||||||
await writeConfig(globalTmp.path, {
|
await writeConfig(globalTmp.path, {
|
||||||
$schema: "https://opencode.ai/config.json",
|
$schema: "https://opencode.ai/config.json",
|
||||||
|
|
@ -52,7 +52,7 @@ async function check(map: (dir: string) => string) {
|
||||||
} finally {
|
} finally {
|
||||||
await Instance.disposeAll()
|
await Instance.disposeAll()
|
||||||
;(Global.Path as { config: string }).config = prev
|
;(Global.Path as { config: string }).config = prev
|
||||||
Config.global.reset()
|
await Config.invalidate()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue