Skip to content

Commit ae9a696

Browse files
authored
refactor: collapse installation barrel into installation/index.ts (#22910)
1 parent bd51a0d commit ae9a696

2 files changed

Lines changed: 338 additions & 337 deletions

File tree

Lines changed: 338 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1,338 @@
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

Comments
 (0)