compliance-close / close-non-compliant (push) Has been cancelled
- 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>
405 lines
16 KiB
TypeScript
405 lines
16 KiB
TypeScript
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 "."
|