import { BinaryCreateRequest, BinaryRecord, BinaryUpdateRequest, BinaryValidationResult, } from "../api-types" import { spawnSync } from "child_process" import { ConfigStore } from "./store" import { EventBus } from "../events/bus" import type { ConfigFile } from "./schema" import { Logger } from "../logger" import { buildSpawnSpec } from "../workspaces/runtime" export class BinaryRegistry { constructor( private readonly configStore: ConfigStore, private readonly eventBus: EventBus | undefined, private readonly logger: Logger, ) {} list(): BinaryRecord[] { return this.mapRecords() } resolveDefault(): BinaryRecord { const binaries = this.mapRecords() if (binaries.length === 0) { this.logger.warn("No configured binaries found, falling back to opencode") return this.buildFallbackRecord("opencode") } return binaries.find((binary) => binary.isDefault) ?? binaries[0] } create(request: BinaryCreateRequest): BinaryRecord { this.logger.debug({ path: request.path }, "Registering OpenCode binary") const entry = { path: request.path, version: undefined, lastUsed: Date.now(), label: request.label, } const config = this.configStore.get() const nextConfig = this.cloneConfig(config) const deduped = nextConfig.opencodeBinaries.filter((binary) => binary.path !== request.path) nextConfig.opencodeBinaries = [entry, ...deduped] if (request.makeDefault) { nextConfig.preferences.lastUsedBinary = request.path } this.configStore.replace(nextConfig) const record = this.getById(request.path) this.emitChange() return record } update(id: string, updates: BinaryUpdateRequest): BinaryRecord { this.logger.debug({ id }, "Updating OpenCode binary") const config = this.configStore.get() const nextConfig = this.cloneConfig(config) nextConfig.opencodeBinaries = nextConfig.opencodeBinaries.map((binary) => binary.path === id ? { ...binary, label: updates.label ?? binary.label } : binary, ) if (updates.makeDefault) { nextConfig.preferences.lastUsedBinary = id } this.configStore.replace(nextConfig) const record = this.getById(id) this.emitChange() return record } remove(id: string) { this.logger.debug({ id }, "Removing OpenCode binary") const config = this.configStore.get() const nextConfig = this.cloneConfig(config) const remaining = nextConfig.opencodeBinaries.filter((binary) => binary.path !== id) nextConfig.opencodeBinaries = remaining if (nextConfig.preferences.lastUsedBinary === id) { nextConfig.preferences.lastUsedBinary = remaining[0]?.path } this.configStore.replace(nextConfig) this.emitChange() } validatePath(path: string): BinaryValidationResult { this.logger.debug({ path }, "Validating OpenCode binary path") return this.validateRecord({ id: path, path, label: this.prettyLabel(path), isDefault: false, }) } private cloneConfig(config: ConfigFile): ConfigFile { return JSON.parse(JSON.stringify(config)) as ConfigFile } private mapRecords(): BinaryRecord[] { const config = this.configStore.get() const configuredBinaries = config.opencodeBinaries.map((binary) => ({ id: binary.path, path: binary.path, label: binary.label ?? this.prettyLabel(binary.path), version: binary.version, isDefault: false, })) const defaultPath = config.preferences.lastUsedBinary ?? configuredBinaries[0]?.path ?? "opencode" const annotated = configuredBinaries.map((binary) => ({ ...binary, isDefault: binary.path === defaultPath, })) if (!annotated.some((binary) => binary.path === defaultPath)) { annotated.unshift(this.buildFallbackRecord(defaultPath)) } return annotated } private getById(id: string): BinaryRecord { return this.mapRecords().find((binary) => binary.id === id) ?? this.buildFallbackRecord(id) } private emitChange() { this.logger.debug("Emitting binaries changed event") this.eventBus?.publish({ type: "config.binariesChanged", binaries: this.mapRecords() }) } private validateRecord(record: BinaryRecord): BinaryValidationResult { const inputPath = record.path if (!inputPath) { return { valid: false, error: "Missing binary path" } } const spec = buildSpawnSpec(inputPath, ["--version"]) try { const result = spawnSync(spec.command, spec.args, { encoding: "utf8", windowsVerbatimArguments: Boolean((spec.options as { windowsVerbatimArguments?: boolean }).windowsVerbatimArguments), }) if (result.error) { return { valid: false, error: result.error.message } } if (result.status !== 0) { const stderr = result.stderr?.trim() const stdout = result.stdout?.trim() const combined = stderr || stdout const error = combined ? `Exited with code ${result.status}: ${combined}` : `Exited with code ${result.status}` return { valid: false, error } } const stdout = (result.stdout ?? "").trim() const firstLine = stdout.split(/\r?\n/).find((line) => line.trim().length > 0) const normalized = firstLine?.trim() const versionMatch = normalized?.match(/([0-9]+\.[0-9]+\.[0-9A-Za-z.-]+)/) const version = versionMatch?.[1] return { valid: true, version } } catch (error) { return { valid: false, error: error instanceof Error ? error.message : String(error) } } } private buildFallbackRecord(path: string): BinaryRecord { return { id: path, path, label: this.prettyLabel(path), isDefault: true, } } private prettyLabel(path: string) { const parts = path.split(/[\\/]/) const last = parts[parts.length - 1] || path return last || path } }