فهرست منبع

feat(desktop): publish v2 beta desktop (#41626)

Luke Parker 4 روز پیش
والد
کامیت
c2b754bb03

+ 9 - 8
.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

+ 1 - 0
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:",

+ 20 - 20
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*"],
+  })
+})

+ 1 - 1
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/",

+ 1 - 0
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:",

+ 2 - 2
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")

+ 1 - 0
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")

+ 13 - 13
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) {

+ 23 - 48
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<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,
   }
 }
 
@@ -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) {

+ 2 - 2
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,

+ 2 - 3
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<boolean> {

+ 20 - 18
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`