mirror of
https://github.com/anomalyco/opencode.git
synced 2026-11-14 03:56:55 +03:00
fix(desktop): delete old staged CLI versions (#53659)
This commit is contained in:
committed by
GitHub
Unverified
parent
a90ad8566d
commit
a2b6005627
@@ -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() ?? "",
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user