Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,10 @@ jobs:
with:
bun-version: "1.3.11"

# Restoring Bun's download cache on Windows was empirically slower than a cold install
# and consumed enough of the 30-minute job budget to starve opencode:test:ci.
- uses: actions/cache@v4
if: runner.os != 'Windows'
with:
path: |
~/.bun/install/cache
Expand Down
200 changes: 131 additions & 69 deletions packages/opencode/src/config/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,12 +22,11 @@ import { Instance, type InstanceContext } from "../project/instance"
import { LSPServer } from "../lsp/server"
import { Installation } from "@/installation"
import { ConfigMarkdown } from "./markdown"
import { constants, existsSync } from "fs"
import { existsSync } from "fs"
import { Bus } from "@/bus"
import { GlobalBus } from "@/bus/global"
import { Event } from "../server/event"
import { Glob } from "../util/glob"
import { iife } from "@/util/iife"
import { Account } from "@/account"
import { isRecord } from "@/util/record"
import { ConfigPaths } from "./paths"
Expand All @@ -36,7 +35,7 @@ import type { ConsoleState } from "./console-state"
import { AppFileSystem } from "@/filesystem"
import { InstanceState } from "@/effect/instance-state"
import { makeRuntime } from "@/effect/run-service"
import { Duration, Effect, Layer, Option, Context } from "effect"
import { Duration, Effect, Layer, Option, Context, Exit, Fiber } from "effect"
import { Flock } from "@/util/flock"
import { isPathPluginSpec, parsePluginSpecifier, resolvePathPluginTarget } from "@/plugin/shared"
import { Npm } from "@/npm"
Expand Down Expand Up @@ -142,65 +141,15 @@ export namespace Config {
export type InstallInput = {
signal?: AbortSignal
waitTick?: (input: { dir: string; attempt: number; delay: number; waited: number }) => void | Promise<void>
queueKey?: string | false
}

type Package = {
dependencies?: Record<string, string>
}

export async function installDependencies(dir: string, input?: InstallInput) {
if (!(await isWritable(dir))) return
const key = process.platform === "win32" ? "config-install:win32" : `config-install:${Filesystem.resolve(dir)}`
await using _ = await Flock.acquire(key, {
signal: input?.signal,
onWait: (tick) =>
input?.waitTick?.({
dir,
attempt: tick.attempt,
delay: tick.delay,
waited: tick.waited,
}),
})
input?.signal?.throwIfAborted()

const pkg = path.join(dir, "package.json")
const plugin = path.join(dir, "node_modules", "@opencode-ai", "plugin", "package.json")
const target = Installation.isLocal() ? "*" : Installation.VERSION
const json = await Filesystem.readJson<Package>(pkg).catch(
(): Package => ({
dependencies: {},
}),
)
const dependencies: Record<string, string> = json.dependencies ?? {}
const hasDep = dependencies["@opencode-ai/plugin"] === target
json.dependencies = {
...dependencies,
"@opencode-ai/plugin": target,
}

const gitignore = path.join(dir, ".gitignore")
const ignore = await Filesystem.exists(gitignore)
const hasPkg = await Filesystem.exists(plugin)
if (!hasDep) {
await Filesystem.writeJson(pkg, json)
}
if (!ignore) {
await Filesystem.write(
gitignore,
["node_modules", "package.json", "package-lock.json", "bun.lock", ".gitignore"].join("\n"),
)
}
if (hasDep && ignore && hasPkg) return
await Npm.install(dir)
}

async function isWritable(dir: string) {
try {
await fsNode.access(dir, constants.W_OK)
return true
} catch {
return false
}
return runPromise((svc) => svc.installDependencies(dir, input))
}

function rel(item: string, patterns: string[]) {
Expand Down Expand Up @@ -1125,19 +1074,23 @@ export namespace Config {
type State = {
config: Info
directories: string[]
deps: Promise<void>[]
deps: {
dir: string
fiber: Fiber.Fiber<Exit.Exit<void, unknown>, never>
}[]
consoleState: ConsoleState
}

export interface Interface {
readonly get: () => Effect.Effect<Info>
readonly getGlobal: () => Effect.Effect<Info>
readonly getConsoleState: () => Effect.Effect<ConsoleState>
readonly installDependencies: (dir: string, input?: InstallInput) => Effect.Effect<void, unknown>
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>
readonly waitForDependencies: (directories?: string[]) => Effect.Effect<void, unknown>
}

export class Service extends Context.Service<Service, Interface>()("@opencode/Config") {}
Expand Down Expand Up @@ -1334,6 +1287,96 @@ export namespace Config {
return yield* cachedGlobal
})

const install = Effect.fnUntraced(function* (dir: string) {
const pkg = path.join(dir, "package.json")
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 dependencies: Record<string, string> = json.dependencies ?? {}
const hasDep = dependencies["@opencode-ai/plugin"] === target
const gitignore = path.join(dir, ".gitignore")
const ignore = yield* fs.existsSafe(gitignore)
const hasPkg = yield* fs.existsSafe(plugin)
if (!hasDep) {
yield* fs.writeJson(pkg, {
...json,
dependencies: {
...dependencies,
"@opencode-ai/plugin": target,
},
})
}
if (!ignore) {
yield* fs.writeFileString(
gitignore,
["node_modules", "package.json", "package-lock.json", "bun.lock", ".gitignore"].join("\n"),
)
}
if (hasDep && ignore && hasPkg) return
yield* Effect.promise(() => Npm.install(dir))
})

const installDependencies = (dir: string, input?: InstallInput) =>
Effect.gen(function* () {
const writable = yield* fs.access(dir, { writable: true }).pipe(
Effect.as(true),
Effect.catch(() => Effect.succeed(false)),
)
if (!writable) return

const controller = new AbortController()
const onAbort = () => controller.abort(input?.signal?.reason)
const scopedKey = `config-install:${Filesystem.resolve(dir)}`
const queueKey = input?.queueKey ?? (process.platform === "win32" ? "config-install:win32" : false)
const signal = (inner: AbortSignal) => AbortSignal.any([controller.signal, inner])
const acquire = (key: string, waitTick = false) =>
Effect.acquireRelease(
Effect.tryPromise({
try: (inner) =>
Flock.acquire(key, {
signal: signal(inner),
onWait: waitTick
? (tick) =>
input?.waitTick?.({
dir,
attempt: tick.attempt,
delay: tick.delay,
waited: tick.waited,
})
: undefined,
}),
catch: (cause) => cause,
}),
(lease) => Effect.promise(() => lease.release()),
{ interruptible: true },
)
yield* Effect.acquireUseRelease(
Effect.sync(() => {
if (!input?.signal) return
if (input.signal.aborted) controller.abort(input.signal.reason)
else input.signal.addEventListener("abort", onAbort, { once: true })
}),
() =>
Effect.scoped(
Effect.gen(function* () {
if (queueKey) {
yield* acquire(queueKey, true)
}
yield* acquire(scopedKey, !queueKey)
input?.signal?.throwIfAborted()
yield* install(dir)
}),
),
() =>
Effect.sync(() => {
input?.signal?.removeEventListener("abort", onAbort)
}),
)
})

const loadInstanceState = Effect.fnUntraced(function* (ctx: InstanceContext) {
const auth = yield* authSvc.all().pipe(Effect.orDie)

Expand Down Expand Up @@ -1416,7 +1459,11 @@ export namespace Config {
log.debug("loading config from OPENCODE_CONFIG_DIR", { path: Flag.OPENCODE_CONFIG_DIR })
}

const deps: Promise<void>[] = []
const deps: State["deps"] = []
const queueKey = (dir: string) => {
if (process.platform !== "win32") return false
return dir.endsWith(".opencode") ? "config-install:win32:local" : "config-install:win32"
}

for (const dir of unique(directories)) {
if (dir.endsWith(".opencode") || dir === Flag.OPENCODE_CONFIG_DIR) {
Expand All @@ -1430,13 +1477,18 @@ export namespace Config {
}
}

const dep = iife(async () => {
await installDependencies(dir)
})
void dep.catch((err) => {
log.warn("background dependency install failed", { dir, error: err })
})
deps.push(dep)
const dep = yield* installDependencies(dir, { queueKey: queueKey(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.forkScoped,
)
deps.push({ dir, fiber: dep })

result.command = mergeDeep(result.command ?? {}, yield* Effect.promise(() => loadCommand(dir)))
result.agent = mergeDeep(result.agent, yield* Effect.promise(() => loadAgent(dir)))
Expand Down Expand Up @@ -1571,8 +1623,17 @@ export namespace Config {
return yield* InstanceState.use(state, (s) => s.consoleState)
})

const waitForDependencies = Effect.fn("Config.waitForDependencies")(function* () {
yield* InstanceState.useEffect(state, (s) => Effect.promise(() => Promise.all(s.deps).then(() => undefined)))
const waitForDependencies = Effect.fn("Config.waitForDependencies")(function* (directories?: string[]) {
const filter = directories ? new Set(directories.map((dir) => Filesystem.resolve(dir))) : undefined
yield* InstanceState.useEffect(state, (s) =>
Effect.forEach(
s.deps.filter((dep) => !filter || filter.has(Filesystem.resolve(dep.dir))),
({ fiber }) => Fiber.join(fiber),
{ concurrency: "unbounded" },
).pipe(
Effect.flatMap((exits) => Effect.forEach(exits, (exit) => exit, { discard: true })),
),
)
})

const update = Effect.fn("Config.update")(function* (config: Info) {
Expand Down Expand Up @@ -1627,6 +1688,7 @@ export namespace Config {
get,
getGlobal,
getConsoleState,
installDependencies,
update,
updateGlobal,
invalidate,
Expand Down Expand Up @@ -1672,7 +1734,7 @@ export namespace Config {
return runPromise((svc) => svc.directories())
}

export async function waitForDependencies() {
return runPromise((svc) => svc.waitForDependencies())
export async function waitForDependencies(directories?: string[]) {
return runPromise((svc) => svc.waitForDependencies(directories))
}
}
9 changes: 5 additions & 4 deletions packages/opencode/src/tool/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -191,7 +191,7 @@ export namespace ToolRegistry {
)
const cfg = yield* config.get()
const rules = Permission.fromConfig(cfg.permission ?? {})
let depsReady = false
const readyDirs = new Set<string>()
for (const match of matches) {
const namespace = path.basename(match, path.extname(match))
const text = yield* Effect.promise(() => Bun.file(match).text())
Expand All @@ -206,9 +206,10 @@ export namespace ToolRegistry {
])
if (ids.length && ids.every((id) => disabled.has(id))) continue
const spec = process.platform === "win32" ? match : pathToFileURL(match).href
if (!depsReady && (yield* Effect.promise(() => needsConfigDependencies(text, path.dirname(path.dirname(match)))))) {
depsReady = true
yield* config.waitForDependencies()
const toolDir = path.dirname(path.dirname(match))
if (!readyDirs.has(toolDir) && (yield* Effect.promise(() => needsConfigDependencies(text, toolDir)))) {
readyDirs.add(toolDir)
yield* config.waitForDependencies([toolDir]).pipe(Effect.orDie)
}
const mod = yield* Effect.promise(() => import(spec))
for (const [id, def] of Object.entries<ToolDefinition>(mod)) {
Expand Down
Loading
Loading