mirror of
https://github.com/anomalyco/opencode.git
synced 2026-07-28 06:02:05 -04:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 9e432a6785 |
@@ -219,7 +219,6 @@ jobs:
|
||||
|
||||
build-electron:
|
||||
needs:
|
||||
- build-cli
|
||||
- version
|
||||
if: github.repository == 'anomalyco/opencode'
|
||||
continue-on-error: false
|
||||
@@ -316,10 +315,7 @@ jobs:
|
||||
env:
|
||||
OPENCODE_VERSION: ${{ needs.version.outputs.version }}
|
||||
OPENCODE_CHANNEL: ${{ (github.ref_name == 'beta' && 'beta') || 'prod' }}
|
||||
OPENCODE_CLI_ARTIFACT: ${{ (runner.os == 'Windows' && 'opencode-cli-windows') || 'opencode-cli' }}
|
||||
RUST_TARGET: ${{ matrix.settings.target }}
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
GITHUB_RUN_ID: ${{ github.run_id }}
|
||||
|
||||
- name: Build
|
||||
run: bun run build
|
||||
|
||||
@@ -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("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*"],
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
@@ -55,8 +55,17 @@ const getBase = (appId: string): Configuration => ({
|
||||
extraMetadata: {
|
||||
desktopName: `${appId}.desktop`,
|
||||
},
|
||||
files: ["out/**/*", "resources/**/*"],
|
||||
files: ["out/**/*", "resources/**/*", "!resources/opencode-cli*"],
|
||||
extraResources: [
|
||||
...(channel === "dev"
|
||||
? [
|
||||
{
|
||||
from: "resources/",
|
||||
to: "",
|
||||
filter: ["opencode-cli*"],
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{
|
||||
from: "native/",
|
||||
to: "native/",
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
#!/usr/bin/env bun
|
||||
import { $ } from "bun"
|
||||
|
||||
import { resolveChannel } from "./utils"
|
||||
import { downloadCliToResources, resolveChannel } from "./utils"
|
||||
|
||||
const channel = resolveChannel()
|
||||
await $`bun ./scripts/copy-icons.ts ${channel}`
|
||||
await $`bun ./scripts/copy-metainfo.ts ${channel}`
|
||||
|
||||
await $`cd ../opencode && bun script/build-node.ts`
|
||||
if (channel === "dev") await downloadCliToResources()
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { $ } from "bun"
|
||||
import { downloadCliToResources } from "./utils"
|
||||
|
||||
await $`bun run install-electron`
|
||||
|
||||
await $`bun ./scripts/copy-icons.ts ${process.env.OPENCODE_CHANNEL ?? "dev"}`
|
||||
|
||||
await $`cd ../opencode && bun script/build-node.ts`
|
||||
await downloadCliToResources()
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
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"
|
||||
|
||||
@@ -8,36 +13,42 @@ export function resolveChannel(): Channel {
|
||||
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",
|
||||
ocBinary: "opencode-darwin-arm64",
|
||||
assetExt: "zip",
|
||||
package: "@opencode-ai/cli-darwin-arm64",
|
||||
os: "darwin",
|
||||
cpu: "arm64",
|
||||
},
|
||||
{
|
||||
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",
|
||||
ocBinary: "opencode-windows-arm64",
|
||||
assetExt: "zip",
|
||||
package: "@opencode-ai/cli-windows-arm64",
|
||||
os: "win32",
|
||||
cpu: "arm64",
|
||||
},
|
||||
{
|
||||
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",
|
||||
ocBinary: "opencode-linux-x64-baseline",
|
||||
assetExt: "tar.gz",
|
||||
package: "@opencode-ai/cli-linux-x64-baseline",
|
||||
os: "linux",
|
||||
cpu: "x64",
|
||||
},
|
||||
{
|
||||
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}`)
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
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") {
|
||||
await $`pwsh -NoLogo -NoProfile -ExecutionPolicy Bypass -File ../../script/sign-windows.ps1 ${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) {
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
@@ -48,6 +48,7 @@ import { registerWslIpcHandlers } from "./wsl/ipc"
|
||||
import { spawnWslSidecar } from "./wsl/sidecar"
|
||||
import { migrate } from "./migrate"
|
||||
import { cleanupStoreFiles } from "./store-cleanup"
|
||||
import { startBackgroundCli } from "./background-cli"
|
||||
|
||||
const APP_NAMES: Record<string, string> = {
|
||||
dev: "OpenCode Dev",
|
||||
@@ -60,6 +61,7 @@ const APP_IDS: Record<string, string> = {
|
||||
prod: "ai.opencode.desktop",
|
||||
}
|
||||
const TEST_ONBOARDING = process.env.OPENCODE_TEST_ONBOARDING === "1"
|
||||
const SIDECAR_VERSION = process.env.OPENCODE_SIDECAR_V2 === "1" ? "v2" : "v1"
|
||||
const jsCallStackFeature = "DocumentPolicyIncludeJSCallStacksInCrashReports"
|
||||
|
||||
let logger: ReturnType<typeof initLogging>
|
||||
@@ -198,7 +200,7 @@ const main = Effect.gen(function* () {
|
||||
return
|
||||
}
|
||||
|
||||
preferAppEnv(app.getPath("userData"))
|
||||
const shellEnv = preferAppEnv(app.getPath("userData"))
|
||||
|
||||
app.on("second-instance", (_event: Event, argv: string[]) => {
|
||||
const urls = argv.filter((arg: string) => arg.startsWith("opencode://"))
|
||||
@@ -312,39 +314,56 @@ 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 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
|
||||
}
|
||||
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()
|
||||
|
||||
const loadingTask = yield* Effect.gen(function* () {
|
||||
logger.log("sidecar connection started", { url })
|
||||
logger.log("sidecar connection started", { version: SIDECAR_VERSION })
|
||||
|
||||
ensureLoopbackNoProxy()
|
||||
useEnvProxy()
|
||||
|
||||
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))
|
||||
}
|
||||
|
||||
logger.log("loading task finished")
|
||||
return
|
||||
}
|
||||
|
||||
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 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 })
|
||||
const { listener, health } = yield* Effect.promise(() =>
|
||||
spawnLocalServer(hostname, port, password, {
|
||||
|
||||
@@ -43,13 +43,15 @@ export function setDefaultServerUrl(url: string | null) {
|
||||
|
||||
export function preferAppEnv(userDataPath: string) {
|
||||
const shell = process.platform === "win32" ? null : getUserShell()
|
||||
const shellEnv = shell ? loadShellEnv(shell, getLogger()) : null
|
||||
Object.assign(process.env, {
|
||||
...(shell ? loadShellEnv(shell, getLogger()) : null),
|
||||
...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 spawnLocalServer(
|
||||
|
||||
Reference in New Issue
Block a user