import { Effect, Layer, Schema, Context, Stream } from "effect" import { serviceUse } from "@opencode-ai/core/effect/service-use" import { FetchHttpClient, HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http" import { withTransientReadRetry } from "@/util/effect-http-client" import { errorMessage } from "@/util/error" import { ChildProcess } from "effect/unstable/process" import { AppProcess } from "@opencode-ai/core/process" import path from "path" import { BusEvent } from "@/bus/bus-event" import * as Log from "@opencode-ai/core/util/log" import { makeRuntime } from "@opencode-ai/core/effect/runtime" import semver from "semver" import { InstallationChannel, InstallationVersion } from "@opencode-ai/core/installation/version" import { NpmConfig } from "@opencode-ai/core/npm-config" const log = Log.create({ service: "installation" }) export type Method = "curl" | "npm" | "yarn" | "pnpm" | "bun" | "brew" | "scoop" | "choco" | "unknown" export type ReleaseType = "patch" | "minor" | "major" export const Event = { Updated: BusEvent.define( "installation.updated", Schema.Struct({ version: Schema.String, }), ), UpdateAvailable: BusEvent.define( "installation.update-available", Schema.Struct({ version: Schema.String, }), ), } export function getReleaseType(current: string, latest: string): ReleaseType { const currMajor = semver.major(current) const currMinor = semver.minor(current) const newMajor = semver.major(latest) const newMinor = semver.minor(latest) if (newMajor > currMajor) return "major" if (newMinor > currMinor) return "minor" return "patch" } export const Info = Schema.Struct({ version: Schema.String, latest: Schema.String, }).annotate({ identifier: "InstallationInfo" }) export type Info = Schema.Schema.Type export function userAgent(client = "cli") { return `opencode/${InstallationChannel}/${InstallationVersion}/${client}` } export const USER_AGENT = userAgent() export function isPreview() { return InstallationChannel !== "latest" } export function isLocal() { return InstallationChannel === "local" } export class UpgradeFailedError extends Schema.TaggedErrorClass()("UpgradeFailedError", { stderr: Schema.String, }) { override get message() { return this.stderr } } // 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 Context.Service()("@opencode/Installation") {} export const use = serviceUse(Service) export const layer: Layer.Layer = Layer.effect( Service, Effect.gen(function* () { const http = yield* HttpClient.HttpClient const httpOk = HttpClient.filterStatusOk(withTransientReadRetry(http)) const appProcess = yield* AppProcess.Service const text = Effect.fnUntraced( function* (cmd: string[], opts?: { cwd?: string; env?: Record }) { const result = yield* appProcess.run( ChildProcess.make(cmd[0], cmd.slice(1), { cwd: opts?.cwd, env: opts?.env, extendEnv: true, }), ) return result.stdout.toString("utf8") }, Effect.catch(() => Effect.succeed("")), ) const run = Effect.fnUntraced( function* (cmd: string[], opts?: { cwd?: string; env?: Record }) { const result = yield* appProcess.run( ChildProcess.make(cmd[0], cmd.slice(1), { cwd: opts?.cwd, env: opts?.env, extendEnv: true, }), ) return { code: result.exitCode, stdout: result.stdout.toString("utf8"), stderr: result.stderr.toString("utf8"), } }, Effect.catch((err) => Effect.succeed({ code: 1, stdout: "", stderr: errorMessage(err) })), ) 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 upgradeFailure = (method: Method, result?: { code: number; stdout: string; stderr: string }) => { if (method === "choco") return "not running from an elevated command shell" if (result) return `Upgrade failed for ${method} (exit code ${result.code}).` return `Upgrade failed for ${method}.` } 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 result = yield* appProcess.run( ChildProcess.make("bash", [], { stdin: Stream.make(bodyBytes), env: { VERSION: target }, extendEnv: true, }), ) return { code: result.exitCode, stdout: result.stdout.toString("utf8"), stderr: result.stderr.toString("utf8"), } }, Effect.mapError(() => new UpgradeFailedError({ stderr: upgradeFailure("curl") })), ) const result: Interface = { info: Effect.fn("Installation.info")(function* () { return { version: InstallationVersion, latest: yield* result.latest(), } }), method: 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 }), latest: Effect.fn("Installation.latest")(function* (installMethod?: Method) { const detectedMethod = installMethod || (yield* result.method()) 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 response = yield* httpOk.execute( HttpClientRequest.get( `${yield* NpmConfig.registry(process.cwd())}/opencode-ai/${InstallationChannel}`, ).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), upgrade: Effect.fn("Installation.upgrade")(function* (m: Method, target: string) { let upgradeResult: { code: number; stdout: string; stderr: string } | undefined switch (m) { case "curl": upgradeResult = yield* upgradeCurl(target) break case "npm": upgradeResult = yield* run(["npm", "install", "-g", `opencode-ai@${target}`]) break case "pnpm": upgradeResult = yield* run(["pnpm", "install", "-g", `opencode-ai@${target}`]) break case "bun": upgradeResult = 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) { upgradeResult = 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) { upgradeResult = pull break } } } upgradeResult = yield* run(["brew", "upgrade", formula], { env }) break } case "choco": upgradeResult = yield* run(["choco", "upgrade", "opencode", `--version=${target}`, "-y"]) break case "scoop": upgradeResult = yield* run(["scoop", "install", `opencode@${target}`]) break default: return yield* new UpgradeFailedError({ stderr: `Unknown installation method: ${m}` }) } if (!upgradeResult || upgradeResult.code !== 0) { return yield* new UpgradeFailedError({ stderr: upgradeFailure(m, upgradeResult) }) } log.info("upgraded", { method: m, target, stdout: upgradeResult.stdout, stderr: upgradeResult.stderr, }) yield* text([process.execPath, "--version"]) }), } return Service.of(result) }), ) export const defaultLayer = layer.pipe(Layer.provide(FetchHttpClient.layer), Layer.provide(AppProcess.defaultLayer)) const { runPromise } = makeRuntime(Service, defaultLayer) export const latest = (...args: Parameters) => runPromise((s) => s.latest(...args)) export const method = () => runPromise((s) => s.method()) export const upgrade = (...args: Parameters) => runPromise((s) => s.upgrade(...args)) export * as Installation from "."