feat(desktop): support local server builds (#41486)

This commit is contained in:
Luke Parker 2026-08-12 09:56:47 +10:00 committed by GitHub
parent 055a380e66
commit 1190ef3818
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 116 additions and 17 deletions

View file

@ -11,8 +11,7 @@
},
"scripts": {
"typecheck": "tsgo -b",
"predev": "bun ./scripts/predev.ts",
"dev": "electron-vite dev",
"dev": "bun ./scripts/dev.ts",
"prebuild": "bun ./scripts/prebuild.ts",
"build": "electron-vite build",
"preview": "electron-vite preview",

View file

@ -0,0 +1,56 @@
import { $ } from "bun"
import { homedir } from "node:os"
import { join } from "node:path"
import { buildCliToResources, downloadCliToResources, windowsify } from "./utils"
type ServerSource = { type: "build" } | { type: "download"; version: string }
type DevOptions = { server: ServerSource; electron: string[] }
async function main() {
const options = selectOptions()
await prepareDesktop()
await prepareServer(options.server)
await startDesktop(options.electron)
}
async function prepareDesktop() {
await $`bun run install-electron`
await $`bun ./scripts/copy-icons.ts ${process.env.OPENCODE_CHANNEL ?? "dev"}`
}
function selectOptions(): DevOptions {
const args = process.argv.slice(2)
const build = args.indexOf("--build-server")
const download = args.indexOf("--download-server")
if (build >= 0 && download >= 0) {
throw new Error("--build-server and --download-server cannot be used together")
}
if (download >= 0 && !args[download + 1]) throw new Error("--download-server requires a version")
const consumed = new Set([build, download, download >= 0 ? download + 1 : -1])
return {
server: download >= 0 ? { type: "download", version: args[download + 1] } : { type: "build" },
electron: args.filter((_, index) => !consumed.has(index)),
}
}
async function prepareServer(source: ServerSource) {
const destination = windowsify("resources/opencode-cli-dev")
if (source.type === "download") return downloadCliToResources(source.version, destination)
return buildCliToResources(destination, developmentStateHome())
}
function developmentStateHome() {
const appData = (() => {
if (process.platform === "darwin") return join(homedir(), "Library", "Application Support")
if (process.platform === "win32") return process.env.APPDATA ?? join(homedir(), "AppData", "Roaming")
return process.env.XDG_CONFIG_HOME ?? join(homedir(), ".config")
})()
return join(appData, "ai.opencode.desktop.dev")
}
async function startDesktop(args: string[]) {
process.env.OPENCODE_DESKTOP_ISOLATED_SERVER = "1"
await $`electron-vite dev ${args}`
}
await main()

View file

@ -1,8 +0,0 @@
import { $ } from "bun"
import { downloadCliToResources } from "./utils"
await $`bun run install-electron`
await $`bun ./scripts/copy-icons.ts ${process.env.OPENCODE_CHANNEL ?? "dev"}`
await downloadCliToResources()

View file

@ -69,10 +69,9 @@ export function getCurrentCli(target = CLI_TARGET ?? nativeTarget()) {
return binaryConfig
}
export async function downloadCliToResources(version = CLI_VERSION) {
export async function downloadCliToResources(version = CLI_VERSION, dest = windowsify("resources/opencode-cli")) {
const cli = getCurrentCli()
const directory = await mkdtemp(join(tmpdir(), "opencode-cli-"))
const dest = windowsify("resources/opencode-cli")
try {
await $`bun install --no-save --cwd ${directory} ${`${cli.package}@${version}`} ${`--os=${cli.os}`} ${`--cpu=${cli.cpu}`}`
await copyFile(
@ -82,13 +81,45 @@ export async function downloadCliToResources(version = CLI_VERSION) {
} finally {
await rm(directory, { recursive: true, force: true })
}
await prepareCli(dest)
console.log(`Copied ${cli.package}@${version} to ${dest}`)
}
export async function buildCliToResources(dest = windowsify("resources/opencode-cli"), stateHome?: string) {
const directory = await mkdtemp(join(tmpdir(), "opencode-cli-"))
const target = `cli-${process.platform === "win32" ? "windows" : process.platform}-${process.arch}`
try {
await $`bun ${join(import.meta.dirname, "../../cli/script/build.ts")} --single --skip-install --outdir=${directory}`.env(
{
...process.env,
OPENCODE_VERSION: `0.0.0-local-${Date.now()}`,
},
)
if (stateHome && (await Bun.file(dest).exists())) {
const child = Bun.spawn([dest, "service", "stop"], {
env: { ...process.env, XDG_STATE_HOME: stateHome },
stdout: "inherit",
stderr: "inherit",
})
const exitCode = await child.exited
if (exitCode !== 0) throw new Error(`Failed to stop development service: ${exitCode}`)
}
await copyFile(join(directory, target, "bin", windowsify("opencode2")), dest)
} finally {
await rm(directory, { recursive: true, force: true })
}
await prepareCli(dest)
console.log(`Built local CLI at ${dest}`)
}
async function prepareCli(dest: string) {
if (process.platform !== "win32") await chmod(dest, 0o755)
if (process.platform === "win32" && process.env.GITHUB_ACTIONS === "true") {
await $`pwsh -NoLogo -NoProfile -ExecutionPolicy Bypass -File ../../script/sign-windows.ps1 ${dest}`
}
if (process.platform === "darwin") await $`codesign --force --sign - ${dest}`
console.log(`Copied ${cli.package}@${version} to ${dest}`)
}
export function windowsify(path: string) {

View file

@ -1,7 +1,7 @@
import { Service } from "@opencode-ai/client/service"
import { execFile } from "node:child_process"
import { existsSync } from "node:fs"
import { chmod, copyFile, mkdir, rename, rm } from "node:fs/promises"
import { chmod, copyFile, mkdir, readdir, rename, rm } from "node:fs/promises"
import { dirname, join } from "node:path"
import { fileURLToPath } from "node:url"
import { promisify } from "node:util"
@ -16,12 +16,14 @@ type Logger = {
}
export async function startBackgroundCli(logger: Logger) {
const isolated = !app.isPackaged && process.env.OPENCODE_DESKTOP_ISOLATED_SERVER === "1"
const bundled = app.isPackaged
? join(process.resourcesPath, executableName())
: join(root, "../../resources", executableName())
: join(root, "../../resources", isolated ? developmentExecutableName() : executableName())
logger.log("v2 CLI executable resolved", { bundled, packaged: app.isPackaged })
const version = parseVersion(await run(bundled, ["--version"], logger))
const binary = app.isPackaged ? await installCli(bundled, version, logger) : bundled
const binary = app.isPackaged || isolated ? await installCli(bundled, version, logger) : bundled
if (isolated) process.env.XDG_STATE_HOME = app.getPath("userData")
const service = await Service.ensure({
version,
command: [binary, "serve", "--service"],
@ -33,6 +35,7 @@ export async function startBackgroundCli(logger: Logger) {
version,
...endpoint(service.url),
})
if (isolated) await cleanCliStages(binary, logger)
return {
url: service.url,
username: service.auth.username,
@ -40,6 +43,20 @@ export async function startBackgroundCli(logger: Logger) {
}
}
async function cleanCliStages(binary: string, logger: Logger) {
const current = dirname(binary)
const root = dirname(current)
await Promise.all(
(await readdir(root, { withFileTypes: true }))
.filter((entry) => entry.isDirectory() && join(root, entry.name) !== current)
.map((entry) =>
rm(join(root, entry.name), { recursive: true, force: true }).catch((error) =>
logger.error("failed to clean staged v2 CLI", { path: join(root, entry.name), error }),
),
),
)
}
async function installCli(source: string, version: string, logger: Logger) {
const directory = join(app.getPath("userData"), "cli", version.replace(/[^a-zA-Z0-9._-]/g, "-"))
const destination = join(directory, executableName())
@ -98,3 +115,7 @@ function endpoint(url: string | undefined) {
function executableName() {
return process.platform === "win32" ? "opencode-cli.exe" : "opencode-cli"
}
function developmentExecutableName() {
return process.platform === "win32" ? "opencode-cli-dev.exe" : "opencode-cli-dev"
}