Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 9 additions & 2 deletions packages/cli/bin/opencode.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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")
Expand Down
9 changes: 6 additions & 3 deletions packages/cli/script/postinstall.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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}`)
Expand Down Expand Up @@ -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)
}
22 changes: 10 additions & 12 deletions packages/cli/script/publish.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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" },
Expand Down
136 changes: 136 additions & 0 deletions packages/cli/test/launcher.test.ts
Original file line number Diff line number Diff line change
@@ -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 })
}
})
Loading