Build1 publisher3 min readPublished
Bundling Node left 24 dylib references pointing at /opt/homebrew
The installer launched fine on the build machine and quit in about a second on a Mac that had never run Homebrew. Getting it to start took 26 dylibs copied into the bundle, every path rewritten to @loader_path, and an ad hoc re-sign.
The Engineer · Build desk

What happened
- A developer wrapped the npm-distributed DeepSeek Harness agent workbench into a double-click macOS DMG that mounts, installs and opens a fullscreen Electron window with no terminal involved.
- On a Mac that had never had Homebrew installed, the app vanished after about a second and left a log file that existed but contained nothing.
- otool -L on the bundled node binary listed 24 dynamic libraries linked by absolute paths under /opt/homebrew/opt/*/lib, among them OpenSSL, ICU, libuv, SQLite and zstd.
- The fix copied every dependency into the bundle and rewrote each reference with install_name_tool -change to a @loader_path form relative to the loading binary.
Compiled by The EngineerSomething wrong?How this is made
Why it matters
- constraint Application logging cannot catch this class of failure, because the abort happens in the loader before the program runs, so the audit has to sit in the build where the Mach-O headers are still readable.
- exposure A test matrix made of developer laptops will pass a build that cannot start for users, since every machine with Homebrew installed resolves the paths successfully.
- cost A copy-and-hope script still ships a broken bundle: the walk has to recurse, and two of the references are invisible to a grep for /opt/homebrew.
- decision Anyone bundling an interpreter now chooses between carrying a rewrite-and-resign stage in the build or sourcing a binary that has no Homebrew references to start with.
dyld resolves a binary's library references when the process loads, before any of the program's own code runs, so with no /opt/homebrew on disk the abort lands ahead of Electron's first log write [6]. The log file on the clean Mac existed and was empty [4]. The developer wrote that the no-Homebrew-required claim made for 0.1.0 was false [16].
One otool -L pass on the bundled node prints 24 Homebrew paths [5]. Copying those 24 files is not sufficient, because dylibs depend on other dylibs: node pulls libnode, libnode pulls ICU, ICU pulls icudata [7]. The scan in the build script greps otool output for /opt/homebrew/, appends anything new to a list, and calls itself on each hit [7].
Two dependencies never show up in that grep, because Homebrew already references them relatively. libicuuc points at @loader_path/libicudata.78.dylib, and libbrotlidec and libbrotlienc point at @rpath/libbrotlicommon.1.dylib [8]. Both files get copied by hand and the @rpath reference rewritten [8]. That is 24 found by the script plus 2 found by reading, so 26 dylibs land in the bundle [18].
The rewrite is install_name_tool -change, once per reference, with the relative depth spelled out each time. bin/node gets @loader_path/../lib/libllhttp.9.4.dylib, and a dylib sitting in lib/ beside its dependency gets a bare @loader_path/libcrypto.3.dylib [9].
Editing Mach-O binaries inside the bundle invalidates its signature, and on Apple Silicon the OS answers with SIGKILL until the bundle is re-signed ad hoc using codesign --force --deep --sign - [10]. A build that skips the re-sign quits instantly and writes empty logs, the same signature as the unresolved dylibs [10].
The count is the part least likely to transfer to your build. 24 is what Homebrew's node linked for the arm64 Node 26.5.0 binary in this bundle [5][3]. Another formula, another Node release, or a binary pulled from somewhere other than Homebrew gives a different graph, and the only trustworthy number is what your own otool -L prints. The transferable part is the developer's summary: "copying a binary into your app bundle does not make it self-contained. You have to walk the dependency graph" [15].
The rebuilt installer came out at 620 MB against 0.1.0's 792 MB, 172 MB smaller, about 22 percent [13][17]. The seed profile went from 7 plugins to 10, with a video editor, AI image generation and cost tracking added, and dsh-vision-router bumped to 2.1.4 [14][19]. The post breaks off mid-item in the section that would explain the size reduction [22].
Verification is two commands: otool -L bin/node | grep homebrew returns nothing, and runtime/bin/node --version prints v26.5.0 [11]. The acceptance test is a grep that comes back empty. The pass is wrapped in scripts/bundle-homebrew-deps.sh, published with the repo and build scripts [12].
What to watch
- Whether the next bundled Node bump needs a fresh otool -L pass or whether bundle-homebrew-deps.sh absorbs it unchanged.
- The unpublished remainder of the post's slimming section, which would account for the 172 MB.
- Whether an official desktop build of dsh appears and removes the need to wrap the npm package at all.