summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorAlbert O'Shea <[email protected]>2025-11-18 17:46:49 +1100
committerGitHub <[email protected]>2025-11-18 00:46:49 -0600
commit5e13527416e183c7ea6d1baa3528b5c30108372e (patch)
tree6d839be0118b84974eacc670a93823974787faba
parentaba94c658f5c0987443196a5e850fdf7293d5006 (diff)
downloadopencode-5e13527416e183c7ea6d1baa3528b5c30108372e.tar.gz
opencode-5e13527416e183c7ea6d1baa3528b5c30108372e.zip
feat: nix support for the nix folks (#3924)
Co-authored-by: opencode <[email protected]> Co-authored-by: opencode-agent[bot] <opencode-agent[bot]@users.noreply.github.com>
-rw-r--r--.github/workflows/update-nix-hashes.yml79
-rw-r--r--.gitignore1
-rw-r--r--README.md3
-rw-r--r--flake.lock27
-rw-r--r--flake.nix107
-rw-r--r--nix/hashes.json3
-rw-r--r--nix/node-modules.nix52
-rw-r--r--nix/opencode.nix108
-rw-r--r--nix/scripts/bun-build.ts117
-rw-r--r--nix/scripts/canonicalize-node-modules.ts94
-rw-r--r--nix/scripts/normalize-bun-binaries.ts138
-rwxr-xr-xnix/scripts/update-hashes.sh112
-rw-r--r--packages/opencode/src/cli/cmd/tui/thread.ts15
-rw-r--r--packages/opencode/src/provider/models-macro.ts7
-rw-r--r--packages/opencode/src/tool/bash.ts14
15 files changed, 869 insertions, 8 deletions
diff --git a/.github/workflows/update-nix-hashes.yml b/.github/workflows/update-nix-hashes.yml
new file mode 100644
index 000000000..fe388697e
--- /dev/null
+++ b/.github/workflows/update-nix-hashes.yml
@@ -0,0 +1,79 @@
+name: Update Nix Hashes
+
+permissions:
+ contents: write
+
+on:
+ workflow_dispatch:
+ push:
+ paths:
+ - 'bun.lock'
+ - 'package.json'
+ - 'packages/*/package.json'
+ pull_request:
+ paths:
+ - 'bun.lock'
+ - 'package.json'
+ - 'packages/*/package.json'
+
+jobs:
+ update:
+ runs-on: ubuntu-latest
+ env:
+ SYSTEM: x86_64-linux
+
+ steps:
+ - name: Checkout repository
+ uses: actions/checkout@v4
+ with:
+ token: ${{ secrets.GITHUB_TOKEN }}
+ fetch-depth: 0
+
+ - name: Setup Nix
+ uses: DeterminateSystems/nix-installer-action@v20
+
+ - name: Configure git
+ run: |
+ git config --global user.email "[email protected]"
+ git config --global user.name "opencode"
+
+ - name: Update node_modules hash
+ run: |
+ set -euo pipefail
+ nix/scripts/update-hashes.sh
+
+ - name: Commit hash changes
+ env:
+ TARGET_BRANCH: ${{ github.head_ref || github.ref_name }}
+ run: |
+ set -euo pipefail
+
+ summarize() {
+ local status="$1"
+ {
+ echo "### Nix Hash Update"
+ echo ""
+ echo "- ref: ${GITHUB_REF_NAME}"
+ echo "- status: ${status}"
+ } >> "$GITHUB_STEP_SUMMARY"
+ if [ -n "${GITHUB_SERVER_URL:-}" ] && [ -n "${GITHUB_REPOSITORY:-}" ] && [ -n "${GITHUB_RUN_ID:-}" ]; then
+ echo "- run: ${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}" >> "$GITHUB_STEP_SUMMARY"
+ fi
+ echo "" >> "$GITHUB_STEP_SUMMARY"
+ }
+
+ FILES=(flake.nix nix/node-modules.nix nix/hashes.json)
+ STATUS="$(git status --short -- "${FILES[@]}" || true)"
+ if [ -z "$STATUS" ]; then
+ summarize "no changes"
+ echo "No changes to tracked Nix files. Hashes are already up to date."
+ exit 0
+ fi
+
+ git add "${FILES[@]}"
+ git commit -m "Update Nix hashes"
+
+ BRANCH="${TARGET_BRANCH:-${GITHUB_REF_NAME}}"
+ git push origin HEAD:"$BRANCH"
+
+ summarize "committed $(git rev-parse --short HEAD)"
diff --git a/.gitignore b/.gitignore
index b539117a2..91263f8c6 100644
--- a/.gitignore
+++ b/.gitignore
@@ -13,6 +13,7 @@ dist
.turbo
**/.serena
.serena/
+/result
refs
Session.vim
opencode.json
diff --git a/README.md b/README.md
index f136ee05e..4173fb811 100644
--- a/README.md
+++ b/README.md
@@ -28,9 +28,10 @@ curl -fsSL https://opencode.ai/install | bash
npm i -g opencode-ai@latest # or bun/pnpm/yarn
scoop bucket add extras; scoop install extras/opencode # Windows
choco install opencode # Windows
-brew install opencode # macOS and Linux
+brew install opencode # macOS and Linux
paru -S opencode-bin # Arch Linux
mise use --pin -g ubi:sst/opencode # Any OS
+nix run nixpkgs#opencode # or github:sst/opencode for latest dev branch
```
> [!TIP]
diff --git a/flake.lock b/flake.lock
new file mode 100644
index 000000000..c9a945db5
--- /dev/null
+++ b/flake.lock
@@ -0,0 +1,27 @@
+{
+ "nodes": {
+ "nixpkgs": {
+ "locked": {
+ "lastModified": 1762156382,
+ "narHash": "sha256-Yg7Ag7ov5+36jEFC1DaZh/12SEXo6OO3/8rqADRxiqs=",
+ "owner": "NixOS",
+ "repo": "nixpkgs",
+ "rev": "7241bcbb4f099a66aafca120d37c65e8dda32717",
+ "type": "github"
+ },
+ "original": {
+ "owner": "NixOS",
+ "ref": "nixpkgs-unstable",
+ "repo": "nixpkgs",
+ "type": "github"
+ }
+ },
+ "root": {
+ "inputs": {
+ "nixpkgs": "nixpkgs"
+ }
+ }
+ },
+ "root": "root",
+ "version": 7
+}
diff --git a/flake.nix b/flake.nix
new file mode 100644
index 000000000..a6614a5dc
--- /dev/null
+++ b/flake.nix
@@ -0,0 +1,107 @@
+{
+ description = "OpenCode development flake";
+
+ inputs = {
+ nixpkgs.url = "github:NixOS/nixpkgs/nixpkgs-unstable";
+ };
+
+ outputs =
+ {
+ nixpkgs,
+ ...
+ }:
+ let
+ systems = [
+ "aarch64-linux"
+ "x86_64-linux"
+ "aarch64-darwin"
+ "x86_64-darwin"
+ ];
+ lib = nixpkgs.lib;
+ forEachSystem = lib.genAttrs systems;
+ pkgsFor = system: nixpkgs.legacyPackages.${system};
+ packageJson = builtins.fromJSON (builtins.readFile ./packages/opencode/package.json);
+ bunTarget = {
+ "aarch64-linux" = "bun-linux-arm64";
+ "x86_64-linux" = "bun-linux-x64";
+ "aarch64-darwin" = "bun-darwin-arm64";
+ "x86_64-darwin" = "bun-darwin-x64";
+ };
+ defaultNodeModules = "sha256-AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=";
+ hashesFile = "${./nix}/hashes.json";
+ hashesData =
+ if builtins.pathExists hashesFile then builtins.fromJSON (builtins.readFile hashesFile) else { };
+ nodeModulesHash = hashesData.nodeModules or defaultNodeModules;
+ modelsDev = forEachSystem (
+ system:
+ let
+ pkgs = pkgsFor system;
+ in
+ pkgs."models-dev"
+ );
+ in
+ {
+ devShells = forEachSystem (
+ system:
+ let
+ pkgs = pkgsFor system;
+ in
+ {
+ default = pkgs.mkShell {
+ packages = with pkgs; [
+ bun
+ nodejs_20
+ pkg-config
+ openssl
+ git
+ ];
+ };
+ }
+ );
+
+ packages = forEachSystem (
+ system:
+ let
+ pkgs = pkgsFor system;
+ mkNodeModules = pkgs.callPackage ./nix/node-modules.nix {
+ hash = nodeModulesHash;
+ };
+ mkPackage = pkgs.callPackage ./nix/opencode.nix { };
+ in
+ {
+ default = mkPackage {
+ version = packageJson.version;
+ src = ./.;
+ scripts = ./nix/scripts;
+ target = bunTarget.${system};
+ modelsDev = "${modelsDev.${system}}/dist/_api.json";
+ mkNodeModules = mkNodeModules;
+ };
+ }
+ );
+
+ apps = forEachSystem (
+ system:
+ let
+ pkgs = pkgsFor system;
+ in
+ {
+ opencode-dev = {
+ type = "app";
+ meta = {
+ description = "Nix devshell shell for OpenCode";
+ runtimeInputs = [ pkgs.bun ];
+ };
+ program = "${
+ pkgs.writeShellApplication {
+ name = "opencode-dev";
+ text = ''
+ exec bun run dev "$@"
+ '';
+ }
+ }/bin/opencode-dev";
+ };
+ }
+ );
+ };
+}
diff --git a/nix/hashes.json b/nix/hashes.json
new file mode 100644
index 000000000..b66407b4f
--- /dev/null
+++ b/nix/hashes.json
@@ -0,0 +1,3 @@
+{
+ "nodeModules": "sha256-srbGIRjvpqUF+jWq4GAx7sGAasq02dRySnxTjijJJT8="
+}
diff --git a/nix/node-modules.nix b/nix/node-modules.nix
new file mode 100644
index 000000000..7b22ef8e7
--- /dev/null
+++ b/nix/node-modules.nix
@@ -0,0 +1,52 @@
+{ hash, lib, stdenvNoCC, bun, cacert, curl }:
+args:
+stdenvNoCC.mkDerivation {
+ pname = "opencode-node_modules";
+ version = args.version;
+ src = args.src;
+
+ impureEnvVars =
+ lib.fetchers.proxyImpureEnvVars
+ ++ [
+ "GIT_PROXY_COMMAND"
+ "SOCKS_SERVER"
+ ];
+
+ nativeBuildInputs = [ bun cacert curl ];
+
+ dontConfigure = true;
+
+ buildPhase = ''
+ runHook preBuild
+ export HOME=$(mktemp -d)
+ export BUN_INSTALL_CACHE_DIR=$(mktemp -d)
+ bun install \
+ --cpu="*" \
+ --os="*" \
+ --frozen-lockfile \
+ --ignore-scripts \
+ --no-progress \
+ --linker=isolated
+ bun --bun ${args.canonicalizeScript}
+ bun --bun ${args.normalizeBinsScript}
+ runHook postBuild
+ '';
+
+ installPhase = ''
+ runHook preInstall
+ mkdir -p $out
+ while IFS= read -r dir; do
+ rel="''${dir#./}"
+ dest="$out/$rel"
+ mkdir -p "$(dirname "$dest")"
+ cp -R "$dir" "$dest"
+ done < <(find . -type d -name node_modules -prune | sort)
+ runHook postInstall
+ '';
+
+ dontFixup = true;
+
+ outputHashAlgo = "sha256";
+ outputHashMode = "recursive";
+ outputHash = hash;
+}
diff --git a/nix/opencode.nix b/nix/opencode.nix
new file mode 100644
index 000000000..bec299760
--- /dev/null
+++ b/nix/opencode.nix
@@ -0,0 +1,108 @@
+{ lib, stdenv, stdenvNoCC, bun, fzf, ripgrep, makeBinaryWrapper }:
+args:
+let
+ scripts = args.scripts;
+ mkModules =
+ attrs:
+ args.mkNodeModules (
+ attrs
+ // {
+ canonicalizeScript = scripts + "/canonicalize-node-modules.ts";
+ normalizeBinsScript = scripts + "/normalize-bun-binaries.ts";
+ }
+ );
+in
+stdenvNoCC.mkDerivation (finalAttrs: {
+ pname = "opencode";
+ version = args.version;
+
+ src = args.src;
+
+ node_modules = mkModules {
+ version = finalAttrs.version;
+ src = finalAttrs.src;
+ };
+
+ nativeBuildInputs = [
+ bun
+ 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";
+
+ buildPhase = ''
+ runHook preBuild
+
+ cp ${scripts + "/bun-build.ts"} bun-build.ts
+
+ substituteInPlace bun-build.ts \
+ --replace '@VERSION@' "${finalAttrs.version}"
+
+ export BUN_COMPILE_TARGET=${args.target}
+ bun --bun bun-build.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
+ exit 1
+ fi
+ if [ ! -f opencode-worker.js ]; then
+ echo "ERROR: opencode worker bundle not found in $(pwd)"
+ ls -la
+ 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
+ runHook postInstall
+ '';
+
+ postFixup = ''
+ wrapProgram "$out/bin/opencode" --prefix PATH : ${lib.makeBinPath [ fzf ripgrep ]}
+ '';
+
+ meta = {
+ description = "AI coding agent built for the terminal";
+ longDescription = ''
+ OpenCode is a terminal-based agent that can build anything.
+ It combines a TypeScript/JavaScript core with a Go-based TUI
+ to provide an interactive AI coding experience.
+ '';
+ homepage = "https://github.com/sst/opencode";
+ license = lib.licenses.mit;
+ platforms = [
+ "aarch64-linux"
+ "x86_64-linux"
+ "aarch64-darwin"
+ "x86_64-darwin"
+ ];
+ mainProgram = "opencode";
+ };
+})
diff --git a/nix/scripts/bun-build.ts b/nix/scripts/bun-build.ts
new file mode 100644
index 000000000..1c61a07d7
--- /dev/null
+++ b/nix/scripts/bun-build.ts
@@ -0,0 +1,117 @@
+import solidPlugin from "./packages/opencode/node_modules/@opentui/solid/scripts/solid-plugin"
+import path from "path"
+import fs from "fs"
+
+const version = "@VERSION@"
+const pkg = path.join(process.cwd(), "packages/opencode")
+const parser = fs.realpathSync(
+ path.join(pkg, "./node_modules/@opentui/core/parser.worker.js"),
+)
+const worker = "./src/cli/cmd/tui/worker.ts"
+const target = process.env["BUN_COMPILE_TARGET"]
+
+if (!target) {
+ throw new Error("BUN_COMPILE_TARGET not set")
+}
+
+process.chdir(pkg)
+
+const manifestName = "opencode-assets.manifest"
+const manifestPath = path.join(pkg, manifestName)
+
+const readTrackedAssets = () => {
+ if (!fs.existsSync(manifestPath)) return []
+ return fs
+ .readFileSync(manifestPath, "utf8")
+ .split("\n")
+ .map((line) => line.trim())
+ .filter((line) => line.length > 0)
+}
+
+const removeTrackedAssets = () => {
+ for (const file of readTrackedAssets()) {
+ const filePath = path.join(pkg, file)
+ if (fs.existsSync(filePath)) {
+ fs.rmSync(filePath, { force: true })
+ }
+ }
+}
+
+const assets = new Set<string>()
+
+const addAsset = async (p: string) => {
+ const file = path.basename(p)
+ const dest = path.join(pkg, file)
+ await Bun.write(dest, Bun.file(p))
+ assets.add(file)
+}
+
+removeTrackedAssets()
+
+const result = await Bun.build({
+ conditions: ["browser"],
+ tsconfig: "./tsconfig.json",
+ plugins: [solidPlugin],
+ sourcemap: "external",
+ entrypoints: ["./src/index.ts", parser, worker],
+ define: {
+ OPENCODE_VERSION: `'@VERSION@'`,
+ OTUI_TREE_SITTER_WORKER_PATH: "/$bunfs/root/" + path.relative(pkg, parser).replace(/\\/g, "/"),
+ OPENCODE_CHANNEL: "'latest'",
+ },
+ compile: {
+ target,
+ outfile: "opencode",
+ execArgv: ["--user-agent=opencode/" + version, "--env-file=\"\"", "--"],
+ windows: {},
+ },
+})
+
+if (!result.success) {
+ console.error("Build failed!")
+ for (const log of result.logs) {
+ console.error(log)
+ }
+ throw new Error("Compilation failed")
+}
+
+const assetOutputs = result.outputs?.filter((x) => x.kind === "asset") ?? []
+for (const x of assetOutputs) {
+ await addAsset(x.path)
+}
+
+const bundle = await Bun.build({
+ entrypoints: [worker],
+ tsconfig: "./tsconfig.json",
+ plugins: [solidPlugin],
+ target: "bun",
+ outdir: "./.opencode-worker",
+ sourcemap: "none",
+})
+
+if (!bundle.success) {
+ console.error("Worker build failed!")
+ for (const log of bundle.logs) {
+ console.error(log)
+ }
+ throw new Error("Worker compilation failed")
+}
+
+const workerAssets = bundle.outputs?.filter((x) => x.kind === "asset") ?? []
+for (const x of workerAssets) {
+ await addAsset(x.path)
+}
+
+const output = bundle.outputs.find((x) => x.kind === "entry-point")
+if (!output) {
+ throw new Error("Worker build produced no entry-point output")
+}
+
+const dest = path.join(pkg, "opencode-worker.js")
+await Bun.write(dest, Bun.file(output.path))
+fs.rmSync(path.dirname(output.path), { recursive: true, force: true })
+
+const list = Array.from(assets)
+await Bun.write(manifestPath, list.length > 0 ? list.join("\n") + "\n" : "")
+
+console.log("Build successful!")
diff --git a/nix/scripts/canonicalize-node-modules.ts b/nix/scripts/canonicalize-node-modules.ts
new file mode 100644
index 000000000..791d96dfe
--- /dev/null
+++ b/nix/scripts/canonicalize-node-modules.ts
@@ -0,0 +1,94 @@
+import { lstat, mkdir, readdir, rm, symlink } from "fs/promises"
+import { join, relative } from "path"
+
+type SemverLike = {
+ valid: (value: string) => string | null
+ rcompare: (left: string, right: string) => number
+}
+
+type Entry = {
+ dir: string
+ version: string
+ label: string
+}
+
+const root = process.cwd()
+const bunRoot = join(root, "node_modules/.bun")
+const linkRoot = join(bunRoot, "node_modules")
+const directories = (await readdir(bunRoot)).sort()
+const versions = new Map<string, Entry[]>()
+
+for (const entry of directories) {
+ const full = join(bunRoot, entry)
+ const info = await lstat(full)
+ if (!info.isDirectory()) {
+ continue
+ }
+ const marker = entry.lastIndexOf("@")
+ if (marker <= 0) {
+ 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 semverModule = (await import(join(bunRoot, "node_modules/semver"))) as SemverLike | {
+ default: SemverLike
+}
+const semver = "default" in semverModule ? semverModule.default : semverModule
+const selections = new Map<string, Entry>()
+
+for (const [slug, list] of versions) {
+ list.sort((a, b) => {
+ const left = semver.valid(a.version)
+ const right = semver.valid(b.version)
+ if (left && right) {
+ const delta = semver.rcompare(left, right)
+ if (delta !== 0) {
+ return delta
+ }
+ }
+ if (left && !right) {
+ return -1
+ }
+ if (!left && right) {
+ return 1
+ }
+ return b.version.localeCompare(a.version)
+ })
+ selections.set(slug, list[0])
+}
+
+await rm(linkRoot, { recursive: true, force: true })
+await mkdir(linkRoot, { recursive: true })
+
+const rewrites: string[] = []
+
+for (const [slug, entry] of Array.from(selections.entries()).sort((a, b) => a[0].localeCompare(b[0]))) {
+ const parts = slug.split("/")
+ const leaf = parts.pop()
+ if (!leaf) {
+ continue
+ }
+ const parent = join(linkRoot, ...parts)
+ await mkdir(parent, { recursive: true })
+ const linkPath = join(parent, leaf)
+ const desired = join(entry.dir, "node_modules", slug)
+ const relativeTarget = relative(parent, desired)
+ const resolved = relativeTarget.length === 0 ? "." : relativeTarget
+ await rm(linkPath, { recursive: true, force: true })
+ await symlink(resolved, linkPath)
+ rewrites.push(slug + " -> " + resolved)
+}
+
+rewrites.sort()
+console.log("[canonicalize-node-modules] rebuilt", rewrites.length, "links")
+for (const line of rewrites.slice(0, 20)) {
+ console.log(" ", line)
+}
+if (rewrites.length > 20) {
+ console.log(" ...")
+} \ No newline at end of file
diff --git a/nix/scripts/normalize-bun-binaries.ts b/nix/scripts/normalize-bun-binaries.ts
new file mode 100644
index 000000000..531d8fd05
--- /dev/null
+++ b/nix/scripts/normalize-bun-binaries.ts
@@ -0,0 +1,138 @@
+import { lstat, mkdir, readdir, rm, symlink } from "fs/promises"
+import { join, relative } from "path"
+
+type PackageManifest = {
+ name?: string
+ bin?: string | Record<string, string>
+}
+
+const root = process.cwd()
+const bunRoot = join(root, "node_modules/.bun")
+const bunEntries = (await safeReadDir(bunRoot)).sort()
+let rewritten = 0
+
+for (const entry of bunEntries) {
+ const modulesRoot = join(bunRoot, entry, "node_modules")
+ if (!(await exists(modulesRoot))) {
+ continue
+ }
+ const binRoot = join(modulesRoot, ".bin")
+ await rm(binRoot, { recursive: true, force: true })
+ await mkdir(binRoot, { recursive: true })
+
+ const packageDirs = await collectPackages(modulesRoot)
+ for (const packageDir of packageDirs) {
+ const manifest = await readManifest(packageDir)
+ if (!manifest) {
+ continue
+ }
+ const binField = manifest.bin
+ if (!binField) {
+ continue
+ }
+ const seen = new Set<string>()
+ if (typeof binField === "string") {
+ const fallback = manifest.name ?? packageDir.split("/").pop()
+ if (fallback) {
+ await linkBinary(binRoot, fallback, packageDir, binField, seen)
+ }
+ } else {
+ const entries = Object.entries(binField).sort((a, b) => a[0].localeCompare(b[0]))
+ for (const [name, target] of entries) {
+ await linkBinary(binRoot, name, packageDir, target, seen)
+ }
+ }
+ }
+}
+
+console.log(`[normalize-bun-binaries] rewrote ${rewritten} links`)
+
+async function collectPackages(modulesRoot: string) {
+ const found: string[] = []
+ const topLevel = (await safeReadDir(modulesRoot)).sort()
+ for (const name of topLevel) {
+ if (name === ".bin" || name === ".bun") {
+ continue
+ }
+ const full = join(modulesRoot, name)
+ if (!(await isDirectory(full))) {
+ continue
+ }
+ if (name.startsWith("@")) {
+ const scoped = (await safeReadDir(full)).sort()
+ for (const child of scoped) {
+ const scopedDir = join(full, child)
+ if (await isDirectory(scopedDir)) {
+ found.push(scopedDir)
+ }
+ }
+ continue
+ }
+ found.push(full)
+ }
+ return found.sort()
+}
+
+async function readManifest(dir: string) {
+ const file = Bun.file(join(dir, "package.json"))
+ if (!(await file.exists())) {
+ return null
+ }
+ const data = (await file.json()) as PackageManifest
+ return data
+}
+
+async function linkBinary(binRoot: string, name: string, packageDir: string, target: string, seen: Set<string>) {
+ if (!name || !target) {
+ return
+ }
+ const normalizedName = normalizeBinName(name)
+ if (seen.has(normalizedName)) {
+ return
+ }
+ const resolved = join(packageDir, target)
+ const script = Bun.file(resolved)
+ if (!(await script.exists())) {
+ return
+ }
+ seen.add(normalizedName)
+ const destination = join(binRoot, normalizedName)
+ const relativeTarget = relative(binRoot, resolved) || "."
+ await rm(destination, { force: true })
+ await symlink(relativeTarget, destination)
+ rewritten++
+}
+
+async function exists(path: string) {
+ try {
+ await lstat(path)
+ return true
+ } catch {
+ return false
+ }
+}
+
+async function isDirectory(path: string) {
+ try {
+ const info = await lstat(path)
+ return info.isDirectory()
+ } catch {
+ return false
+ }
+}
+
+async function safeReadDir(path: string) {
+ try {
+ return await readdir(path)
+ } catch {
+ return []
+ }
+}
+
+function normalizeBinName(name: string) {
+ const slash = name.lastIndexOf("/")
+ if (slash >= 0) {
+ return name.slice(slash + 1)
+ }
+ return name
+}
diff --git a/nix/scripts/update-hashes.sh b/nix/scripts/update-hashes.sh
new file mode 100755
index 000000000..7bf183c5b
--- /dev/null
+++ b/nix/scripts/update-hashes.sh
@@ -0,0 +1,112 @@
+#!/usr/bin/env bash
+
+set -euo pipefail
+
+DUMMY="sha256-AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA="
+SYSTEM=${SYSTEM:-x86_64-linux}
+DEFAULT_HASH_FILE=${MODULES_HASH_FILE:-nix/hashes.json}
+HASH_FILE=${HASH_FILE:-$DEFAULT_HASH_FILE}
+
+if [ ! -f "$HASH_FILE" ]; then
+ cat >"$HASH_FILE" <<EOF
+{
+ "nodeModules": "$DUMMY"
+}
+EOF
+fi
+
+if git rev-parse --is-inside-work-tree >/dev/null 2>&1; then
+ if ! git ls-files --error-unmatch "$HASH_FILE" >/dev/null 2>&1; then
+ git add -N "$HASH_FILE" >/dev/null 2>&1 || true
+ fi
+fi
+
+export DUMMY
+export NIX_KEEP_OUTPUTS=1
+export NIX_KEEP_DERIVATIONS=1
+
+cleanup() {
+ rm -f "${JSON_OUTPUT:-}" "${BUILD_LOG:-}" "${TMP_EXPR:-}"
+}
+
+trap cleanup EXIT
+
+write_node_modules_hash() {
+ local value="$1"
+ local temp
+ temp=$(mktemp)
+ jq --arg value "$value" '.nodeModules = $value' "$HASH_FILE" >"$temp"
+ mv "$temp" "$HASH_FILE"
+}
+
+TARGET="packages.${SYSTEM}.default"
+MODULES_ATTR=".#packages.${SYSTEM}.default.node_modules"
+CORRECT_HASH=""
+
+DRV_PATH="$(nix eval --raw "${MODULES_ATTR}.drvPath")"
+
+echo "Setting dummy node_modules outputHash for ${SYSTEM}..."
+write_node_modules_hash "$DUMMY"
+
+BUILD_LOG=$(mktemp)
+JSON_OUTPUT=$(mktemp)
+
+echo "Building node_modules for ${SYSTEM} to discover correct outputHash..."
+echo "Attempting to realize derivation: ${DRV_PATH}"
+REALISE_OUT=$(nix-store --realise "$DRV_PATH" --keep-failed 2>&1 | tee "$BUILD_LOG" || true)
+
+BUILD_PATH=$(echo "$REALISE_OUT" | grep "^/nix/store/" | head -n1 || true)
+if [ -n "$BUILD_PATH" ] && [ -d "$BUILD_PATH" ]; then
+ echo "Realized node_modules output: $BUILD_PATH"
+ CORRECT_HASH=$(nix hash path --sri "$BUILD_PATH" 2>/dev/null || true)
+fi
+
+if [ -z "$CORRECT_HASH" ]; then
+ CORRECT_HASH="$(grep -E 'got:\s+sha256-[A-Za-z0-9+/=]+' "$BUILD_LOG" | awk '{print $2}' | head -n1 || true)"
+
+ if [ -z "$CORRECT_HASH" ]; then
+ CORRECT_HASH="$(grep -A2 'hash mismatch' "$BUILD_LOG" | grep 'got:' | awk '{print $2}' | sed 's/sha256:/sha256-/' || true)"
+ fi
+
+ if [ -z "$CORRECT_HASH" ]; then
+ echo "Searching for kept failed build directory..."
+ KEPT_DIR=$(grep -oE "build directory.*'[^']+'" "$BUILD_LOG" | grep -oE "'/[^']+'" | tr -d "'" | head -n1)
+
+ if [ -z "$KEPT_DIR" ]; then
+ KEPT_DIR=$(grep -oE '/nix/var/nix/builds/[^ ]+' "$BUILD_LOG" | head -n1)
+ fi
+
+ if [ -n "$KEPT_DIR" ] && [ -d "$KEPT_DIR" ]; then
+ echo "Found kept build directory: $KEPT_DIR"
+ if [ -d "$KEPT_DIR/build" ]; then
+ HASH_PATH="$KEPT_DIR/build"
+ else
+ HASH_PATH="$KEPT_DIR"
+ fi
+
+ echo "Attempting to hash: $HASH_PATH"
+ ls -la "$HASH_PATH" || true
+
+ if [ -d "$HASH_PATH/node_modules" ]; then
+ CORRECT_HASH=$(nix hash path --sri "$HASH_PATH" 2>/dev/null || true)
+ echo "Computed hash from kept build: $CORRECT_HASH"
+ fi
+ fi
+ fi
+fi
+
+if [ -z "$CORRECT_HASH" ]; then
+ echo "Failed to determine correct node_modules hash for ${SYSTEM}."
+ echo "Build log:"
+ cat "$BUILD_LOG"
+ exit 1
+fi
+
+write_node_modules_hash "$CORRECT_HASH"
+
+jq -e --arg hash "$CORRECT_HASH" '.nodeModules == $hash' "$HASH_FILE" >/dev/null
+
+echo "node_modules hash updated for ${SYSTEM}: $CORRECT_HASH"
+
+rm -f "$BUILD_LOG"
+unset BUILD_LOG
diff --git a/packages/opencode/src/cli/cmd/tui/thread.ts b/packages/opencode/src/cli/cmd/tui/thread.ts
index 5b6f5e83b..bd9b9dbab 100644
--- a/packages/opencode/src/cli/cmd/tui/thread.ts
+++ b/packages/opencode/src/cli/cmd/tui/thread.ts
@@ -57,11 +57,16 @@ 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()
- let workerPath: string | URL = new URL("./worker.ts", import.meta.url)
-
- if (typeof OPENCODE_WORKER_PATH !== "undefined") {
- workerPath = OPENCODE_WORKER_PATH
- }
+ const defaultWorker = new URL("./worker.ts", import.meta.url)
+ // Nix build creates a bundled worker next to the binary; prefer it when present.
+ const execDir = path.dirname(process.execPath)
+ const bundledWorker = path.join(execDir, "opencode-worker.js")
+ const hasBundledWorker = await Bun.file(bundledWorker).exists()
+ const workerPath = (() => {
+ if (typeof OPENCODE_WORKER_PATH !== "undefined") return OPENCODE_WORKER_PATH
+ if (hasBundledWorker) return bundledWorker
+ return defaultWorker
+ })()
try {
process.chdir(cwd)
} catch (e) {
diff --git a/packages/opencode/src/provider/models-macro.ts b/packages/opencode/src/provider/models-macro.ts
index 91a0348e8..6c8492a70 100644
--- a/packages/opencode/src/provider/models-macro.ts
+++ b/packages/opencode/src/provider/models-macro.ts
@@ -1,4 +1,11 @@
export async function data() {
+ const path = Bun.env.MODELS_DEV_API_JSON
+ if (path) {
+ const file = Bun.file(path)
+ if (await file.exists()) {
+ return await file.text()
+ }
+ }
const json = await fetch("https://models.dev/api.json").then((x) => x.text())
return json
}
diff --git a/packages/opencode/src/tool/bash.ts b/packages/opencode/src/tool/bash.ts
index f184d5efe..445c7b68a 100644
--- a/packages/opencode/src/tool/bash.ts
+++ b/packages/opencode/src/tool/bash.ts
@@ -11,6 +11,7 @@ import { $ } from "bun"
import { Filesystem } from "@/util/filesystem"
import { Wildcard } from "@/util/wildcard"
import { Permission } from "@/permission"
+import { fileURLToPath } from "url"
const MAX_OUTPUT_LENGTH = 30_000
const DEFAULT_TIMEOUT = 1 * 60 * 1000
@@ -19,20 +20,29 @@ const SIGKILL_TIMEOUT_MS = 200
export const log = Log.create({ service: "bash-tool" })
+const resolveWasm = (asset: string) => {
+ if (asset.startsWith("file://")) return fileURLToPath(asset)
+ if (asset.startsWith("/")) return asset
+ const url = new URL(asset, import.meta.url)
+ return fileURLToPath(url)
+}
+
const parser = lazy(async () => {
const { Parser } = await import("web-tree-sitter")
const { default: treeWasm } = await import("web-tree-sitter/tree-sitter.wasm" as string, {
with: { type: "wasm" },
})
+ const treePath = resolveWasm(treeWasm)
await Parser.init({
locateFile() {
- return treeWasm
+ return treePath
},
})
const { default: bashWasm } = await import("tree-sitter-bash/tree-sitter-bash.wasm" as string, {
with: { type: "wasm" },
})
- const bashLanguage = await Language.load(bashWasm)
+ const bashPath = resolveWasm(bashWasm)
+ const bashLanguage = await Language.load(bashPath)
const p = new Parser()
p.setLanguage(bashLanguage)
return p