diff --git a/packages/desktop/package.json b/packages/desktop/package.json index 390b7c704eb..4cae0fe87f4 100644 --- a/packages/desktop/package.json +++ b/packages/desktop/package.json @@ -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", diff --git a/packages/desktop/scripts/dev.ts b/packages/desktop/scripts/dev.ts new file mode 100644 index 00000000000..be990aa2edf --- /dev/null +++ b/packages/desktop/scripts/dev.ts @@ -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() diff --git a/packages/desktop/scripts/predev.ts b/packages/desktop/scripts/predev.ts deleted file mode 100644 index 538302e3624..00000000000 --- a/packages/desktop/scripts/predev.ts +++ /dev/null @@ -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() diff --git a/packages/desktop/scripts/utils.ts b/packages/desktop/scripts/utils.ts index 34b0405d9f0..920fe7c0dbf 100644 --- a/packages/desktop/scripts/utils.ts +++ b/packages/desktop/scripts/utils.ts @@ -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) { diff --git a/packages/desktop/src/main/background-cli.ts b/packages/desktop/src/main/background-cli.ts index ec37429a4b7..2addb1fe7f0 100644 --- a/packages/desktop/src/main/background-cli.ts +++ b/packages/desktop/src/main/background-cli.ts @@ -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" +}