node v2 cli support (#36309)

This commit is contained in:
Simon Klee 2026-07-17 14:45:06 +02:00 committed by GitHub
commit a1b274e6f8
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
75 changed files with 1502 additions and 367 deletions

View file

@ -0,0 +1,201 @@
#!/usr/bin/env bun
import { spawnSync } from "node:child_process"
import { createHash } from "node:crypto"
import { chmod, copyFile, mkdir, mkdtemp, realpath, rename, rm, stat, writeFile } from "node:fs/promises"
import os from "node:os"
import path from "node:path"
import { build } from "vite"
import { Script } from "@opencode-ai/script"
import pkg from "../package.json"
import { modelsData } from "./generate"
import { collectNodeAssets, copyNodeAssets, hashNodeAssets, seaAssetMap } from "./node-assets"
import { mainConfig } from "../vite.node.config"
import { nodeExecArgv, nodeTarget, type NodeTarget } from "../src/node/target"
const NODE_VERSION = "26.4.0"
const dir = path.resolve(import.meta.dirname, "..")
const outdir = path.resolve(
dir,
process.argv.find((arg) => arg.startsWith("--outdir="))?.slice("--outdir=".length) ?? "dist",
)
if (outdir === dir) throw new Error("--outdir must not be the package directory")
if (outdir === path.join(dir, "dist-node")) {
throw new Error("--outdir must not be dist-node because it contains temporary files")
}
const bundleOnly = process.argv.includes("--bundle-only")
const single = process.argv.includes("--single")
const skipInstall = process.argv.includes("--skip-install")
const requested = process.argv.find((arg) => arg.startsWith("--target="))?.slice("--target=".length)
const allTargets = [
nodeTarget("linux", "arm64"),
nodeTarget("linux", "x64"),
nodeTarget("darwin", "arm64"),
nodeTarget("win32", "arm64"),
nodeTarget("win32", "x64"),
]
const targets = requested
? allTargets.filter((target) => targetName(target) === requested)
: single || bundleOnly
? [nodeTarget(process.platform, process.arch)]
: allTargets
if (targets.length === 0) {
if (requested === "darwin-x64") throw new Error("Node 26.4 SEA does not support macOS x64")
throw new Error(`Unknown Node target: ${requested}`)
}
if (!bundleOnly && targets.some((target) => target.platform === "darwin" && target.arch === "x64")) {
throw new Error("Node 26.4 SEA does not support macOS x64")
}
process.chdir(dir)
if (!skipInstall) run(process.execPath, ["install", "--os=*", "--cpu=*"])
if (!bundleOnly) await rm(outdir, { recursive: true, force: true })
const builder =
!bundleOnly || targets.some((target) => target.platform === process.platform && target.arch === process.arch)
? await resolveHostNode()
: undefined
for (const target of targets) {
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)
const input = { version: Script.version, channel: Script.channel, models: modelsData, assetHash, target }
await build(mainConfig(input))
await copyNodeAssets(assets)
const host = target.platform === process.platform && target.arch === process.arch
if (host) {
if (!builder) throw new Error("Node SEA builder is unavailable")
run(builder, [...nodeExecArgv, "dist-node/opencode.mjs", "--version"])
run(builder, [...nodeExecArgv, "dist-node/opencode.mjs", "--help"])
}
if (bundleOnly) continue
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 })
const config = {
main: "dist-node/opencode.mjs",
mainFormat: "module",
executable: await resolveTargetNode(target, builder),
output: path.relative(dir, output),
disableExperimentalSEAWarning: true,
useSnapshot: false,
useCodeCache: false,
execArgv: nodeExecArgv,
execArgvExtension: "none",
assets: await seaAssetMap(),
}
await writeFile("dist-node/sea.json", `${JSON.stringify(config, null, 2)}\n`)
run(builder, ["--build-sea", "dist-node/sea.json"])
if (target.platform !== "win32") await chmod(output, 0o755)
if (target.platform === "darwin" && process.platform === "darwin") run("codesign", ["--sign", "-", output])
if (target.platform === "darwin" && process.platform !== "darwin") {
console.warn(`${output} must be signed on macOS before it can run`)
}
await writeFile(
path.join(outdir, name, "package.json"),
`${JSON.stringify(
{
name: `@opencode-ai/${name}`,
version: Script.version,
license: pkg.license,
repository: { type: "git", url: "git+https://github.com/anomalyco/opencode.git" },
os: [target.platform],
cpu: [target.arch],
},
null,
2,
)}\n`,
)
if (host) await smoke(output)
}
async function resolveHostNode() {
const candidates = [process.env.NODE_BIN, "node"].filter((item): item is string => Boolean(item))
for (const candidate of candidates) {
const result = spawnSync(
candidate,
["-p", "JSON.stringify({version:process.versions.node,path:process.execPath})"],
{
encoding: "utf8",
},
)
if (result.status !== 0) continue
const info = JSON.parse(result.stdout) as { version: string; path: string }
if (info.version === NODE_VERSION) return realpath(info.path)
}
return resolveTargetNode(nodeTarget(process.platform, process.arch))
}
async function resolveTargetNode(target: NodeTarget, host?: string) {
if (host && target.platform === process.platform && target.arch === process.arch) return host
const cache = path.resolve(dir, ".cache", "node")
const platform = target.platform === "win32" ? "win" : target.platform
const archiveName = `node-v${NODE_VERSION}-${platform}-${target.arch}`
const targetDirectory = path.join(cache, archiveName)
const executable = path.join(targetDirectory, target.platform === "win32" ? "node.exe" : "bin/node")
if (
(await stat(executable).then(
() => true,
() => false,
)) &&
(await stat(path.join(targetDirectory, ".verified")).then(
() => true,
() => false,
))
)
return realpath(executable)
await mkdir(cache, { recursive: true })
const extension = target.platform === "win32" ? "zip" : "tar.gz"
const filename = `${archiveName}.${extension}`
const archive = path.join(cache, filename)
const base = `https://nodejs.org/dist/v${NODE_VERSION}`
const [response, sums] = await Promise.all([fetch(`${base}/${filename}`), fetch(`${base}/SHASUMS256.txt`)])
if (!response.ok) throw new Error(`Failed to download Node ${NODE_VERSION}: ${response.status}`)
if (!sums.ok) throw new Error(`Failed to download Node ${NODE_VERSION} checksums: ${sums.status}`)
const data = new Uint8Array(await response.arrayBuffer())
const expected = (await sums.text())
.split("\n")
.find((line) => line.endsWith(` ${filename}`))
?.split(/\s+/)[0]
if (!expected) throw new Error(`Missing checksum for ${filename}`)
if (createHash("sha256").update(data).digest("hex") !== expected) throw new Error(`Checksum mismatch for ${filename}`)
await writeFile(archive, data)
const temporary = path.join(cache, `${archiveName}.${process.pid}.tmp`)
await rm(temporary, { recursive: true, force: true })
await mkdir(temporary)
if (target.platform !== "win32") run("tar", ["-xzf", archive, "-C", temporary])
else if (process.platform === "win32") run("tar", ["-xf", archive, "-C", temporary])
else run("unzip", ["-q", archive, "-d", temporary])
await rm(targetDirectory, { recursive: true, force: true })
await rename(path.join(temporary, archiveName), targetDirectory)
await writeFile(path.join(targetDirectory, ".verified"), `${expected}\n`)
await rm(temporary, { recursive: true, force: true })
await rm(archive, { force: true })
return realpath(executable)
}
async function smoke(output: string) {
const root = await mkdtemp(path.join(os.tmpdir(), "opencode-node-smoke-"))
const executable = path.join(root, path.basename(output))
await copyFile(output, executable)
if (process.platform !== "win32") await chmod(executable, 0o755)
run(executable, ["--version"], root)
run(executable, ["--help"], root)
await rm(root, { recursive: true, force: true })
}
function targetName(target: NodeTarget) {
return `${target.platform === "win32" ? "windows" : target.platform}-${target.arch}`
}
function run(command: string, args: readonly string[], cwd = dir) {
const result = spawnSync(command, args, { cwd, stdio: "inherit", env: process.env })
if (result.error) throw result.error
if (result.status !== 0) throw new Error(`${command} exited with status ${result.status ?? "unknown"}`)
}

View file

@ -1,7 +1,6 @@
#!/usr/bin/env bun
import { $ } from "bun"
import fs from "fs"
import { rm } from "fs/promises"
import path from "path"
import { Script } from "@opencode-ai/script"
@ -11,9 +10,14 @@ import { modelsData } from "./generate"
const dir = path.resolve(import.meta.dirname, "..")
const binary = "opencode2"
const outdir = path.resolve(
dir,
process.argv.find((arg) => arg.startsWith("--outdir="))?.slice("--outdir=".length) ?? "dist",
)
if (outdir === dir) throw new Error("--outdir must not be the package directory")
process.chdir(dir)
await rm("dist", { recursive: true, force: true })
await rm(outdir, { recursive: true, force: true })
const singleFlag = process.argv.includes("--single")
const baselineFlag = process.argv.includes("--baseline")
@ -50,10 +54,6 @@ const targets = singleFlag
if (!skipInstall) await $`bun install --os="*" --cpu="*" @opentui/core@${pkg.dependencies["@opentui/core"]}`
const localParserWorker = path.resolve(dir, "node_modules/@opentui/core/parser.worker.js")
const rootParserWorker = path.resolve(dir, "../../node_modules/@opentui/core/parser.worker.js")
const parserWorker = fs.realpathSync(fs.existsSync(localParserWorker) ? localParserWorker : rootParserWorker)
for (const item of targets) {
const target = [
binary,
@ -67,7 +67,7 @@ for (const item of targets) {
const name = target.replace(binary, "cli")
console.log(`building ${name}`)
const result = await Bun.build({
entrypoints: ["./src/index.ts", parserWorker],
entrypoints: ["./src/index.ts"],
tsconfig: "./tsconfig.json",
plugins: [plugin],
external: ["node-gyp"],
@ -81,7 +81,7 @@ for (const item of targets) {
autoloadTsconfig: true,
autoloadPackageJson: true,
target: target.replace(binary, "bun") as Bun.Build.CompileTarget,
outfile: `./dist/${name}/bin/${binary}`,
outfile: path.join(outdir, name, "bin", binary),
execArgv: [`--user-agent=${binary}/${Script.version}`, "--use-system-ca", "--"],
windows: {},
},
@ -93,10 +93,6 @@ for (const item of targets) {
OPENCODE_LIBC: item.os === "linux" ? `'${item.abi ?? "glibc"}'` : "undefined",
// FFF_LIBC selects the fff native lib variant: "musl" or "gnu".
FFF_LIBC: item.os === "linux" ? `'${item.abi ?? "gnu"}'` : "undefined",
OTUI_TREE_SITTER_WORKER_PATH:
(item.os === "win32" ? '"B:/~BUN/root/' : '"/$bunfs/root/') +
path.relative(dir, parserWorker).replaceAll("\\", "/") +
'"',
...(item.os === "linux" ? { "process.env.OPENTUI_LIBC": JSON.stringify(item.abi ?? "glibc") } : {}),
},
})
@ -107,7 +103,7 @@ for (const item of targets) {
}
await Bun.write(
`./dist/${name}/package.json`,
path.join(outdir, name, "package.json"),
JSON.stringify(
{
name: `@opencode-ai/${name}`,

View file

@ -1,7 +1,9 @@
import { readFile } from "node:fs/promises"
const modelsUrl = process.env.OPENCODE_MODELS_URL || "https://models.dev"
export const modelsData = process.env.MODELS_DEV_API_JSON
? await Bun.file(process.env.MODELS_DEV_API_JSON).text()
? await readFile(process.env.MODELS_DEV_API_JSON, "utf8")
: await fetch(`${modelsUrl}/api.json`).then((response) => response.text())
console.log("Loaded models.dev snapshot")

View file

@ -0,0 +1,83 @@
import { createHash } from "node:crypto"
import { copyFile, mkdir, readdir, readFile, stat } from "node:fs/promises"
import { createRequire } from "node:module"
import path from "node:path"
import { fileURLToPath } from "node:url"
import { getNodeAssets } from "@opentui/core/node-assets"
import { attentionSoundAssets, type NodeTarget, photonWasmAsset } from "../src/node/target"
const dir = path.resolve(import.meta.dirname, "..")
// Bun's compiler discovers file imports and embeds them in its virtual filesystem. Vite only bundles the JavaScript
// portion of the Node executable, while SEA embeds only the assets explicitly listed in its build configuration.
// Collect and stage those files under stable keys so the SEA prelude can extract them to real paths at startup;
// native addons, helper executables, and other path-based consumers cannot use assets directly from SEA memory.
export type NodeAsset = {
readonly key: string
readonly source: string
}
async function files(root: string, current = root): Promise<string[]> {
return (
await Promise.all(
(await readdir(current, { withFileTypes: true })).map((entry) => {
const target = path.join(current, entry.name)
return entry.isDirectory() ? files(root, target) : [path.relative(root, target)]
}),
)
).flat()
}
export async function collectNodeAssets(target: NodeTarget) {
const ptyEntry = fileURLToPath(import.meta.resolve(target.nodePtyPackage))
const ptyRoot = path.resolve(path.dirname(ptyEntry), "..")
const assets: NodeAsset[] = [
...getNodeAssets({
platform: target.platform,
arch: target.arch,
...(target.platform === "linux" ? { libc: "glibc" as const } : {}),
}),
{ key: target.parcelWatcherAsset, source: fileURLToPath(import.meta.resolve(target.parcelWatcherPackage)) },
{
key: photonWasmAsset,
source: createRequire(path.resolve(dir, "../core/package.json")).resolve(photonWasmAsset),
},
...attentionSoundAssets.map((key) => ({
key,
source: path.resolve(dir, "../ui/src/assets/audio", path.basename(key)),
})),
...(await files(ptyRoot))
.filter((relative) => !relative.endsWith(".map") && !relative.endsWith(".pdb"))
.map((relative) => ({
key: `${target.nodePtyPackage}/${relative}`,
source: path.join(ptyRoot, relative),
})),
]
await Promise.all(assets.map((asset) => stat(asset.source)))
return assets
}
export async function hashNodeAssets(assets: readonly NodeAsset[]) {
const hash = createHash("sha256")
for (const asset of assets.toSorted((left, right) => left.key.localeCompare(right.key))) {
hash.update(asset.key)
hash.update(await readFile(asset.source))
}
return hash.digest("hex").slice(0, 16)
}
export async function copyNodeAssets(assets: readonly NodeAsset[]) {
const root = path.join(dir, "dist-node", "assets")
await Promise.all(
assets.map(async (asset) => {
const target = path.join(root, asset.key)
await mkdir(path.dirname(target), { recursive: true })
await copyFile(asset.source, target)
}),
)
}
export async function seaAssetMap() {
const root = path.join(dir, "dist-node", "assets")
return Object.fromEntries((await files(root)).map((key) => [key.replaceAll(path.sep, "/"), path.join(root, key)]))
}

View file

@ -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<string, string> = {}
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<string, string> = {}
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-",
})

View file

@ -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-"))