From c2b754bb0362a19af600e4278dbed24acb3e6598 Mon Sep 17 00:00:00 2001 From: Luke Parker <10430890+Hona@users.noreply.github.com> Date: Tue, 11 Aug 2026 14:33:31 +1000 Subject: [PATCH] feat(desktop): publish v2 beta desktop (#41626) --- .github/workflows/publish.yml | 17 ++--- bun.lock | 1 + .../desktop/electron-builder.config.test.ts | 40 +++++------ packages/desktop/electron-builder.config.ts | 2 +- packages/desktop/package.json | 1 + packages/desktop/scripts/copy-bundles.ts | 4 +- packages/desktop/scripts/prebuild.ts | 1 + packages/desktop/scripts/utils.ts | 26 +++---- packages/desktop/src/main/background-cli.ts | 71 ++++++------------- packages/desktop/src/main/index.ts | 4 +- packages/desktop/src/main/server.ts | 5 +- script/publish.ts | 38 +++++----- 12 files changed, 95 insertions(+), 115 deletions(-) diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index f18b792959..8816668830 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -91,7 +91,7 @@ jobs: opencode-app-secret: ${{ secrets.OPENCODE_APP_SECRET }} - name: Build legacy CLI - if: github.ref_name != 'v2' + if: github.ref_name != 'v2' && github.ref_name != 'beta' run: ./packages/opencode/script/build.ts ${{ (github.ref_name == 'beta' && '--sourcemaps') || '' }} env: OPENCODE_VERSION: ${{ needs.version.outputs.version }} @@ -109,7 +109,7 @@ jobs: GH_TOKEN: ${{ steps.committer.outputs.token }} - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 - if: github.ref_name != 'v2' + if: github.ref_name != 'v2' && github.ref_name != 'beta' with: name: opencode-cli path: | @@ -117,7 +117,7 @@ jobs: packages/opencode/dist/opencode-linux* - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 - if: github.ref_name != 'v2' + if: github.ref_name != 'v2' && github.ref_name != 'beta' with: name: opencode-cli-windows path: packages/opencode/dist/opencode-windows* @@ -184,7 +184,7 @@ jobs: - build-cli - version runs-on: blacksmith-4vcpu-windows-2025 - if: github.repository == 'anomalyco/opencode' && github.ref_name != 'v2' + if: github.repository == 'anomalyco/opencode' && github.ref_name != 'v2' && github.ref_name != 'beta' env: AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }} AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }} @@ -377,7 +377,7 @@ jobs: env: OPENCODE_VERSION: ${{ needs.version.outputs.version }} OPENCODE_CHANNEL: ${{ (github.ref_name == 'beta' && 'beta') || 'prod' }} - RUST_TARGET: ${{ matrix.settings.target }} + OPENCODE_CLI_TARGET: ${{ matrix.settings.target }} - name: Build run: bun run build @@ -393,6 +393,7 @@ jobs: VITE_SENTRY_DSN: ${{ vars.WEB_SENTRY_DSN }} VITE_SENTRY_ENVIRONMENT: ${{ (github.ref_name == 'beta' && 'beta') || 'production' }} VITE_SENTRY_RELEASE: desktop@${{ needs.version.outputs.version }} + OPENCODE_CLI_TARGET: ${{ matrix.settings.target }} - name: Package if: needs.version.outputs.release @@ -496,19 +497,19 @@ jobs: registry-url: "https://registry.npmjs.org" - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 - if: github.ref_name != 'v2' + if: github.ref_name != 'v2' && github.ref_name != 'beta' with: name: opencode-cli path: packages/opencode/dist - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 - if: github.ref_name != 'v2' + if: github.ref_name != 'v2' && github.ref_name != 'beta' with: name: opencode-cli-windows path: packages/opencode/dist - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 - if: github.ref_name != 'v2' + if: github.ref_name != 'v2' && github.ref_name != 'beta' with: name: opencode-cli-signed-windows path: packages/opencode/dist diff --git a/bun.lock b/bun.lock index 12dc5a491d..12b5771fa7 100644 --- a/bun.lock +++ b/bun.lock @@ -439,6 +439,7 @@ "@actions/artifact": "4.0.0", "@lydell/node-pty": "catalog:", "@opencode-ai/app": "workspace:*", + "@opencode-ai/client": "workspace:*", "@opencode-ai/ui": "workspace:*", "@sentry/solid": "catalog:", "@sentry/vite-plugin": "catalog:", diff --git a/packages/desktop/electron-builder.config.test.ts b/packages/desktop/electron-builder.config.test.ts index 3fb1adb6c1..d143117f04 100644 --- a/packages/desktop/electron-builder.config.test.ts +++ b/packages/desktop/electron-builder.config.test.ts @@ -57,35 +57,35 @@ test("keeps a hidden prod launcher for old Linux pins", async () => { 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 () => { +for (const channel of ["dev", "beta"] as const) { + test(`bundles the CLI outside the ${channel} app archive`, 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 module = await import(`./electron-builder.config.ts?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({ + expect(config.files).toContain("!resources/opencode-cli*") + expect(config.extraResources).toContainEqual({ from: "resources/", to: "", filter: ["opencode-cli*"], }) }) } + +test("does not bundle the CLI in prod builds", async () => { + const previous = process.env.OPENCODE_CHANNEL + process.env.OPENCODE_CHANNEL = "prod" + const module = await import("./electron-builder.config.ts?no-cli-resource=prod") + 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 508c0df5e9..e4cf6f423c 100644 --- a/packages/desktop/electron-builder.config.ts +++ b/packages/desktop/electron-builder.config.ts @@ -57,7 +57,7 @@ const getBase = (appId: string): Configuration => ({ }, files: ["out/**/*", "resources/**/*", "!resources/opencode-cli*"], extraResources: [ - ...(channel === "dev" + ...(channel !== "prod" ? [ { from: "resources/", diff --git a/packages/desktop/package.json b/packages/desktop/package.json index 47550938e3..390b7c704e 100644 --- a/packages/desktop/package.json +++ b/packages/desktop/package.json @@ -37,6 +37,7 @@ "@actions/artifact": "4.0.0", "@lydell/node-pty": "catalog:", "@opencode-ai/app": "workspace:*", + "@opencode-ai/client": "workspace:*", "@opencode-ai/ui": "workspace:*", "@sentry/solid": "catalog:", "@sentry/vite-plugin": "catalog:", diff --git a/packages/desktop/scripts/copy-bundles.ts b/packages/desktop/scripts/copy-bundles.ts index 6ef3335eb7..edc1462c09 100644 --- a/packages/desktop/scripts/copy-bundles.ts +++ b/packages/desktop/scripts/copy-bundles.ts @@ -1,9 +1,9 @@ import { $ } from "bun" import * as path from "node:path" -import { RUST_TARGET } from "./utils" +import { CLI_TARGET } from "./utils" -if (!RUST_TARGET) throw new Error("RUST_TARGET not defined") +if (!CLI_TARGET) throw new Error("OPENCODE_CLI_TARGET not defined") const BUNDLE_DIR = "dist" const BUNDLES_OUT_DIR = path.join(process.cwd(), "dist/bundles") diff --git a/packages/desktop/scripts/prebuild.ts b/packages/desktop/scripts/prebuild.ts index 8d1041180a..72f3b5a30a 100644 --- a/packages/desktop/scripts/prebuild.ts +++ b/packages/desktop/scripts/prebuild.ts @@ -8,3 +8,4 @@ await $`bun ./scripts/copy-icons.ts ${channel}` await $`bun ./scripts/copy-metainfo.ts ${channel}` if (channel === "dev") await downloadCliToResources() +if (channel === "beta") await downloadCliToResources("next") diff --git a/packages/desktop/scripts/utils.ts b/packages/desktop/scripts/utils.ts index e5f87491e1..34b0405d9f 100644 --- a/packages/desktop/scripts/utils.ts +++ b/packages/desktop/scripts/utils.ts @@ -13,46 +13,46 @@ export function resolveChannel(): Channel { return "dev" } -export const CLI_BINARIES: Array<{ rustTarget: string; package: string; os: string; cpu: string }> = [ +export const CLI_BINARIES: Array<{ target: string; package: string; os: string; cpu: string }> = [ { - rustTarget: "aarch64-apple-darwin", + target: "aarch64-apple-darwin", package: "@opencode-ai/cli-darwin-arm64", os: "darwin", cpu: "arm64", }, { - rustTarget: "x86_64-apple-darwin", + target: "x86_64-apple-darwin", package: "@opencode-ai/cli-darwin-x64-baseline", os: "darwin", cpu: "x64", }, { - rustTarget: "aarch64-pc-windows-msvc", + target: "aarch64-pc-windows-msvc", package: "@opencode-ai/cli-windows-arm64", os: "win32", cpu: "arm64", }, { - rustTarget: "x86_64-pc-windows-msvc", + target: "x86_64-pc-windows-msvc", package: "@opencode-ai/cli-windows-x64-baseline", os: "win32", cpu: "x64", }, { - rustTarget: "x86_64-unknown-linux-gnu", + target: "x86_64-unknown-linux-gnu", package: "@opencode-ai/cli-linux-x64-baseline", os: "linux", cpu: "x64", }, { - rustTarget: "aarch64-unknown-linux-gnu", + target: "aarch64-unknown-linux-gnu", package: "@opencode-ai/cli-linux-arm64", os: "linux", cpu: "arm64", }, ] -export const RUST_TARGET = Bun.env.RUST_TARGET +export const CLI_TARGET = Bun.env.OPENCODE_CLI_TARGET function nativeTarget() { const { platform, arch } = process @@ -62,19 +62,19 @@ function nativeTarget() { throw new Error(`Unsupported platform: ${platform}/${arch}`) } -export function getCurrentCli(target = RUST_TARGET ?? nativeTarget()) { - const binaryConfig = CLI_BINARIES.find((item) => item.rustTarget === target) +export function getCurrentCli(target = CLI_TARGET ?? nativeTarget()) { + const binaryConfig = CLI_BINARIES.find((item) => item.target === target) if (!binaryConfig) throw new Error(`CLI configuration not available for target '${target}'`) return binaryConfig } -export async function downloadCliToResources() { +export async function downloadCliToResources(version = CLI_VERSION) { 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 $`bun install --no-save --cwd ${directory} ${`${cli.package}@${version}`} ${`--os=${cli.os}`} ${`--cpu=${cli.cpu}`}` await copyFile( join(directory, "node_modules", cli.package, "bin", cli.os === "win32" ? "opencode2.exe" : "opencode2"), dest, @@ -88,7 +88,7 @@ export async function downloadCliToResources() { } if (process.platform === "darwin") await $`codesign --force --sign - ${dest}` - console.log(`Copied ${cli.package} to ${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 66602d51f1..ec37429a4b 100644 --- a/packages/desktop/src/main/background-cli.ts +++ b/packages/desktop/src/main/background-cli.ts @@ -1,3 +1,4 @@ +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" @@ -8,52 +9,34 @@ 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) { +export async function startBackgroundCli(logger: Logger) { 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 version = parseVersion(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, + const service = await Service.ensure({ + version, + command: [binary, "serve", "--service"], + onStart: (reason, previousVersion) => logger.log("v2 CLI background service starting", { reason, previousVersion }), }) + if (service.auth?.type !== "basic") throw new Error("V2 CLI background service did not provide authentication") logger.log("v2 CLI background service ready", { - existing: Boolean(found), - username: "opencode", - ...endpoint(url), + username: service.auth.username, + version, + ...endpoint(service.url), }) return { - url, - username: "opencode", - password, + url: service.url, + username: service.auth.username, + password: service.auth.password, } } @@ -77,21 +60,13 @@ async function installCli(source: string, version: string, logger: Logger) { return destination } -async function run( - binary: string, - args: string[], - logger: Logger, - options: { redact?: boolean; stateHome?: string } = {}, -) { +async function run(binary: string, args: string[], logger: Logger) { 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( + return execFileAsync(binary, args, { 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 }) + logger.log("v2 CLI command completed", { args, stdout, stderr }) return stdout }, (error: unknown) => { @@ -99,7 +74,7 @@ async function run( logger.error("v2 CLI command failed", { args, error: error instanceof Error ? error.message : String(error), - stdout: options.redact && output.stdout ? "[redacted]" : (output.stdout?.trim() ?? ""), + stdout: output.stdout?.trim() ?? "", stderr: output.stderr?.trim() ?? "", }) throw error @@ -107,11 +82,11 @@ async function run( ) } -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 parseVersion(output: string) { + const marker = output.lastIndexOf(" v") + const version = marker === -1 ? output : output.slice(marker + 2) + if (!version) throw new Error("V2 CLI did not provide a version") + return version } function endpoint(url: string | undefined) { diff --git a/packages/desktop/src/main/index.ts b/packages/desktop/src/main/index.ts index 0cb08b1945..3dd3157126 100644 --- a/packages/desktop/src/main/index.ts +++ b/packages/desktop/src/main/index.ts @@ -181,7 +181,7 @@ const main = Effect.gen(function* () { return } - const shellEnv = preferAppEnv(app.getPath("userData")) + preferAppEnv() app.on("second-instance", (_event: Event, argv: string[]) => { const urls = argv.filter((arg: string) => arg.startsWith("opencode://")) @@ -310,7 +310,7 @@ const main = Effect.gen(function* () { useEnvProxy() logger.log("starting v2 background service") - const sidecar = yield* Effect.promise(() => startBackgroundCli(logger, shellEnv?.XDG_STATE_HOME)) + const sidecar = yield* Effect.promise(() => startBackgroundCli(logger)) yield* Deferred.succeed(serverReady, { url: sidecar.url, username: sidecar.username, diff --git a/packages/desktop/src/main/server.ts b/packages/desktop/src/main/server.ts index 36f58772b4..0242d38806 100644 --- a/packages/desktop/src/main/server.ts +++ b/packages/desktop/src/main/server.ts @@ -17,17 +17,16 @@ export function setDefaultServerUrl(url: string | null) { getStore().delete(DEFAULT_SERVER_URL_KEY) } -export function preferAppEnv(userDataPath: string) { +export function preferAppEnv() { const shell = process.platform === "win32" ? null : getUserShell() const shellEnv = shell ? loadShellEnv(shell, getLogger()) : null + if (!shellEnv?.XDG_STATE_HOME) delete process.env.XDG_STATE_HOME Object.assign(process.env, { ...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 checkHealth(url: string, password?: string | null): Promise { diff --git a/script/publish.ts b/script/publish.ts index 4baf6b03b7..7c2590ab9b 100755 --- a/script/publish.ts +++ b/script/publish.ts @@ -35,32 +35,34 @@ if (Script.release && !Script.preview) { await prepareReleaseFiles() -console.log("\n=== schema ===\n") -await $`bun ./packages/schema/script/publish.ts` +if (Script.channel !== "beta") { + console.log("\n=== schema ===\n") + await $`bun ./packages/schema/script/publish.ts` -console.log("\n=== theme ===\n") -await $`bun ./packages/theme/script/publish.ts` + console.log("\n=== theme ===\n") + await $`bun ./packages/theme/script/publish.ts` -console.log("\n=== ai ===\n") -await $`bun ./packages/ai/script/publish.ts` + console.log("\n=== ai ===\n") + await $`bun ./packages/ai/script/publish.ts` -console.log("\n=== util ===\n") -await $`bun ./packages/util/script/publish.ts` + console.log("\n=== util ===\n") + await $`bun ./packages/util/script/publish.ts` -console.log("\n=== protocol ===\n") -await $`bun ./packages/protocol/script/publish.ts` + console.log("\n=== protocol ===\n") + await $`bun ./packages/protocol/script/publish.ts` -console.log("\n=== client ===\n") -await $`bun ./packages/client/script/publish.ts` + console.log("\n=== client ===\n") + await $`bun ./packages/client/script/publish.ts` -console.log("\n=== cli ===\n") -await $`bun ./packages/cli/script/publish.ts` + console.log("\n=== cli ===\n") + await $`bun ./packages/cli/script/publish.ts` -console.log("\n=== plugin ===\n") -await $`bun ./packages/plugin/script/publish.ts` + console.log("\n=== plugin ===\n") + await $`bun ./packages/plugin/script/publish.ts` -console.log("\n=== ui ===\n") -await $`bun ./packages/ui/script/publish.ts` + console.log("\n=== ui ===\n") + await $`bun ./packages/ui/script/publish.ts` +} if (Script.release) { await $`bun ./packages/desktop/scripts/finalize-latest-json.ts`