fix(desktop): delete old staged CLI versions (#53659)

This commit is contained in:
Luke Parker
2026-10-07 12:10:05 +10:00
committed by GitHub
Unverified
parent a90ad8566d
commit a2b6005627
4 changed files with 87 additions and 41 deletions
@@ -1,7 +1,8 @@
import { app } from "electron"
import { Context, Effect, FileSystem, Layer, Path } from "effect"
import { BackgroundServiceState } from "./background-service-state"
import { cleanStages, DesktopCli } from "./desktop-cli"
import { cleanStages } from "./cli-stages"
import { DesktopCli } from "./desktop-cli"
import { SidecarCredentials } from "./sidecar-credentials"
import { sidecarProbe } from "./sidecar-probe"
@@ -32,7 +33,7 @@ const connect = Effect.fn("BackgroundService.connect")(function* (mode: "initial
yield* Effect.logInfo("starting v2 background service")
const path = yield* Path.Path
const desktopCli = yield* DesktopCli.Service
const runFork = Effect.runForkWith(yield* Effect.context())
const runFork = Effect.runForkWith(yield* Effect.context<FileSystem.FileSystem | Path.Path>())
const isolated = !app.isPackaged && process.env.OPENCODE_DESKTOP_ISOLATED_SERVER === "1"
const cli = yield* desktopCli.resolve
const version = mode === "initial" ? cli.version : undefined
@@ -76,7 +77,14 @@ const connect = Effect.fn("BackgroundService.connect")(function* (mode: "initial
...endpoint(url.origin),
})
if (mode === "initial" && isolated && cli.binary) yield* cleanStages(cli.binary).pipe(Effect.orDie)
// Only packaged and isolated builds run a staged copy; any other binary sits in the source tree.
// The service now runs the current version, so an older copy at most belongs to a process still exiting.
if (mode === "initial" && (app.isPackaged || isolated) && cli.binary)
runFork(
cleanStages(cli.binary).pipe(
Effect.catch((error) => Effect.logError("failed to clean staged v2 CLIs", { error })),
),
)
const ready = { url: url.origin, password: service.auth.password } satisfies SidecarCredentials.Data
SidecarCredentials.set(ready)
@@ -0,0 +1,38 @@
import { describe, expect, test } from "bun:test"
import { mkdirSync, mkdtempSync, readdirSync, rmSync, writeFileSync } from "node:fs"
import { tmpdir } from "node:os"
import path from "node:path"
import { NodeFileSystem, NodePath } from "@effect/platform-node"
import { Effect, Layer } from "effect"
import { cleanStages } from "./cli-stages"
describe("cleanStages", () => {
test.each([
{ name: "upgrade", staged: ["2.0.18", "2.0.19", "2.0.20"], current: "2.0.20" },
{ name: "downgrade", staged: ["2.0.18", "2.0.19", "2.0.20"], current: "2.0.18" },
{ name: "prerelease", staged: ["2.0.20", "2.1.0-beta.1", "local"], current: "2.1.0-beta.1" },
{ name: "only current", staged: ["2.0.20"], current: "2.0.20" },
])("keeps only the current staged CLI after $name", async (input) => {
const root = mkdtempSync(path.join(tmpdir(), "opencode-cli-stages-"))
try {
input.staged.forEach((version) => {
mkdirSync(path.join(root, version))
writeFileSync(path.join(root, version, "opencode-cli"), version)
})
writeFileSync(path.join(root, "unrelated"), "")
await Effect.runPromise(
cleanStages(path.join(root, input.current, "opencode-cli")).pipe(
Effect.provide(Layer.mergeAll(NodeFileSystem.layer, NodePath.layer)),
),
)
expect(readdirSync(root).sort()).toEqual([input.current, "unrelated"])
expect(readdirSync(path.join(root, input.current))).toEqual(["opencode-cli"])
} finally {
rmSync(root, { recursive: true, force: true })
}
})
})
@@ -0,0 +1,27 @@
import { Effect, FileSystem, Path } from "effect"
// Each version stages its own copy of the CLI, and only the current one is ever started again: a
// downgrade stages its copy anew. Windows refuses to delete an executable that has not exited yet,
// so a failed removal is logged and retried on the next launch.
export const cleanStages = Effect.fn("DesktopCli.cleanStages")(function* (binary: string) {
const fs = yield* FileSystem.FileSystem
const path = yield* Path.Path
const current = path.dirname(binary)
const root = path.dirname(current)
const entries = yield* fs.readDirectory(root)
yield* Effect.forEach(
entries,
Effect.fnUntraced(function* (entry) {
const target = path.join(root, entry)
if (target === current) return
const stat = yield* fs.stat(target).pipe(Effect.orElseSucceed(() => undefined))
if (stat?.type !== "Directory") return
yield* fs
.remove(target, { recursive: true, force: true })
.pipe(Effect.catch((error) => Effect.logError("failed to clean staged v2 CLI", { path: target, error })))
}),
{ concurrency: "unbounded" },
)
})
@@ -4,7 +4,7 @@ import { execFile, spawn } from "node:child_process"
import { existsSync, readFileSync } from "node:fs"
import { promisify } from "node:util"
import { app } from "electron"
import { Context, Effect, FileSystem, Layer, Option, Path } from "effect"
import { Context, Effect, FileSystem, Layer, Option, Path, Schema } from "effect"
import installer from "../../../../../install?raw"
import { DesktopPaths } from "../paths"
import { BUNDLED_CLI_VERSION_KEY } from "../storage/keys"
@@ -114,9 +114,9 @@ const bundledVersion = Effect.fn("DesktopCli.bundledVersion")(function* (bundled
const stat = yield* fs.stat(bundled).pipe(Effect.orElseSucceed(() => undefined))
const identity = stat ? `${stat.size}:${Option.getOrUndefined(stat.mtime)?.getTime() ?? ""}` : undefined
const store = getStore()
const cached = store.get(BUNDLED_CLI_VERSION_KEY)
const cached = Option.getOrUndefined(Schema.decodeUnknownOption(VersionCache)(store.get(BUNDLED_CLI_VERSION_KEY)))
if (identity && isVersionCache(cached) && cached.path === bundled && cached.identity === identity) {
if (identity && cached?.path === bundled && cached.identity === identity) {
yield* Effect.logInfo("v2 CLI version reused", { version: cached.version })
return cached.version
@@ -124,42 +124,15 @@ const bundledVersion = Effect.fn("DesktopCli.bundledVersion")(function* (bundled
const version = parseCliVersion(yield* run(bundled, ["--version"]))
if (identity) store.set(BUNDLED_CLI_VERSION_KEY, { path: bundled, identity, version } satisfies VersionCache)
if (identity)
store.set(BUNDLED_CLI_VERSION_KEY, { path: bundled, identity, version } satisfies typeof VersionCache.Type)
return version
})
type VersionCache = { path: string; identity: string; version: string }
const VersionCache = Schema.Struct({ path: Schema.String, identity: Schema.String, version: Schema.String })
function isVersionCache(value: unknown): value is VersionCache {
if (!value || typeof value !== "object") return false
const cache = value as Record<string, unknown>
return typeof cache.path === "string" && typeof cache.identity === "string" && typeof cache.version === "string"
}
export const cleanStages = Effect.fn("DesktopCli.cleanStages")(function* (binary: string) {
const fs = yield* FileSystem.FileSystem
const path = yield* Path.Path
const current = path.dirname(binary)
const root = path.dirname(current)
const entries = yield* fs.readDirectory(root)
yield* Effect.forEach(
entries,
Effect.fnUntraced(function* (entry) {
const target = path.join(root, entry)
if (target === current) return
const stat = yield* fs.stat(target).pipe(Effect.orElseSucceed(() => undefined))
if (stat?.type !== "Directory") return
yield* fs
.remove(target, { recursive: true, force: true })
.pipe(Effect.catch((error) => Effect.logError("failed to clean staged v2 CLI", { path: target, error })))
}),
{ concurrency: "unbounded" },
)
})
const ExecFailure = Schema.Struct({ stdout: Schema.optional(Schema.String), stderr: Schema.optional(Schema.String) })
const installCli = Effect.fn("DesktopCli.install")(function* (source: string, version: string) {
const fs = yield* FileSystem.FileSystem
@@ -191,13 +164,13 @@ const run = Effect.fn("DesktopCli.run")(function* (binary: string, args: string[
const result = yield* Effect.tryPromise(() => execFileAsync(binary, args, { windowsHide: true })).pipe(
Effect.tapError((error) => {
const output = error as { stdout?: string; stderr?: string }
const output = Option.getOrUndefined(Schema.decodeUnknownOption(ExecFailure)(error.cause))
return Effect.logError("v2 CLI command failed", {
args,
error: error instanceof Error ? error.message : String(error),
stdout: output.stdout?.trim() ?? "",
stderr: output.stderr?.trim() ?? "",
error: error.cause instanceof Error ? error.cause.message : String(error.cause),
stdout: output?.stdout?.trim() ?? "",
stderr: output?.stderr?.trim() ?? "",
})
}),
)