From 386eb03427432787285d87d2c1afcc489779cde4 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Thu, 19 Mar 2026 14:29:34 -0400 Subject: [PATCH 1/8] effectify Installation service and drop Effect suffix from service namespaces MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Migrate Installation to Effect with HttpClient for version fetching and ChildProcessSpawner for package manager detection/upgrade. Register in global ManagedRuntime. Split into effect.ts (service) and index.ts (legacy adapters) to break the runtime circular dependency. Rename AccountEffect → Account, AuthEffect → Auth, TruncateEffect → Truncate to establish consistent naming where both the effect.ts and index.ts files use the same namespace name. --- packages/opencode/src/account/effect.ts | 32 +- packages/opencode/src/account/index.ts | 12 +- packages/opencode/src/auth/effect.ts | 2 +- packages/opencode/src/auth/index.ts | 4 +- packages/opencode/src/cli/cmd/account.ts | 10 +- packages/opencode/src/cli/cmd/upgrade.ts | 4 +- packages/opencode/src/effect/runtime.ts | 14 +- packages/opencode/src/installation/effect.ts | 326 ++++++++++++++++++ packages/opencode/src/installation/index.ts | 317 ++--------------- packages/opencode/src/provider/auth.ts | 4 +- packages/opencode/src/tool/truncate-effect.ts | 2 +- packages/opencode/src/tool/truncate.ts | 2 +- .../opencode/test/account/service.test.ts | 12 +- .../test/installation/installation.test.ts | 93 +++-- .../opencode/test/tool/truncation.test.ts | 6 +- 15 files changed, 481 insertions(+), 359 deletions(-) create mode 100644 packages/opencode/src/installation/effect.ts diff --git a/packages/opencode/src/account/effect.ts b/packages/opencode/src/account/effect.ts index 444676046e4..60d20695273 100644 --- a/packages/opencode/src/account/effect.ts +++ b/packages/opencode/src/account/effect.ts @@ -6,7 +6,7 @@ import { AccountRepo, type AccountRow } from "./repo" import { type AccountError, AccessToken, - Account, + Account as AccountSchema, AccountID, DeviceCode, RefreshToken, @@ -24,10 +24,30 @@ import { UserCode, } from "./schema" -export * from "./schema" +export { + Account as AccountSchema, + AccountID, + type AccountError, + AccountRepoError, + AccountServiceError, + AccessToken, + RefreshToken, + DeviceCode, + UserCode, + Org, + OrgID, + Login, + PollSuccess, + PollPending, + PollSlow, + PollExpired, + PollDenied, + PollError, + PollResult, +} from "./schema" export type AccountOrgs = { - account: Account + account: AccountSchema orgs: readonly Org[] } @@ -108,10 +128,10 @@ const mapAccountServiceError = ), ) -export namespace AccountEffect { +export namespace Account { export interface Interface { - readonly active: () => Effect.Effect, AccountError> - readonly list: () => Effect.Effect + readonly active: () => Effect.Effect, AccountError> + readonly list: () => Effect.Effect readonly orgsByAccount: () => Effect.Effect readonly remove: (accountID: AccountID) => Effect.Effect readonly use: (accountID: AccountID, orgID: Option.Option) => Effect.Effect diff --git a/packages/opencode/src/account/index.ts b/packages/opencode/src/account/index.ts index 3a9d758e2f1..2a16fcdb3cc 100644 --- a/packages/opencode/src/account/index.ts +++ b/packages/opencode/src/account/index.ts @@ -1,11 +1,11 @@ import { Effect, Option } from "effect" import { - Account as AccountSchema, + Account as S, + AccountSchema, type AccountError, type AccessToken, AccountID, - AccountEffect, OrgID, } from "./effect" @@ -13,12 +13,12 @@ export { AccessToken, AccountID, OrgID } from "./effect" import { runtime } from "@/effect/runtime" -function runSync(f: (service: AccountEffect.Interface) => Effect.Effect) { - return runtime.runSync(AccountEffect.Service.use(f)) +function runSync(f: (service: S.Interface) => Effect.Effect) { + return runtime.runSync(S.Service.use(f)) } -function runPromise(f: (service: AccountEffect.Interface) => Effect.Effect) { - return runtime.runPromise(AccountEffect.Service.use(f)) +function runPromise(f: (service: S.Interface) => Effect.Effect) { + return runtime.runPromise(S.Service.use(f)) } export namespace Account { diff --git a/packages/opencode/src/auth/effect.ts b/packages/opencode/src/auth/effect.ts index e03ad958674..14a97080792 100644 --- a/packages/opencode/src/auth/effect.ts +++ b/packages/opencode/src/auth/effect.ts @@ -37,7 +37,7 @@ const file = path.join(Global.Path.data, "auth.json") const fail = (message: string) => (cause: unknown) => new AuthError({ message, cause }) -export namespace AuthEffect { +export namespace Auth { export interface Interface { readonly get: (providerID: string) => Effect.Effect readonly all: () => Effect.Effect, AuthError> diff --git a/packages/opencode/src/auth/index.ts b/packages/opencode/src/auth/index.ts index 6f588e93751..411d9dccc07 100644 --- a/packages/opencode/src/auth/index.ts +++ b/packages/opencode/src/auth/index.ts @@ -5,8 +5,8 @@ import * as S from "./effect" export { OAUTH_DUMMY_KEY } from "./effect" -function runPromise(f: (service: S.AuthEffect.Interface) => Effect.Effect) { - return runtime.runPromise(S.AuthEffect.Service.use(f)) +function runPromise(f: (service: S.Auth.Interface) => Effect.Effect) { + return runtime.runPromise(S.Auth.Service.use(f)) } export namespace Auth { diff --git a/packages/opencode/src/cli/cmd/account.ts b/packages/opencode/src/cli/cmd/account.ts index c2b47da11c3..fb702c95a56 100644 --- a/packages/opencode/src/cli/cmd/account.ts +++ b/packages/opencode/src/cli/cmd/account.ts @@ -2,7 +2,7 @@ import { cmd } from "./cmd" import { Duration, Effect, Match, Option } from "effect" import { UI } from "../ui" import { runtime } from "@/effect/runtime" -import { AccountID, AccountEffect, OrgID, PollExpired, type PollResult } from "@/account/effect" +import { AccountID, Account, OrgID, PollExpired, type PollResult } from "@/account/effect" import { type AccountError } from "@/account/schema" import * as Prompt from "../effect/prompt" import open from "open" @@ -17,7 +17,7 @@ const isActiveOrgChoice = ( ) => Option.isSome(active) && active.value.id === choice.accountID && active.value.active_org_id === choice.orgID const loginEffect = Effect.fn("login")(function* (url: string) { - const service = yield* AccountEffect.Service + const service = yield* Account.Service yield* Prompt.intro("Log in") const login = yield* service.login(url) @@ -58,7 +58,7 @@ const loginEffect = Effect.fn("login")(function* (url: string) { }) const logoutEffect = Effect.fn("logout")(function* (email?: string) { - const service = yield* AccountEffect.Service + const service = yield* Account.Service const accounts = yield* service.list() if (accounts.length === 0) return yield* println("Not logged in") @@ -98,7 +98,7 @@ interface OrgChoice { } const switchEffect = Effect.fn("switch")(function* () { - const service = yield* AccountEffect.Service + const service = yield* Account.Service const groups = yield* service.orgsByAccount() if (groups.length === 0) return yield* println("Not logged in") @@ -129,7 +129,7 @@ const switchEffect = Effect.fn("switch")(function* () { }) const orgsEffect = Effect.fn("orgs")(function* () { - const service = yield* AccountEffect.Service + const service = yield* Account.Service const groups = yield* service.orgsByAccount() if (groups.length === 0) return yield* println("No accounts found") diff --git a/packages/opencode/src/cli/cmd/upgrade.ts b/packages/opencode/src/cli/cmd/upgrade.ts index 4438fa3b84f..0182056633c 100644 --- a/packages/opencode/src/cli/cmd/upgrade.ts +++ b/packages/opencode/src/cli/cmd/upgrade.ts @@ -58,10 +58,10 @@ export const UpgradeCommand = { spinner.stop("Upgrade failed", 1) if (err instanceof Installation.UpgradeFailedError) { // necessary because choco only allows install/upgrade in elevated terminals - if (method === "choco" && err.data.stderr.includes("not running from an elevated command shell")) { + if (method === "choco" && err.stderr.includes("not running from an elevated command shell")) { prompts.log.error("Please run the terminal as Administrator and try again") } else { - prompts.log.error(err.data.stderr) + prompts.log.error(err.stderr) } } else if (err instanceof Error) prompts.log.error(err.message) prompts.outro("Done") diff --git a/packages/opencode/src/effect/runtime.ts b/packages/opencode/src/effect/runtime.ts index f52203b2220..7af55074177 100644 --- a/packages/opencode/src/effect/runtime.ts +++ b/packages/opencode/src/effect/runtime.ts @@ -1,17 +1,19 @@ import { Effect, Layer, ManagedRuntime } from "effect" -import { AccountEffect } from "@/account/effect" -import { AuthEffect } from "@/auth/effect" +import { Account } from "@/account/effect" +import { Auth } from "@/auth/effect" import { Instances } from "@/effect/instances" import type { InstanceServices } from "@/effect/instances" -import { TruncateEffect } from "@/tool/truncate-effect" +import { Installation } from "@/installation/effect" +import { Truncate } from "@/tool/truncate-effect" import { Instance } from "@/project/instance" export const runtime = ManagedRuntime.make( Layer.mergeAll( - AccountEffect.defaultLayer, // - TruncateEffect.defaultLayer, + Account.defaultLayer, // + Installation.defaultLayer, + Truncate.defaultLayer, Instances.layer, - ).pipe(Layer.provideMerge(AuthEffect.layer)), + ).pipe(Layer.provideMerge(Auth.layer)), ) export function runPromiseInstance(effect: Effect.Effect) { diff --git a/packages/opencode/src/installation/effect.ts b/packages/opencode/src/installation/effect.ts new file mode 100644 index 00000000000..825a98d0ce1 --- /dev/null +++ b/packages/opencode/src/installation/effect.ts @@ -0,0 +1,326 @@ +import { NodeChildProcessSpawner, NodeFileSystem, NodePath } from "@effect/platform-node" +import { Effect, Layer, Schema, ServiceMap, Stream } from "effect" +import { FetchHttpClient, HttpClient, HttpClientRequest } from "effect/unstable/http" +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, + }) {} + + 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(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 = JSON.parse(infoJson) + const version = info.formulae?.[0]?.versions?.stable + if (!version) throw new Error(`Could not detect version for tap formula: ${formula}`) + return version as string + } + const response = yield* httpOk.execute( + HttpClientRequest.get("https://formulae.brew.sh/api/formula/opencode.json").pipe( + HttpClientRequest.acceptJson, + ), + ) + const data: any = yield* response.json + return data.versions.stable as string + } + + 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: any = yield* response.json + return data.version as string + } + + 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: any = yield* response.json + return data.d.results[0].Version as string + } + + 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: any = yield* response.json + return data.version as string + } + + const response = yield* httpOk.execute( + HttpClientRequest.get("https://api.github.com/repos/anomalyco/opencode/releases/latest").pipe( + HttpClientRequest.acceptJson, + ), + ) + const data: any = yield* response.json + return (data.tag_name as string).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), + ) +} diff --git a/packages/opencode/src/installation/index.ts b/packages/opencode/src/installation/index.ts index 92a3bfc7961..d54d6c0ecae 100644 --- a/packages/opencode/src/installation/index.ts +++ b/packages/opencode/src/installation/index.ts @@ -1,303 +1,42 @@ -import { BusEvent } from "@/bus/bus-event" -import path from "path" -import z from "zod" -import { NamedError } from "@opencode-ai/util/error" -import { Log } from "../util/log" -import { iife } from "@/util/iife" -import { Flag } from "../flag/flag" -import { Process } from "@/util/process" -import { buffer } from "node:stream/consumers" - -declare global { - const OPENCODE_VERSION: string - const OPENCODE_CHANNEL: string -} +import { Effect } from "effect" +import { Installation as S } from "./effect" +import { runtime } from "@/effect/runtime" export namespace Installation { - const log = Log.create({ service: "installation" }) + export type Method = S.Method + export type Info = S.Info + export type Interface = S.Interface + export type UpgradeFailedError = S.UpgradeFailedError - async function text(cmd: string[], opts: { cwd?: string; env?: NodeJS.ProcessEnv } = {}) { - return Process.text(cmd, { - cwd: opts.cwd, - env: opts.env, - nothrow: true, - }).then((x) => x.text) - } + export const Event = S.Event + export const Info = S.Info + export const VERSION = S.VERSION + export const CHANNEL = S.CHANNEL + export const USER_AGENT = S.USER_AGENT + export const isPreview = S.isPreview + export const isLocal = S.isLocal + export const UpgradeFailedError = S.UpgradeFailedError + export const Service = S.Service + export const layer = S.layer + export const defaultLayer = S.defaultLayer - async function upgradeCurl(target: string) { - const body = await fetch("https://opencode.ai/install").then((res) => { - if (!res.ok) throw new Error(res.statusText) - return res.text() - }) - const proc = Process.spawn(["bash"], { - stdin: "pipe", - stdout: "pipe", - stderr: "pipe", - env: { - ...process.env, - VERSION: target, - }, - }) - if (!proc.stdin || !proc.stdout || !proc.stderr) throw new Error("Process output not available") - proc.stdin.end(body) - const [code, stdout, stderr] = await Promise.all([proc.exited, buffer(proc.stdout), buffer(proc.stderr)]) - return { - code, - stdout, - stderr, - } + function runPromise(f: (service: S.Interface) => Effect.Effect) { + return runtime.runPromise(S.Service.use(f)) } - export type Method = Awaited> - - 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 async function info() { - return { - version: VERSION, - latest: await latest(), - } + export function info(): Promise { + return runPromise((svc) => svc.info()) } - export function isPreview() { - return CHANNEL !== "latest" + export function method(): Promise { + return runPromise((svc) => svc.method()) } - export function isLocal() { - return CHANNEL === "local" - } - - export async function method() { - if (process.execPath.includes(path.join(".opencode", "bin"))) return "curl" - if (process.execPath.includes(path.join(".local", "bin"))) return "curl" - const exec = process.execPath.toLowerCase() - - const checks = [ - { - name: "npm" as const, - command: () => text(["npm", "list", "-g", "--depth=0"]), - }, - { - name: "yarn" as const, - command: () => text(["yarn", "global", "list"]), - }, - { - name: "pnpm" as const, - command: () => text(["pnpm", "list", "-g", "--depth=0"]), - }, - { - name: "bun" as const, - command: () => text(["bun", "pm", "ls", "-g"]), - }, - { - name: "brew" as const, - command: () => text(["brew", "list", "--formula", "opencode"]), - }, - { - name: "scoop" as const, - command: () => text(["scoop", "list", "opencode"]), - }, - { - name: "choco" as const, - 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 = await 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" + export function latest(installMethod?: Method): Promise { + return runPromise((svc) => svc.latest(installMethod)) } - export const UpgradeFailedError = NamedError.create( - "UpgradeFailedError", - z.object({ - stderr: z.string(), - }), - ) - - async function getBrewFormula() { - const tapFormula = await text(["brew", "list", "--formula", "anomalyco/tap/opencode"]) - if (tapFormula.includes("opencode")) return "anomalyco/tap/opencode" - const coreFormula = await text(["brew", "list", "--formula", "opencode"]) - if (coreFormula.includes("opencode")) return "opencode" - return "opencode" - } - - export async function upgrade(method: Method, target: string) { - let result: Awaited> | undefined - switch (method) { - case "curl": - result = await upgradeCurl(target) - break - case "npm": - result = await Process.run(["npm", "install", "-g", `opencode-ai@${target}`], { nothrow: true }) - break - case "pnpm": - result = await Process.run(["pnpm", "install", "-g", `opencode-ai@${target}`], { nothrow: true }) - break - case "bun": - result = await Process.run(["bun", "install", "-g", `opencode-ai@${target}`], { nothrow: true }) - break - case "brew": { - const formula = await getBrewFormula() - const env = { - HOMEBREW_NO_AUTO_UPDATE: "1", - ...process.env, - } - if (formula.includes("/")) { - const tap = await Process.run(["brew", "tap", "anomalyco/tap"], { env, nothrow: true }) - if (tap.code !== 0) { - result = tap - break - } - const repo = await Process.text(["brew", "--repo", "anomalyco/tap"], { env, nothrow: true }) - if (repo.code !== 0) { - result = repo - break - } - const dir = repo.text.trim() - if (dir) { - const pull = await Process.run(["git", "pull", "--ff-only"], { cwd: dir, env, nothrow: true }) - if (pull.code !== 0) { - result = pull - break - } - } - } - result = await Process.run(["brew", "upgrade", formula], { env, nothrow: true }) - break - } - - case "choco": - result = await Process.run(["choco", "upgrade", "opencode", `--version=${target}`, "-y"], { nothrow: true }) - break - case "scoop": - result = await Process.run(["scoop", "install", `opencode@${target}`], { nothrow: true }) - break - default: - throw new Error(`Unknown method: ${method}`) - } - if (!result || result.code !== 0) { - const stderr = - method === "choco" ? "not running from an elevated command shell" : result?.stderr.toString("utf8") || "" - throw new UpgradeFailedError({ - stderr: stderr, - }) - } - log.info("upgraded", { - method, - target, - stdout: result.stdout.toString(), - stderr: result.stderr.toString(), - }) - await Process.text([process.execPath, "--version"], { nothrow: true }) - } - - 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 async function latest(installMethod?: Method) { - const detectedMethod = installMethod || (await method()) - - if (detectedMethod === "brew") { - const formula = await getBrewFormula() - if (formula.includes("/")) { - const infoJson = await text(["brew", "info", "--json=v2", formula]) - const info = JSON.parse(infoJson) - const version = info.formulae?.[0]?.versions?.stable - if (!version) throw new Error(`Could not detect version for tap formula: ${formula}`) - return version - } - return fetch("https://formulae.brew.sh/api/formula/opencode.json") - .then((res) => { - if (!res.ok) throw new Error(res.statusText) - return res.json() - }) - .then((data: any) => data.versions.stable) - } - - if (detectedMethod === "npm" || detectedMethod === "bun" || detectedMethod === "pnpm") { - const registry = await iife(async () => { - const r = (await text(["npm", "config", "get", "registry"])).trim() - const reg = r || "https://registry.npmjs.org" - return reg.endsWith("/") ? reg.slice(0, -1) : reg - }) - const channel = CHANNEL - return fetch(`${registry}/opencode-ai/${channel}`) - .then((res) => { - if (!res.ok) throw new Error(res.statusText) - return res.json() - }) - .then((data: any) => data.version) - } - - if (detectedMethod === "choco") { - return fetch( - "https://community.chocolatey.org/api/v2/Packages?$filter=Id%20eq%20%27opencode%27%20and%20IsLatestVersion&$select=Version", - { headers: { Accept: "application/json;odata=verbose" } }, - ) - .then((res) => { - if (!res.ok) throw new Error(res.statusText) - return res.json() - }) - .then((data: any) => data.d.results[0].Version) - } - - if (detectedMethod === "scoop") { - return fetch("https://raw.githubusercontent.com/ScoopInstaller/Main/master/bucket/opencode.json", { - headers: { Accept: "application/json" }, - }) - .then((res) => { - if (!res.ok) throw new Error(res.statusText) - return res.json() - }) - .then((data: any) => data.version) - } - - return fetch("https://api.github.com/repos/anomalyco/opencode/releases/latest") - .then((res) => { - if (!res.ok) throw new Error(res.statusText) - return res.json() - }) - .then((data: any) => data.tag_name.replace(/^v/, "")) + export function upgrade(m: Method, target: string): Promise { + return runPromise((svc) => svc.upgrade(m, target)) } } diff --git a/packages/opencode/src/provider/auth.ts b/packages/opencode/src/provider/auth.ts index 5204b5fb8d3..fe6409776df 100644 --- a/packages/opencode/src/provider/auth.ts +++ b/packages/opencode/src/provider/auth.ts @@ -106,7 +106,7 @@ export namespace ProviderAuth { export const layer = Layer.effect( Service, Effect.gen(function* () { - const auth = yield* Auth.AuthEffect.Service + const auth = yield* Auth.Auth.Service const hooks = yield* Effect.promise(async () => { const mod = await import("../plugin") const plugins = await mod.Plugin.list() @@ -213,7 +213,7 @@ export namespace ProviderAuth { }), ) - export const defaultLayer = layer.pipe(Layer.provide(Auth.AuthEffect.layer)) + export const defaultLayer = layer.pipe(Layer.provide(Auth.Auth.layer)) export async function methods() { return runPromiseInstance(Service.use((svc) => svc.methods())) diff --git a/packages/opencode/src/tool/truncate-effect.ts b/packages/opencode/src/tool/truncate-effect.ts index 4431c18f839..99e554f0252 100644 --- a/packages/opencode/src/tool/truncate-effect.ts +++ b/packages/opencode/src/tool/truncate-effect.ts @@ -9,7 +9,7 @@ import { Log } from "../util/log" import { ToolID } from "./schema" import { TRUNCATION_DIR } from "./truncation-dir" -export namespace TruncateEffect { +export namespace Truncate { const log = Log.create({ service: "truncation" }) const RETENTION = Duration.days(7) diff --git a/packages/opencode/src/tool/truncate.ts b/packages/opencode/src/tool/truncate.ts index 159b2d1d5b7..17105463837 100644 --- a/packages/opencode/src/tool/truncate.ts +++ b/packages/opencode/src/tool/truncate.ts @@ -1,6 +1,6 @@ import type { Agent } from "../agent/agent" import { runtime } from "@/effect/runtime" -import { TruncateEffect as S } from "./truncate-effect" +import { Truncate as S } from "./truncate-effect" export namespace Truncate { export const MAX_LINES = S.MAX_LINES diff --git a/packages/opencode/test/account/service.test.ts b/packages/opencode/test/account/service.test.ts index 94cd9eb94db..7cb3005b4eb 100644 --- a/packages/opencode/test/account/service.test.ts +++ b/packages/opencode/test/account/service.test.ts @@ -3,7 +3,7 @@ import { Duration, Effect, Layer, Option, Schema } from "effect" import { HttpClient, HttpClientResponse } from "effect/unstable/http" import { AccountRepo } from "../../src/account/repo" -import { AccountEffect } from "../../src/account/effect" +import { Account } from "../../src/account/effect" import { AccessToken, AccountID, DeviceCode, Login, Org, OrgID, RefreshToken, UserCode } from "../../src/account/schema" import { Database } from "../../src/storage/db" import { testEffect } from "../lib/effect" @@ -19,7 +19,7 @@ const truncate = Layer.effectDiscard( const it = testEffect(Layer.merge(AccountRepo.layer, truncate)) const live = (client: HttpClient.HttpClient) => - AccountEffect.layer.pipe(Layer.provide(Layer.succeed(HttpClient.HttpClient, client))) + Account.layer.pipe(Layer.provide(Layer.succeed(HttpClient.HttpClient, client))) const json = (req: Parameters[0], body: unknown, status = 200) => HttpClientResponse.fromWeb( @@ -77,7 +77,7 @@ it.effect("orgsByAccount groups orgs per account", () => }), ) - const rows = yield* AccountEffect.Service.use((s) => s.orgsByAccount()).pipe(Effect.provide(live(client))) + const rows = yield* Account.Service.use((s) => s.orgsByAccount()).pipe(Effect.provide(live(client))) expect(rows.map((row) => [row.account.id, row.orgs.map((org) => org.id)]).map(([id, orgs]) => [id, orgs])).toEqual([ [AccountID.make("user-1"), [OrgID.make("org-1")]], @@ -115,7 +115,7 @@ it.effect("token refresh persists the new token", () => ), ) - const token = yield* AccountEffect.Service.use((s) => s.token(id)).pipe(Effect.provide(live(client))) + const token = yield* Account.Service.use((s) => s.token(id)).pipe(Effect.provide(live(client))) expect(Option.getOrThrow(token)).toBeDefined() expect(String(Option.getOrThrow(token))).toBe("at_new") @@ -158,7 +158,7 @@ it.effect("config sends the selected org header", () => }), ) - const cfg = yield* AccountEffect.Service.use((s) => s.config(id, OrgID.make("org-9"))).pipe( + const cfg = yield* Account.Service.use((s) => s.config(id, OrgID.make("org-9"))).pipe( Effect.provide(live(client)), ) @@ -198,7 +198,7 @@ it.effect("poll stores the account and first org on success", () => ), ) - const res = yield* AccountEffect.Service.use((s) => s.poll(login)).pipe(Effect.provide(live(client))) + const res = yield* Account.Service.use((s) => s.poll(login)).pipe(Effect.provide(live(client))) expect(res._tag).toBe("PollSuccess") if (res._tag === "PollSuccess") { diff --git a/packages/opencode/test/installation/installation.test.ts b/packages/opencode/test/installation/installation.test.ts index a7cfe50d954..b7472b4a16b 100644 --- a/packages/opencode/test/installation/installation.test.ts +++ b/packages/opencode/test/installation/installation.test.ts @@ -1,47 +1,82 @@ -import { afterEach, describe, expect, test } from "bun:test" -import { Installation } from "../../src/installation" +import { describe, expect, test } from "bun:test" +import { Effect, Layer, Stream } from "effect" +import { HttpClient, HttpClientResponse } from "effect/unstable/http" +import { ChildProcessSpawner } from "effect/unstable/process" +import { Installation } from "../../src/installation/effect" -const fetch0 = globalThis.fetch +function mockHttpClient(handler: (request: Parameters[0]) => Response) { + const client = HttpClient.make((request) => Effect.succeed(HttpClientResponse.fromWeb(request, handler(request)))) + return Layer.succeed(HttpClient.HttpClient, client) +} -afterEach(() => { - globalThis.fetch = fetch0 -}) +function emptySpawner() { + const spawner = ChildProcessSpawner.make( + () => + Effect.succeed( + ChildProcessSpawner.makeHandle({ + pid: ChildProcessSpawner.ProcessId(0), + exitCode: Effect.succeed(ChildProcessSpawner.ExitCode(0)), + isRunning: Effect.succeed(false), + kill: () => Effect.void, + stdin: { [Symbol.for("effect/Sink/TypeId")]: Symbol.for("effect/Sink/TypeId") } as any, + stdout: Stream.empty, + stderr: Stream.empty, + all: Stream.empty, + getInputFd: () => ({ [Symbol.for("effect/Sink/TypeId")]: Symbol.for("effect/Sink/TypeId") }) as any, + getOutputFd: () => Stream.empty, + }), + ), + ) + return Layer.succeed(ChildProcessSpawner.ChildProcessSpawner, spawner) +} + +function testLayer(handler: (request: Parameters[0]) => Response) { + return Installation.layer.pipe(Layer.provide(mockHttpClient(handler)), Layer.provide(emptySpawner())) +} describe("installation", () => { test("reads release version from GitHub releases", async () => { - globalThis.fetch = (async () => - new Response(JSON.stringify({ tag_name: "v1.2.3" }), { - status: 200, - headers: { "content-type": "application/json" }, - })) as unknown as typeof fetch + const layer = testLayer( + () => + new Response(JSON.stringify({ tag_name: "v1.2.3" }), { + status: 200, + headers: { "content-type": "application/json" }, + }), + ) - expect(await Installation.latest("unknown")).toBe("1.2.3") + const result = await Effect.runPromise( + Installation.Service.use((svc) => svc.latest("unknown")).pipe(Effect.provide(layer)), + ) + expect(result).toBe("1.2.3") }) test("reads scoop manifest versions", async () => { - globalThis.fetch = (async () => - new Response(JSON.stringify({ version: "2.3.4" }), { - status: 200, - headers: { "content-type": "application/json" }, - })) as unknown as typeof fetch + const layer = testLayer( + () => + new Response(JSON.stringify({ version: "2.3.4" }), { + status: 200, + headers: { "content-type": "application/json" }, + }), + ) - expect(await Installation.latest("scoop")).toBe("2.3.4") + const result = await Effect.runPromise( + Installation.Service.use((svc) => svc.latest("scoop")).pipe(Effect.provide(layer)), + ) + expect(result).toBe("2.3.4") }) test("reads chocolatey feed versions", async () => { - globalThis.fetch = (async () => - new Response( - JSON.stringify({ - d: { - results: [{ Version: "3.4.5" }], - }, - }), - { + const layer = testLayer( + () => + new Response(JSON.stringify({ d: { results: [{ Version: "3.4.5" }] } }), { status: 200, headers: { "content-type": "application/json" }, - }, - )) as unknown as typeof fetch + }), + ) - expect(await Installation.latest("choco")).toBe("3.4.5") + const result = await Effect.runPromise( + Installation.Service.use((svc) => svc.latest("choco")).pipe(Effect.provide(layer)), + ) + expect(result).toBe("3.4.5") }) }) diff --git a/packages/opencode/test/tool/truncation.test.ts b/packages/opencode/test/tool/truncation.test.ts index 71439f76049..9af5e67f0f6 100644 --- a/packages/opencode/test/tool/truncation.test.ts +++ b/packages/opencode/test/tool/truncation.test.ts @@ -2,7 +2,7 @@ import { describe, test, expect } from "bun:test" import { NodeFileSystem } from "@effect/platform-node" import { Effect, FileSystem, Layer } from "effect" import { Truncate } from "../../src/tool/truncate" -import { TruncateEffect } from "../../src/tool/truncate-effect" +import { Truncate as TruncateSvc } from "../../src/tool/truncate-effect" import { Identifier } from "../../src/id/id" import { Filesystem } from "../../src/util/filesystem" import path from "path" @@ -129,7 +129,7 @@ describe("Truncate", () => { describe("cleanup", () => { const DAY_MS = 24 * 60 * 60 * 1000 - const it = testEffect(Layer.mergeAll(TruncateEffect.defaultLayer, NodeFileSystem.layer)) + const it = testEffect(Layer.mergeAll(TruncateSvc.defaultLayer, NodeFileSystem.layer)) it.effect("deletes files older than 7 days and preserves recent files", () => Effect.gen(function* () { @@ -142,7 +142,7 @@ describe("Truncate", () => { yield* writeFileStringScoped(old, "old content") yield* writeFileStringScoped(recent, "recent content") - yield* TruncateEffect.Service.use((s) => s.cleanup()) + yield* TruncateSvc.Service.use((s) => s.cleanup()) expect(yield* fs.exists(old)).toBe(false) expect(yield* fs.exists(recent)).toBe(true) From 4710fb4f7a5b72a9fa058ec05923f4f0418c90ef Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Thu, 19 Mar 2026 14:31:30 -0400 Subject: [PATCH 2/8] add withTransientReadRetry to Installation HTTP client --- packages/opencode/src/installation/effect.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/opencode/src/installation/effect.ts b/packages/opencode/src/installation/effect.ts index 825a98d0ce1..e6ec1f0aee9 100644 --- a/packages/opencode/src/installation/effect.ts +++ b/packages/opencode/src/installation/effect.ts @@ -1,6 +1,7 @@ import { NodeChildProcessSpawner, NodeFileSystem, NodePath } from "@effect/platform-node" import { Effect, Layer, Schema, ServiceMap, Stream } from "effect" import { FetchHttpClient, HttpClient, HttpClientRequest } 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" @@ -76,7 +77,7 @@ export namespace Installation { Service, Effect.gen(function* () { const http = yield* HttpClient.HttpClient - const httpOk = HttpClient.filterStatusOk(http) + const httpOk = HttpClient.filterStatusOk(withTransientReadRetry(http)) const spawner = yield* ChildProcessSpawner.ChildProcessSpawner const text = Effect.fnUntraced( From 4da1a917d47f5e7c9dcb0af185f490f8ef2bd626 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Thu, 19 Mar 2026 14:46:27 -0400 Subject: [PATCH 3/8] use Schema for Installation version API responses Replace raw JSON.parse and any casts with Schema.Class definitions and HttpClientResponse.schemaBodyJson for type-safe parsing of GitHub, npm, brew, choco, and scoop version responses. --- packages/opencode/src/installation/effect.ts | 40 ++++++++++++-------- 1 file changed, 25 insertions(+), 15 deletions(-) diff --git a/packages/opencode/src/installation/effect.ts b/packages/opencode/src/installation/effect.ts index e6ec1f0aee9..2c5fd229ab6 100644 --- a/packages/opencode/src/installation/effect.ts +++ b/packages/opencode/src/installation/effect.ts @@ -1,6 +1,6 @@ import { NodeChildProcessSpawner, NodeFileSystem, NodePath } from "@effect/platform-node" import { Effect, Layer, Schema, ServiceMap, Stream } from "effect" -import { FetchHttpClient, HttpClient, HttpClientRequest } from "effect/unstable/http" +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" @@ -60,6 +60,18 @@ export namespace Installation { 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 @@ -188,18 +200,16 @@ export namespace Installation { const formula = yield* getBrewFormula() if (formula.includes("/")) { const infoJson = yield* text(["brew", "info", "--json=v2", formula]) - const info = JSON.parse(infoJson) - const version = info.formulae?.[0]?.versions?.stable - if (!version) throw new Error(`Could not detect version for tap formula: ${formula}`) - return version as string + 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: any = yield* response.json - return data.versions.stable as string + const data = yield* HttpClientResponse.schemaBodyJson(BrewFormula)(response) + return data.versions.stable } if (detectedMethod === "npm" || detectedMethod === "bun" || detectedMethod === "pnpm") { @@ -210,8 +220,8 @@ export namespace Installation { const response = yield* httpOk.execute( HttpClientRequest.get(`${registry}/opencode-ai/${channel}`).pipe(HttpClientRequest.acceptJson), ) - const data: any = yield* response.json - return data.version as string + const data = yield* HttpClientResponse.schemaBodyJson(NpmPackage)(response) + return data.version } if (detectedMethod === "choco") { @@ -220,8 +230,8 @@ export namespace Installation { "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: any = yield* response.json - return data.d.results[0].Version as string + const data = yield* HttpClientResponse.schemaBodyJson(ChocoPackage)(response) + return data.d.results[0].Version } if (detectedMethod === "scoop") { @@ -230,8 +240,8 @@ export namespace Installation { "https://raw.githubusercontent.com/ScoopInstaller/Main/master/bucket/opencode.json", ).pipe(HttpClientRequest.setHeaders({ Accept: "application/json" })), ) - const data: any = yield* response.json - return data.version as string + const data = yield* HttpClientResponse.schemaBodyJson(ScoopManifest)(response) + return data.version } const response = yield* httpOk.execute( @@ -239,8 +249,8 @@ export namespace Installation { HttpClientRequest.acceptJson, ), ) - const data: any = yield* response.json - return (data.tag_name as string).replace(/^v/, "") + const data = yield* HttpClientResponse.schemaBodyJson(GitHubRelease)(response) + return data.tag_name.replace(/^v/, "") }, Effect.orDie, ) From 8972c881a8864f70d3fae22fb786bfd07c570429 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Thu, 19 Mar 2026 14:50:13 -0400 Subject: [PATCH 4/8] add tests for all Installation version fetch paths Cover npm registry, bun (via npm registry), brew formulae API, brew tap CLI JSON parsing, GitHub releases, scoop, and chocolatey. Tests use mock HttpClient and ChildProcessSpawner layers. --- .../test/installation/installation.test.ts | 195 ++++++++++++------ 1 file changed, 134 insertions(+), 61 deletions(-) diff --git a/packages/opencode/test/installation/installation.test.ts b/packages/opencode/test/installation/installation.test.ts index b7472b4a16b..477f169df3d 100644 --- a/packages/opencode/test/installation/installation.test.ts +++ b/packages/opencode/test/installation/installation.test.ts @@ -1,82 +1,155 @@ import { describe, expect, test } from "bun:test" import { Effect, Layer, Stream } from "effect" -import { HttpClient, HttpClientResponse } from "effect/unstable/http" -import { ChildProcessSpawner } from "effect/unstable/process" +import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http" +import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process" import { Installation } from "../../src/installation/effect" -function mockHttpClient(handler: (request: Parameters[0]) => Response) { +const encoder = new TextEncoder() + +function mockHttpClient(handler: (request: HttpClientRequest.HttpClientRequest) => Response) { const client = HttpClient.make((request) => Effect.succeed(HttpClientResponse.fromWeb(request, handler(request)))) return Layer.succeed(HttpClient.HttpClient, client) } -function emptySpawner() { - const spawner = ChildProcessSpawner.make( - () => - Effect.succeed( - ChildProcessSpawner.makeHandle({ - pid: ChildProcessSpawner.ProcessId(0), - exitCode: Effect.succeed(ChildProcessSpawner.ExitCode(0)), - isRunning: Effect.succeed(false), - kill: () => Effect.void, - stdin: { [Symbol.for("effect/Sink/TypeId")]: Symbol.for("effect/Sink/TypeId") } as any, - stdout: Stream.empty, - stderr: Stream.empty, - all: Stream.empty, - getInputFd: () => ({ [Symbol.for("effect/Sink/TypeId")]: Symbol.for("effect/Sink/TypeId") }) as any, - getOutputFd: () => Stream.empty, - }), - ), - ) +function mockSpawner(handler: (cmd: string, args: readonly string[]) => string = () => "") { + const spawner = ChildProcessSpawner.make((command) => { + const std = ChildProcess.isStandardCommand(command) ? command : undefined + const output = handler(std?.command ?? "", std?.args ?? []) + return Effect.succeed( + ChildProcessSpawner.makeHandle({ + pid: ChildProcessSpawner.ProcessId(0), + exitCode: Effect.succeed(ChildProcessSpawner.ExitCode(0)), + isRunning: Effect.succeed(false), + kill: () => Effect.void, + stdin: { [Symbol.for("effect/Sink/TypeId")]: Symbol.for("effect/Sink/TypeId") } as any, + stdout: output ? Stream.make(encoder.encode(output)) : Stream.empty, + stderr: Stream.empty, + all: Stream.empty, + getInputFd: () => ({ [Symbol.for("effect/Sink/TypeId")]: Symbol.for("effect/Sink/TypeId") }) as any, + getOutputFd: () => Stream.empty, + }), + ) + }) return Layer.succeed(ChildProcessSpawner.ChildProcessSpawner, spawner) } -function testLayer(handler: (request: Parameters[0]) => Response) { - return Installation.layer.pipe(Layer.provide(mockHttpClient(handler)), Layer.provide(emptySpawner())) +function jsonResponse(body: unknown) { + return new Response(JSON.stringify(body), { + status: 200, + headers: { "content-type": "application/json" }, + }) +} + +function testLayer( + httpHandler: (request: HttpClientRequest.HttpClientRequest) => Response, + spawnHandler?: (cmd: string, args: readonly string[]) => string, +) { + return Installation.layer.pipe( + Layer.provide(mockHttpClient(httpHandler)), + Layer.provide(mockSpawner(spawnHandler)), + ) } describe("installation", () => { - test("reads release version from GitHub releases", async () => { - const layer = testLayer( - () => - new Response(JSON.stringify({ tag_name: "v1.2.3" }), { - status: 200, - headers: { "content-type": "application/json" }, - }), - ) + describe("latest", () => { + test("reads release version from GitHub releases", async () => { + const layer = testLayer(() => jsonResponse({ tag_name: "v1.2.3" })) - const result = await Effect.runPromise( - Installation.Service.use((svc) => svc.latest("unknown")).pipe(Effect.provide(layer)), - ) - expect(result).toBe("1.2.3") - }) + const result = await Effect.runPromise( + Installation.Service.use((svc) => svc.latest("unknown")).pipe(Effect.provide(layer)), + ) + expect(result).toBe("1.2.3") + }) - test("reads scoop manifest versions", async () => { - const layer = testLayer( - () => - new Response(JSON.stringify({ version: "2.3.4" }), { - status: 200, - headers: { "content-type": "application/json" }, - }), - ) + test("strips v prefix from GitHub release tag", async () => { + const layer = testLayer(() => jsonResponse({ tag_name: "v4.0.0-beta.1" })) - const result = await Effect.runPromise( - Installation.Service.use((svc) => svc.latest("scoop")).pipe(Effect.provide(layer)), - ) - expect(result).toBe("2.3.4") - }) + const result = await Effect.runPromise( + Installation.Service.use((svc) => svc.latest("curl")).pipe(Effect.provide(layer)), + ) + expect(result).toBe("4.0.0-beta.1") + }) - test("reads chocolatey feed versions", async () => { - const layer = testLayer( - () => - new Response(JSON.stringify({ d: { results: [{ Version: "3.4.5" }] } }), { - status: 200, - headers: { "content-type": "application/json" }, - }), - ) + test("reads npm registry versions", async () => { + const layer = testLayer( + () => jsonResponse({ version: "1.5.0" }), + (cmd, args) => { + if (cmd === "npm" && args.includes("registry")) return "https://registry.npmjs.org\n" + return "" + }, + ) - const result = await Effect.runPromise( - Installation.Service.use((svc) => svc.latest("choco")).pipe(Effect.provide(layer)), - ) - expect(result).toBe("3.4.5") + const result = await Effect.runPromise( + Installation.Service.use((svc) => svc.latest("npm")).pipe(Effect.provide(layer)), + ) + expect(result).toBe("1.5.0") + }) + + test("reads npm registry versions for bun method", async () => { + const layer = testLayer( + () => jsonResponse({ version: "1.6.0" }), + () => "", + ) + + const result = await Effect.runPromise( + Installation.Service.use((svc) => svc.latest("bun")).pipe(Effect.provide(layer)), + ) + expect(result).toBe("1.6.0") + }) + + test("reads scoop manifest versions", async () => { + const layer = testLayer(() => jsonResponse({ version: "2.3.4" })) + + const result = await Effect.runPromise( + Installation.Service.use((svc) => svc.latest("scoop")).pipe(Effect.provide(layer)), + ) + expect(result).toBe("2.3.4") + }) + + test("reads chocolatey feed versions", async () => { + const layer = testLayer(() => jsonResponse({ d: { results: [{ Version: "3.4.5" }] } })) + + const result = await Effect.runPromise( + Installation.Service.use((svc) => svc.latest("choco")).pipe(Effect.provide(layer)), + ) + expect(result).toBe("3.4.5") + }) + + test("reads brew formulae API versions", async () => { + const layer = testLayer( + () => jsonResponse({ versions: { stable: "2.0.0" } }), + (cmd, args) => { + // getBrewFormula: return core formula (no tap) + if (cmd === "brew" && args.includes("--formula") && args.includes("anomalyco/tap/opencode")) return "" + if (cmd === "brew" && args.includes("--formula") && args.includes("opencode")) return "opencode" + return "" + }, + ) + + const result = await Effect.runPromise( + Installation.Service.use((svc) => svc.latest("brew")).pipe(Effect.provide(layer)), + ) + expect(result).toBe("2.0.0") + }) + + test("reads brew tap info JSON via CLI", async () => { + const brewInfoJson = JSON.stringify({ + formulae: [{ versions: { stable: "2.1.0" } }], + }) + const layer = testLayer( + () => jsonResponse({}), // HTTP not used for tap formula + (cmd, args) => { + if (cmd === "brew" && args.includes("anomalyco/tap/opencode") && args.includes("--formula")) + return "opencode" + if (cmd === "brew" && args.includes("--json=v2")) return brewInfoJson + return "" + }, + ) + + const result = await Effect.runPromise( + Installation.Service.use((svc) => svc.latest("brew")).pipe(Effect.provide(layer)), + ) + expect(result).toBe("2.1.0") + }) }) }) From c619537cbe80692661cab981dc37297547397a6b Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Thu, 19 Mar 2026 15:04:09 -0400 Subject: [PATCH 5/8] fix circular dependency: use dynamic import for runtime in installation adapters --- packages/opencode/src/installation/index.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/packages/opencode/src/installation/index.ts b/packages/opencode/src/installation/index.ts index d54d6c0ecae..ad84bdfd693 100644 --- a/packages/opencode/src/installation/index.ts +++ b/packages/opencode/src/installation/index.ts @@ -1,6 +1,5 @@ import { Effect } from "effect" import { Installation as S } from "./effect" -import { runtime } from "@/effect/runtime" export namespace Installation { export type Method = S.Method @@ -20,7 +19,12 @@ export namespace Installation { export const layer = S.layer export const defaultLayer = S.defaultLayer - function runPromise(f: (service: S.Interface) => Effect.Effect) { + // Dynamic import breaks the circular dependency: many foundational modules + // (db.ts, provider/models.ts) import @/installation at load time, and a + // static import of runtime here would create a cycle since runtime + // transitively loads those same modules. + async function runPromise(f: (service: S.Interface) => Effect.Effect) { + const { runtime } = await import("@/effect/runtime") return runtime.runPromise(S.Service.use(f)) } From af69bc118468e09b4d92b0d6f67ada592dd744cf Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Thu, 19 Mar 2026 15:10:35 -0400 Subject: [PATCH 6/8] consolidate Installation back into single index.ts file --- packages/opencode/src/effect/runtime.ts | 2 +- packages/opencode/src/installation/effect.ts | 337 ---------------- packages/opencode/src/installation/index.ts | 365 ++++++++++++++++-- .../test/installation/installation.test.ts | 2 +- 4 files changed, 342 insertions(+), 364 deletions(-) delete mode 100644 packages/opencode/src/installation/effect.ts diff --git a/packages/opencode/src/effect/runtime.ts b/packages/opencode/src/effect/runtime.ts index 7af55074177..e6f1f326262 100644 --- a/packages/opencode/src/effect/runtime.ts +++ b/packages/opencode/src/effect/runtime.ts @@ -3,7 +3,7 @@ import { Account } from "@/account/effect" import { Auth } from "@/auth/effect" import { Instances } from "@/effect/instances" import type { InstanceServices } from "@/effect/instances" -import { Installation } from "@/installation/effect" +import { Installation } from "@/installation" import { Truncate } from "@/tool/truncate-effect" import { Instance } from "@/project/instance" diff --git a/packages/opencode/src/installation/effect.ts b/packages/opencode/src/installation/effect.ts deleted file mode 100644 index 2c5fd229ab6..00000000000 --- a/packages/opencode/src/installation/effect.ts +++ /dev/null @@ -1,337 +0,0 @@ -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), - ) -} diff --git a/packages/opencode/src/installation/index.ts b/packages/opencode/src/installation/index.ts index ad84bdfd693..308fd0b0728 100644 --- a/packages/opencode/src/installation/index.ts +++ b/packages/opencode/src/installation/index.ts @@ -1,31 +1,346 @@ -import { Effect } from "effect" -import { Installation as S } from "./effect" +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 { - export type Method = S.Method - export type Info = S.Info - export type Interface = S.Interface - export type UpgradeFailedError = S.UpgradeFailedError - - export const Event = S.Event - export const Info = S.Info - export const VERSION = S.VERSION - export const CHANNEL = S.CHANNEL - export const USER_AGENT = S.USER_AGENT - export const isPreview = S.isPreview - export const isLocal = S.isLocal - export const UpgradeFailedError = S.UpgradeFailedError - export const Service = S.Service - export const layer = S.layer - export const defaultLayer = S.defaultLayer - - // Dynamic import breaks the circular dependency: many foundational modules - // (db.ts, provider/models.ts) import @/installation at load time, and a - // static import of runtime here would create a cycle since runtime - // transitively loads those same modules. - async function runPromise(f: (service: S.Interface) => Effect.Effect) { + 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(S.Service.use(f)) + return runtime.runPromise(Service.use(f)) } export function info(): Promise { diff --git a/packages/opencode/test/installation/installation.test.ts b/packages/opencode/test/installation/installation.test.ts index 477f169df3d..aa3ce958780 100644 --- a/packages/opencode/test/installation/installation.test.ts +++ b/packages/opencode/test/installation/installation.test.ts @@ -2,7 +2,7 @@ import { describe, expect, test } from "bun:test" import { Effect, Layer, Stream } from "effect" import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http" import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process" -import { Installation } from "../../src/installation/effect" +import { Installation } from "../../src/installation" const encoder = new TextEncoder() From f9f580f0ac3b13bd9cec34bdbe8b66b70baa1ac7 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Thu, 19 Mar 2026 19:24:01 -0400 Subject: [PATCH 7/8] fix remaining AccountEffect references after merge --- packages/opencode/test/account/service.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/opencode/test/account/service.test.ts b/packages/opencode/test/account/service.test.ts index 80602282501..e0d0530fb3d 100644 --- a/packages/opencode/test/account/service.test.ts +++ b/packages/opencode/test/account/service.test.ts @@ -52,7 +52,7 @@ const deviceTokenClient = (body: unknown, status = 400) => ) const poll = (body: unknown, status = 400) => - AccountEffect.Service.use((s) => s.poll(login())).pipe(Effect.provide(live(deviceTokenClient(body, status)))) + Account.Service.use((s) => s.poll(login())).pipe(Effect.provide(live(deviceTokenClient(body, status)))) it.effect("orgsByAccount groups orgs per account", () => Effect.gen(function* () { From 4c51d2b40996176a512757842a2f46005295f6d7 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Thu, 19 Mar 2026 21:02:22 -0400 Subject: [PATCH 8/8] refactor(account): expose Account.Info instead of Account.Account --- packages/opencode/src/account/effect.ts | 10 +++++----- packages/opencode/src/account/index.ts | 15 ++++----------- packages/opencode/src/account/repo.ts | 8 ++++---- packages/opencode/src/account/schema.ts | 2 +- 4 files changed, 14 insertions(+), 21 deletions(-) diff --git a/packages/opencode/src/account/effect.ts b/packages/opencode/src/account/effect.ts index f46257b7832..8686ef42a99 100644 --- a/packages/opencode/src/account/effect.ts +++ b/packages/opencode/src/account/effect.ts @@ -6,9 +6,9 @@ import { AccountRepo, type AccountRow } from "./repo" import { type AccountError, AccessToken, - Account as AccountSchema, AccountID, DeviceCode, + Info, RefreshToken, AccountServiceError, Login, @@ -25,7 +25,6 @@ import { } from "./schema" export { - Account as AccountSchema, AccountID, type AccountError, AccountRepoError, @@ -34,6 +33,7 @@ export { RefreshToken, DeviceCode, UserCode, + Info, Org, OrgID, Login, @@ -47,7 +47,7 @@ export { } from "./schema" export type AccountOrgs = { - account: AccountSchema + account: Info orgs: readonly Org[] } @@ -130,8 +130,8 @@ const mapAccountServiceError = export namespace Account { export interface Interface { - readonly active: () => Effect.Effect, AccountError> - readonly list: () => Effect.Effect + readonly active: () => Effect.Effect, AccountError> + readonly list: () => Effect.Effect readonly orgsByAccount: () => Effect.Effect readonly remove: (accountID: AccountID) => Effect.Effect readonly use: (accountID: AccountID, orgID: Option.Option) => Effect.Effect diff --git a/packages/opencode/src/account/index.ts b/packages/opencode/src/account/index.ts index 2a16fcdb3cc..753b80c5f1e 100644 --- a/packages/opencode/src/account/index.ts +++ b/packages/opencode/src/account/index.ts @@ -1,13 +1,6 @@ import { Effect, Option } from "effect" -import { - Account as S, - AccountSchema, - type AccountError, - type AccessToken, - AccountID, - OrgID, -} from "./effect" +import { Account as S, type AccountError, type AccessToken, AccountID, Info as Model, OrgID } from "./effect" export { AccessToken, AccountID, OrgID } from "./effect" @@ -22,10 +15,10 @@ function runPromise(f: (service: S.Interface) => Effect.Effect service.active())) } diff --git a/packages/opencode/src/account/repo.ts b/packages/opencode/src/account/repo.ts index 5caf1a3b946..1659546a26e 100644 --- a/packages/opencode/src/account/repo.ts +++ b/packages/opencode/src/account/repo.ts @@ -3,7 +3,7 @@ import { Effect, Layer, Option, Schema, ServiceMap } from "effect" import { Database } from "@/storage/db" import { AccountStateTable, AccountTable } from "./account.sql" -import { AccessToken, Account, AccountID, AccountRepoError, OrgID, RefreshToken } from "./schema" +import { AccessToken, AccountID, AccountRepoError, Info, OrgID, RefreshToken } from "./schema" export type AccountRow = (typeof AccountTable)["$inferSelect"] @@ -13,8 +13,8 @@ const ACCOUNT_STATE_ID = 1 export namespace AccountRepo { export interface Service { - readonly active: () => Effect.Effect, AccountRepoError> - readonly list: () => Effect.Effect + readonly active: () => Effect.Effect, AccountRepoError> + readonly list: () => Effect.Effect readonly remove: (accountID: AccountID) => Effect.Effect readonly use: (accountID: AccountID, orgID: Option.Option) => Effect.Effect readonly getRow: (accountID: AccountID) => Effect.Effect, AccountRepoError> @@ -40,7 +40,7 @@ export class AccountRepo extends ServiceMap.Service = Layer.effect( AccountRepo, Effect.gen(function* () { - const decode = Schema.decodeUnknownSync(Account) + const decode = Schema.decodeUnknownSync(Info) const query = (f: (db: DbClient) => A) => Effect.try({ diff --git a/packages/opencode/src/account/schema.ts b/packages/opencode/src/account/schema.ts index 9b31c4ba652..830b203a9f8 100644 --- a/packages/opencode/src/account/schema.ts +++ b/packages/opencode/src/account/schema.ts @@ -38,7 +38,7 @@ export const UserCode = Schema.String.pipe( ) export type UserCode = Schema.Schema.Type -export class Account extends Schema.Class("Account")({ +export class Info extends Schema.Class("Account")({ id: AccountID, email: Schema.String, url: Schema.String,