From 9dc8cc4e39e19801ed75f3b434077ddf008d9fc3 Mon Sep 17 00:00:00 2001 From: marwanvx Date: Mon, 14 Sep 2026 03:45:04 +0300 Subject: [PATCH] fix(cli): ship runtime binary launcher and avoid broken postinstall stub Ship bin/opencode.cjs as the npm distribution entry point instead of a dummy stub. Modern package managers (Node 26 / npm 12+ default allowScripts: false, pnpm, bun) block lifecycle install scripts by default, leaving the stub in place. Use require.resolve and directory traversal to locate the native binary from optionalDependencies at runtime, and treat postinstall pre-caching as an optional optimization that exits cleanly if blocked. Fixes #48884 --- packages/cli/bin/opencode.cjs | 11 ++- packages/cli/script/postinstall.mjs | 9 +- packages/cli/script/publish.ts | 22 ++--- packages/cli/test/launcher.test.ts | 136 ++++++++++++++++++++++++++++ 4 files changed, 161 insertions(+), 17 deletions(-) create mode 100644 packages/cli/test/launcher.test.ts diff --git a/packages/cli/bin/opencode.cjs b/packages/cli/bin/opencode.cjs index f4178dbbb5a3..46f62b0a768d 100755 --- a/packages/cli/bin/opencode.cjs +++ b/packages/cli/bin/opencode.cjs @@ -33,9 +33,9 @@ function run(target) { const envPath = process.env.OPENCODE_BIN_PATH const scriptDir = path.dirname(fs.realpathSync(__filename)) const command = path.basename(__filename).replace(/\.cjs$/, "") -const nodeBuild = command === "opencode-node" +const nodeBuild = command === "opencode-node" || command === "opencode2-node" const sourceCommand = nodeBuild ? "opencode2-node" : "opencode" -const cached = path.join(scriptDir, `.${command}`) +const cached = path.join(scriptDir, `.${sourceCommand}`) 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/cli${nodeBuild ? "-node" : ""}-` + platform + "-" + arch @@ -109,6 +109,13 @@ const names = (() => { })() function findBinary(startDir) { + for (const name of names) { + try { + const pkgPath = require.resolve(`${name}/package.json`, { paths: [startDir] }) + const candidate = path.join(path.dirname(pkgPath), "bin", binary) + if (fs.existsSync(candidate)) return candidate + } catch {} + } let current = startDir for (;;) { const modules = path.join(current, "node_modules") diff --git a/packages/cli/script/postinstall.mjs b/packages/cli/script/postinstall.mjs index 6c4b6378e7c2..891661d3ed35 100644 --- a/packages/cli/script/postinstall.mjs +++ b/packages/cli/script/postinstall.mjs @@ -17,7 +17,7 @@ const sourceCommand = packageJson.opencodeSourceBinary ?? 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 sourceBinary = platform === "windows" ? `${sourceCommand}.exe` : sourceCommand -const targetBinary = path.resolve(directory, packageJson.bin[command]) +const targetBinary = path.resolve(directory, "bin", `.${sourceCommand}`) const dependencies = packageJson.optionalDependencies ?? {} const base = Object.keys(dependencies).find((name) => name.endsWith(`-${platform}-${arch}`)) if (!base) throw new Error(`OpenCode does not provide a binary for ${platform}-${arch}`) @@ -171,6 +171,9 @@ function main() { try { main() } catch (error) { - console.error(error instanceof Error ? error.message : String(error)) - process.exit(1) + console.warn( + `[opencode] Note: Postinstall pre-caching skipped (${error instanceof Error ? error.message : String(error)}). ` + + `The runtime launcher will locate the binary automatically.`, + ) + process.exit(0) } diff --git a/packages/cli/script/publish.ts b/packages/cli/script/publish.ts index bcb511a2e1f4..fb9c8d037ff5 100755 --- a/packages/cli/script/publish.ts +++ b/packages/cli/script/publish.ts @@ -50,23 +50,21 @@ async function publishDistribution(input: { await $`mkdir -p ${input.root}/${input.name}/bin` await $`cp ./script/postinstall.mjs ${input.root}/${input.name}/postinstall.mjs` - await Bun.file(`${input.root}/${input.name}/bin/${input.command}.exe`).write( - [ - `echo "Error: ${input.name}'s postinstall script was not run." >&2`, - 'echo "" >&2', - 'echo "This occurs when installation scripts are disabled." >&2', - 'echo "Run the package postinstall script or reinstall with scripts enabled." >&2', - "exit 1", - "", - ].join("\n"), - ) + await $`cp ./bin/opencode.cjs ${input.root}/${input.name}/bin/${input.command}.cjs` + await chmod(`${input.root}/${input.name}/bin/${input.command}.cjs`, 0o755) + if (input.legacyCommand) { + await Bun.file(`${input.root}/${input.name}/bin/${input.legacyCommand}.cjs`).write( + `#!/usr/bin/env node\n\nrequire("./${input.command}.cjs")\n`, + ) + await chmod(`${input.root}/${input.name}/bin/${input.legacyCommand}.cjs`, 0o755) + } await Bun.file(`${input.root}/${input.name}/package.json`).write( JSON.stringify( { name: input.name, bin: { - [input.command]: `./bin/${input.command}.exe`, - ...(input.legacyCommand ? { [input.legacyCommand]: `./bin/${input.command}.exe` } : {}), + [input.command]: `./bin/${input.command}.cjs`, + ...(input.legacyCommand ? { [input.legacyCommand]: `./bin/${input.legacyCommand}.cjs` } : {}), }, ...(input.command !== input.binary ? { opencodeSourceBinary: input.binary } : {}), scripts: { postinstall: "node ./postinstall.mjs" }, diff --git a/packages/cli/test/launcher.test.ts b/packages/cli/test/launcher.test.ts new file mode 100644 index 000000000000..4506f80d8875 --- /dev/null +++ b/packages/cli/test/launcher.test.ts @@ -0,0 +1,136 @@ +import { expect, test } from "bun:test" +import path from "node:path" +import fs from "node:fs" +import os from "node:os" +import childProcess from "node:child_process" + +test("launcher finds native binary via require.resolve and directory traversal", () => { + const temp = fs.mkdtempSync(path.join(os.tmpdir(), "opencode-launcher-test-")) + try { + const pkgDir = path.join(temp, "node_modules", "@opencode", "cli") + const binDir = path.join(pkgDir, "bin") + const nativeDir = path.join(temp, "node_modules", "@opencode", "cli-linux-x64", "bin") + fs.mkdirSync(binDir, { recursive: true }) + fs.mkdirSync(nativeDir, { recursive: true }) + const dummyBinary = path.join(nativeDir, "opencode") + fs.writeFileSync(dummyBinary, "#!/bin/sh\necho opencode-native-ok\n") + fs.chmodSync(dummyBinary, 0o755) + + fs.writeFileSync( + path.join(temp, "node_modules", "@opencode", "cli-linux-x64", "package.json"), + JSON.stringify({ name: "@opencode/cli-linux-x64", version: "2.0.3" }), + ) + + // Copy opencode.cjs into test binDir + const launcherScript = path.join(binDir, "opencode.cjs") + fs.copyFileSync( + path.resolve(__dirname, "../bin/opencode.cjs"), + launcherScript, + ) + fs.chmodSync(launcherScript, 0o755) + + // Run the launcher script with node + const result = childProcess.spawnSync("node", [launcherScript], { + cwd: temp, + encoding: "utf8", + }) + + expect(result.stdout.trim()).toBe("opencode-native-ok") + expect(result.status).toBe(0) + } finally { + fs.rmSync(temp, { recursive: true, force: true }) + } +}) + +test("legacy alias opencode2.cjs forwards to opencode.cjs", () => { + const temp = fs.mkdtempSync(path.join(os.tmpdir(), "opencode-launcher-alias-")) + try { + const pkgDir = path.join(temp, "node_modules", "@opencode", "cli") + const binDir = path.join(pkgDir, "bin") + const nativeDir = path.join(temp, "node_modules", "@opencode", "cli-linux-x64", "bin") + fs.mkdirSync(binDir, { recursive: true }) + fs.mkdirSync(nativeDir, { recursive: true }) + const dummyBinary = path.join(nativeDir, "opencode") + fs.writeFileSync(dummyBinary, "#!/bin/sh\necho opencode2-native-ok: \"$@\"\n") + fs.chmodSync(dummyBinary, 0o755) + + fs.writeFileSync( + path.join(temp, "node_modules", "@opencode", "cli-linux-x64", "package.json"), + JSON.stringify({ name: "@opencode/cli-linux-x64", version: "2.0.3" }), + ) + + fs.copyFileSync( + path.resolve(__dirname, "../bin/opencode.cjs"), + path.join(binDir, "opencode.cjs"), + ) + fs.chmodSync(path.join(binDir, "opencode.cjs"), 0o755) + + const aliasScript = path.join(binDir, "opencode2.cjs") + fs.writeFileSync(aliasScript, `#!/usr/bin/env node\n\nrequire("./opencode.cjs")\n`) + fs.chmodSync(aliasScript, 0o755) + + const result = childProcess.spawnSync("node", [aliasScript, "--test-flag", "value"], { + cwd: temp, + encoding: "utf8", + }) + + expect(result.stdout.trim()).toBe("opencode2-native-ok: --test-flag value") + expect(result.status).toBe(0) + } finally { + fs.rmSync(temp, { recursive: true, force: true }) + } +}) + +test("launcher uses pre-cached binary when available", () => { + const temp = fs.mkdtempSync(path.join(os.tmpdir(), "opencode-launcher-cached-")) + try { + const binDir = path.join(temp, "bin") + fs.mkdirSync(binDir, { recursive: true }) + + const cachedBinary = path.join(binDir, ".opencode") + fs.writeFileSync(cachedBinary, "#!/bin/sh\necho cached-ok\n") + fs.chmodSync(cachedBinary, 0o755) + + const launcherScript = path.join(binDir, "opencode.cjs") + fs.copyFileSync( + path.resolve(__dirname, "../bin/opencode.cjs"), + launcherScript, + ) + fs.chmodSync(launcherScript, 0o755) + + const result = childProcess.spawnSync("node", [launcherScript], { + cwd: temp, + encoding: "utf8", + }) + + expect(result.stdout.trim()).toBe("cached-ok") + expect(result.status).toBe(0) + } finally { + fs.rmSync(temp, { recursive: true, force: true }) + } +}) + +test("launcher reports clear error when native package is missing", () => { + const temp = fs.mkdtempSync(path.join(os.tmpdir(), "opencode-launcher-missing-")) + try { + const binDir = path.join(temp, "bin") + fs.mkdirSync(binDir, { recursive: true }) + + const launcherScript = path.join(binDir, "opencode.cjs") + fs.copyFileSync( + path.resolve(__dirname, "../bin/opencode.cjs"), + launcherScript, + ) + fs.chmodSync(launcherScript, 0o755) + + const result = childProcess.spawnSync("node", [launcherScript], { + cwd: temp, + encoding: "utf8", + }) + + expect(result.status).toBe(1) + expect(result.stderr).toContain("It seems that your package manager failed to install the right opencode CLI package") + } finally { + fs.rmSync(temp, { recursive: true, force: true }) + } +})