Skip to content
Merged
40 changes: 40 additions & 0 deletions nix/bundle.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
#!/usr/bin/env bun

import solidPlugin from "./node_modules/@opentui/solid/scripts/solid-plugin"
import path from "path"
import fs from "fs"

const dir = process.cwd()
const parser = fs.realpathSync(path.join(dir, "node_modules/@opentui/core/parser.worker.js"))
const worker = "./src/cli/cmd/tui/worker.ts"
const version = process.env.OPENCODE_VERSION ?? "local"
const channel = process.env.OPENCODE_CHANNEL ?? "local"

fs.rmSync(path.join(dir, "dist"), { recursive: true, force: true })

const result = await Bun.build({
entrypoints: ["./src/index.ts", worker, parser],
outdir: "./dist",
target: "bun",
sourcemap: "none",
tsconfig: "./tsconfig.json",
plugins: [solidPlugin],
external: ["@opentui/core"],
define: {
OPENCODE_VERSION: `'${version}'`,
OPENCODE_CHANNEL: `'${channel}'`,
// Leave undefined so runtime picks bundled/dist worker or fallback in code.
OPENCODE_WORKER_PATH: "undefined",
OTUI_TREE_SITTER_WORKER_PATH: 'new URL("./cli/cmd/tui/parser.worker.js", import.meta.url).href',
},
})

if (!result.success) {
console.error("bundle failed")
for (const log of result.logs) console.error(log)
process.exit(1)
}

const parserOut = path.join(dir, "dist/src/cli/cmd/tui/parser.worker.js")
fs.mkdirSync(path.dirname(parserOut), { recursive: true })
await Bun.write(parserOut, Bun.file(parser))
93 changes: 59 additions & 34 deletions nix/opencode.nix
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
{ lib, stdenv, stdenvNoCC, bun, fzf, ripgrep, makeBinaryWrapper }:
{ lib, stdenvNoCC, bun, fzf, ripgrep, makeBinaryWrapper }:
args:
let
scripts = args.scripts;
Expand Down Expand Up @@ -28,64 +28,89 @@ stdenvNoCC.mkDerivation (finalAttrs: {
makeBinaryWrapper
];

configurePhase = ''
runHook preConfigure
cp -R ${finalAttrs.node_modules}/. .
runHook postConfigure
'';

env.MODELS_DEV_API_JSON = args.modelsDev;
env.OPENCODE_VERSION = args.version;
env.OPENCODE_CHANNEL = "stable";
dontConfigure = true;

buildPhase = ''
runHook preBuild

cp ${scripts + "/bun-build.ts"} bun-build.ts
cp -r ${finalAttrs.node_modules}/node_modules .
cp -r ${finalAttrs.node_modules}/packages .

substituteInPlace bun-build.ts \
--replace '@VERSION@' "${finalAttrs.version}"
(
cd packages/opencode

export BUN_COMPILE_TARGET=${args.target}
bun --bun bun-build.ts
chmod -R u+w ./node_modules
mkdir -p ./node_modules/@opencode-ai
rm -f ./node_modules/@opencode-ai/{script,sdk,plugin}
ln -s $(pwd)/../../packages/script ./node_modules/@opencode-ai/script
ln -s $(pwd)/../../packages/sdk/js ./node_modules/@opencode-ai/sdk
ln -s $(pwd)/../../packages/plugin ./node_modules/@opencode-ai/plugin

cp ${./bundle.ts} ./bundle.ts
chmod +x ./bundle.ts
bun run ./bundle.ts
)

runHook postBuild
'';

dontStrip = true;

installPhase = ''
runHook preInstall

cd packages/opencode
if [ ! -f opencode ]; then
echo "ERROR: opencode binary not found in $(pwd)"
ls -la
if [ ! -d dist ]; then
echo "ERROR: dist directory missing after bundle step"
exit 1
fi
if [ ! -f opencode-worker.js ]; then
echo "ERROR: opencode worker bundle not found in $(pwd)"
ls -la

mkdir -p $out/lib/opencode
cp -r dist $out/lib/opencode/
chmod -R u+w $out/lib/opencode/dist

# Select bundled worker assets deterministically (sorted find output)
worker_file=$(find "$out/lib/opencode/dist" -type f \( -path '*/tui/worker.*' -o -name 'worker.*' \) | sort | head -n1)
parser_worker_file=$(find "$out/lib/opencode/dist" -type f -name 'parser.worker.*' | sort | head -n1)
if [ -z "$worker_file" ]; then
echo "ERROR: bundled worker not found"
exit 1
fi

install -Dm755 opencode $out/bin/opencode
install -Dm644 opencode-worker.js $out/bin/opencode-worker.js
if [ -f opencode-assets.manifest ]; then
while IFS= read -r asset; do
[ -z "$asset" ] && continue
if [ ! -f "$asset" ]; then
echo "ERROR: referenced asset \"$asset\" missing"
exit 1
fi
install -Dm644 "$asset" "$out/bin/$(basename "$asset")"
done < opencode-assets.manifest
fi
main_wasm=$(printf '%s\n' "$out"/lib/opencode/dist/tree-sitter-*.wasm | sort | head -n1)
wasm_list=$(find "$out/lib/opencode/dist" -maxdepth 1 -name 'tree-sitter-*.wasm' -print)
for patch_file in "$worker_file" "$parser_worker_file"; do
[ -z "$patch_file" ] && continue
[ ! -f "$patch_file" ] && continue
if [ -n "$wasm_list" ] && grep -q 'tree-sitter' "$patch_file"; then
# Rewrite wasm references to absolute store paths to avoid runtime resolve failures.
bun --bun ${scripts + "/patch-wasm.ts"} "$patch_file" "$main_wasm" $wasm_list
fi
done

mkdir -p $out/lib/opencode/node_modules
cp -r ../../node_modules/.bun $out/lib/opencode/node_modules/
mkdir -p $out/lib/opencode/node_modules/@opentui

mkdir -p $out/bin
makeWrapper ${bun}/bin/bun $out/bin/opencode \
--add-flags "run" \
--add-flags "$out/lib/opencode/dist/src/index.js" \
--prefix PATH : ${lib.makeBinPath [ fzf ripgrep ]} \
--argv0 opencode

runHook postInstall
'';

postFixup = ''
wrapProgram "$out/bin/opencode" --prefix PATH : ${lib.makeBinPath [ fzf ripgrep ]}
postInstall = ''
for pkg in $out/lib/opencode/node_modules/.bun/@opentui+core-* $out/lib/opencode/node_modules/.bun/@opentui+solid-* $out/lib/opencode/node_modules/.bun/@opentui+core@* $out/lib/opencode/node_modules/.bun/@opentui+solid@*; do
if [ -d "$pkg" ]; then
pkgName=$(basename "$pkg" | sed 's/@opentui+\([^@]*\)@.*/\1/')
ln -sf ../.bun/$(basename "$pkg")/node_modules/@opentui/$pkgName \
$out/lib/opencode/node_modules/@opentui/$pkgName
fi
done
'';

meta = {
Expand Down
31 changes: 24 additions & 7 deletions nix/scripts/canonicalize-node-modules.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,15 +24,13 @@ for (const entry of directories) {
if (!info.isDirectory()) {
continue
}
const marker = entry.lastIndexOf("@")
if (marker <= 0) {
const parsed = parseEntry(entry)
if (!parsed) {
continue
}
const slug = entry.slice(0, marker).replace(/\+/g, "/")
const version = entry.slice(marker + 1)
const list = versions.get(slug) ?? []
list.push({ dir: full, version, label: entry })
versions.set(slug, list)
const list = versions.get(parsed.name) ?? []
list.push({ dir: full, version: parsed.version, label: entry })
versions.set(parsed.name, list)
}

const semverModule = (await import(join(bunRoot, "node_modules/semver"))) as
Expand Down Expand Up @@ -79,6 +77,12 @@ for (const [slug, entry] of Array.from(selections.entries()).sort((a, b) => a[0]
await mkdir(parent, { recursive: true })
const linkPath = join(parent, leaf)
const desired = join(entry.dir, "node_modules", slug)
const exists = await lstat(desired)
.then(info => info.isDirectory())
.catch(() => false)
if (!exists) {
continue
}
const relativeTarget = relative(parent, desired)
const resolved = relativeTarget.length === 0 ? "." : relativeTarget
await rm(linkPath, { recursive: true, force: true })
Expand All @@ -94,3 +98,16 @@ for (const line of rewrites.slice(0, 20)) {
if (rewrites.length > 20) {
console.log(" ...")
}

function parseEntry(label: string) {
const marker = label.startsWith("@") ? label.indexOf("@", 1) : label.indexOf("@")
if (marker <= 0) {
return null
}
const name = label.slice(0, marker).replace(/\+/g, "/")
const version = label.slice(marker + 1)
if (!name || !version) {
return null
}
return { name, version }
}
39 changes: 39 additions & 0 deletions nix/scripts/patch-wasm.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
#!/usr/bin/env bun

import fs from "fs"
import path from "path"

/**
* Rewrite tree-sitter wasm references inside a JS file to absolute paths.
* argv: [node, script, file, mainWasm, ...wasmPaths]
*/
const [, , file, mainWasm, ...wasmPaths] = process.argv

if (!file || !mainWasm) {
console.error("usage: patch-wasm <file> <mainWasm> [wasmPaths...]")
process.exit(1)
}

const content = fs.readFileSync(file, "utf8")
const byName = new Map<string, string>()

for (const wasm of wasmPaths) {
const name = path.basename(wasm)
byName.set(name, wasm)
}

let next = content

for (const [name, wasmPath] of byName) {
next = next.replaceAll(name, wasmPath)
}

next = next.replaceAll("tree-sitter.wasm", mainWasm).replaceAll("web-tree-sitter/tree-sitter.wasm", mainWasm)

// Collapse any relative prefixes before absolute store paths (e.g., "../../../..//nix/store/...")
next = next.replace(/(\.\/)+/g, "./")
next = next.replace(/(\.\.\/)+\/?(\/nix\/store[^"']+)/g, "/$2")
next = next.replace(/(["'])\/{2,}(\/nix\/store[^"']+)(["'])/g, "$1/$2$3")
next = next.replace(/(["'])\/\/(nix\/store[^"']+)(["'])/g, "$1/$2$3")

if (next !== content) fs.writeFileSync(file, next)
14 changes: 6 additions & 8 deletions packages/opencode/src/cli/cmd/tui/thread.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,16 +58,14 @@ export const TuiThreadCommand = cmd({
// Resolve relative paths against PWD to preserve behavior when using --cwd flag
const baseCwd = process.env.PWD ?? process.cwd()
const cwd = args.project ? path.resolve(baseCwd, args.project) : process.cwd()
const defaultWorker = new URL("./worker.ts", import.meta.url)
// Nix build creates a bundled worker next to the binary; prefer it when present.
const localWorker = new URL("./worker.ts", import.meta.url)
const distWorker = new URL("./cli/cmd/tui/worker.js", import.meta.url)
const execDir = path.dirname(process.execPath)
const bundledWorker = path.join(execDir, "opencode-worker.js")
const hasBundledWorker = await Bun.file(bundledWorker).exists()
const workerPath = (() => {
const workerPath = await iife(async () => {
if (typeof OPENCODE_WORKER_PATH !== "undefined") return OPENCODE_WORKER_PATH
if (hasBundledWorker) return bundledWorker
return defaultWorker
})()
if (await Bun.file(distWorker).exists()) return distWorker
return localWorker
})
try {
process.chdir(cwd)
} catch (e) {
Expand Down
Loading