fix(cli): prevent repeated updates and npm cache growth (#45091)

Co-authored-by: neriousy <34747899+neriousy@users.noreply.github.com>
This commit is contained in:
opencode-agent[bot] 2026-08-28 01:30:34 +02:00 committed by GitHub
parent f0d5b5e073
commit b20748f821
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 105 additions and 13 deletions

View file

@ -0,0 +1,58 @@
import { NodeFileSystem } from "@effect/platform-node"
import { Global } from "@opencode-ai/util/global"
import { AppProcess } from "@opencode-ai/util/process"
import { Effect, FileSystem, Layer, Stream } from "effect"
import { ChildProcessSpawner } from "effect/unstable/process"
import assert from "node:assert/strict"
import path from "node:path"
import { Updater } from "../updater"
const latest = { version: "0.0.0-beta-17498" }
const installs: string[] = []
// This fixture runs in its own process; no real update requests or installs occur.
globalThis.fetch = Object.assign(async () => Response.json(latest), { preconnect() {} })
await Effect.runPromise(
Effect.gen(function* () {
const fs = yield* FileSystem.FileSystem
const directory = yield* fs.makeTempDirectoryScoped({ prefix: "opencode-updater-" })
const dependencies = Layer.mergeAll(
Layer.succeed(FileSystem.FileSystem, fs),
Layer.succeed(
Global.Service,
Global.make({ home: directory, config: directory, cache: path.join(directory, "cache") }),
),
Layer.succeed(AppProcess.Service, {
...ChildProcessSpawner.make(() => Effect.die("Unexpected process spawn")),
runStream: () => Stream.die("Unexpected streaming process"),
run: (command) => {
assert.equal(command._tag, "StandardCommand")
if (command.command === "npm" && command.args[0] === "install") {
assert.ok(command.args.includes("--global"))
installs.push(command.args.at(-1)!)
}
return Effect.succeed({
command: command.command,
exitCode: 0,
stdout: Buffer.from(command.command === "npm" ? "@opencode-ai/cli" : ""),
stderr: Buffer.alloc(0),
stdoutTruncated: false,
stderrTruncated: false,
})
},
}),
)
yield* Effect.gen(function* () {
const updater = yield* Updater.Service
yield* updater.check()
assert.deepEqual(installs, ["@opencode-ai/cli@0.0.0-beta-17498"])
yield* updater.check()
yield* updater.check()
assert.deepEqual(installs, ["@opencode-ai/cli@0.0.0-beta-17498"])
latest.version = "0.0.0-beta-17499"
yield* updater.check()
assert.deepEqual(installs, ["@opencode-ai/cli@0.0.0-beta-17498", "@opencode-ai/cli@0.0.0-beta-17499"])
}).pipe(Effect.provide(Updater.layer.pipe(Layer.provide(dependencies))))
}).pipe(Effect.scoped, Effect.provide(NodeFileSystem.layer)),
)

View file

@ -5,9 +5,9 @@ const maximumComponent = "9007199254740991"
const versionPattern =
/^v?([0-9]+)\.([0-9]+)\.([0-9]+)(?:-([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$/
export function action(current: string, latest: string, policy: Policy): Action {
export function action(current: string, latest: string, policy: Policy, installed = current): Action {
if (policy === false) return "none"
const currentVersion = parseReleaseVersion(current)
const currentVersion = parseReleaseVersion(installed)
const latestVersion = parseReleaseVersion(latest)
if (!currentVersion || !latestVersion || sameRelease(currentVersion, latestVersion)) return "none"
// Major upgrades are never installed automatically.

View file

@ -1,8 +1,30 @@
import { describe, expect, test } from "bun:test"
import path from "node:path"
import { action } from "./updater-action"
import { decodePolicy } from "./updater"
describe("updater", () => {
test("remembers successful installs across checks and accepts the next release", async () => {
// Isolate compiled version constants and the update endpoint from other tests.
const child = Bun.spawn(
[
process.execPath,
"--define",
'OPENCODE_VERSION="0.0.0-next-16473"',
"--define",
'OPENCODE_CHANNEL="beta"',
path.join(import.meta.dir, "fixtures/updater.ts"),
],
{ env: { ...process.env, OPENCODE_DISABLE_AUTOUPDATE: "" }, stdout: "pipe", stderr: "pipe" },
)
const [code, stdout, stderr] = await Promise.all([
child.exited,
new Response(child.stdout).text(),
new Response(child.stderr).text(),
])
expect(code, stdout + stderr).toBe(0)
})
test("reads autoupdate from JSONC", () => {
expect(decodePolicy('{ // preference\n "autoupdate": "notify",\n}')).toBe("notify")
expect(decodePolicy('{ "autoupdate": false }')).toBe(false)
@ -32,6 +54,14 @@ describe("updater", () => {
expect(action("1.2.3", "1.2.3", true)).toBe("none")
})
test("skips an installed update but still accepts the next release", () => {
const current = "0.0.0-next-16473"
const installed = "0.0.0-beta-17498"
expect(action(current, installed, true)).toBe("upgrade")
expect(action(current, installed, true, installed)).toBe("none")
expect(action(current, "0.0.0-beta-17499", true, installed)).toBe("upgrade")
})
test("upgrades when latest is lower (rollback)", () => {
expect(action("1.2.4", "1.2.3", true)).toBe("upgrade")
})

View file

@ -1,7 +1,7 @@
import { Global } from "@opencode-ai/util/global"
import { AppProcess } from "@opencode-ai/util/process"
import { OPENCODE_CHANNEL, OPENCODE_LOCAL, OPENCODE_VERSION } from "../version"
import { Context, Duration, Effect, FileSystem, Layer } from "effect"
import { Context, Duration, Effect, FileSystem, Layer, Ref } from "effect"
import { ChildProcess } from "effect/unstable/process"
import { parse, type ParseError } from "jsonc-parser"
import path from "node:path"
@ -38,6 +38,7 @@ export const layer = Layer.effect(
const fs = yield* FileSystem.FileSystem
const global = yield* Global.Service
const appProcess = yield* AppProcess.Service
const installed = yield* Ref.make(OPENCODE_VERSION)
const channel = OPENCODE_CHANNEL.replace(/[^a-zA-Z0-9._-]/g, "-")
const readPolicy = Effect.fnUntraced(function* () {
@ -112,18 +113,20 @@ export const layer = Layer.effect(
const upgrade = Effect.fnUntraced(function* (method: Method, version: string) {
const target = `${packageName}@${version}`
const commands: Record<Exclude<Method, "bun" | "curl">, string[]> = {
npm: ["npm", "install", "--global", target],
const commands: Record<Exclude<Method, "bun" | "curl" | "npm">, string[]> = {
pnpm: ["pnpm", "add", "--global", `--allow-build=${packageName}`, target],
yarn: ["yarn", "global", "add", target],
}
const result = yield* Effect.scoped(
Effect.gen(function* () {
if (method === "bun") {
// Bun does not prune old versions from its shared package cache.
if (method === "bun" || method === "npm") {
yield* fs.makeDirectory(global.cache, { recursive: true })
const cache = yield* fs.makeTempDirectoryScoped({ directory: global.cache, prefix: "update-" })
return yield* run(["bun", "install", "--global", "--trust", "--cache-dir", cache, target], "5 minutes")
const command =
method === "bun"
? ["bun", "install", "--global", "--trust", "--cache-dir", cache, target]
: ["npm", "install", "--global", "--cache", cache, target]
return yield* run(command, "5 minutes")
}
if (method === "curl") {
yield* fs.makeDirectory(global.cache, { recursive: true })
@ -156,18 +159,19 @@ export const layer = Layer.effect(
return yield* Effect.gen(function* () {
const version = yield* latest()
const current = yield* Ref.get(installed)
yield* Effect.logInfo("update check", {
current: OPENCODE_VERSION,
current,
latest: version,
})
const next = action(OPENCODE_VERSION, version, policy)
const next = action(OPENCODE_VERSION, version, policy, current)
if (next === "none") return yield* Effect.logInfo("update check done", { action: "up-to-date" })
if (next === "notify")
return yield* Effect.logInfo("OpenCode update available", { current: OPENCODE_VERSION, latest: version })
if (next === "notify") return yield* Effect.logInfo("OpenCode update available", { current, latest: version })
const detected = yield* method()
if (!detected) return yield* Effect.logWarning("automatic update skipped: installation method not found")
yield* upgrade(detected, version)
yield* Effect.logInfo("updated OpenCode", { from: OPENCODE_VERSION, to: version, method: detected })
yield* Ref.set(installed, version)
yield* Effect.logInfo("updated OpenCode", { from: current, to: version, method: detected })
})
},
Effect.catchCause((cause) => Effect.logWarning("automatic update failed", { cause })),