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
204 changes: 135 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,100 @@ export namespace Config {
return yield* cachedGlobal
})

const install = Effect.fnUntraced(function* (dir: string) {
const pkg = path.join(dir, "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 required = {
...dependencies,
"@opencode-ai/plugin": target,
}
const gitignore = path.join(dir, ".gitignore")
const ignore = yield* fs.existsSafe(gitignore)
const installed = yield* Effect.forEach(
Object.keys(required),
(name) => fs.existsSafe(path.join(dir, "node_modules", ...name.split("/"), "package.json")),
{ concurrency: "unbounded" },
)
if (!hasDep) {
yield* fs.writeJson(pkg, {
...json,
dependencies: required,
})
}
if (!ignore) {
yield* fs.writeFileString(
gitignore,
["node_modules", "package.json", "package-lock.json", "bun.lock", ".gitignore"].join("\n"),
)
}
if (hasDep && ignore && installed.every(Boolean)) 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 +1463,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 +1481,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 +1627,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 +1692,7 @@ export namespace Config {
get,
getGlobal,
getConsoleState,
installDependencies,
update,
updateGlobal,
invalidate,
Expand Down Expand Up @@ -1672,7 +1738,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))
}
}
72 changes: 72 additions & 0 deletions packages/opencode/src/config/dependency.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
import path from "path"
import { builtinModules, isBuiltin } from "module"
import { Filesystem } from "@/util/filesystem"

const DEPENDENCY_IMPORT =
/(?:^|\n)\s*(?:import\s+(?:[^"'`]+\s+from\s+)?|export\s+[^"'`]+\s+from\s+)["']([^./"'`][^"'`]*)["']|import\s*\(\s*["']([^./"'`][^"'`]*)["']\s*\)|require\(\s*["']([^./"'`][^"'`]*)["']\s*\)/gm
const LOCAL_IMPORT =
/(?:^|\n)\s*(?:import\s+(?:[^"'`]+\s+from\s+)?|export\s+[^"'`]+\s+from\s+)["']((?:\.\.?\/)[^"'`]*)["']|import\s*\(\s*["']((?:\.\.?\/)[^"'`]*)["']\s*\)|require\(\s*["']((?:\.\.?\/)[^"'`]*)["']\s*\)/gm
const BUILTIN_MODULES = new Set(builtinModules)
const LOCAL_IMPORT_EXTENSIONS = [".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs"]

function isTypeOnlyImport(statement: string) {
const normalized = statement.trimStart()
if (/^(?:import|export)\s+type\b/s.test(normalized)) return true

const named = normalized.match(/^(?:import|export)\s*\{([^}]*)\}\s+from\b/s)
if (!named) return false
const specifiers = named[1]
.split(",")
.map((part) => part.trim())
.filter(Boolean)
return specifiers.length > 0 && specifiers.every((part) => /^type\s+(?!as\b)\S/s.test(part))
}

function packageName(spec: string) {
if (spec.startsWith("node:") || isBuiltin(spec) || BUILTIN_MODULES.has(spec)) return
if (spec.startsWith("@")) {
const [scope, name] = spec.split("/")
if (!scope || !name) return
return `${scope}/${name}`
}
return spec.split("/")[0]
}

async function resolveLocalImport(file: string, spec: string) {
const target = path.resolve(path.dirname(file), spec)
const candidates = path.extname(target)
? [target]
: [
...LOCAL_IMPORT_EXTENSIONS.map((ext) => `${target}${ext}`),
...LOCAL_IMPORT_EXTENSIONS.map((ext) => path.join(target, `index${ext}`)),
]
for (const candidate of candidates) {
if (await Filesystem.exists(candidate)) return candidate
}
}

export async function needsConfigDependencies(file: string, configDir: string, visited = new Set<string>()) {
const resolved = path.resolve(file)
if (visited.has(resolved)) return false
visited.add(resolved)
const text = await Filesystem.readText(resolved).catch(() => "")
for (const match of text.matchAll(DEPENDENCY_IMPORT)) {
if (isTypeOnlyImport(match[0])) continue
const spec = match[1] ?? match[2] ?? match[3]
if (!spec) continue
const pkg = packageName(spec)
if (!pkg) continue
const pkgPath = path.join(configDir, "node_modules", ...pkg.split("/"))
if (await Filesystem.exists(path.join(pkgPath, "package.json"))) continue
return true
}
for (const match of text.matchAll(LOCAL_IMPORT)) {
if (isTypeOnlyImport(match[0])) continue
const spec = match[1] ?? match[2] ?? match[3]
if (!spec) continue
const next = await resolveLocalImport(resolved, spec)
if (!next) continue
if (await needsConfigDependencies(next, configDir, visited)) return true
}
return false
}
Loading
Loading