fix(desktop): bundle the CLI in production releases (#46705)

This commit is contained in:
Luke Parker 2026-09-02 12:15:25 +10:00 committed by GitHub
parent 0b771030ed
commit ddda404d99
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 56 additions and 26 deletions

View file

@ -417,7 +417,6 @@ jobs:
- uses: actions/checkout@f43a0e5ff2bd294095638e18286ca9a3d1956744 # v3.6.0
- uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
if: github.ref_name == 'beta'
with:
name: opencode-preview-cli
path: packages/cli/dist
@ -480,7 +479,7 @@ jobs:
OPENCODE_VERSION: ${{ needs.version.outputs.version }}
OPENCODE_CHANNEL: ${{ (github.ref_name == 'beta' && 'beta') || 'prod' }}
OPENCODE_CLI_TARGET: ${{ matrix.settings.target }}
OPENCODE_CLI_DIST: ${{ (github.ref_name == 'beta' && format('{0}/packages/cli/dist', github.workspace)) || '' }}
OPENCODE_CLI_DIST: ${{ github.workspace }}/packages/cli/dist
- name: Build
run: bun run build

View file

@ -87,6 +87,11 @@ stdenv.mkDerivation (finalAttrs: {
cd packages/desktop
export OPENCODE_CLI_DIST="$TMPDIR/desktop-cli"
cli_package=$(bun -e 'import { getCurrentCli } from "./scripts/utils.ts"; console.log(getCurrentCli().package.replace("@opencode-ai/", ""))')
mkdir -p "$OPENCODE_CLI_DIST/$cli_package/bin"
cp ${lib.getExe opencode} "$OPENCODE_CLI_DIST/$cli_package/bin/opencode2"
bun run build
npx electron-builder --dir \
--config electron-builder.config.ts \

View file

@ -17,3 +17,25 @@ bundle the assets as an application. The resulting app will be in `dist/`.
```bash
bun run build && bun run package
```
Production builds require a prebuilt V2 CLI distribution. The release workflow supplies the artifact from the same run:
```bash
OPENCODE_CHANNEL=prod OPENCODE_CLI_DIST=/absolute/path/to/packages/cli/dist bun run build
OPENCODE_CHANNEL=prod bun run package
```
Set `OPENCODE_CLI_TARGET` when packaging for a different architecture. The CLI is placed outside `app.asar` in the
application's resources directory, and packaging fails if it is missing.
CLI preparation uses these channel rules:
| Channel | Without `OPENCODE_CLI_DIST` | With `OPENCODE_CLI_DIST` |
| -------------------------------------- | ------------------------------ | --------------------------------------------- |
| `dev`, `local`, unset, or unrecognized | Download the dev CLI | Download the dev CLI; ignore the distribution |
| `beta` | Download the beta CLI | Copy the supplied CLI; fail if it is missing |
| `prod`, `latest` | Fail before changing resources | Copy the supplied CLI; fail if it is missing |
`bun dev` is separate from packaging: it uses local renderer/server mode, the dev app identity, and the CLI source by
default. `bun dev --download-server <version>` instead downloads that CLI version for local development. Neither path
requires `OPENCODE_CLI_DIST` or runs the production prebuild.

View file

@ -190,19 +190,8 @@ for (const channel of ["dev", "beta"] as const) {
{
from: "resources/",
to: "",
filter: ["opencode-cli*"],
filter: ["opencode-cli", "opencode-cli.exe"],
},
])
})
}
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).toEqual([])
})

View file

@ -1,4 +1,5 @@
import { execFile } from "node:child_process"
import { stat } from "node:fs/promises"
import path from "node:path"
import { fileURLToPath } from "node:url"
import { promisify } from "node:util"
@ -45,6 +46,7 @@ export function macSignOptions(options: CustomMacSignOptions): CustomMacSignOpti
const channel = (() => {
const raw = process.env.OPENCODE_CHANNEL
if (raw === "dev" || raw === "beta" || raw === "prod") return raw
if (raw === "latest") return "prod"
return "dev"
})()
@ -84,16 +86,21 @@ const getBase = (appId: string): Configuration => ({
"!**/node_modules/js-yaml/dist/{js-yaml.js,js-yaml.min.js,*.map}",
"!**/node_modules/js-yaml/bin{,/**/*}",
],
extraResources:
channel !== "prod"
? [
{
from: "resources/",
to: "",
filter: ["opencode-cli*"],
},
]
: [],
extraResources: [
{
from: "resources/",
to: "",
filter: ["opencode-cli", "opencode-cli.exe"],
},
],
afterPack: async (context) => {
const cli = path.join(
context.packager.getResourcesDir(context.appOutDir),
context.electronPlatformName === "win32" ? "opencode-cli.exe" : "opencode-cli",
)
const file = await stat(cli)
if (!file.isFile() || file.size === 0) throw new Error(`Bundled CLI must be a non-empty file: ${cli}`)
},
mac: {
category: "public.app-category.developer-tools",
icon: `resources/icons/icon.icns`,

View file

@ -34,7 +34,8 @@ export default defineConfig(({ command }) => ({
dedupe: ["effect"],
},
define: {
"import.meta.env.OPENCODE_CHANNEL": JSON.stringify(channel),
// Local renderer/server mode still uses the dev application identity and updater policy.
"import.meta.env.OPENCODE_CHANNEL": JSON.stringify(channel === "local" ? "dev" : channel),
},
build: {
minify: command === "build",

View file

@ -4,9 +4,15 @@ import { $ } from "bun"
import { copyBuiltCliToResources, downloadCliToResources, resolveChannel } from "./utils"
const channel = resolveChannel()
if (channel === "prod" && !Bun.env.OPENCODE_CLI_DIST) {
throw new Error("OPENCODE_CLI_DIST is required for production desktop builds")
}
await $`bun ./scripts/copy-icons.ts ${channel}`
await $`bun ./scripts/copy-metainfo.ts ${channel}`
if (channel === "dev") await downloadCliToResources()
if (channel === "beta" && Bun.env.OPENCODE_CLI_DIST) await copyBuiltCliToResources(Bun.env.OPENCODE_CLI_DIST)
if ((channel === "beta" || channel === "prod") && Bun.env.OPENCODE_CLI_DIST) {
await copyBuiltCliToResources(Bun.env.OPENCODE_CLI_DIST)
}
if (channel === "beta" && !Bun.env.OPENCODE_CLI_DIST) await downloadCliToResources("beta")

View file

@ -10,6 +10,7 @@ export type Channel = "dev" | "beta" | "prod"
export function resolveChannel(): Channel {
const raw = Bun.env.OPENCODE_CHANNEL
if (raw === "dev" || raw === "beta" || raw === "prod") return raw
if (raw === "latest") return "prod"
return "dev"
}