diff --git a/packages/desktop/src/main/service/background-service.ts b/packages/desktop/src/main/service/background-service.ts index 3f5b14890c36..2d156b25e8a4 100644 --- a/packages/desktop/src/main/service/background-service.ts +++ b/packages/desktop/src/main/service/background-service.ts @@ -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()) 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) diff --git a/packages/desktop/src/main/service/cli-stages.test.ts b/packages/desktop/src/main/service/cli-stages.test.ts new file mode 100644 index 000000000000..06c63e082fd7 --- /dev/null +++ b/packages/desktop/src/main/service/cli-stages.test.ts @@ -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 }) + } + }) +}) diff --git a/packages/desktop/src/main/service/cli-stages.ts b/packages/desktop/src/main/service/cli-stages.ts new file mode 100644 index 000000000000..32315559b28f --- /dev/null +++ b/packages/desktop/src/main/service/cli-stages.ts @@ -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" }, + ) +}) diff --git a/packages/desktop/src/main/service/desktop-cli.ts b/packages/desktop/src/main/service/desktop-cli.ts index 4f8359cd7c08..0113f36fe490 100644 --- a/packages/desktop/src/main/service/desktop-cli.ts +++ b/packages/desktop/src/main/service/desktop-cli.ts @@ -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 - - 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() ?? "", }) }), )