Files
zmo-cli/packages/opencode/src/installation/index.ts
T
lminandClaude Opus 4.7 c034a178b9
compliance-close / close-non-compliant (push) Has been cancelled
feat: Windows 下 zmo upgrade 支持自更新
- installation/index.ts 新增 Windows 升级路径:下载 release zip →
  PowerShell 解压 → 写 .cmd 在进程退出后替换 exe(绕过运行中文件锁)
- method() 在 Windows 兜底返回 curl,让手动下载的 exe 也能走自动更新
- 修 cmd/upgrade.ts 残留的 "opencode upgrade skipped" 文案

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-05 18:06:09 +08:00

405 lines
16 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<typeof Info>
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>()("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<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 use = serviceUse(Service)
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 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<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
}
}
// 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<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 "."