|
1 | | -export * as Installation from "./installation" |
| 1 | +import { Effect, Layer, Schema, Context, Stream } from "effect" |
| 2 | +import { FetchHttpClient, HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http" |
| 3 | +import * as CrossSpawnSpawner from "@/effect/cross-spawn-spawner" |
| 4 | +import { withTransientReadRetry } from "@/util/effect-http-client" |
| 5 | +import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process" |
| 6 | +import path from "path" |
| 7 | +import z from "zod" |
| 8 | +import { BusEvent } from "@/bus/bus-event" |
| 9 | +import { Flag } from "../flag/flag" |
| 10 | +import { Log } from "../util" |
| 11 | + |
| 12 | +import semver from "semver" |
| 13 | +import { InstallationChannel, InstallationVersion } from "./version" |
| 14 | + |
| 15 | +const log = Log.create({ service: "installation" }) |
| 16 | + |
| 17 | +export type Method = "curl" | "npm" | "yarn" | "pnpm" | "bun" | "brew" | "scoop" | "choco" | "unknown" |
| 18 | + |
| 19 | +export type ReleaseType = "patch" | "minor" | "major" |
| 20 | + |
| 21 | +export const Event = { |
| 22 | + Updated: BusEvent.define( |
| 23 | + "installation.updated", |
| 24 | + z.object({ |
| 25 | + version: z.string(), |
| 26 | + }), |
| 27 | + ), |
| 28 | + UpdateAvailable: BusEvent.define( |
| 29 | + "installation.update-available", |
| 30 | + z.object({ |
| 31 | + version: z.string(), |
| 32 | + }), |
| 33 | + ), |
| 34 | +} |
| 35 | + |
| 36 | +export function getReleaseType(current: string, latest: string): ReleaseType { |
| 37 | + const currMajor = semver.major(current) |
| 38 | + const currMinor = semver.minor(current) |
| 39 | + const newMajor = semver.major(latest) |
| 40 | + const newMinor = semver.minor(latest) |
| 41 | + |
| 42 | + if (newMajor > currMajor) return "major" |
| 43 | + if (newMinor > currMinor) return "minor" |
| 44 | + return "patch" |
| 45 | +} |
| 46 | + |
| 47 | +export const Info = z |
| 48 | + .object({ |
| 49 | + version: z.string(), |
| 50 | + latest: z.string(), |
| 51 | + }) |
| 52 | + .meta({ |
| 53 | + ref: "InstallationInfo", |
| 54 | + }) |
| 55 | +export type Info = z.infer<typeof Info> |
| 56 | + |
| 57 | +export const USER_AGENT = `opencode/${InstallationChannel}/${InstallationVersion}/${Flag.OPENCODE_CLIENT}` |
| 58 | + |
| 59 | +export function isPreview() { |
| 60 | + return InstallationChannel !== "latest" |
| 61 | +} |
| 62 | + |
| 63 | +export function isLocal() { |
| 64 | + return InstallationChannel === "local" |
| 65 | +} |
| 66 | + |
| 67 | +export class UpgradeFailedError extends Schema.TaggedErrorClass<UpgradeFailedError>()("UpgradeFailedError", { |
| 68 | + stderr: Schema.String, |
| 69 | +}) {} |
| 70 | + |
| 71 | +// Response schemas for external version APIs |
| 72 | +const GitHubRelease = Schema.Struct({ tag_name: Schema.String }) |
| 73 | +const NpmPackage = Schema.Struct({ version: Schema.String }) |
| 74 | +const BrewFormula = Schema.Struct({ versions: Schema.Struct({ stable: Schema.String }) }) |
| 75 | +const BrewInfoV2 = Schema.Struct({ |
| 76 | + formulae: Schema.Array(Schema.Struct({ versions: Schema.Struct({ stable: Schema.String }) })), |
| 77 | +}) |
| 78 | +const ChocoPackage = Schema.Struct({ |
| 79 | + d: Schema.Struct({ results: Schema.Array(Schema.Struct({ Version: Schema.String })) }), |
| 80 | +}) |
| 81 | +const ScoopManifest = NpmPackage |
| 82 | + |
| 83 | +export interface Interface { |
| 84 | + readonly info: () => Effect.Effect<Info> |
| 85 | + readonly method: () => Effect.Effect<Method> |
| 86 | + readonly latest: (method?: Method) => Effect.Effect<string> |
| 87 | + readonly upgrade: (method: Method, target: string) => Effect.Effect<void, UpgradeFailedError> |
| 88 | +} |
| 89 | + |
| 90 | +export class Service extends Context.Service<Service, Interface>()("@opencode/Installation") {} |
| 91 | + |
| 92 | +export const layer: Layer.Layer<Service, never, HttpClient.HttpClient | ChildProcessSpawner.ChildProcessSpawner> = |
| 93 | + Layer.effect( |
| 94 | + Service, |
| 95 | + Effect.gen(function* () { |
| 96 | + const http = yield* HttpClient.HttpClient |
| 97 | + const httpOk = HttpClient.filterStatusOk(withTransientReadRetry(http)) |
| 98 | + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner |
| 99 | + |
| 100 | + const text = Effect.fnUntraced( |
| 101 | + function* (cmd: string[], opts?: { cwd?: string; env?: Record<string, string> }) { |
| 102 | + const proc = ChildProcess.make(cmd[0], cmd.slice(1), { |
| 103 | + cwd: opts?.cwd, |
| 104 | + env: opts?.env, |
| 105 | + extendEnv: true, |
| 106 | + }) |
| 107 | + const handle = yield* spawner.spawn(proc) |
| 108 | + const out = yield* Stream.mkString(Stream.decodeText(handle.stdout)) |
| 109 | + yield* handle.exitCode |
| 110 | + return out |
| 111 | + }, |
| 112 | + Effect.scoped, |
| 113 | + Effect.catch(() => Effect.succeed("")), |
| 114 | + ) |
| 115 | + |
| 116 | + const run = Effect.fnUntraced( |
| 117 | + function* (cmd: string[], opts?: { cwd?: string; env?: Record<string, string> }) { |
| 118 | + const proc = ChildProcess.make(cmd[0], cmd.slice(1), { |
| 119 | + cwd: opts?.cwd, |
| 120 | + env: opts?.env, |
| 121 | + extendEnv: true, |
| 122 | + }) |
| 123 | + const handle = yield* spawner.spawn(proc) |
| 124 | + const [stdout, stderr] = yield* Effect.all( |
| 125 | + [Stream.mkString(Stream.decodeText(handle.stdout)), Stream.mkString(Stream.decodeText(handle.stderr))], |
| 126 | + { concurrency: 2 }, |
| 127 | + ) |
| 128 | + const code = yield* handle.exitCode |
| 129 | + return { code, stdout, stderr } |
| 130 | + }, |
| 131 | + Effect.scoped, |
| 132 | + Effect.catch(() => Effect.succeed({ code: ChildProcessSpawner.ExitCode(1), stdout: "", stderr: "" })), |
| 133 | + ) |
| 134 | + |
| 135 | + const getBrewFormula = Effect.fnUntraced(function* () { |
| 136 | + const tapFormula = yield* text(["brew", "list", "--formula", "anomalyco/tap/opencode"]) |
| 137 | + if (tapFormula.includes("opencode")) return "anomalyco/tap/opencode" |
| 138 | + const coreFormula = yield* text(["brew", "list", "--formula", "opencode"]) |
| 139 | + if (coreFormula.includes("opencode")) return "opencode" |
| 140 | + return "opencode" |
| 141 | + }) |
| 142 | + |
| 143 | + const upgradeCurl = Effect.fnUntraced( |
| 144 | + function* (target: string) { |
| 145 | + const response = yield* httpOk.execute(HttpClientRequest.get("https://opencode.ai/install")) |
| 146 | + const body = yield* response.text |
| 147 | + const bodyBytes = new TextEncoder().encode(body) |
| 148 | + const proc = ChildProcess.make("bash", [], { |
| 149 | + stdin: Stream.make(bodyBytes), |
| 150 | + env: { VERSION: target }, |
| 151 | + extendEnv: true, |
| 152 | + }) |
| 153 | + const handle = yield* spawner.spawn(proc) |
| 154 | + const [stdout, stderr] = yield* Effect.all( |
| 155 | + [Stream.mkString(Stream.decodeText(handle.stdout)), Stream.mkString(Stream.decodeText(handle.stderr))], |
| 156 | + { concurrency: 2 }, |
| 157 | + ) |
| 158 | + const code = yield* handle.exitCode |
| 159 | + return { code, stdout, stderr } |
| 160 | + }, |
| 161 | + Effect.scoped, |
| 162 | + Effect.orDie, |
| 163 | + ) |
| 164 | + |
| 165 | + const methodImpl = Effect.fn("Installation.method")(function* () { |
| 166 | + if (process.execPath.includes(path.join(".opencode", "bin"))) return "curl" as Method |
| 167 | + if (process.execPath.includes(path.join(".local", "bin"))) return "curl" as Method |
| 168 | + const exec = process.execPath.toLowerCase() |
| 169 | + |
| 170 | + const checks: Array<{ name: Method; command: () => Effect.Effect<string> }> = [ |
| 171 | + { name: "npm", command: () => text(["npm", "list", "-g", "--depth=0"]) }, |
| 172 | + { name: "yarn", command: () => text(["yarn", "global", "list"]) }, |
| 173 | + { name: "pnpm", command: () => text(["pnpm", "list", "-g", "--depth=0"]) }, |
| 174 | + { name: "bun", command: () => text(["bun", "pm", "ls", "-g"]) }, |
| 175 | + { name: "brew", command: () => text(["brew", "list", "--formula", "opencode"]) }, |
| 176 | + { name: "scoop", command: () => text(["scoop", "list", "opencode"]) }, |
| 177 | + { name: "choco", command: () => text(["choco", "list", "--limit-output", "opencode"]) }, |
| 178 | + ] |
| 179 | + |
| 180 | + checks.sort((a, b) => { |
| 181 | + const aMatches = exec.includes(a.name) |
| 182 | + const bMatches = exec.includes(b.name) |
| 183 | + if (aMatches && !bMatches) return -1 |
| 184 | + if (!aMatches && bMatches) return 1 |
| 185 | + return 0 |
| 186 | + }) |
| 187 | + |
| 188 | + for (const check of checks) { |
| 189 | + const output = yield* check.command() |
| 190 | + const installedName = |
| 191 | + check.name === "brew" || check.name === "choco" || check.name === "scoop" ? "opencode" : "opencode-ai" |
| 192 | + if (output.includes(installedName)) { |
| 193 | + return check.name |
| 194 | + } |
| 195 | + } |
| 196 | + |
| 197 | + return "unknown" as Method |
| 198 | + }) |
| 199 | + |
| 200 | + const latestImpl = Effect.fn("Installation.latest")(function* (installMethod?: Method) { |
| 201 | + const detectedMethod = installMethod || (yield* methodImpl()) |
| 202 | + |
| 203 | + if (detectedMethod === "brew") { |
| 204 | + const formula = yield* getBrewFormula() |
| 205 | + if (formula.includes("/")) { |
| 206 | + const infoJson = yield* text(["brew", "info", "--json=v2", formula]) |
| 207 | + const info = yield* Schema.decodeUnknownEffect(Schema.fromJsonString(BrewInfoV2))(infoJson) |
| 208 | + return info.formulae[0].versions.stable |
| 209 | + } |
| 210 | + const response = yield* httpOk.execute( |
| 211 | + HttpClientRequest.get("https://formulae.brew.sh/api/formula/opencode.json").pipe( |
| 212 | + HttpClientRequest.acceptJson, |
| 213 | + ), |
| 214 | + ) |
| 215 | + const data = yield* HttpClientResponse.schemaBodyJson(BrewFormula)(response) |
| 216 | + return data.versions.stable |
| 217 | + } |
| 218 | + |
| 219 | + if (detectedMethod === "npm" || detectedMethod === "bun" || detectedMethod === "pnpm") { |
| 220 | + const r = (yield* text(["npm", "config", "get", "registry"])).trim() |
| 221 | + const reg = r || "https://registry.npmjs.org" |
| 222 | + const registry = reg.endsWith("/") ? reg.slice(0, -1) : reg |
| 223 | + const channel = InstallationChannel |
| 224 | + const response = yield* httpOk.execute( |
| 225 | + HttpClientRequest.get(`${registry}/opencode-ai/${channel}`).pipe(HttpClientRequest.acceptJson), |
| 226 | + ) |
| 227 | + const data = yield* HttpClientResponse.schemaBodyJson(NpmPackage)(response) |
| 228 | + return data.version |
| 229 | + } |
| 230 | + |
| 231 | + if (detectedMethod === "choco") { |
| 232 | + const response = yield* httpOk.execute( |
| 233 | + HttpClientRequest.get( |
| 234 | + "https://community.chocolatey.org/api/v2/Packages?$filter=Id%20eq%20%27opencode%27%20and%20IsLatestVersion&$select=Version", |
| 235 | + ).pipe(HttpClientRequest.setHeaders({ Accept: "application/json;odata=verbose" })), |
| 236 | + ) |
| 237 | + const data = yield* HttpClientResponse.schemaBodyJson(ChocoPackage)(response) |
| 238 | + return data.d.results[0].Version |
| 239 | + } |
| 240 | + |
| 241 | + if (detectedMethod === "scoop") { |
| 242 | + const response = yield* httpOk.execute( |
| 243 | + HttpClientRequest.get( |
| 244 | + "https://raw.githubusercontent.com/ScoopInstaller/Main/master/bucket/opencode.json", |
| 245 | + ).pipe(HttpClientRequest.setHeaders({ Accept: "application/json" })), |
| 246 | + ) |
| 247 | + const data = yield* HttpClientResponse.schemaBodyJson(ScoopManifest)(response) |
| 248 | + return data.version |
| 249 | + } |
| 250 | + |
| 251 | + const response = yield* httpOk.execute( |
| 252 | + HttpClientRequest.get("https://api.github.com/repos/anomalyco/opencode/releases/latest").pipe( |
| 253 | + HttpClientRequest.acceptJson, |
| 254 | + ), |
| 255 | + ) |
| 256 | + const data = yield* HttpClientResponse.schemaBodyJson(GitHubRelease)(response) |
| 257 | + return data.tag_name.replace(/^v/, "") |
| 258 | + }, Effect.orDie) |
| 259 | + |
| 260 | + const upgradeImpl = Effect.fn("Installation.upgrade")(function* (m: Method, target: string) { |
| 261 | + let result: { code: ChildProcessSpawner.ExitCode; stdout: string; stderr: string } | undefined |
| 262 | + switch (m) { |
| 263 | + case "curl": |
| 264 | + result = yield* upgradeCurl(target) |
| 265 | + break |
| 266 | + case "npm": |
| 267 | + result = yield* run(["npm", "install", "-g", `opencode-ai@${target}`]) |
| 268 | + break |
| 269 | + case "pnpm": |
| 270 | + result = yield* run(["pnpm", "install", "-g", `opencode-ai@${target}`]) |
| 271 | + break |
| 272 | + case "bun": |
| 273 | + result = yield* run(["bun", "install", "-g", `opencode-ai@${target}`]) |
| 274 | + break |
| 275 | + case "brew": { |
| 276 | + const formula = yield* getBrewFormula() |
| 277 | + const env = { HOMEBREW_NO_AUTO_UPDATE: "1" } |
| 278 | + if (formula.includes("/")) { |
| 279 | + const tap = yield* run(["brew", "tap", "anomalyco/tap"], { env }) |
| 280 | + if (tap.code !== 0) { |
| 281 | + result = tap |
| 282 | + break |
| 283 | + } |
| 284 | + const repo = yield* text(["brew", "--repo", "anomalyco/tap"]) |
| 285 | + const dir = repo.trim() |
| 286 | + if (dir) { |
| 287 | + const pull = yield* run(["git", "pull", "--ff-only"], { cwd: dir, env }) |
| 288 | + if (pull.code !== 0) { |
| 289 | + result = pull |
| 290 | + break |
| 291 | + } |
| 292 | + } |
| 293 | + } |
| 294 | + result = yield* run(["brew", "upgrade", formula], { env }) |
| 295 | + break |
| 296 | + } |
| 297 | + case "choco": |
| 298 | + result = yield* run(["choco", "upgrade", "opencode", `--version=${target}`, "-y"]) |
| 299 | + break |
| 300 | + case "scoop": |
| 301 | + result = yield* run(["scoop", "install", `opencode@${target}`]) |
| 302 | + break |
| 303 | + default: |
| 304 | + return yield* new UpgradeFailedError({ stderr: `Unknown method: ${m}` }) |
| 305 | + } |
| 306 | + if (!result || result.code !== 0) { |
| 307 | + const stderr = m === "choco" ? "not running from an elevated command shell" : result?.stderr || "" |
| 308 | + return yield* new UpgradeFailedError({ stderr }) |
| 309 | + } |
| 310 | + log.info("upgraded", { |
| 311 | + method: m, |
| 312 | + target, |
| 313 | + stdout: result.stdout, |
| 314 | + stderr: result.stderr, |
| 315 | + }) |
| 316 | + yield* text([process.execPath, "--version"]) |
| 317 | + }) |
| 318 | + |
| 319 | + return Service.of({ |
| 320 | + info: Effect.fn("Installation.info")(function* () { |
| 321 | + return { |
| 322 | + version: InstallationVersion, |
| 323 | + latest: yield* latestImpl(), |
| 324 | + } |
| 325 | + }), |
| 326 | + method: methodImpl, |
| 327 | + latest: latestImpl, |
| 328 | + upgrade: upgradeImpl, |
| 329 | + }) |
| 330 | + }), |
| 331 | + ) |
| 332 | + |
| 333 | +export const defaultLayer = layer.pipe( |
| 334 | + Layer.provide(FetchHttpClient.layer), |
| 335 | + Layer.provide(CrossSpawnSpawner.defaultLayer), |
| 336 | +) |
| 337 | + |
| 338 | +export * as Installation from "." |
0 commit comments