import { Effect, Layer, Schema, Context } from "effect"
import { FetchHttpClient, HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
import { withTransientReadRetry } from "@/util/effect-http-client"
import { errorMessage } from "@/util/error"
import { ChildProcess } from "effect/unstable/process"
import { AppProcess } from "@opencode-ai/core/process"
import path from "path"
import { BusEvent } from "@/bus/bus-event"
import * as Log from "@opencode-ai/core/util/log"
import { makeRuntime } from "@opencode-ai/core/effect/runtime"
import semver from "semver"
import { InstallationChannel, InstallationVersion } from "@opencode-ai/core/installation/version"
import { NpmConfig } from "@opencode-ai/core/npm-config"
const log = Log.create({ service: "installation" })
export type Method = "npm" | "pnpm" | "bun" | "unknown"
export type ReleaseType = "patch" | "minor" | "major"
export const Event = {
Updated: BusEvent.define(
"installation.updated",
Schema.Struct({
version: Schema.String,
}),
),
UpdateAvailable: BusEvent.define(
"installation.update-available",
Schema.Struct({
version: Schema.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 = Schema.Struct({
version: Schema.String,
latest: Schema.String,
}).annotate({ identifier: "InstallationInfo" })
export type Info = Schema.Schema.Type<typeof Info>
export function userAgent(client = "cli") {
return `opencode/${InstallationChannel}/${InstallationVersion}/${client}`
}
export const USER_AGENT = userAgent()
export function isPreview() {
return InstallationChannel !== "latest"
}
export function isLocal() {
return InstallationChannel === "local"
}
export class UpgradeFailedError extends Schema.TaggedErrorClass<UpgradeFailedError>()("UpgradeFailedError", {
stderr: Schema.String,
}) {}
const NpmPackage = Schema.Struct({ version: Schema.String })
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 | AppProcess.Service> = Layer.effect(
Service,
Effect.gen(function* () {
const http = yield* HttpClient.HttpClient
const httpOk = HttpClient.filterStatusOk(withTransientReadRetry(http))
const appProcess = yield* AppProcess.Service
const text = Effect.fnUntraced(
function* (cmd: string[], opts?: { cwd?: string; env?: Record<string, string> }) {
const result = yield* appProcess.run(
ChildProcess.make(cmd[0], cmd.slice(1), {
cwd: opts?.cwd,
env: opts?.env,
extendEnv: true,
}),
)
return result.stdout.toString("utf8")
},
Effect.catch(() => Effect.succeed("")),
)
const run = Effect.fnUntraced(
function* (cmd: string[], opts?: { cwd?: string; env?: Record<string, string> }) {
const result = yield* appProcess.run(
ChildProcess.make(cmd[0], cmd.slice(1), {
cwd: opts?.cwd,
env: opts?.env,
extendEnv: true,
}),
)
return {
code: result.exitCode,
stdout: result.stdout.toString("utf8"),
stderr: result.stderr.toString("utf8"),
}
},
Effect.catch((err) => Effect.succeed({ code: 1, stdout: "", stderr: errorMessage(err) })),
)
const result: Interface = {
info: Effect.fn("Installation.info")(function* () {
return {
version: InstallationVersion,
latest: yield* result.latest(),
}
}),
method: Effect.fn("Installation.method")(function* () {
const exec = process.execPath.toLowerCase()
const checks: Array<{ name: Method; command: () => Effect.Effect<string> }> = [
{ name: "npm", command: () => text(["npm", "list", "-g", "--depth=0"]) },
{ name: "pnpm", command: () => text(["pnpm", "list", "-g", "--depth=0"]) },
{ name: "bun", command: () => text(["bun", "pm", "ls", "-g"]) },
]
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 = "@deveco/deveco-code"
if (output.includes(installedName)) {
return check.name
}
}
return "unknown" as Method
}),
latest: Effect.fn("Installation.latest")(function* (installMethod?: Method) {
const detectedMethod = installMethod || (yield* result.method())
const response = yield* httpOk.execute(
HttpClientRequest.get(
`${yield* NpmConfig.registry(process.cwd())}/@deveco%2fdeveco-code/${InstallationChannel}`,
).pipe(HttpClientRequest.acceptJson),
)
const data = yield* HttpClientResponse.schemaBodyJson(NpmPackage)(response)
return data.version
}, Effect.orDie),
upgrade: Effect.fn("Installation.upgrade")(function* (m: Method, target: string) {
let upgradeResult: { code: number; stdout: string; stderr: string } | undefined
switch (m) {
case "npm":
upgradeResult = yield* run(["npm", "install", "-g", `@deveco/deveco-code@${target}`])
break
case "pnpm":
upgradeResult = yield* run(["pnpm", "install", "-g", `@deveco/deveco-code@${target}`])
break
case "bun":
upgradeResult = yield* run(["bun", "install", "-g", `@deveco/deveco-code@${target}`])
break
default:
return yield* new UpgradeFailedError({ stderr: `Unknown method: ${m}` })
}
if (!upgradeResult || upgradeResult.code !== 0) {
return yield* new UpgradeFailedError({ stderr: upgradeResult?.stderr || "" })
}
log.info("upgraded", {
method: m,
target,
stdout: upgradeResult.stdout,
stderr: upgradeResult.stderr,
})
yield* text([process.execPath, "--version"])
}),
}
return Service.of(result)
}),
)
export const defaultLayer = layer.pipe(Layer.provide(FetchHttpClient.layer), Layer.provide(AppProcess.defaultLayer))
const { runPromise } = makeRuntime(Service, defaultLayer)
export const latest = (...args: Parameters<Interface["latest"]>) => runPromise((s) => s.latest(...args))
export const method = () => runPromise((s) => s.method())
export const upgrade = (...args: Parameters<Interface["upgrade"]>) => runPromise((s) => s.upgrade(...args))
export * as Installation from "."