From 09549661e111f768331e01cc278ffa2f2e32d9e5 Mon Sep 17 00:00:00 2001 From: Dax Date: Fri, 15 May 2026 18:43:37 -0400 Subject: [PATCH] Fix npm CLI binary installation (#27801) --- .github/workflows/publish.yml | 1 + packages/opencode/script/build.ts | 1 + packages/opencode/script/postinstall.mjs | 247 +++++++++++++++-------- packages/opencode/script/publish.ts | 16 +- 4 files changed, 182 insertions(+), 83 deletions(-) diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 5f8bac973a..9887cbe4dc 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -7,6 +7,7 @@ on: - ci - dev - beta + - fix/npm-native-binary-install - snapshot-* workflow_dispatch: inputs: diff --git a/packages/opencode/script/build.ts b/packages/opencode/script/build.ts index 2f2edb4ff5..bbbe6bcfce 100755 --- a/packages/opencode/script/build.ts +++ b/packages/opencode/script/build.ts @@ -244,6 +244,7 @@ for (const item of targets) { { name, version: Script.version, + preferUnplugged: true, os: [item.os], cpu: [item.arch], }, diff --git a/packages/opencode/script/postinstall.mjs b/packages/opencode/script/postinstall.mjs index 7c6f85d2b1..fa303b746b 100644 --- a/packages/opencode/script/postinstall.mjs +++ b/packages/opencode/script/postinstall.mjs @@ -1,102 +1,189 @@ #!/usr/bin/env node +import childProcess from "child_process" import fs from "fs" -import path from "path" import os from "os" -import { fileURLToPath } from "url" +import path from "path" import { createRequire } from "module" +import { fileURLToPath } from "url" const __dirname = path.dirname(fileURLToPath(import.meta.url)) const require = createRequire(import.meta.url) +const packageJson = JSON.parse(fs.readFileSync(path.join(__dirname, "package.json"), "utf8")) -function detectPlatformAndArch() { - // Map platform names - let platform - switch (os.platform()) { - case "darwin": - platform = "darwin" - break - case "linux": - platform = "linux" - break - case "win32": - platform = "windows" - break - default: - platform = os.platform() - break - } - - // Map architecture names - let arch - switch (os.arch()) { - case "x64": - arch = "x64" - break - case "arm64": - arch = "arm64" - break - case "arm": - arch = "arm" - break - default: - arch = os.arch() - break - } - - return { platform, arch } +const platformMap = { + darwin: "darwin", + linux: "linux", + win32: "windows", +} +const archMap = { + x64: "x64", + arm64: "arm64", + arm: "arm", } -function findBinary() { - const { platform, arch } = detectPlatformAndArch() - const packageName = `opencode-${platform}-${arch}` - const binaryName = platform === "windows" ? "opencode.exe" : "opencode" +const platform = platformMap[os.platform()] ?? os.platform() +const arch = archMap[os.arch()] ?? os.arch() +const base = `opencode-${platform}-${arch}` +const sourceBinary = platform === "windows" ? "opencode.exe" : "opencode" +const targetBinary = path.join(__dirname, "bin", "opencode.exe") - try { - // Use require.resolve to find the package - const packageJsonPath = require.resolve(`${packageName}/package.json`) - const packageDir = path.dirname(packageJsonPath) - const binaryPath = path.join(packageDir, "bin", binaryName) +function supportsAvx2() { + if (arch !== "x64") return false - if (!fs.existsSync(binaryPath)) { - throw new Error(`Binary not found at ${binaryPath}`) - } - - return { binaryPath, binaryName } - } catch (error) { - throw new Error(`Could not find package ${packageName}: ${error.message}`, { cause: error }) - } -} - -async function main() { - try { - if (os.platform() === "win32") { - // On Windows, the .exe is already included in the package and bin field points to it - // No postinstall setup needed - console.log("Windows detected: binary setup not needed (using packaged .exe)") - return - } - - // On non-Windows platforms, just verify the binary package exists - // Don't replace the wrapper script - it handles binary execution - const { binaryPath } = findBinary() - const target = path.join(__dirname, "bin", ".opencode") - if (fs.existsSync(target)) fs.unlinkSync(target) + if (platform === "linux") { try { - fs.linkSync(binaryPath, target) + return /(^|\s)avx2(\s|$)/i.test(fs.readFileSync("/proc/cpuinfo", "utf8")) } catch { - fs.copyFileSync(binaryPath, target) + return false } - fs.chmodSync(target, 0o755) - } catch (error) { - console.error("Failed to setup opencode binary:", error.message) - process.exit(1) } + + if (platform === "darwin") { + try { + const result = childProcess.spawnSync("sysctl", ["-n", "hw.optional.avx2_0"], { + encoding: "utf8", + timeout: 1500, + }) + if (result.status !== 0) return false + return (result.stdout || "").trim() === "1" + } catch { + return false + } + } + + if (platform === "windows") { + const command = + '(Add-Type -MemberDefinition "[DllImport(""kernel32.dll"")] public static extern bool IsProcessorFeaturePresent(int ProcessorFeature);" -Name Kernel32 -Namespace Win32 -PassThru)::IsProcessorFeaturePresent(40)' + + for (const executable of ["powershell.exe", "pwsh.exe", "pwsh", "powershell"]) { + try { + const result = childProcess.spawnSync(executable, ["-NoProfile", "-NonInteractive", "-Command", command], { + encoding: "utf8", + timeout: 3000, + windowsHide: true, + }) + if (result.status !== 0) continue + const output = (result.stdout || "").trim().toLowerCase() + if (output === "true" || output === "1") return true + if (output === "false" || output === "0") return false + } catch { + continue + } + } + } + + return false +} + +function isMusl() { + if (platform !== "linux") return false + + try { + if (fs.existsSync("/etc/alpine-release")) return true + } catch { + // Ignore filesystem probes that are blocked by the host. + } + + try { + const result = childProcess.spawnSync("ldd", ["--version"], { encoding: "utf8" }) + return `${result.stdout || ""}${result.stderr || ""}`.toLowerCase().includes("musl") + } catch { + return false + } +} + +function packageNames() { + const baseline = arch === "x64" && !supportsAvx2() + + if (platform === "linux") { + if (isMusl()) { + if (arch === "x64") + return baseline + ? [`${base}-baseline-musl`, `${base}-musl`, `${base}-baseline`, base] + : [`${base}-musl`, `${base}-baseline-musl`, base, `${base}-baseline`] + return [`${base}-musl`, base] + } + + if (arch === "x64") + return baseline + ? [`${base}-baseline`, base, `${base}-baseline-musl`, `${base}-musl`] + : [base, `${base}-baseline`, `${base}-musl`, `${base}-baseline-musl`] + return [base, `${base}-musl`] + } + + if (arch === "x64") return baseline ? [`${base}-baseline`, base] : [base, `${base}-baseline`] + return [base] +} + +function resolveBinary(name) { + const packageJsonPath = require.resolve(`${name}/package.json`) + const binaryPath = path.join(path.dirname(packageJsonPath), "bin", sourceBinary) + if (!fs.existsSync(binaryPath)) throw new Error(`Binary not found at ${binaryPath}`) + return binaryPath +} + +function installPackage(name) { + const version = packageJson.optionalDependencies?.[name] + if (!version) return + + const temp = fs.mkdtempSync(path.join(os.tmpdir(), "opencode-install-")) + try { + const result = childProcess.spawnSync( + "npm", + ["install", "--ignore-scripts", "--no-save", "--loglevel=error", "--prefix", temp, `${name}@${version}`], + { stdio: "inherit", windowsHide: true }, + ) + if (result.status !== 0) return + const packageDir = path.join(temp, "node_modules", name) + copyBinary(path.join(packageDir, "bin", sourceBinary), targetBinary) + return true + } finally { + fs.rmSync(temp, { recursive: true, force: true }) + } +} + +function copyBinary(source, target) { + if (!fs.existsSync(source)) throw new Error(`Binary not found at ${source}`) + fs.mkdirSync(path.dirname(target), { recursive: true }) + if (fs.existsSync(target)) fs.unlinkSync(target) + try { + fs.linkSync(source, target) + } catch { + fs.copyFileSync(source, target) + } + fs.chmodSync(target, 0o755) +} + +function verifyBinary() { + const result = childProcess.spawnSync(targetBinary, ["--version"], { + encoding: "utf8", + stdio: "ignore", + windowsHide: true, + }) + return result.status === 0 +} + +function main() { + for (const name of packageNames()) { + try { + copyBinary(resolveBinary(name), targetBinary) + if (verifyBinary()) return + } catch { + if (installPackage(name) && verifyBinary()) return + } + } + + throw new Error( + `It seems your package manager failed to install the right opencode CLI package. Try manually installing ${packageNames() + .map((name) => JSON.stringify(name)) + .join(" or ")}.`, + ) } try { - void main() + main() } catch (error) { - console.error("Postinstall script error:", error.message) - process.exit(0) + console.error(error.message) + process.exit(1) } diff --git a/packages/opencode/script/publish.ts b/packages/opencode/script/publish.ts index eb48524228..e4c1732d38 100755 --- a/packages/opencode/script/publish.ts +++ b/packages/opencode/script/publish.ts @@ -32,22 +32,32 @@ console.log("binaries", binaries) const version = Object.values(binaries)[0] await $`mkdir -p ./dist/${pkg.name}` -await $`cp -r ./bin ./dist/${pkg.name}/bin` +await $`mkdir -p ./dist/${pkg.name}/bin` await $`cp ./script/postinstall.mjs ./dist/${pkg.name}/postinstall.mjs` await Bun.file(`./dist/${pkg.name}/LICENSE`).write(await Bun.file("../../LICENSE").text()) +await Bun.file(`./dist/${pkg.name}/bin/${pkg.name}.exe`).write( + [ + "#!/usr/bin/env node", + "console.error('The opencode native binary was not installed. Run `node postinstall.mjs` from the opencode-ai package directory to finish setup.')", + "process.exit(1)", + "", + ].join("\n"), +) await Bun.file(`./dist/${pkg.name}/package.json`).write( JSON.stringify( { name: pkg.name + "-ai", bin: { - [pkg.name]: `./bin/${pkg.name}`, + [pkg.name]: `./bin/${pkg.name}.exe`, }, scripts: { - postinstall: "bun ./postinstall.mjs || node ./postinstall.mjs", + postinstall: "node ./postinstall.mjs", }, version: version, license: pkg.license, + os: ["darwin", "linux", "win32"], + cpu: ["arm64", "x64"], optionalDependencies: binaries, }, null,