From 988a66ca4fbb919eb9dcb19217f56e53da1be5b0 Mon Sep 17 00:00:00 2001 From: Luke Parker <10430890+Hona@users.noreply.github.com> Date: Sat, 19 Sep 2026 09:36:55 +1000 Subject: [PATCH] perf(desktop): ship the bundled CLI version instead of spawning for it (#49762) --- .../desktop/electron-builder.config.test.ts | 2 +- packages/desktop/electron-builder.config.ts | 4 +- packages/desktop/scripts/utils.ts | 24 +++++++---- .../desktop/src/main/service/desktop-cli.ts | 41 ++++++++++++++++++- packages/desktop/src/main/storage/keys.ts | 1 + 5 files changed, 60 insertions(+), 12 deletions(-) diff --git a/packages/desktop/electron-builder.config.test.ts b/packages/desktop/electron-builder.config.test.ts index c862c18c34d..6e773dd1d58 100644 --- a/packages/desktop/electron-builder.config.test.ts +++ b/packages/desktop/electron-builder.config.test.ts @@ -202,7 +202,7 @@ for (const channel of ["dev", "beta"] as const) { { from: "resources/", to: "", - filter: ["opencode-cli", "opencode-cli.exe"], + filter: ["opencode-cli", "opencode-cli.exe", "opencode-cli.version"], }, ]) }) diff --git a/packages/desktop/electron-builder.config.ts b/packages/desktop/electron-builder.config.ts index 6d8dddcd960..6830c8a9c18 100644 --- a/packages/desktop/electron-builder.config.ts +++ b/packages/desktop/electron-builder.config.ts @@ -90,7 +90,7 @@ const getBase = (appId: string): Configuration => ({ { from: "resources/", to: "", - filter: ["opencode-cli", "opencode-cli.exe"], + filter: ["opencode-cli", "opencode-cli.exe", "opencode-cli.version"], }, ], afterPack: async (context) => { @@ -100,6 +100,8 @@ const getBase = (appId: string): Configuration => ({ ) const file = await stat(cli) if (!file.isFile() || file.size === 0) throw new Error(`Bundled CLI must be a non-empty file: ${cli}`) + const version = path.join(path.dirname(cli), "opencode-cli.version") + if ((await stat(version)).size === 0) throw new Error(`Bundled CLI version must be a non-empty file: ${version}`) }, mac: { category: "public.app-category.developer-tools", diff --git a/packages/desktop/scripts/utils.ts b/packages/desktop/scripts/utils.ts index 0ad8b9ee4cf..9753f2ade52 100644 --- a/packages/desktop/scripts/utils.ts +++ b/packages/desktop/scripts/utils.ts @@ -1,7 +1,7 @@ import { $ } from "bun" import { chmod, copyFile, mkdtemp, rm } from "node:fs/promises" import { tmpdir } from "node:os" -import { join } from "node:path" +import { dirname, join } from "node:path" const CLI_VERSION = "dev" @@ -75,10 +75,7 @@ export async function downloadCliToResources(version = CLI_VERSION, dest = windo const directory = await mkdtemp(join(tmpdir(), "opencode-cli-")) try { await $`bun install --no-save --cwd ${directory} ${`${cli.package}@${version}`} ${`--os=${cli.os}`} ${`--cpu=${cli.cpu}`}` - await copyCliToResources( - join(directory, "node_modules", cli.package, "bin", cli.os === "win32" ? "opencode.exe" : "opencode"), - dest, - ) + await copyCliToResources(join(directory, "node_modules", cli.package), dest) } finally { await rm(directory, { recursive: true, force: true }) } @@ -89,12 +86,23 @@ export async function downloadCliToResources(version = CLI_VERSION, dest = windo export async function copyBuiltCliToResources(root: string, dest = windowsify("resources/opencode-cli")) { const cli = getCurrentCli() const directory = cli.package.replace("@opencode/", "") - await copyCliToResources(join(root, directory, "bin", cli.os === "win32" ? "opencode.exe" : "opencode"), dest) + await copyCliToResources(join(root, directory), dest) } -async function copyCliToResources(source: string, dest: string) { - await copyFile(source, dest) +// The package directory is an npm package: its package.json version is the string the executable +// prints for --version. Writing it next to the executable spares the desktop a ~400 ms spawn of the +// 200 MB binary on first launch. +async function copyCliToResources(pkg: string, dest: string) { + const cli = getCurrentCli() + await copyFile(join(pkg, "bin", cli.os === "win32" ? "opencode.exe" : "opencode"), dest) await prepareCli(dest) + const manifest = (await Bun.file(join(pkg, "package.json")).json()) as { version?: string } + if (!manifest.version) throw new Error(`Bundled CLI package has no version: ${pkg}`) + await Bun.write(versionFile(dest), manifest.version) +} + +export function versionFile(cli: string) { + return join(dirname(cli), "opencode-cli.version") } async function prepareCli(dest: string) { diff --git a/packages/desktop/src/main/service/desktop-cli.ts b/packages/desktop/src/main/service/desktop-cli.ts index 81fb6b40e1a..02cc68c0084 100644 --- a/packages/desktop/src/main/service/desktop-cli.ts +++ b/packages/desktop/src/main/service/desktop-cli.ts @@ -3,9 +3,11 @@ export * as DesktopCli from "./desktop-cli" import { execFile, spawn } from "node:child_process" import { promisify } from "node:util" import { app } from "electron" -import { Context, Effect, FileSystem, Layer, Path } from "effect" +import { Context, Effect, FileSystem, Layer, Option, Path } from "effect" import installer from "../../../../../install?raw" import { DesktopPaths } from "../paths" +import { BUNDLED_CLI_VERSION_KEY } from "../storage/keys" +import { getStore } from "../storage/store" import { parseCliVersion } from "./cli-version" const execFileAsync = promisify(execFile) @@ -79,11 +81,46 @@ const resolveBundledCli = Effect.fn("DesktopCli.resolveBundled")(function* (isol ? path.join(process.resourcesPath, executableName()) : path.join(paths.developmentResourcesRoot, isolated ? developmentExecutableName() : executableName()) yield* Effect.logInfo("v2 CLI executable resolved", { bundled, packaged: app.isPackaged }) - const version = parseCliVersion(yield* run(bundled, ["--version"])) + const version = yield* bundledVersion(bundled) const binary = app.isPackaged || isolated ? yield* installCli(bundled, version) : bundled return { version, binary, command: [binary] } }) +// Spawning the bundled executable for `--version` costs ~400 ms of startup on a 200 MB binary (and +// several seconds on the first launch after an update, while the antivirus scans it). The build +// writes the version next to the executable, so a packaged app never spawns; the per-identity cache +// covers executables that arrived without that file. +const bundledVersion = Effect.fn("DesktopCli.bundledVersion")(function* (bundled: string) { + const fs = yield* FileSystem.FileSystem + const path = yield* Path.Path + const shipped = yield* fs + .readFileString(path.join(path.dirname(bundled), "opencode-cli.version")) + .pipe(Effect.map((text) => text.trim()), Effect.orElseSucceed(() => "")) + if (shipped) { + yield* Effect.logInfo("v2 CLI version bundled", { version: shipped }) + return shipped + } + 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) + if (identity && isVersionCache(cached) && cached.path === bundled && cached.identity === identity) { + yield* Effect.logInfo("v2 CLI version reused", { version: cached.version }) + return cached.version + } + const version = parseCliVersion(yield* run(bundled, ["--version"])) + if (identity) store.set(BUNDLED_CLI_VERSION_KEY, { path: bundled, identity, version } satisfies VersionCache) + return version +}) + +type VersionCache = { path: string; identity: string; version: 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 diff --git a/packages/desktop/src/main/storage/keys.ts b/packages/desktop/src/main/storage/keys.ts index 6a268417e0a..40183f430c3 100644 --- a/packages/desktop/src/main/storage/keys.ts +++ b/packages/desktop/src/main/storage/keys.ts @@ -5,3 +5,4 @@ export const WSL_SERVERS_KEY = "wslServers" export const PINCH_ZOOM_ENABLED_KEY = "pinchZoomEnabled" export const BACKGROUND_COLOR_KEY = "backgroundColor" export const WINDOW_IDS_KEY = "windowIds" +export const BUNDLED_CLI_VERSION_KEY = "bundledCliVersion"