Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 9 additions & 8 deletions .github/workflows/publish.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Comment on lines 93 to 96
OPENCODE_VERSION: ${{ needs.version.outputs.version }}
Expand All @@ -109,15 +109,15 @@ 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: |
packages/opencode/dist/opencode-darwin*
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*
Expand Down Expand Up @@ -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 }}
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

40 changes: 20 additions & 20 deletions packages/desktop/electron-builder.config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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*"],
})
})
2 changes: 1 addition & 1 deletion packages/desktop/electron-builder.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@ const getBase = (appId: string): Configuration => ({
},
files: ["out/**/*", "resources/**/*", "!resources/opencode-cli*"],
extraResources: [
...(channel === "dev"
...(channel !== "prod"
? [
{
from: "resources/",
Expand Down
1 change: 1 addition & 0 deletions packages/desktop/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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:",
Expand Down
4 changes: 2 additions & 2 deletions packages/desktop/scripts/copy-bundles.ts
Original file line number Diff line number Diff line change
@@ -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")
Expand Down
1 change: 1 addition & 0 deletions packages/desktop/scripts/prebuild.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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")
26 changes: 13 additions & 13 deletions packages/desktop/scripts/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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,
Expand All @@ -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) {
Expand Down
71 changes: 23 additions & 48 deletions packages/desktop/src/main/background-cli.ts
Original file line number Diff line number Diff line change
@@ -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"
Expand All @@ -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<string, unknown>): void
error(message: string, meta?: Record<string, unknown>): 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,
}
}

Expand All @@ -77,41 +60,33 @@ 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) => {
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() ?? ""),
stdout: 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 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) {
Expand Down
Loading
Loading