From 098aaa1b84fbb8cc20e58b05068e0010b7e6fe68 Mon Sep 17 00:00:00 2001 From: Simon Klee Date: Thu, 16 Jul 2026 08:43:29 +0200 Subject: [PATCH] feat(cli): publish Node builds as opencode2-node --- .github/workflows/publish.yml | 54 ++++++++++++ .github/workflows/test.yml | 14 ++++ packages/cli/bin/opencode2.cjs | 11 ++- packages/cli/script/build-node.ts | 6 +- packages/cli/script/publish.ts | 82 +++++++++++-------- packages/cli/script/service-smoke.ts | 7 +- packages/cli/src/services/updater.ts | 7 +- packages/cli/vite.node.config.ts | 2 +- packages/core/package.json | 5 ++ packages/core/src/node-ffi.d.ts | 18 ++++ .../core/src/util/process-lock-ffi.bun.ts | 50 +++++++++++ .../core/src/util/process-lock-ffi.node.ts | 43 ++++++++++ packages/core/src/util/process-lock.ts | 54 +----------- 13 files changed, 258 insertions(+), 95 deletions(-) create mode 100644 packages/core/src/node-ffi.d.ts create mode 100644 packages/core/src/util/process-lock-ffi.bun.ts create mode 100644 packages/core/src/util/process-lock-ffi.node.ts diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 8981aad49a..5b02651cf4 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -121,6 +121,53 @@ jobs: outputs: version: ${{ needs.version.outputs.version }} + build-node-cli: + needs: version + if: github.repository == 'anomalyco/opencode' + strategy: + fail-fast: false + matrix: + settings: + - target: linux-arm64 + host: blacksmith-4vcpu-ubuntu-2404-arm + - target: linux-x64 + host: blacksmith-4vcpu-ubuntu-2404 + - target: darwin-arm64 + host: macos-26 + - target: windows-arm64 + host: windows-2025 + - target: windows-x64 + host: blacksmith-4vcpu-windows-2025 + runs-on: ${{ matrix.settings.host }} + defaults: + run: + shell: bash + steps: + - uses: actions/checkout@f43a0e5ff2bd294095638e18286ca9a3d1956744 # v3.6.0 + + - uses: ./.github/actions/setup-bun + + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 + with: + node-version: "26.4.0" + + - name: Build + run: bun packages/cli/script/build-node.ts --target=${{ matrix.settings.target }} --outdir=dist/node + env: + OPENCODE_VERSION: ${{ needs.version.outputs.version }} + OPENCODE_RELEASE: ${{ needs.version.outputs.release }} + + - name: Verify service lifecycle + if: matrix.settings.target != 'windows-arm64' + working-directory: packages/cli + run: bun run script/service-smoke.ts --node + + - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + with: + name: opencode-node-cli-${{ matrix.settings.target }} + path: packages/cli/dist/node/cli-node-* + if-no-files-found: error + sign-cli-windows: needs: - build-cli @@ -413,6 +460,7 @@ jobs: needs: - version - build-cli + - build-node-cli - sign-cli-windows - build-electron if: always() && !failure() && !cancelled() @@ -461,6 +509,12 @@ jobs: name: opencode-preview-cli path: packages/cli/dist + - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 + with: + pattern: opencode-node-cli-* + path: packages/cli/dist/node + merge-multiple: true + - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 if: needs.version.outputs.release with: diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 9b2e87fc7a..b486b68a93 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -78,6 +78,20 @@ jobs: bun run script/build.ts --single --skip-install bun run script/service-smoke.ts + - name: Setup Node build runtime + if: always() + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 + with: + node-version: "26.4.0" + + - name: Verify Node build + if: always() + timeout-minutes: 15 + working-directory: packages/cli + run: | + bun run script/build-node.ts --single --skip-install --outdir=dist/node + bun run script/service-smoke.ts --node + - name: Check generated client if: runner.os == 'Linux' working-directory: packages/client diff --git a/packages/cli/bin/opencode2.cjs b/packages/cli/bin/opencode2.cjs index 8795c71cd2..d3f1c9260d 100755 --- a/packages/cli/bin/opencode2.cjs +++ b/packages/cli/bin/opencode2.cjs @@ -31,11 +31,13 @@ function run(target) { const envPath = process.env.OPENCODE_BIN_PATH const scriptDir = path.dirname(fs.realpathSync(__filename)) -const cached = path.join(scriptDir, ".opencode2") +const command = path.basename(__filename).replace(/\.cjs$/, "") +const nodeBuild = command === "opencode2-node" +const cached = path.join(scriptDir, `.${command}`) const platform = { darwin: "darwin", linux: "linux", win32: "windows" }[os.platform()] || os.platform() const arch = { x64: "x64", arm64: "arm64", arm: "arm" }[os.arch()] || os.arch() -const base = "@opencode-ai/cli-" + platform + "-" + arch -const binary = platform === "windows" ? "opencode2.exe" : "opencode2" +const base = `@opencode-ai/cli${nodeBuild ? "-node" : ""}-` + platform + "-" + arch +const binary = platform === "windows" ? `${command}.exe` : command function supportsAvx2() { if (arch !== "x64") return false @@ -77,6 +79,7 @@ function supportsAvx2() { } const names = (() => { + if (nodeBuild) return [base] const baseline = arch === "x64" && !supportsAvx2() if (platform === "linux") { const musl = (() => { @@ -121,7 +124,7 @@ function findBinary(startDir) { const resolved = envPath || (fs.existsSync(cached) ? cached : findBinary(scriptDir)) if (!resolved) { console.error( - "It seems that your package manager failed to install the right opencode2 CLI package. Try manually installing " + + `It seems that your package manager failed to install the right ${command} CLI package. Try manually installing ` + names.map((name) => `"${name}"`).join(" or ") + " package", ) diff --git a/packages/cli/script/build-node.ts b/packages/cli/script/build-node.ts index 3326e6315b..0b4e29930e 100644 --- a/packages/cli/script/build-node.ts +++ b/packages/cli/script/build-node.ts @@ -57,7 +57,7 @@ const builder = : undefined for (const target of targets) { - console.log(`building cli-${targetName(target)}`) + console.log(`building cli-node-${targetName(target)}`) const assets = await collectNodeAssets(target) await rm("dist-node", { recursive: true, force: true }) const assetHash = await hashNodeAssets(assets) @@ -73,8 +73,8 @@ for (const target of targets) { } if (bundleOnly) continue - const name = `cli-${targetName(target)}` - const binary = target.platform === "win32" ? "opencode2.exe" : "opencode2" + const name = `cli-node-${targetName(target)}` + const binary = target.platform === "win32" ? "opencode2-node.exe" : "opencode2-node" const output = path.join(outdir, name, "bin", binary) if (!builder) throw new Error("Node SEA builder is unavailable") await mkdir(path.dirname(output), { recursive: true }) diff --git a/packages/cli/script/publish.ts b/packages/cli/script/publish.ts index 116e5efa32..0c0cb66f21 100755 --- a/packages/cli/script/publish.ts +++ b/packages/cli/script/publish.ts @@ -18,37 +18,55 @@ async function publish(dir: string, name: string, version: string) { await $`npm publish *.tgz --access public --tag ${Script.channel}`.cwd(dir) } -const binaries: Record = {} -for (const filepath of new Bun.Glob("*/package.json").scanSync({ cwd: "./dist" })) { - const item = await Bun.file(`./dist/${filepath}`).json() - binaries[item.name] = item.version +async function publishDistribution(input: { root: string; name: string; binary: string; packagePrefix: string }) { + const binaries: Record = {} + for (const filepath of new Bun.Glob("*/package.json").scanSync({ cwd: input.root })) { + const item = await Bun.file(`${input.root}/${filepath}`).json() + if (!item.name.startsWith(input.packagePrefix)) continue + binaries[item.name] = item.version + } + console.log(input.name, "binaries", binaries) + const versions = new Set(Object.values(binaries)) + if (versions.size > 1) throw new Error(`Binary package versions do not match for ${input.name}`) + const version = versions.values().next().value + if (!version) throw new Error(`No binary packages found for ${input.name}`) + + await $`mkdir -p ${input.root}/${input.name}/bin` + await $`cp ./bin/opencode2.cjs ${input.root}/${input.name}/bin/${input.binary}` + await Bun.file(`${input.root}/${input.name}/package.json`).write( + JSON.stringify( + { + name: input.name, + bin: { [input.binary]: `./bin/${input.binary}` }, + version, + license: pkg.license, + repository: { type: "git", url: "git+https://github.com/anomalyco/opencode.git" }, + os: ["darwin", "linux", "win32"], + cpu: ["arm64", "x64"], + optionalDependencies: binaries, + }, + null, + 2, + ), + ) + + await Promise.all( + Object.entries(binaries).map(([name, version]) => + publish(`${input.root}/${name.replace("@opencode-ai/", "")}`, name, version), + ), + ) + await publish(`${input.root}/${input.name}`, input.name, version) } -console.log("binaries", binaries) -const version = Object.values(binaries)[0] -const name = pkg.name -await $`mkdir -p ./dist/${name}/bin` -await $`cp ./bin/opencode2.cjs ./dist/${name}/bin/opencode2` -await Bun.file(`./dist/${name}/package.json`).write( - JSON.stringify( - { - name, - bin: { opencode2: "./bin/opencode2" }, - version, - license: pkg.license, - repository: { type: "git", url: "git+https://github.com/anomalyco/opencode.git" }, - os: ["darwin", "linux", "win32"], - cpu: ["arm64", "x64"], - optionalDependencies: binaries, - }, - null, - 2, - ), -) - -await Promise.all( - Object.entries(binaries).map(([name, version]) => - publish(`./dist/${name.replace("@opencode-ai/", "")}`, name, version), - ), -) -await publish(`./dist/${name}`, name, version) +await publishDistribution({ + root: "./dist", + name: pkg.name, + binary: "opencode2", + packagePrefix: "@opencode-ai/cli-", +}) +await publishDistribution({ + root: "./dist/node", + name: "opencode2-node", + binary: "opencode2-node", + packagePrefix: "@opencode-ai/cli-node-", +}) diff --git a/packages/cli/script/service-smoke.ts b/packages/cli/script/service-smoke.ts index c86e9f8280..2a51fc22ff 100644 --- a/packages/cli/script/service-smoke.ts +++ b/packages/cli/script/service-smoke.ts @@ -7,9 +7,10 @@ import fs from "node:fs/promises" import os from "node:os" import path from "node:path" -const target = `cli-${process.platform === "win32" ? "windows" : process.platform}-${process.arch}` -const directory = path.join(import.meta.dir, "..", "dist", target, "bin") -const binary = path.join(directory, `opencode2${process.platform === "win32" ? ".exe" : ""}`) +const nodeBuild = process.argv.includes("--node") +const target = `cli${nodeBuild ? "-node" : ""}-${process.platform === "win32" ? "windows" : process.platform}-${process.arch}` +const directory = path.join(import.meta.dir, "..", "dist", ...(nodeBuild ? ["node"] : []), target, "bin") +const binary = path.join(directory, `opencode2${nodeBuild ? "-node" : ""}${process.platform === "win32" ? ".exe" : ""}`) if (!(await Bun.file(binary).exists())) throw new Error(`Missing compiled CLI in ${directory}`) const root = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-service-smoke-")) diff --git a/packages/cli/src/services/updater.ts b/packages/cli/src/services/updater.ts index 5f3083b7c9..fd46b90084 100644 --- a/packages/cli/src/services/updater.ts +++ b/packages/cli/src/services/updater.ts @@ -12,11 +12,16 @@ import { parse, type ParseError } from "jsonc-parser" import path from "node:path" import semver from "semver" +declare const OPENCODE_CLI_NAME: string | undefined + export type Policy = boolean | "notify" export type Action = "none" | "upgrade" type Method = "npm" | "pnpm" | "bun" | "yarn" -const packageName = "@opencode-ai/cli" +const packageName = + typeof OPENCODE_CLI_NAME === "string" && OPENCODE_CLI_NAME === "opencode2-node" + ? OPENCODE_CLI_NAME + : "@opencode-ai/cli" export interface Interface { readonly check: () => Effect.Effect diff --git a/packages/cli/vite.node.config.ts b/packages/cli/vite.node.config.ts index 239f0ebd90..8e9d662999 100644 --- a/packages/cli/vite.node.config.ts +++ b/packages/cli/vite.node.config.ts @@ -186,7 +186,7 @@ export function mainConfig(input: NodeBuildInput): UserConfig { esbuild: { jsx: "automatic" }, define: { OPENCODE_VERSION: JSON.stringify(input.version), - OPENCODE_CLI_NAME: JSON.stringify("opencode2"), + OPENCODE_CLI_NAME: JSON.stringify("opencode2-node"), OPENCODE_MODELS_DEV: input.models, OPENCODE_CHANNEL: JSON.stringify(input.channel), OPENCODE_LIBC: input.target.platform === "linux" ? JSON.stringify("glibc") : "undefined", diff --git a/packages/core/package.json b/packages/core/package.json index 07a84770b0..ee472c5b41 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -47,6 +47,11 @@ "bun": "./src/runtime/import.bun.ts", "node": "./src/runtime/import.node.ts", "default": "./src/runtime/import.bun.ts" + }, + "#process-lock-ffi": { + "bun": "./src/util/process-lock-ffi.bun.ts", + "node": "./src/util/process-lock-ffi.node.ts", + "default": "./src/util/process-lock-ffi.bun.ts" } }, "devDependencies": { diff --git a/packages/core/src/node-ffi.d.ts b/packages/core/src/node-ffi.d.ts new file mode 100644 index 0000000000..5f4360783e --- /dev/null +++ b/packages/core/src/node-ffi.d.ts @@ -0,0 +1,18 @@ +declare module "node:ffi" { + type Signature = { + readonly arguments?: readonly string[] + readonly return?: string + } + + type ForeignFunction = (...args: ReadonlyArray) => number | bigint + + export function dlopen( + path: string, + definitions: Readonly>, + ): { + readonly lib: { close(): void } + readonly functions: Readonly> + } + + export function getInt32(pointer: number | bigint, offset?: number): number +} diff --git a/packages/core/src/util/process-lock-ffi.bun.ts b/packages/core/src/util/process-lock-ffi.bun.ts new file mode 100644 index 0000000000..ca821cb6a8 --- /dev/null +++ b/packages/core/src/util/process-lock-ffi.bun.ts @@ -0,0 +1,50 @@ +import { dlopen, read, type Pointer } from "bun:ffi" +import { existsSync } from "node:fs" + +export type LockResult = + | { readonly acquired: true } + | { readonly acquired: false; readonly held: true } + | { readonly acquired: false; readonly held: false; readonly code: number } + +const LOCK_EX = 2 +const LOCK_NB = 4 +const DARWIN_EWOULDBLOCK = 35 +const LINUX_EWOULDBLOCK = 11 + +export function lockDarwin(fd: number): LockResult { + const library = dlopen("/usr/lib/libSystem.B.dylib", { + flock: { args: ["i32", "i32"], returns: "i32" }, + __error: { args: [], returns: "ptr" }, + }) + try { + const result = library.symbols.flock(fd, LOCK_EX | LOCK_NB) + const code = result === 0 ? 0 : errorCode(library.symbols.__error()) + if (result === 0) return { acquired: true } + if (code === DARWIN_EWOULDBLOCK) return { acquired: false, held: true } + return { acquired: false, held: false, code } + } finally { + library.close() + } +} + +export function lockLinux(fd: number): LockResult { + const musl = `/lib/libc.musl-${process.arch === "arm64" ? "aarch64" : "x86_64"}.so.1` + const library = dlopen(existsSync(musl) ? musl : "libc.so.6", { + flock: { args: ["i32", "i32"], returns: "i32" }, + __errno_location: { args: [], returns: "ptr" }, + }) + try { + const result = library.symbols.flock(fd, LOCK_EX | LOCK_NB) + const code = result === 0 ? 0 : errorCode(library.symbols.__errno_location()) + if (result === 0) return { acquired: true } + if (code === LINUX_EWOULDBLOCK) return { acquired: false, held: true } + return { acquired: false, held: false, code } + } finally { + library.close() + } +} + +function errorCode(pointer: Pointer | null) { + if (pointer === null) throw new Error("Failed to read process lock error code") + return read.i32(pointer, 0) +} diff --git a/packages/core/src/util/process-lock-ffi.node.ts b/packages/core/src/util/process-lock-ffi.node.ts new file mode 100644 index 0000000000..5416bec635 --- /dev/null +++ b/packages/core/src/util/process-lock-ffi.node.ts @@ -0,0 +1,43 @@ +import { dlopen, getInt32 } from "node:ffi" + +export type LockResult = + | { readonly acquired: true } + | { readonly acquired: false; readonly held: true } + | { readonly acquired: false; readonly held: false; readonly code: number } + +const LOCK_EX = 2 +const LOCK_NB = 4 +const DARWIN_EWOULDBLOCK = 35 +const LINUX_EWOULDBLOCK = 11 + +export function lockDarwin(fd: number): LockResult { + const library = dlopen("/usr/lib/libSystem.B.dylib", { + flock: { arguments: ["int32", "int32"], return: "int32" }, + __error: { arguments: [], return: "pointer" }, + }) + try { + const result = library.functions.flock(fd, LOCK_EX | LOCK_NB) + const code = result === 0 ? 0 : getInt32(library.functions.__error(), 0) + if (result === 0) return { acquired: true } + if (code === DARWIN_EWOULDBLOCK) return { acquired: false, held: true } + return { acquired: false, held: false, code } + } finally { + library.lib.close() + } +} + +export function lockLinux(fd: number): LockResult { + const library = dlopen("libc.so.6", { + flock: { arguments: ["int32", "int32"], return: "int32" }, + __errno_location: { arguments: [], return: "pointer" }, + }) + try { + const result = library.functions.flock(fd, LOCK_EX | LOCK_NB) + const code = result === 0 ? 0 : getInt32(library.functions.__errno_location(), 0) + if (result === 0) return { acquired: true } + if (code === LINUX_EWOULDBLOCK) return { acquired: false, held: true } + return { acquired: false, held: false, code } + } finally { + library.lib.close() + } +} diff --git a/packages/core/src/util/process-lock.ts b/packages/core/src/util/process-lock.ts index 045ac9fa05..2c2a721448 100644 --- a/packages/core/src/util/process-lock.ts +++ b/packages/core/src/util/process-lock.ts @@ -1,5 +1,5 @@ -import { dlopen, read, type Pointer } from "bun:ffi" -import { closeSync, existsSync, mkdirSync, openSync } from "node:fs" +import { lockDarwin, lockLinux, type LockResult } from "#process-lock-ffi" +import { closeSync, mkdirSync, openSync } from "node:fs" import { connect, createServer, type Server, type Socket } from "node:net" import path from "node:path" import { Effect, Schema } from "effect" @@ -76,60 +76,12 @@ export namespace ProcessLock { }) } -type Result = - | { readonly acquired: true } - | { readonly acquired: false; readonly held: true } - | { readonly acquired: false; readonly held: false; readonly code: number } - -const LOCK_EX = 2 -const LOCK_NB = 4 -const DARWIN_EWOULDBLOCK = 35 -const LINUX_EWOULDBLOCK = 11 - -function lock(fd: number): Result { +function lock(fd: number): LockResult { if (process.platform === "darwin") return lockDarwin(fd) if (process.platform === "linux") return lockLinux(fd) throw new Error(`Unsupported process lock platform: ${process.platform}`) } -function lockDarwin(fd: number): Result { - const library = dlopen("/usr/lib/libSystem.B.dylib", { - flock: { args: ["i32", "i32"], returns: "i32" }, - __error: { args: [], returns: "ptr" }, - }) - try { - const result = library.symbols.flock(fd, LOCK_EX | LOCK_NB) - const code = result === 0 ? 0 : errorCode(library.symbols.__error()) - if (result === 0) return { acquired: true } - if (code === DARWIN_EWOULDBLOCK) return { acquired: false, held: true } - return { acquired: false, held: false, code } - } finally { - library.close() - } -} - -function lockLinux(fd: number): Result { - const musl = `/lib/libc.musl-${process.arch === "arm64" ? "aarch64" : "x86_64"}.so.1` - const library = dlopen(existsSync(musl) ? musl : "libc.so.6", { - flock: { args: ["i32", "i32"], returns: "i32" }, - __errno_location: { args: [], returns: "ptr" }, - }) - try { - const result = library.symbols.flock(fd, LOCK_EX | LOCK_NB) - const code = result === 0 ? 0 : errorCode(library.symbols.__errno_location()) - if (result === 0) return { acquired: true } - if (code === LINUX_EWOULDBLOCK) return { acquired: false, held: true } - return { acquired: false, held: false, code } - } finally { - library.close() - } -} - -function errorCode(pointer: Pointer | null) { - if (pointer === null) throw new Error("Failed to read process lock error code") - return read.i32(pointer, 0) -} - function acquireWindows(file: string) { return Effect.callback((resume) => { const server = createServer()