import { NodeChildProcessSpawner, NodeFileSystem, NodePath } from "@effect/platform-node" import { Effect, Layer, Schema, ServiceMap, Stream } from "effect" import { FetchHttpClient, HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http" import { withTransientReadRetry } from "@/util/effect-http-client" import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process" import path from "path" import z from "zod" import { BusEvent } from "@/bus/bus-event" import { Flag } from "../flag/flag" import { Log } from "../util/log" declare global { const OPENCODE_VERSION: string const OPENCODE_CHANNEL: string } export namespace Installation { const log = Log.create({ service: "installation" }) export type Method = "curl" | "npm" | "yarn" | "pnpm" | "bun" | "brew" | "scoop" | "choco" | "unknown" export const Event = { Updated: BusEvent.define( "installation.updated", z.object({ version: z.string(), }), ), UpdateAvailable: BusEvent.define( "installation.update-available", z.object({ version: z.string(), }), ), } export const Info = z .object({ version: z.string(), latest: z.string(), }) .meta({ ref: "InstallationInfo", }) export type Info = z.infer export const VERSION = typeof OPENCODE_VERSION === "string" ? OPENCODE_VERSION : "local" export const CHANNEL = typeof OPENCODE_CHANNEL === "string" ? OPENCODE_CHANNEL : "local" export const USER_AGENT = `opencode/${CHANNEL}/${VERSION}/${Flag.OPENCODE_CLIENT}` export function isPreview() { return CHANNEL !== "latest" } export function isLocal() { return CHANNEL === "local" } export class UpgradeFailedError extends Schema.TaggedErrorClass()("UpgradeFailedError", { stderr: Schema.String, }) {} // Response schemas for external version APIs const GitHubRelease = Schema.Struct({ tag_name: Schema.String }) const NpmPackage = Schema.Struct({ version: Schema.String }) const BrewFormula = Schema.Struct({ versions: Schema.Struct({ stable: Schema.String }) }) const BrewInfoV2 = Schema.Struct({ formulae: Schema.Array(Schema.Struct({ versions: Schema.Struct({ stable: Schema.String }) })), }) const ChocoPackage = Schema.Struct({ d: Schema.Struct({ results: Schema.Array(Schema.Struct({ Version: Schema.String })) }), }) const ScoopManifest = NpmPackage export interface Interface { readonly info: () => Effect.Effect readonly method: () => Effect.Effect readonly latest: (method?: Method) => Effect.Effect readonly upgrade: (method: Method, target: string) => Effect.Effect } export class Service extends ServiceMap.Service()("@opencode/Installation") {} export const layer: Layer.Layer< Service, never, HttpClient.HttpClient | ChildProcessSpawner.ChildProcessSpawner > = Layer.effect( Service, Effect.gen(function* () { const http = yield* HttpClient.HttpClient const httpOk = HttpClient.filterStatusOk(withTransientReadRetry(http)) const spawner = yield* ChildProcessSpawner.ChildProcessSpawner const text = Effect.fnUntraced( function* (cmd: string[], opts?: { cwd?: string; env?: Record }) { const proc = ChildProcess.make(cmd[0], cmd.slice(1), { cwd: opts?.cwd, env: opts?.env, extendEnv: true, }) const handle = yield* spawner.spawn(proc) const out = yield* Stream.mkString(Stream.decodeText(handle.stdout)) yield* handle.exitCode return out }, Effect.scoped, Effect.catch(() => Effect.succeed("")), ) const run = Effect.fnUntraced( function* (cmd: string[], opts?: { cwd?: string; env?: Record }) { const proc = ChildProcess.make(cmd[0], cmd.slice(1), { cwd: opts?.cwd, env: opts?.env, extendEnv: true, }) const handle = yield* spawner.spawn(proc) const [stdout, stderr] = yield* Effect.all( [Stream.mkString(Stream.decodeText(handle.stdout)), Stream.mkString(Stream.decodeText(handle.stderr))], { concurrency: 2 }, ) const code = yield* handle.exitCode return { code, stdout, stderr } }, Effect.scoped, Effect.catch(() => Effect.succeed({ code: ChildProcessSpawner.ExitCode(1), stdout: "", stderr: "" })), ) const getBrewFormula = Effect.fnUntraced(function* () { const tapFormula = yield* text(["brew", "list", "--formula", "anomalyco/tap/opencode"]) if (tapFormula.includes("opencode")) return "anomalyco/tap/opencode" const coreFormula = yield* text(["brew", "list", "--formula", "opencode"]) if (coreFormula.includes("opencode")) return "opencode" return "opencode" }) const upgradeCurl = Effect.fnUntraced( function* (target: string) { const response = yield* httpOk.execute(HttpClientRequest.get("https://opencode.ai/install")) const body = yield* response.text const bodyBytes = new TextEncoder().encode(body) const proc = ChildProcess.make("bash", [], { stdin: Stream.make(bodyBytes), env: { VERSION: target }, extendEnv: true, }) const handle = yield* spawner.spawn(proc) const [stdout, stderr] = yield* Effect.all( [Stream.mkString(Stream.decodeText(handle.stdout)), Stream.mkString(Stream.decodeText(handle.stderr))], { concurrency: 2 }, ) const code = yield* handle.exitCode return { code, stdout, stderr } }, Effect.scoped, Effect.orDie, ) const methodImpl = Effect.fn("Installation.method")(function* () { if (process.execPath.includes(path.join(".opencode", "bin"))) return "curl" as Method if (process.execPath.includes(path.join(".local", "bin"))) return "curl" as Method const exec = process.execPath.toLowerCase() const checks: Array<{ name: Method; command: () => Effect.Effect }> = [ { name: "npm", command: () => text(["npm", "list", "-g", "--depth=0"]) }, { name: "yarn", command: () => text(["yarn", "global", "list"]) }, { name: "pnpm", command: () => text(["pnpm", "list", "-g", "--depth=0"]) }, { name: "bun", command: () => text(["bun", "pm", "ls", "-g"]) }, { name: "brew", command: () => text(["brew", "list", "--formula", "opencode"]) }, { name: "scoop", command: () => text(["scoop", "list", "opencode"]) }, { name: "choco", command: () => text(["choco", "list", "--limit-output", "opencode"]) }, ] checks.sort((a, b) => { const aMatches = exec.includes(a.name) const bMatches = exec.includes(b.name) if (aMatches && !bMatches) return -1 if (!aMatches && bMatches) return 1 return 0 }) for (const check of checks) { const output = yield* check.command() const installedName = check.name === "brew" || check.name === "choco" || check.name === "scoop" ? "opencode" : "opencode-ai" if (output.includes(installedName)) { return check.name } } return "unknown" as Method }) const latestImpl = Effect.fn("Installation.latest")( function* (installMethod?: Method) { const detectedMethod = installMethod || (yield* methodImpl()) if (detectedMethod === "brew") { const formula = yield* getBrewFormula() if (formula.includes("/")) { const infoJson = yield* text(["brew", "info", "--json=v2", formula]) const info = yield* Schema.decodeUnknownEffect(Schema.fromJsonString(BrewInfoV2))(infoJson) return info.formulae[0].versions.stable } const response = yield* httpOk.execute( HttpClientRequest.get("https://formulae.brew.sh/api/formula/opencode.json").pipe( HttpClientRequest.acceptJson, ), ) const data = yield* HttpClientResponse.schemaBodyJson(BrewFormula)(response) return data.versions.stable } if (detectedMethod === "npm" || detectedMethod === "bun" || detectedMethod === "pnpm") { const r = (yield* text(["npm", "config", "get", "registry"])).trim() const reg = r || "https://registry.npmjs.org" const registry = reg.endsWith("/") ? reg.slice(0, -1) : reg const channel = CHANNEL const response = yield* httpOk.execute( HttpClientRequest.get(`${registry}/opencode-ai/${channel}`).pipe(HttpClientRequest.acceptJson), ) const data = yield* HttpClientResponse.schemaBodyJson(NpmPackage)(response) return data.version } if (detectedMethod === "choco") { const response = yield* httpOk.execute( HttpClientRequest.get( "https://community.chocolatey.org/api/v2/Packages?$filter=Id%20eq%20%27opencode%27%20and%20IsLatestVersion&$select=Version", ).pipe(HttpClientRequest.setHeaders({ Accept: "application/json;odata=verbose" })), ) const data = yield* HttpClientResponse.schemaBodyJson(ChocoPackage)(response) return data.d.results[0].Version } if (detectedMethod === "scoop") { const response = yield* httpOk.execute( HttpClientRequest.get( "https://raw.githubusercontent.com/ScoopInstaller/Main/master/bucket/opencode.json", ).pipe(HttpClientRequest.setHeaders({ Accept: "application/json" })), ) const data = yield* HttpClientResponse.schemaBodyJson(ScoopManifest)(response) return data.version } const response = yield* httpOk.execute( HttpClientRequest.get("https://api.github.com/repos/anomalyco/opencode/releases/latest").pipe( HttpClientRequest.acceptJson, ), ) const data = yield* HttpClientResponse.schemaBodyJson(GitHubRelease)(response) return data.tag_name.replace(/^v/, "") }, Effect.orDie, ) const upgradeImpl = Effect.fn("Installation.upgrade")(function* (m: Method, target: string) { let result: { code: ChildProcessSpawner.ExitCode; stdout: string; stderr: string } | undefined switch (m) { case "curl": result = yield* upgradeCurl(target) break case "npm": result = yield* run(["npm", "install", "-g", `opencode-ai@${target}`]) break case "pnpm": result = yield* run(["pnpm", "install", "-g", `opencode-ai@${target}`]) break case "bun": result = yield* run(["bun", "install", "-g", `opencode-ai@${target}`]) break case "brew": { const formula = yield* getBrewFormula() const env = { HOMEBREW_NO_AUTO_UPDATE: "1" } if (formula.includes("/")) { const tap = yield* run(["brew", "tap", "anomalyco/tap"], { env }) if (tap.code !== 0) { result = tap break } const repo = yield* text(["brew", "--repo", "anomalyco/tap"]) const dir = repo.trim() if (dir) { const pull = yield* run(["git", "pull", "--ff-only"], { cwd: dir, env }) if (pull.code !== 0) { result = pull break } } } result = yield* run(["brew", "upgrade", formula], { env }) break } case "choco": result = yield* run(["choco", "upgrade", "opencode", `--version=${target}`, "-y"]) break case "scoop": result = yield* run(["scoop", "install", `opencode@${target}`]) break default: throw new Error(`Unknown method: ${m}`) } if (!result || result.code !== 0) { const stderr = m === "choco" ? "not running from an elevated command shell" : result?.stderr || "" return yield* new UpgradeFailedError({ stderr }) } log.info("upgraded", { method: m, target, stdout: result.stdout, stderr: result.stderr, }) yield* text([process.execPath, "--version"]) }) return Service.of({ info: Effect.fn("Installation.info")(function* () { return { version: VERSION, latest: yield* latestImpl(), } }), method: methodImpl, latest: latestImpl, upgrade: upgradeImpl, }) }), ) export const defaultLayer = layer.pipe( Layer.provide(FetchHttpClient.layer), Layer.provide(NodeChildProcessSpawner.layer), Layer.provide(NodeFileSystem.layer), Layer.provide(NodePath.layer), ) // Legacy adapters — dynamic import avoids circular dependency since // foundational modules (db.ts, provider/models.ts) import Installation // at load time, and runtime transitively loads those same modules. async function runPromise(f: (service: Interface) => Effect.Effect) { const { runtime } = await import("@/effect/runtime") return runtime.runPromise(Service.use(f)) } export function info(): Promise { return runPromise((svc) => svc.info()) } export function method(): Promise { return runPromise((svc) => svc.method()) } export function latest(installMethod?: Method): Promise { return runPromise((svc) => svc.latest(installMethod)) } export function upgrade(m: Method, target: string): Promise { return runPromise((svc) => svc.upgrade(m, target)) } }