Sfoglia il codice sorgente

feat(desktop): add opt-in v2 sidecar (#39286)

Brendan Allan 1 mese fa
parent
commit
9e432a6785

+ 0 - 4
.github/workflows/publish.yml

@@ -219,7 +219,6 @@ jobs:
 
 
   build-electron:
   build-electron:
     needs:
     needs:
-      - build-cli
       - version
       - version
     if: github.repository == 'anomalyco/opencode'
     if: github.repository == 'anomalyco/opencode'
     continue-on-error: false
     continue-on-error: false
@@ -316,10 +315,7 @@ jobs:
         env:
         env:
           OPENCODE_VERSION: ${{ needs.version.outputs.version }}
           OPENCODE_VERSION: ${{ needs.version.outputs.version }}
           OPENCODE_CHANNEL: ${{ (github.ref_name == 'beta' && 'beta') || 'prod' }}
           OPENCODE_CHANNEL: ${{ (github.ref_name == 'beta' && 'beta') || 'prod' }}
-          OPENCODE_CLI_ARTIFACT: ${{ (runner.os == 'Windows' && 'opencode-cli-windows') || 'opencode-cli' }}
           RUST_TARGET: ${{ matrix.settings.target }}
           RUST_TARGET: ${{ matrix.settings.target }}
-          GH_TOKEN: ${{ github.token }}
-          GITHUB_RUN_ID: ${{ github.run_id }}
 
 
       - name: Build
       - name: Build
         run: bun run build
         run: bun run build

+ 33 - 0
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("StartupWMClass=ai.opencode.desktop")
   expect(desktop).toContain("NoDisplay=true")
   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*"],
+    })
+  })
+}

+ 10 - 1
packages/desktop/electron-builder.config.ts

@@ -55,8 +55,17 @@ const getBase = (appId: string): Configuration => ({
   extraMetadata: {
   extraMetadata: {
     desktopName: `${appId}.desktop`,
     desktopName: `${appId}.desktop`,
   },
   },
-  files: ["out/**/*", "resources/**/*"],
+  files: ["out/**/*", "resources/**/*", "!resources/opencode-cli*"],
   extraResources: [
   extraResources: [
+    ...(channel === "dev"
+      ? [
+          {
+            from: "resources/",
+            to: "",
+            filter: ["opencode-cli*"],
+          },
+        ]
+      : []),
     {
     {
       from: "native/",
       from: "native/",
       to: "native/",
       to: "native/",

+ 2 - 1
packages/desktop/scripts/prebuild.ts

@@ -1,10 +1,11 @@
 #!/usr/bin/env bun
 #!/usr/bin/env bun
 import { $ } from "bun"
 import { $ } from "bun"
 
 
-import { resolveChannel } from "./utils"
+import { downloadCliToResources, resolveChannel } from "./utils"
 
 
 const channel = resolveChannel()
 const channel = resolveChannel()
 await $`bun ./scripts/copy-icons.ts ${channel}`
 await $`bun ./scripts/copy-icons.ts ${channel}`
 await $`bun ./scripts/copy-metainfo.ts ${channel}`
 await $`bun ./scripts/copy-metainfo.ts ${channel}`
 
 
 await $`cd ../opencode && bun script/build-node.ts`
 await $`cd ../opencode && bun script/build-node.ts`
+if (channel === "dev") await downloadCliToResources()

+ 2 - 0
packages/desktop/scripts/predev.ts

@@ -1,7 +1,9 @@
 import { $ } from "bun"
 import { $ } from "bun"
+import { downloadCliToResources } from "./utils"
 
 
 await $`bun run install-electron`
 await $`bun run install-electron`
 
 
 await $`bun ./scripts/copy-icons.ts ${process.env.OPENCODE_CHANNEL ?? "dev"}`
 await $`bun ./scripts/copy-icons.ts ${process.env.OPENCODE_CHANNEL ?? "dev"}`
 
 
 await $`cd ../opencode && bun script/build-node.ts`
 await $`cd ../opencode && bun script/build-node.ts`
+await downloadCliToResources()

+ 42 - 22
packages/desktop/scripts/utils.ts

@@ -1,4 +1,9 @@
 import { $ } from "bun"
 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"
 export type Channel = "dev" | "beta" | "prod"
 
 
@@ -8,36 +13,42 @@ export function resolveChannel(): Channel {
   return "dev"
   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",
     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",
     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",
     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",
     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",
     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",
     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}`)
   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
   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") {
   if (process.platform === "win32" && process.env.GITHUB_ACTIONS === "true") {
     await $`pwsh -NoLogo -NoProfile -ExecutionPolicy Bypass -File ../../script/sign-windows.ps1 ${dest}`
     await $`pwsh -NoLogo -NoProfile -ExecutionPolicy Bypass -File ../../script/sign-windows.ps1 ${dest}`
   }
   }
   if (process.platform === "darwin") await $`codesign --force --sign - ${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) {
 export function windowsify(path: string) {

+ 125 - 0
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<string, unknown>): void
+  error(message: string, meta?: Record<string, unknown>): 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"
+}

+ 47 - 28
packages/desktop/src/main/index.ts

@@ -48,6 +48,7 @@ import { registerWslIpcHandlers } from "./wsl/ipc"
 import { spawnWslSidecar } from "./wsl/sidecar"
 import { spawnWslSidecar } from "./wsl/sidecar"
 import { migrate } from "./migrate"
 import { migrate } from "./migrate"
 import { cleanupStoreFiles } from "./store-cleanup"
 import { cleanupStoreFiles } from "./store-cleanup"
+import { startBackgroundCli } from "./background-cli"
 
 
 const APP_NAMES: Record<string, string> = {
 const APP_NAMES: Record<string, string> = {
   dev: "OpenCode Dev",
   dev: "OpenCode Dev",
@@ -60,6 +61,7 @@ const APP_IDS: Record<string, string> = {
   prod: "ai.opencode.desktop",
   prod: "ai.opencode.desktop",
 }
 }
 const TEST_ONBOARDING = process.env.OPENCODE_TEST_ONBOARDING === "1"
 const TEST_ONBOARDING = process.env.OPENCODE_TEST_ONBOARDING === "1"
+const SIDECAR_VERSION = process.env.OPENCODE_SIDECAR_V2 === "1" ? "v2" : "v1"
 const jsCallStackFeature = "DocumentPolicyIncludeJSCallStacksInCrashReports"
 const jsCallStackFeature = "DocumentPolicyIncludeJSCallStacksInCrashReports"
 
 
 let logger: ReturnType<typeof initLogging>
 let logger: ReturnType<typeof initLogging>
@@ -198,7 +200,7 @@ const main = Effect.gen(function* () {
     return
     return
   }
   }
 
 
-  preferAppEnv(app.getPath("userData"))
+  const shellEnv = preferAppEnv(app.getPath("userData"))
 
 
   app.on("second-instance", (_event: Event, argv: string[]) => {
   app.on("second-instance", (_event: Event, argv: string[]) => {
     const urls = argv.filter((arg: string) => arg.startsWith("opencode://"))
     const urls = argv.filter((arg: string) => arg.startsWith("opencode://"))
@@ -312,38 +314,55 @@ 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 loadingTask = yield* Effect.gen(function* () {
+    logger.log("sidecar connection started", { version: SIDECAR_VERSION })
+
+    ensureLoopbackNoProxy()
+    useEnvProxy()
 
 
-    const res = yield* Deferred.make<number, unknown>()
-    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
+    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))
       }
       }
-      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()
+      logger.log("loading task finished")
+      return
+    }
 
 
-  const loadingTask = yield* Effect.gen(function* () {
-    logger.log("sidecar connection started", { url })
+    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
+      }
 
 
-    ensureLoopbackNoProxy()
-    useEnvProxy()
+      const res = yield* Deferred.make<number, unknown>()
+      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 })
     logger.log("spawning sidecar", { url })
     const { listener, health } = yield* Effect.promise(() =>
     const { listener, health } = yield* Effect.promise(() =>

+ 3 - 1
packages/desktop/src/main/server.ts

@@ -43,13 +43,15 @@ export function setDefaultServerUrl(url: string | null) {
 
 
 export function preferAppEnv(userDataPath: string) {
 export function preferAppEnv(userDataPath: string) {
   const shell = process.platform === "win32" ? null : getUserShell()
   const shell = process.platform === "win32" ? null : getUserShell()
+  const shellEnv = shell ? loadShellEnv(shell, getLogger()) : null
   Object.assign(process.env, {
   Object.assign(process.env, {
-    ...(shell ? loadShellEnv(shell, getLogger()) : null),
+    ...shellEnv,
     OPENCODE_EXPERIMENTAL_ICON_DISCOVERY: "true",
     OPENCODE_EXPERIMENTAL_ICON_DISCOVERY: "true",
     OPENCODE_EXPERIMENTAL_FILEWATCHER: "true",
     OPENCODE_EXPERIMENTAL_FILEWATCHER: "true",
     OPENCODE_CLIENT: "desktop",
     OPENCODE_CLIENT: "desktop",
     XDG_STATE_HOME: process.env.XDG_STATE_HOME ?? userDataPath,
     XDG_STATE_HOME: process.env.XDG_STATE_HOME ?? userDataPath,
   })
   })
+  return shellEnv
 }
 }
 
 
 export async function spawnLocalServer(
 export async function spawnLocalServer(