Selaa lähdekoodia

effectify Installation service, drop Effect suffix from namespaces (#18266)

Kit Langton 5 kuukautta sitten
vanhempi
commit
e78944e9a4

+ 26 - 6
packages/opencode/src/account/effect.ts

@@ -6,9 +6,9 @@ import { AccountRepo, type AccountRow } from "./repo"
 import {
   type AccountError,
   AccessToken,
-  Account,
   AccountID,
   DeviceCode,
+  Info,
   RefreshToken,
   AccountServiceError,
   Login,
@@ -24,10 +24,30 @@ import {
   UserCode,
 } from "./schema"
 
-export * from "./schema"
+export {
+  AccountID,
+  type AccountError,
+  AccountRepoError,
+  AccountServiceError,
+  AccessToken,
+  RefreshToken,
+  DeviceCode,
+  UserCode,
+  Info,
+  Org,
+  OrgID,
+  Login,
+  PollSuccess,
+  PollPending,
+  PollSlow,
+  PollExpired,
+  PollDenied,
+  PollError,
+  PollResult,
+} from "./schema"
 
 export type AccountOrgs = {
-  account: Account
+  account: Info
   orgs: readonly Org[]
 }
 
@@ -108,10 +128,10 @@ const mapAccountServiceError =
       ),
     )
 
-export namespace AccountEffect {
+export namespace Account {
   export interface Interface {
-    readonly active: () => Effect.Effect<Option.Option<Account>, AccountError>
-    readonly list: () => Effect.Effect<Account[], AccountError>
+    readonly active: () => Effect.Effect<Option.Option<Info>, AccountError>
+    readonly list: () => Effect.Effect<Info[], AccountError>
     readonly orgsByAccount: () => Effect.Effect<readonly AccountOrgs[], AccountError>
     readonly remove: (accountID: AccountID) => Effect.Effect<void, AccountError>
     readonly use: (accountID: AccountID, orgID: Option.Option<OrgID>) => Effect.Effect<void, AccountError>

+ 8 - 15
packages/opencode/src/account/index.ts

@@ -1,31 +1,24 @@
 import { Effect, Option } from "effect"
 
-import {
-  Account as AccountSchema,
-  type AccountError,
-  type AccessToken,
-  AccountID,
-  AccountEffect,
-  OrgID,
-} from "./effect"
+import { Account as S, type AccountError, type AccessToken, AccountID, Info as Model, OrgID } from "./effect"
 
 export { AccessToken, AccountID, OrgID } from "./effect"
 
 import { runtime } from "@/effect/runtime"
 
-function runSync<A>(f: (service: AccountEffect.Interface) => Effect.Effect<A, AccountError>) {
-  return runtime.runSync(AccountEffect.Service.use(f))
+function runSync<A>(f: (service: S.Interface) => Effect.Effect<A, AccountError>) {
+  return runtime.runSync(S.Service.use(f))
 }
 
-function runPromise<A>(f: (service: AccountEffect.Interface) => Effect.Effect<A, AccountError>) {
-  return runtime.runPromise(AccountEffect.Service.use(f))
+function runPromise<A>(f: (service: S.Interface) => Effect.Effect<A, AccountError>) {
+  return runtime.runPromise(S.Service.use(f))
 }
 
 export namespace Account {
-  export const Account = AccountSchema
-  export type Account = AccountSchema
+  export const Info = Model
+  export type Info = Model
 
-  export function active(): Account | undefined {
+  export function active(): Info | undefined {
     return Option.getOrUndefined(runSync((service) => service.active()))
   }
 

+ 4 - 4
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<Option.Option<Account>, AccountRepoError>
-    readonly list: () => Effect.Effect<Account[], AccountRepoError>
+    readonly active: () => Effect.Effect<Option.Option<Info>, AccountRepoError>
+    readonly list: () => Effect.Effect<Info[], AccountRepoError>
     readonly remove: (accountID: AccountID) => Effect.Effect<void, AccountRepoError>
     readonly use: (accountID: AccountID, orgID: Option.Option<OrgID>) => Effect.Effect<void, AccountRepoError>
     readonly getRow: (accountID: AccountID) => Effect.Effect<Option.Option<AccountRow>, AccountRepoError>
@@ -40,7 +40,7 @@ export class AccountRepo extends ServiceMap.Service<AccountRepo, AccountRepo.Ser
   static readonly layer: Layer.Layer<AccountRepo> = Layer.effect(
     AccountRepo,
     Effect.gen(function* () {
-      const decode = Schema.decodeUnknownSync(Account)
+      const decode = Schema.decodeUnknownSync(Info)
 
       const query = <A>(f: (db: DbClient) => A) =>
         Effect.try({

+ 1 - 1
packages/opencode/src/account/schema.ts

@@ -38,7 +38,7 @@ export const UserCode = Schema.String.pipe(
 )
 export type UserCode = Schema.Schema.Type<typeof UserCode>
 
-export class Account extends Schema.Class<Account>("Account")({
+export class Info extends Schema.Class<Info>("Account")({
   id: AccountID,
   email: Schema.String,
   url: Schema.String,

+ 1 - 1
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<Info | undefined, AuthError>
     readonly all: () => Effect.Effect<Record<string, Info>, AuthError>

+ 2 - 2
packages/opencode/src/auth/index.ts

@@ -5,8 +5,8 @@ import * as S from "./effect"
 
 export { OAUTH_DUMMY_KEY } from "./effect"
 
-function runPromise<A>(f: (service: S.AuthEffect.Interface) => Effect.Effect<A, S.AuthError>) {
-  return runtime.runPromise(S.AuthEffect.Service.use(f))
+function runPromise<A>(f: (service: S.Auth.Interface) => Effect.Effect<A, S.AuthError>) {
+  return runtime.runPromise(S.Auth.Service.use(f))
 }
 
 export namespace Auth {

+ 5 - 5
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")

+ 2 - 2
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")

+ 8 - 6
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"
+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<A, E>(effect: Effect.Effect<A, E, InstanceServices>) {

+ 294 - 236
packages/opencode/src/installation/index.ts

@@ -1,12 +1,13 @@
-import { BusEvent } from "@/bus/bus-event"
+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 { NamedError } from "@opencode-ai/util/error"
-import { Log } from "../util/log"
-import { iife } from "@/util/iife"
+import { BusEvent } from "@/bus/bus-event"
 import { Flag } from "../flag/flag"
-import { Process } from "@/util/process"
-import { buffer } from "node:stream/consumers"
+import { Log } from "../util/log"
 
 declare global {
   const OPENCODE_VERSION: string
@@ -16,39 +17,7 @@ declare global {
 export namespace Installation {
   const log = Log.create({ service: "installation" })
 
-  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)
-  }
-
-  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,
-    }
-  }
-
-  export type Method = Awaited<ReturnType<typeof method>>
+  export type Method = "curl" | "npm" | "yarn" | "pnpm" | "bun" | "brew" | "scoop" | "choco" | "unknown"
 
   export const Event = {
     Updated: BusEvent.define(
@@ -75,12 +44,9 @@ export namespace Installation {
     })
   export type Info = z.infer<typeof Info>
 
-  export async function info() {
-    return {
-      version: VERSION,
-      latest: await latest(),
-    }
-  }
+  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"
@@ -90,214 +56,306 @@ export namespace Installation {
     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
-    })
+  export class UpgradeFailedError extends Schema.TaggedErrorClass<UpgradeFailedError>()("UpgradeFailedError", {
+    stderr: Schema.String,
+  }) {}
 
-    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
-      }
-    }
+  // 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
 
-    return "unknown"
+  export interface Interface {
+    readonly info: () => Effect.Effect<Info>
+    readonly method: () => Effect.Effect<Method>
+    readonly latest: (method?: Method) => Effect.Effect<string>
+    readonly upgrade: (method: Method, target: string) => Effect.Effect<void, UpgradeFailedError>
   }
 
-  export const UpgradeFailedError = NamedError.create(
-    "UpgradeFailedError",
-    z.object({
-      stderr: z.string(),
-    }),
-  )
+  export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/Installation") {}
 
-  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 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<string, string> }) {
+          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("")),
+      )
 
-  export async function upgrade(method: Method, target: string) {
-    let result: Awaited<ReturnType<typeof upgradeCurl>> | 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,
+      const run = Effect.fnUntraced(
+        function* (cmd: string[], opts?: { cwd?: string; env?: Record<string, string> }) {
+          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<string> }> = [
+          { 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
+          }
         }
-        if (formula.includes("/")) {
-          const tap = await Process.run(["brew", "tap", "anomalyco/tap"], { env, nothrow: true })
-          if (tap.code !== 0) {
-            result = tap
-            break
+
+        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
           }
-          const repo = await Process.text(["brew", "--repo", "anomalyco/tap"], { env, nothrow: true })
-          if (repo.code !== 0) {
-            result = repo
-            break
+
+          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
           }
-          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
+
+          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}`)
         }
-        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,
+        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"])
       })
-    }
-    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
+      return Service.of({
+        info: Effect.fn("Installation.info")(function* () {
+          return {
+            version: VERSION,
+            latest: yield* latestImpl(),
+          }
+        }),
+        method: methodImpl,
+        latest: latestImpl,
+        upgrade: upgradeImpl,
       })
-      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)
-    }
+  export const defaultLayer = layer.pipe(
+    Layer.provide(FetchHttpClient.layer),
+    Layer.provide(NodeChildProcessSpawner.layer),
+    Layer.provide(NodeFileSystem.layer),
+    Layer.provide(NodePath.layer),
+  )
 
-    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)
-    }
+  // 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<A>(f: (service: Interface) => Effect.Effect<A, any>) {
+    const { runtime } = await import("@/effect/runtime")
+    return runtime.runPromise(Service.use(f))
+  }
 
-    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 info(): Promise<Info> {
+    return runPromise((svc) => svc.info())
+  }
+
+  export function method(): Promise<Method> {
+    return runPromise((svc) => svc.method())
+  }
+
+  export function latest(installMethod?: Method): Promise<string> {
+    return runPromise((svc) => svc.latest(installMethod))
+  }
+
+  export function upgrade(m: Method, target: string): Promise<void> {
+    return runPromise((svc) => svc.upgrade(m, target))
   }
 }

+ 2 - 2
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()))

+ 1 - 1
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)
 

+ 1 - 1
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

+ 7 - 7
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<typeof HttpClientResponse.fromWeb>[0], body: unknown, status = 200) =>
   HttpClientResponse.fromWeb(
@@ -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* () {
@@ -97,7 +97,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")]],
@@ -135,7 +135,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")
@@ -178,7 +178,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)),
     )
 
@@ -209,7 +209,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") {

+ 143 - 35
packages/opencode/test/installation/installation.test.ts

@@ -1,47 +1,155 @@
-import { afterEach, describe, expect, test } from "bun:test"
+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"
 
-const fetch0 = globalThis.fetch
+const encoder = new TextEncoder()
 
-afterEach(() => {
-  globalThis.fetch = fetch0
-})
+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)
+}
 
-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
-
-    expect(await Installation.latest("unknown")).toBe("1.2.3")
+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)
+}
 
-  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
-
-    expect(await Installation.latest("scoop")).toBe("2.3.4")
+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", () => {
+  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")
+    })
+
+    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("curl")).pipe(Effect.provide(layer)),
+      )
+      expect(result).toBe("4.0.0-beta.1")
+    })
+
+    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("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 chocolatey feed versions", async () => {
-    globalThis.fetch = (async () =>
-      new Response(
-        JSON.stringify({
-          d: {
-            results: [{ Version: "3.4.5" }],
-          },
-        }),
-        {
-          status: 200,
-          headers: { "content-type": "application/json" },
+    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 ""
         },
-      )) 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("brew")).pipe(Effect.provide(layer)),
+      )
+      expect(result).toBe("2.1.0")
+    })
   })
 })

+ 3 - 3
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 { Process } from "../../src/util/process"
 import { Filesystem } from "../../src/util/filesystem"
@@ -139,7 +139,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* () {
@@ -152,7 +152,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)