Forráskód Böngészése

feat(desktop): support local server builds (#41486)

Luke Parker 4 napja
szülő
commit
1190ef3818

+ 1 - 2
packages/desktop/package.json

@@ -11,8 +11,7 @@
   },
   "scripts": {
     "typecheck": "tsgo -b",
-    "predev": "bun ./scripts/predev.ts",
-    "dev": "electron-vite dev",
+    "dev": "bun ./scripts/dev.ts",
     "prebuild": "bun ./scripts/prebuild.ts",
     "build": "electron-vite build",
     "preview": "electron-vite preview",

+ 56 - 0
packages/desktop/scripts/dev.ts

@@ -0,0 +1,56 @@
+import { $ } from "bun"
+import { homedir } from "node:os"
+import { join } from "node:path"
+import { buildCliToResources, downloadCliToResources, windowsify } from "./utils"
+
+type ServerSource = { type: "build" } | { type: "download"; version: string }
+type DevOptions = { server: ServerSource; electron: string[] }
+
+async function main() {
+  const options = selectOptions()
+  await prepareDesktop()
+  await prepareServer(options.server)
+  await startDesktop(options.electron)
+}
+
+async function prepareDesktop() {
+  await $`bun run install-electron`
+  await $`bun ./scripts/copy-icons.ts ${process.env.OPENCODE_CHANNEL ?? "dev"}`
+}
+
+function selectOptions(): DevOptions {
+  const args = process.argv.slice(2)
+  const build = args.indexOf("--build-server")
+  const download = args.indexOf("--download-server")
+  if (build >= 0 && download >= 0) {
+    throw new Error("--build-server and --download-server cannot be used together")
+  }
+  if (download >= 0 && !args[download + 1]) throw new Error("--download-server requires a version")
+  const consumed = new Set([build, download, download >= 0 ? download + 1 : -1])
+  return {
+    server: download >= 0 ? { type: "download", version: args[download + 1] } : { type: "build" },
+    electron: args.filter((_, index) => !consumed.has(index)),
+  }
+}
+
+async function prepareServer(source: ServerSource) {
+  const destination = windowsify("resources/opencode-cli-dev")
+  if (source.type === "download") return downloadCliToResources(source.version, destination)
+  return buildCliToResources(destination, developmentStateHome())
+}
+
+function developmentStateHome() {
+  const appData = (() => {
+    if (process.platform === "darwin") return join(homedir(), "Library", "Application Support")
+    if (process.platform === "win32") return process.env.APPDATA ?? join(homedir(), "AppData", "Roaming")
+    return process.env.XDG_CONFIG_HOME ?? join(homedir(), ".config")
+  })()
+  return join(appData, "ai.opencode.desktop.dev")
+}
+
+async function startDesktop(args: string[]) {
+  process.env.OPENCODE_DESKTOP_ISOLATED_SERVER = "1"
+  await $`electron-vite dev ${args}`
+}
+
+await main()

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

@@ -1,8 +0,0 @@
-import { $ } from "bun"
-import { downloadCliToResources } from "./utils"
-
-await $`bun run install-electron`
-
-await $`bun ./scripts/copy-icons.ts ${process.env.OPENCODE_CHANNEL ?? "dev"}`
-
-await downloadCliToResources()

+ 35 - 4
packages/desktop/scripts/utils.ts

@@ -69,10 +69,9 @@ export function getCurrentCli(target = CLI_TARGET ?? nativeTarget()) {
   return binaryConfig
 }
 
-export async function downloadCliToResources(version = CLI_VERSION) {
+export async function downloadCliToResources(version = CLI_VERSION, dest = windowsify("resources/opencode-cli")) {
   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}@${version}`} ${`--os=${cli.os}`} ${`--cpu=${cli.cpu}`}`
     await copyFile(
@@ -82,13 +81,45 @@ export async function downloadCliToResources(version = CLI_VERSION) {
   } finally {
     await rm(directory, { recursive: true, force: true })
   }
+  await prepareCli(dest)
+
+  console.log(`Copied ${cli.package}@${version} to ${dest}`)
+}
+
+export async function buildCliToResources(dest = windowsify("resources/opencode-cli"), stateHome?: string) {
+  const directory = await mkdtemp(join(tmpdir(), "opencode-cli-"))
+  const target = `cli-${process.platform === "win32" ? "windows" : process.platform}-${process.arch}`
+  try {
+    await $`bun ${join(import.meta.dirname, "../../cli/script/build.ts")} --single --skip-install --outdir=${directory}`.env(
+      {
+        ...process.env,
+        OPENCODE_VERSION: `0.0.0-local-${Date.now()}`,
+      },
+    )
+    if (stateHome && (await Bun.file(dest).exists())) {
+      const child = Bun.spawn([dest, "service", "stop"], {
+        env: { ...process.env, XDG_STATE_HOME: stateHome },
+        stdout: "inherit",
+        stderr: "inherit",
+      })
+      const exitCode = await child.exited
+      if (exitCode !== 0) throw new Error(`Failed to stop development service: ${exitCode}`)
+    }
+    await copyFile(join(directory, target, "bin", windowsify("opencode2")), dest)
+  } finally {
+    await rm(directory, { recursive: true, force: true })
+  }
+  await prepareCli(dest)
+
+  console.log(`Built local CLI at ${dest}`)
+}
+
+async function prepareCli(dest: string) {
   if (process.platform !== "win32") await chmod(dest, 0o755)
   if (process.platform === "win32" && process.env.GITHUB_ACTIONS === "true") {
     await $`pwsh -NoLogo -NoProfile -ExecutionPolicy Bypass -File ../../script/sign-windows.ps1 ${dest}`
   }
   if (process.platform === "darwin") await $`codesign --force --sign - ${dest}`
-
-  console.log(`Copied ${cli.package}@${version} to ${dest}`)
 }
 
 export function windowsify(path: string) {

+ 24 - 3
packages/desktop/src/main/background-cli.ts

@@ -1,7 +1,7 @@
 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"
+import { chmod, copyFile, mkdir, readdir, rename, rm } from "node:fs/promises"
 import { dirname, join } from "node:path"
 import { fileURLToPath } from "node:url"
 import { promisify } from "node:util"
@@ -16,12 +16,14 @@ type Logger = {
 }
 
 export async function startBackgroundCli(logger: Logger) {
+  const isolated = !app.isPackaged && process.env.OPENCODE_DESKTOP_ISOLATED_SERVER === "1"
   const bundled = app.isPackaged
     ? join(process.resourcesPath, executableName())
-    : join(root, "../../resources", executableName())
+    : join(root, "../../resources", isolated ? developmentExecutableName() : executableName())
   logger.log("v2 CLI executable resolved", { bundled, packaged: app.isPackaged })
   const version = parseVersion(await run(bundled, ["--version"], logger))
-  const binary = app.isPackaged ? await installCli(bundled, version, logger) : bundled
+  const binary = app.isPackaged || isolated ? await installCli(bundled, version, logger) : bundled
+  if (isolated) process.env.XDG_STATE_HOME = app.getPath("userData")
   const service = await Service.ensure({
     version,
     command: [binary, "serve", "--service"],
@@ -33,6 +35,7 @@ export async function startBackgroundCli(logger: Logger) {
     version,
     ...endpoint(service.url),
   })
+  if (isolated) await cleanCliStages(binary, logger)
   return {
     url: service.url,
     username: service.auth.username,
@@ -40,6 +43,20 @@ export async function startBackgroundCli(logger: Logger) {
   }
 }
 
+async function cleanCliStages(binary: string, logger: Logger) {
+  const current = dirname(binary)
+  const root = dirname(current)
+  await Promise.all(
+    (await readdir(root, { withFileTypes: true }))
+      .filter((entry) => entry.isDirectory() && join(root, entry.name) !== current)
+      .map((entry) =>
+        rm(join(root, entry.name), { recursive: true, force: true }).catch((error) =>
+          logger.error("failed to clean staged v2 CLI", { path: join(root, entry.name), error }),
+        ),
+      ),
+  )
+}
+
 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())
@@ -98,3 +115,7 @@ function endpoint(url: string | undefined) {
 function executableName() {
   return process.platform === "win32" ? "opencode-cli.exe" : "opencode-cli"
 }
+
+function developmentExecutableName() {
+  return process.platform === "win32" ? "opencode-cli-dev.exe" : "opencode-cli-dev"
+}