Prechádzať zdrojové kódy

feat: unwrap uinstallation namespace to flat exports + barrel (#22707)

Kit Langton 1 deň pred
rodič
commit
360d8dd940

+ 1 - 340
packages/opencode/src/installation/index.ts

@@ -1,340 +1 @@
-import { Effect, Layer, Schema, Context, Stream } from "effect"
-import { FetchHttpClient, HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
-import * as CrossSpawnSpawner from "@/effect/cross-spawn-spawner"
-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"
-import { CHANNEL as channel, VERSION as version } from "./meta"
-
-import semver from "semver"
-
-export namespace Installation {
-  const log = Log.create({ service: "installation" })
-
-  export type Method = "curl" | "npm" | "yarn" | "pnpm" | "bun" | "brew" | "scoop" | "choco" | "unknown"
-
-  export type ReleaseType = "patch" | "minor" | "major"
-
-  export const Event = {
-    Updated: BusEvent.define(
-      "installation.updated",
-      z.object({
-        version: z.string(),
-      }),
-    ),
-    UpdateAvailable: BusEvent.define(
-      "installation.update-available",
-      z.object({
-        version: z.string(),
-      }),
-    ),
-  }
-
-  export function getReleaseType(current: string, latest: string): ReleaseType {
-    const currMajor = semver.major(current)
-    const currMinor = semver.minor(current)
-    const newMajor = semver.major(latest)
-    const newMinor = semver.minor(latest)
-
-    if (newMajor > currMajor) return "major"
-    if (newMinor > currMinor) return "minor"
-    return "patch"
-  }
-
-  export const Info = z
-    .object({
-      version: z.string(),
-      latest: z.string(),
-    })
-    .meta({
-      ref: "InstallationInfo",
-    })
-  export type Info = z.infer<typeof Info>
-
-  export const VERSION = version
-  export const CHANNEL = channel
-  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>()("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<Info>
-    readonly method: () => Effect.Effect<Method>
-    readonly latest: (method?: Method) => Effect.Effect<string>
-    readonly upgrade: (method: Method, target: string) => Effect.Effect<void, UpgradeFailedError>
-  }
-
-  export class Service extends Context.Service<Service, Interface>()("@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<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("")),
-        )
-
-        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
-            }
-          }
-
-          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:
-              return yield* new UpgradeFailedError({ stderr: `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(CrossSpawnSpawner.defaultLayer),
-  )
-}
+export * as Installation from "./installation"

+ 338 - 0
packages/opencode/src/installation/installation.ts

@@ -0,0 +1,338 @@
+import { Effect, Layer, Schema, Context, Stream } from "effect"
+import { FetchHttpClient, HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
+import * as CrossSpawnSpawner from "@/effect/cross-spawn-spawner"
+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"
+import { CHANNEL as channel, VERSION as version } from "./meta"
+
+import semver from "semver"
+
+const log = Log.create({ service: "installation" })
+
+export type Method = "curl" | "npm" | "yarn" | "pnpm" | "bun" | "brew" | "scoop" | "choco" | "unknown"
+
+export type ReleaseType = "patch" | "minor" | "major"
+
+export const Event = {
+  Updated: BusEvent.define(
+    "installation.updated",
+    z.object({
+      version: z.string(),
+    }),
+  ),
+  UpdateAvailable: BusEvent.define(
+    "installation.update-available",
+    z.object({
+      version: z.string(),
+    }),
+  ),
+}
+
+export function getReleaseType(current: string, latest: string): ReleaseType {
+  const currMajor = semver.major(current)
+  const currMinor = semver.minor(current)
+  const newMajor = semver.major(latest)
+  const newMinor = semver.minor(latest)
+
+  if (newMajor > currMajor) return "major"
+  if (newMinor > currMinor) return "minor"
+  return "patch"
+}
+
+export const Info = z
+  .object({
+    version: z.string(),
+    latest: z.string(),
+  })
+  .meta({
+    ref: "InstallationInfo",
+  })
+export type Info = z.infer<typeof Info>
+
+export const VERSION = version
+export const CHANNEL = channel
+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>()("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<Info>
+  readonly method: () => Effect.Effect<Method>
+  readonly latest: (method?: Method) => Effect.Effect<string>
+  readonly upgrade: (method: Method, target: string) => Effect.Effect<void, UpgradeFailedError>
+}
+
+export class Service extends Context.Service<Service, Interface>()("@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<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("")),
+      )
+
+      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
+          }
+        }
+
+        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:
+            return yield* new UpgradeFailedError({ stderr: `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(CrossSpawnSpawner.defaultLayer),
+)