diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 037020c03af..15f865b742f 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -219,7 +219,6 @@ jobs: build-electron: needs: - - build-cli - version if: github.repository == 'anomalyco/opencode' continue-on-error: false @@ -316,10 +315,7 @@ jobs: env: OPENCODE_VERSION: ${{ needs.version.outputs.version }} OPENCODE_CHANNEL: ${{ (github.ref_name == 'beta' && 'beta') || 'prod' }} - OPENCODE_CLI_ARTIFACT: ${{ (runner.os == 'Windows' && 'opencode-cli-windows') || 'opencode-cli' }} RUST_TARGET: ${{ matrix.settings.target }} - GH_TOKEN: ${{ github.token }} - GITHUB_RUN_ID: ${{ github.run_id }} - name: Build run: bun run build diff --git a/packages/desktop/electron-builder.config.test.ts b/packages/desktop/electron-builder.config.test.ts index 4ae53fa043d..3fb1adb6c17 100644 --- a/packages/desktop/electron-builder.config.test.ts +++ b/packages/desktop/electron-builder.config.test.ts @@ -56,3 +56,36 @@ test("keeps a hidden prod launcher for old Linux pins", async () => { expect(desktop).toContain("StartupWMClass=ai.opencode.desktop") expect(desktop).toContain("NoDisplay=true") }) + +test("bundles the CLI outside the dev app archive", async () => { + const previous = process.env.OPENCODE_CHANNEL + process.env.OPENCODE_CHANNEL = "dev" + const module = await import("./electron-builder.config.ts?cli-resource") + const config = module.default as Configuration + if (previous === undefined) delete process.env.OPENCODE_CHANNEL + else process.env.OPENCODE_CHANNEL = previous + + expect(config.files).toContain("!resources/opencode-cli*") + expect(config.extraResources).toContainEqual({ + from: "resources/", + to: "", + filter: ["opencode-cli*"], + }) +}) + +for (const channel of ["beta", "prod"] as const) { + test(`does not bundle the CLI in ${channel} builds`, async () => { + const previous = process.env.OPENCODE_CHANNEL + process.env.OPENCODE_CHANNEL = channel + const module = await import(`./electron-builder.config.ts?no-cli-resource=${channel}`) + const config = module.default as Configuration + if (previous === undefined) delete process.env.OPENCODE_CHANNEL + else process.env.OPENCODE_CHANNEL = previous + + expect(config.extraResources).not.toContainEqual({ + from: "resources/", + to: "", + filter: ["opencode-cli*"], + }) + }) +} diff --git a/packages/desktop/electron-builder.config.ts b/packages/desktop/electron-builder.config.ts index a10a757dbb8..508c0df5e91 100644 --- a/packages/desktop/electron-builder.config.ts +++ b/packages/desktop/electron-builder.config.ts @@ -55,8 +55,17 @@ const getBase = (appId: string): Configuration => ({ extraMetadata: { desktopName: `${appId}.desktop`, }, - files: ["out/**/*", "resources/**/*"], + files: ["out/**/*", "resources/**/*", "!resources/opencode-cli*"], extraResources: [ + ...(channel === "dev" + ? [ + { + from: "resources/", + to: "", + filter: ["opencode-cli*"], + }, + ] + : []), { from: "native/", to: "native/", diff --git a/packages/desktop/scripts/prebuild.ts b/packages/desktop/scripts/prebuild.ts index 79b0e30afcd..636850a3875 100644 --- a/packages/desktop/scripts/prebuild.ts +++ b/packages/desktop/scripts/prebuild.ts @@ -1,10 +1,11 @@ #!/usr/bin/env bun import { $ } from "bun" -import { resolveChannel } from "./utils" +import { downloadCliToResources, resolveChannel } from "./utils" const channel = resolveChannel() await $`bun ./scripts/copy-icons.ts ${channel}` await $`bun ./scripts/copy-metainfo.ts ${channel}` await $`cd ../opencode && bun script/build-node.ts` +if (channel === "dev") await downloadCliToResources() diff --git a/packages/desktop/scripts/predev.ts b/packages/desktop/scripts/predev.ts index 4a9682306de..bfa399e4e53 100644 --- a/packages/desktop/scripts/predev.ts +++ b/packages/desktop/scripts/predev.ts @@ -1,7 +1,9 @@ import { $ } from "bun" +import { downloadCliToResources } from "./utils" await $`bun run install-electron` await $`bun ./scripts/copy-icons.ts ${process.env.OPENCODE_CHANNEL ?? "dev"}` await $`cd ../opencode && bun script/build-node.ts` +await downloadCliToResources() diff --git a/packages/desktop/scripts/utils.ts b/packages/desktop/scripts/utils.ts index 19b96b0a161..bf728758ed1 100644 --- a/packages/desktop/scripts/utils.ts +++ b/packages/desktop/scripts/utils.ts @@ -1,4 +1,9 @@ import { $ } from "bun" +import { chmod, copyFile, mkdtemp, rm } from "node:fs/promises" +import { tmpdir } from "node:os" +import { join } from "node:path" + +const CLI_VERSION = "0.0.0-next-16350" export type Channel = "dev" | "beta" | "prod" @@ -8,36 +13,42 @@ export function resolveChannel(): Channel { return "dev" } -export const SIDECAR_BINARIES: Array<{ rustTarget: string; ocBinary: string; assetExt: string }> = [ +export const CLI_BINARIES: Array<{ rustTarget: string; package: string; os: string; cpu: string }> = [ { rustTarget: "aarch64-apple-darwin", - ocBinary: "opencode-darwin-arm64", - assetExt: "zip", + package: "@opencode-ai/cli-darwin-arm64", + os: "darwin", + cpu: "arm64", }, { rustTarget: "x86_64-apple-darwin", - ocBinary: "opencode-darwin-x64-baseline", - assetExt: "zip", + package: "@opencode-ai/cli-darwin-x64-baseline", + os: "darwin", + cpu: "x64", }, { rustTarget: "aarch64-pc-windows-msvc", - ocBinary: "opencode-windows-arm64", - assetExt: "zip", + package: "@opencode-ai/cli-windows-arm64", + os: "win32", + cpu: "arm64", }, { rustTarget: "x86_64-pc-windows-msvc", - ocBinary: "opencode-windows-x64-baseline", - assetExt: "zip", + package: "@opencode-ai/cli-windows-x64-baseline", + os: "win32", + cpu: "x64", }, { rustTarget: "x86_64-unknown-linux-gnu", - ocBinary: "opencode-linux-x64-baseline", - assetExt: "tar.gz", + package: "@opencode-ai/cli-linux-x64-baseline", + os: "linux", + cpu: "x64", }, { rustTarget: "aarch64-unknown-linux-gnu", - ocBinary: "opencode-linux-arm64", - assetExt: "tar.gz", + package: "@opencode-ai/cli-linux-arm64", + os: "linux", + cpu: "arm64", }, ] @@ -51,24 +62,33 @@ function nativeTarget() { throw new Error(`Unsupported platform: ${platform}/${arch}`) } -export function getCurrentSidecar(target = RUST_TARGET ?? nativeTarget()) { - const binaryConfig = SIDECAR_BINARIES.find((b) => b.rustTarget === target) - if (!binaryConfig) throw new Error(`Sidecar configuration not available for Rust target '${target}'`) +export function getCurrentCli(target = RUST_TARGET ?? nativeTarget()) { + const binaryConfig = CLI_BINARIES.find((item) => item.rustTarget === target) + if (!binaryConfig) throw new Error(`CLI configuration not available for target '${target}'`) return binaryConfig } -export async function copyBinaryToSidecarFolder(source: string) { - const dir = `resources` - await $`mkdir -p ${dir}` - const dest = windowsify(`${dir}/opencode-cli`) - await $`cp ${source} ${dest}` +export async function downloadCliToResources() { + 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}@${CLI_VERSION}`} ${`--os=${cli.os}`} ${`--cpu=${cli.cpu}`}` + await copyFile( + join(directory, "node_modules", cli.package, "bin", cli.os === "win32" ? "opencode2.exe" : "opencode2"), + dest, + ) + } finally { + await rm(directory, { recursive: true, force: true }) + } + 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 ${source} to ${dest}`) + console.log(`Copied ${cli.package} 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 new file mode 100644 index 00000000000..66602d51f11 --- /dev/null +++ b/packages/desktop/src/main/background-cli.ts @@ -0,0 +1,125 @@ +import { execFile } from "node:child_process" +import { existsSync } from "node:fs" +import { chmod, copyFile, mkdir, rename, rm } from "node:fs/promises" +import { dirname, join } from "node:path" +import { fileURLToPath } from "node:url" +import { promisify } from "node:util" +import { app } from "electron" + +const execFileAsync = promisify(execFile) +const root = dirname(fileURLToPath(import.meta.url)) +const stateHome = process.env.XDG_STATE_HOME +const desktopStateNames = ["ai.opencode.desktop.dev", "ai.opencode.desktop.beta", "ai.opencode.desktop"] + +type Logger = { + log(message: string, meta?: Record): void + error(message: string, meta?: Record): void +} + +export async function startBackgroundCli(logger: Logger, shellStateHome?: string) { + const bundled = app.isPackaged + ? join(process.resourcesPath, executableName()) + : join(root, "../../resources", executableName()) + logger.log("v2 CLI executable resolved", { bundled, packaged: app.isPackaged }) + const version = await run(bundled, ["--version"], logger) + const binary = app.isPackaged ? await installCli(bundled, version, logger) : bundled + + const candidates = [ + ...new Set([stateHome, shellStateHome, ...desktopStateNames.map((name) => join(app.getPath("appData"), name))]), + ].filter((candidate) => candidate === undefined || existsSync(candidate)) + const discovered = await Promise.all( + candidates.map(async (candidate) => ({ + stateHome: candidate, + url: serviceUrl(await run(binary, ["service", "status"], logger, { stateHome: candidate })), + })), + ) + const found = discovered.find((candidate) => candidate.url !== undefined) + logger.log("v2 CLI background instance checked", { + detected: Boolean(found), + ...endpoint(found?.url), + }) + + const daemonStateHome = found?.stateHome ?? stateHome + const url = await run(binary, ["service", "start"], logger, { stateHome: daemonStateHome }) + const password = await run(binary, ["service", "get", "password"], logger, { + redact: true, + stateHome: daemonStateHome, + }) + logger.log("v2 CLI background service ready", { + existing: Boolean(found), + username: "opencode", + ...endpoint(url), + }) + return { + url, + username: "opencode", + password, + } +} + +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()) + if (existsSync(destination)) { + logger.log("v2 CLI staged executable reused", { path: destination, version }) + return destination + } + + const temp = destination + `.${process.pid}.tmp` + await mkdir(directory, { recursive: true }) + await copyFile(source, temp) + if (process.platform !== "win32") await chmod(temp, 0o755) + await rename(temp, destination).catch(async (error) => { + await rm(temp, { force: true }) + throw error + }) + logger.log("v2 CLI executable staged", { source, path: destination, version }) + return destination +} + +async function run( + binary: string, + args: string[], + logger: Logger, + options: { redact?: boolean; stateHome?: string } = {}, +) { + logger.log("v2 CLI command started", { binary, args }) + const env = { ...process.env } + if (options.stateHome === undefined) delete env.XDG_STATE_HOME + else env.XDG_STATE_HOME = options.stateHome + return execFileAsync(binary, args, { env, windowsHide: true }).then( + (result) => { + const stdout = result.stdout.trim() + const stderr = result.stderr.trim() + logger.log("v2 CLI command completed", { args, stdout: options.redact ? "[redacted]" : stdout, stderr }) + return stdout + }, + (error: unknown) => { + const output = error as { stdout?: string; stderr?: string } + logger.error("v2 CLI command failed", { + args, + error: error instanceof Error ? error.message : String(error), + stdout: options.redact && output.stdout ? "[redacted]" : (output.stdout?.trim() ?? ""), + stderr: output.stderr?.trim() ?? "", + }) + throw error + }, + ) +} + +function serviceUrl(status: string) { + if (URL.canParse(status)) return status + if (!status.startsWith("running ")) return + const url = status.slice("running ".length).trim() + return URL.canParse(url) ? url : undefined +} + +function endpoint(url: string | undefined) { + if (!url || !URL.canParse(url)) return {} + const parsed = new URL(url) + return { url, hostname: parsed.hostname, port: parsed.port } +} + +function executableName() { + return process.platform === "win32" ? "opencode-cli.exe" : "opencode-cli" +} diff --git a/packages/desktop/src/main/index.ts b/packages/desktop/src/main/index.ts index c7c1643092d..67696a5470a 100644 --- a/packages/desktop/src/main/index.ts +++ b/packages/desktop/src/main/index.ts @@ -48,6 +48,7 @@ import { registerWslIpcHandlers } from "./wsl/ipc" import { spawnWslSidecar } from "./wsl/sidecar" import { migrate } from "./migrate" import { cleanupStoreFiles } from "./store-cleanup" +import { startBackgroundCli } from "./background-cli" const APP_NAMES: Record = { dev: "OpenCode Dev", @@ -60,6 +61,7 @@ const APP_IDS: Record = { prod: "ai.opencode.desktop", } const TEST_ONBOARDING = process.env.OPENCODE_TEST_ONBOARDING === "1" +const SIDECAR_VERSION = process.env.OPENCODE_SIDECAR_V2 === "1" ? "v2" : "v1" const jsCallStackFeature = "DocumentPolicyIncludeJSCallStacksInCrashReports" let logger: ReturnType @@ -198,7 +200,7 @@ const main = Effect.gen(function* () { return } - preferAppEnv(app.getPath("userData")) + const shellEnv = preferAppEnv(app.getPath("userData")) app.on("second-instance", (_event: Event, argv: string[]) => { const urls = argv.filter((arg: string) => arg.startsWith("opencode://")) @@ -312,39 +314,56 @@ const main = Effect.gen(function* () { ), ) - const port = yield* Effect.gen(function* () { - const fromEnv = process.env.OPENCODE_PORT - if (fromEnv) { - const parsed = Number.parseInt(fromEnv, 10) - if (!Number.isNaN(parsed)) return parsed - } - - const res = yield* Deferred.make() - const server = createServer() - server.on("error", (e) => Deferred.failSync(res, () => e)) - server.listen(0, "127.0.0.1", () => { - const address = server.address() - if (typeof address !== "object" || !address) { - server.close() - Deferred.failSync(res, () => new Error("Failed to get port")) - return - } - const port = address.port - server.close(() => Effect.runSync(Deferred.succeed(res, port))) - }) - - return yield* Deferred.await(res) - }) - const hostname = "127.0.0.1" - const url = `http://${hostname}:${port}` - const password = randomUUID() - const loadingTask = yield* Effect.gen(function* () { - logger.log("sidecar connection started", { url }) + logger.log("sidecar connection started", { version: SIDECAR_VERSION }) ensureLoopbackNoProxy() useEnvProxy() + if (SIDECAR_VERSION === "v2") { + logger.log("spawning v2 sidecar") + const sidecar = yield* Effect.promise(() => startBackgroundCli(logger, shellEnv?.XDG_STATE_HOME)) + yield* Deferred.succeed(serverReady, { + url: sidecar.url, + username: sidecar.username, + password: sidecar.password, + }) + + if (process.platform === "win32") { + void wslServers.initialize().catch((error) => logger.error("wsl server initialization failed", error)) + } + + logger.log("loading task finished") + return + } + + const port = yield* Effect.gen(function* () { + const fromEnv = process.env.OPENCODE_PORT + if (fromEnv) { + const parsed = Number.parseInt(fromEnv, 10) + if (!Number.isNaN(parsed)) return parsed + } + + const res = yield* Deferred.make() + const socket = createServer() + socket.on("error", (e) => Deferred.failSync(res, () => e)) + socket.listen(0, "127.0.0.1", () => { + const address = socket.address() + if (typeof address !== "object" || !address) { + socket.close() + Deferred.failSync(res, () => new Error("Failed to get port")) + return + } + const port = address.port + socket.close(() => Effect.runSync(Deferred.succeed(res, port))) + }) + + return yield* Deferred.await(res) + }) + const hostname = "127.0.0.1" + const url = `http://${hostname}:${port}` + const password = randomUUID() + logger.log("spawning sidecar", { url }) const { listener, health } = yield* Effect.promise(() => spawnLocalServer(hostname, port, password, { diff --git a/packages/desktop/src/main/server.ts b/packages/desktop/src/main/server.ts index c620b76ae88..ae1a98efdfa 100644 --- a/packages/desktop/src/main/server.ts +++ b/packages/desktop/src/main/server.ts @@ -43,13 +43,15 @@ export function setDefaultServerUrl(url: string | null) { export function preferAppEnv(userDataPath: string) { const shell = process.platform === "win32" ? null : getUserShell() + const shellEnv = shell ? loadShellEnv(shell, getLogger()) : null Object.assign(process.env, { - ...(shell ? loadShellEnv(shell, getLogger()) : null), + ...shellEnv, OPENCODE_EXPERIMENTAL_ICON_DISCOVERY: "true", OPENCODE_EXPERIMENTAL_FILEWATCHER: "true", OPENCODE_CLIENT: "desktop", XDG_STATE_HOME: process.env.XDG_STATE_HOME ?? userDataPath, }) + return shellEnv } export async function spawnLocalServer(