import { Effect, Layer, Schema, Context, Stream } from "effect" import { serviceUse } from "@opencode-ai/core/effect/service-use" 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 os from "os" import fs from "fs" import { EventV2 } from "@opencode-ai/core/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 = "curl" | "npm" | "yarn" | "pnpm" | "bun" | "brew" | "scoop" | "choco" | "unknown" export type ReleaseType = "patch" | "minor" | "major" export const Event = { Updated: EventV2.define({ type: "installation.updated", schema: { version: Schema.String, }, }), UpdateAvailable: EventV2.define({ type: "installation.update-available", schema: { 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 export function userAgent(client = "cli") { return `zmo/${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", { stderr: Schema.String, }) { override get message() { return this.stderr } } // 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 readonly method: () => Effect.Effect readonly latest: (method?: Method) => Effect.Effect readonly upgrade: (method: Method, target: string) => Effect.Effect } export class Service extends Context.Service()("@opencode/Installation") {} export const use = serviceUse(Service) export const layer: Layer.Layer = 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 }) { 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 }) { 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 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 upgradeFailure = (method: Method, result?: { code: number; stdout: string; stderr: string }) => { if (method === "choco") return "not running from an elevated command shell" if (result) return `Upgrade failed for ${method} (exit code ${result.code}).` return `Upgrade failed for ${method}.` } const upgradeScriptShell = Effect.fnUntraced(function* () { const bashVersion = yield* text(["bash", "--version"]) if (bashVersion) return "bash" return "sh" }) const upgradeWindows = Effect.fnUntraced(function* (target: string) { // Windows 自更新:运行中的 exe 无法直接覆盖自身, // 因此下载新 zip → 解压 → 写一个 .cmd 在本进程退出后替换 exe。 const exePath = process.execPath const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "zmo-upgrade-")) const zipPath = path.join(tmpDir, "zmo-windows-x64.zip") const url = `https://github.com/helpLogin/zmo-cli/releases/download/v${target}/zmo-windows-x64.zip` // 1. 下载新版 zip const res = yield* httpOk.execute(HttpClientRequest.get(url)) const buf = yield* res.arrayBuffer fs.writeFileSync(zipPath, Buffer.from(buf)) // 2. 用 PowerShell 解压(Win10+ 自带 Expand-Archive) const unzip = yield* run([ "powershell", "-NoProfile", "-Command", `Expand-Archive -Path '${zipPath}' -DestinationPath '${tmpDir}' -Force`, ]) if (unzip.code !== 0) return unzip const newExe = path.join(tmpDir, "zmo.exe") if (!fs.existsSync(newExe)) { return { code: 1, stdout: "", stderr: "downloaded archive did not contain zmo.exe" } } // 3. 写 .cmd:等待本进程退出 → 覆盖旧 exe → 清理临时目录 const cmdPath = path.join(tmpDir, "zmo-replace.cmd") const script = [ "@echo off", "ping 127.0.0.1 -n 3 >nul", `:retry`, `move /Y "${newExe}" "${exePath}" >nul 2>&1`, `if errorlevel 1 (`, ` ping 127.0.0.1 -n 2 >nul`, ` goto retry`, `)`, `rmdir /S /Q "${tmpDir}" >nul 2>&1`, ].join("\r\n") fs.writeFileSync(cmdPath, script) // 4. detached 启动 cmd,本进程随后正常退出,让 cmd 完成替换 Bun.spawn(["cmd", "/c", cmdPath], { stdio: ["ignore", "ignore", "ignore"] }).unref() return { code: 0, stdout: `zmo will finish updating to v${target} after exit. Please restart zmo.`, stderr: "" } }, Effect.catch((err) => Effect.succeed({ code: 1, stdout: "", stderr: errorMessage(err) }))) const upgradeCurl = Effect.fnUntraced( function* (target: string) { if (process.platform === "win32") { return yield* upgradeWindows(target) } const response = yield* httpOk.execute(HttpClientRequest.get("https://raw.githubusercontent.com/helpLogin/zmo-cli/main/install")) const body = yield* response.text const bodyBytes = new TextEncoder().encode(body) const shell = yield* upgradeScriptShell() const result = yield* appProcess.run( ChildProcess.make(shell, [], { stdin: Stream.make(bodyBytes), env: { VERSION: target }, extendEnv: true, }), ) return { code: result.exitCode, stdout: result.stdout.toString("utf8"), stderr: result.stderr.toString("utf8"), } }, Effect.mapError(() => new UpgradeFailedError({ stderr: upgradeFailure("curl") })), ) const result: Interface = { info: Effect.fn("Installation.info")(function* () { return { version: InstallationVersion, latest: yield* result.latest(), } }), method: Effect.fn("Installation.method")(function* () { if (process.execPath.includes(path.join(".zmo", "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 }> = [ { 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 } } // Windows 上手动下载的 exe 不在包管理器里,但我们的 Windows 升级路径 // 可自我替换任意位置的 exe,所以兜底按 curl 处理以启用自动更新。 if (process.platform === "win32") return "curl" as Method return "unknown" as Method }), latest: Effect.fn("Installation.latest")(function* (installMethod?: Method) { const detectedMethod = installMethod || (yield* result.method()) 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 response = yield* httpOk.execute( HttpClientRequest.get( `${yield* NpmConfig.registry(process.cwd())}/opencode-ai/${InstallationChannel}`, ).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/helpLogin/zmo-cli/releases/latest").pipe( HttpClientRequest.acceptJson, ), ) const data = yield* HttpClientResponse.schemaBodyJson(GitHubRelease)(response) return data.tag_name.replace(/^v/, "") }, Effect.orDie), upgrade: Effect.fn("Installation.upgrade")(function* (m: Method, target: string) { let upgradeResult: { code: number; stdout: string; stderr: string } | undefined switch (m) { case "curl": upgradeResult = yield* upgradeCurl(target) break case "npm": upgradeResult = yield* run(["npm", "install", "-g", `opencode-ai@${target}`]) break case "pnpm": upgradeResult = yield* run(["pnpm", "install", "-g", `opencode-ai@${target}`]) break case "bun": upgradeResult = 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) { upgradeResult = 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) { upgradeResult = pull break } } } upgradeResult = yield* run(["brew", "upgrade", formula], { env }) break } case "choco": upgradeResult = yield* run(["choco", "upgrade", "opencode", `--version=${target}`, "-y"]) break case "scoop": upgradeResult = yield* run(["scoop", "install", `opencode@${target}`]) break default: return yield* new UpgradeFailedError({ stderr: `Unknown installation method: ${m}` }) } if (!upgradeResult || upgradeResult.code !== 0) { return yield* new UpgradeFailedError({ stderr: upgradeFailure(m, upgradeResult) }) } 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) => runPromise((s) => s.latest(...args)) export const method = () => runPromise((s) => s.method()) export const upgrade = (...args: Parameters) => runPromise((s) => s.upgrade(...args)) export * as Installation from "."