feat(cli): check for updates every 10 minutes (#52552)

This commit is contained in:
James Long
2026-10-01 16:29:04 -04:00
committed by GitHub
Unverified
parent afc5d1e21f
commit 5256112bac
5 changed files with 48 additions and 28 deletions
+30 -16
View File
@@ -4,7 +4,7 @@ import { run } from "@opencode/tui"
import { Commands } from "../commands"
import { Runtime } from "../../framework/runtime"
import { Config } from "../../config"
import { Context, Effect, Fiber, FileSystem, Option, Queue } from "effect"
import { Context, Effect, FileSystem, Option, Queue, Schedule, Semaphore } from "effect"
import { ServerConnection } from "../../services/server-connection"
import { Updater } from "../../services/updater"
import { UpdatePreflight } from "../../services/update-preflight"
@@ -61,13 +61,29 @@ export default Runtime.handler(Commands, (input) =>
})) !== undefined
const updater = yield* Updater.Service
let installing: string | undefined
const updateListeners = new Set<(version: string) => void>()
const update = yield* updater
let latest: Updater.RunResult | undefined
const installListeners = new Set<(version: string) => void>()
const resultListeners = new Set<(result: Updater.RunResult) => void>()
// Background checks, `/update` lookups, and manual installs take turns so two installs never overlap.
const checking = yield* Semaphore.make(1)
yield* updater
.run((version) => {
installing = version
updateListeners.forEach((notify) => notify(version))
installListeners.forEach((notify) => notify(version))
})
.pipe(Effect.ensuring(Effect.sync(() => (installing = undefined))), Effect.forkScoped)
.pipe(
Effect.ensuring(Effect.sync(() => (installing = undefined))),
Effect.tap((result) =>
Effect.sync(() => {
if (!result || (result.type === latest?.type && result.version === latest.version)) return
latest = result
resultListeners.forEach((notify) => notify(result))
}),
),
checking.withPermits(1),
Effect.repeat(Schedule.spaced("10 minutes")),
Effect.forkScoped({ startImmediately: true }),
)
preflight.loading()
const config = yield* Config.Service
const npm = yield* Npm.Service
@@ -106,21 +122,19 @@ export default Runtime.handler(Commands, (input) =>
},
updater: {
remote: requestedServer !== undefined,
subscribe: (notify, signal) =>
runPromise(
Fiber.join(update).pipe(
Effect.flatMap((result) => (result === undefined ? Effect.void : Effect.sync(() => notify(result)))),
),
{ signal },
),
subscribe: (notify) => {
if (latest) notify(latest)
resultListeners.add(notify)
return () => resultListeners.delete(notify)
},
check: (signal, notify) => {
if (installing) notify(installing)
updateListeners.add(notify)
return runPromise(Fiber.join(update).pipe(Effect.flatMap(() => updater.check())), { signal }).finally(() =>
updateListeners.delete(notify),
installListeners.add(notify)
return runPromise(checking.withPermits(1)(updater.check()), { signal }).finally(() =>
installListeners.delete(notify),
)
},
apply: (version) => runPromise(updater.apply(version)),
apply: (version) => runPromise(checking.withPermits(1)(updater.apply(version))),
},
packages: {
prepare: (spec, install = true) => runPromise(install ? npm.add(spec) : npm.resolve(spec)),
+2 -1
View File
@@ -13,6 +13,7 @@ import { Global } from "@opencode/util/global"
import { AppProcess } from "@opencode/util/process"
import { Config } from "./config"
import { Npm } from "@opencode/util/npm"
import { EffectFlock } from "@opencode/util/effect-flock"
import { Heap } from "./heap"
import { CpuProfile } from "./cpu-profile"
@@ -113,7 +114,7 @@ Effect.gen(function* () {
Effect.provide(Config.layer),
Effect.provide(Updater.layer),
Effect.provide(
LayerNode.compile(LayerNode.group([Global.node, AppProcess.node, Npm.node]), {
LayerNode.compile(LayerNode.group([Global.node, AppProcess.node, Npm.node, EffectFlock.node]), {
replacements: [
Global.node.replace(
Global.layerWith(process.env.OPENCODE_CONFIG_DIR ? { config: process.env.OPENCODE_CONFIG_DIR } : {}),
+5
View File
@@ -1,5 +1,6 @@
import { Global } from "@opencode/util/global"
import { AppProcess } from "@opencode/util/process"
import { EffectFlock } from "@opencode/util/effect-flock"
import { OPENCODE_ARTIFACT, OPENCODE_CHANNEL, OPENCODE_LOCAL, OPENCODE_VERSION } from "../version"
import { Context, Duration, Effect, FileSystem, Layer, Option, Ref, Schema } from "effect"
import { ChildProcess } from "effect/unstable/process"
@@ -127,6 +128,7 @@ const make = Effect.gen(function* () {
const fs = yield* FileSystem.FileSystem
const global = yield* Global.Service
const appProcess = yield* AppProcess.Service
const flock = yield* EffectFlock.Service
const installedVersion = yield* Ref.make(OPENCODE_VERSION)
const channel = OPENCODE_CHANNEL.replace(/[^a-zA-Z0-9._-]/g, "-")
const installedPackage = yield* Effect.gen(function* () {
@@ -370,6 +372,9 @@ const make = Effect.gen(function* () {
}
yield* Effect.scoped(
Effect.gen(function* () {
// Other OpenCode processes may be installing at the same time. Wait longer than the
// slowest install (curl runs two 5-minute commands).
yield* flock.acquire("cli-upgrade", undefined, { timeoutMs: Duration.toMillis("15 minutes") })
if (method === "bun") {
// Bun does not prune old versions from its shared package cache.
yield* fs.makeDirectory(global.cache, { recursive: true })
+8 -1
View File
@@ -1,8 +1,10 @@
import { NodeServices } from "@effect/platform-node"
import { EffectFlock } from "@opencode/util/effect-flock"
import { LayerNode } from "@opencode/util/effect/layer-node"
import { Global } from "@opencode/util/global"
import { AppProcess } from "@opencode/util/process"
import { expect, spyOn, test } from "bun:test"
import { Effect, FileSystem, PlatformError, Stream } from "effect"
import { Effect, FileSystem, Layer, PlatformError, Stream } from "effect"
import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"
import { existsSync, readdirSync, readFileSync } from "node:fs"
import path from "node:path"
@@ -59,6 +61,11 @@ function fixture(
const commands: string[][] = []
const updater = yield* Updater.Service.pipe(
Effect.provide(Updater.layer),
Effect.provide(
LayerNode.compile(EffectFlock.node, {
replacements: [Global.node.replace(Layer.succeed(Global.Service, global))],
}),
),
Effect.provideService(Global.Service, global),
Effect.provideService(FileSystem.FileSystem, {
...fs,
@@ -17,7 +17,7 @@ export type UpdateState =
export type UpdateSource = {
readonly remote: boolean
readonly subscribe: (notify: (notice: ClientNotice) => void, signal: AbortSignal) => Promise<void>
readonly subscribe: (notify: (notice: ClientNotice) => void) => () => void
readonly check: (
signal: AbortSignal,
onInstall: (version: string) => void,
@@ -115,15 +115,8 @@ export const { use: useUpdateNotification, provider: UpdateNotificationProvider
}
onMount(() => {
const updater = props.updater
if (!updater) return
const controller = new AbortController()
onCleanup(() => controller.abort())
void updater
.subscribe((notice) => notify({ ...notice, source: "client" }), controller.signal)
.catch((error) => {
if (!controller.signal.aborted) log.error("update check failed", { error })
})
if (!props.updater) return
onCleanup(props.updater.subscribe((notice) => notify({ ...notice, source: "client" })))
})
onCleanup(