Skip to content

fix(install): build native addons on Homebrew installs - #120

Merged
hristo2612 merged 1 commit into
mainfrom
fix/homebrew-install-native-deps
Aug 3, 2026
Merged

fix(install): build native addons on Homebrew installs#120
hristo2612 merged 1 commit into
mainfrom
fix/homebrew-install-native-deps

Conversation

@hristo2612

Copy link
Copy Markdown
Owner

Thanks to @yoobi for both reports — they were precise, correctly identified the shared cause in the formula, and correctly called out that #109 was masked by #108. Diagnosis confirmed on a reproduction of the published 0.29.0 tarball.

Root causes

Both issues come from one line. Homebrew's std_npm_args folds in Language::Node.npm_install_security_args, whose ignore_scripts: parameter defaults to true, so the formula installed the whole tree with --ignore-scripts.

#108 — better-sqlite3 binding never built. better-sqlite3 declares "install": "prebuild-install || node-gyp rebuild --release". With scripts suppressed it never runs, and the package has no build/ directory at all. The gateway dies at initDb with Could not locate the bindings file.

#109 — node-pty spawn-helper not executable. With scripts suppressed, node-pty falls back to its checked-in prebuilds, whose spawn-helper ships at mode 0644. On macOS unixTerminal.js posix_spawns that helper on every pty, so every session dies with posix_spawnp failed.. jinn-cli's own postinstall exists to restore that bit and is skipped for exactly the same reason.

A third cause, which the one-line formula fix would have hit

Enabling scripts alone is not sufficient, and on its own makes things worse. std_npm_args also passes --build-from-source. Under that flag node-pty compiles and deletes prebuilds/ entirely, leaving only build/Release/{pty.node,spawn-helper}. The previous scripts/fix-node-pty-permissions.mjs did readdir(nodePtyRoot + "/prebuilds") and threw on ENOENT, which fails npm install and would have turned a runtime crash into a hard brew install failure.

Verified against the published tarball:

npm error command sh -c node scripts/fix-node-pty-permissions.mjs
npm error Error: jinn-cli postinstall could not read node-pty prebuilds at .../node_modules/node-pty
npm error   [cause]: Error: ENOENT: no such file or directory, scandir '.../node-pty/prebuilds'

Why this shipped undetected

require('better-sqlite3') succeeds on a binding-less install — the binding is resolved lazily inside the Database constructor, not at module load. Measured on the broken tree:

require('better-sqlite3') alone: SUCCEEDS (false negative!)
construct: threw, classified as: binding-missing

So two things that looked like they were guarding this were not:

  • assertNativeRuntime() in bin/jinn.ts and daemon-entry.ts only did a bare require, so it passed and let the raw bindings stack trace escape from deep inside boot.
  • The formula's test do block asserted require('better-sqlite3'). Contrary to the hope in Homebrew install: jinn start crashes because the better-sqlite3 native binding is never built #108, that line would not have caught this bug even if brew test had run. The same is true of require('node-pty'), which passes fine while spawn-helper is 0644.

That block also asserted require('classic-level'), which is a root workspace dependency and is not published inside jinn-cli, so it could only ever fail once someone actually ran brew test.

What changed

  1. Formula/jinn.rbsystem "npm", "install", *std_npm_args(ignore_scripts: false). This makes the compile that depends_on "python" => :build and --build-from-source already imply actually happen.
  2. packages/jinn/scripts/fix-node-pty-permissions.mjs — covers every layout node-pty resolves from (build/Release, build/Debug, prebuilds/<platform>-<arch>) instead of assuming prebuilds/, and never fails an install over a permission fix-up. It warns and defers to the runtime repair instead.
  3. packages/jinn/src/shared/runtime-guard.tsassertNativeRuntime() now opens a :memory: database rather than only requiring the module, and distinguishes binding-missing from abi-mismatch so a never-built addon stops being reported as a Node version mismatch it is not. New repairNodePtySpawnHelper() restores the spawn-helper exec bit at startup; wired into both bin/jinn.ts and daemon-entry.ts.
  4. Formula test do — opens a database and really spawns through node-pty, since both bugs survive a bare require. Drops the classic-level assertion.

Why a runtime self-heal as well as the formula fix

The formula fix is the real root-cause fix and is what closes both issues for new installs. The startup repair is kept alongside it for the part packaging cannot reach:

The equivalent was deliberately not done for better-sqlite3: fabricating a native addon at runtime means shelling out to node-gyp/prebuild-install, which needs network and a compiler toolchain and takes minutes. Doing that silently from a daemon start is worse than failing. Instead the guard now detects that exact state and prints the precise command to run. Shipping prebuilt binaries in the tarball was also rejected — better-sqlite3 is a transitive dependency, not ours to vendor, and it would multiply the package size per platform.

Verification

Reproduced and fixed against the real published jinn-cli-0.29.0 tarball in a scratch prefix, using Homebrew's exact flags.

Broken (current formula, --ignore-scripts) — both symptoms reproduce:

better-sqlite3/build/Release/: No such file or directory
-rw-r--r--  node-pty/prebuilds/darwin-arm64/spawn-helper
Error: posix_spawnp failed.

Startup repair, applied to that same broken tree:

=== BEFORE ===
-rw-r--r--  .../node-pty/prebuilds/darwin-arm64/spawn-helper
Error: posix_spawnp failed.
=== APPLY REPAIR ===
repaired: [ '.../node-pty/prebuilds/darwin-arm64/spawn-helper' ]
=== AFTER ===
-rwxr-xr-x  .../node-pty/prebuilds/darwin-arm64/spawn-helper
PTY SPAWN OK
=== IDEMPOTENT re-run ===
repaired: []

Fixed formula flags + this branch's package (npm install --global --build-from-source, i.e. std_npm_args(ignore_scripts: false)):

NPM INSTALL EXIT=0
-rwxr-xr-x  node-pty/build/Release/pty.node
-rwxr-xr-x  node-pty/build/Release/spawn-helper
-rwxr-xr-x  better-sqlite3/build/Release/better_sqlite3.node
  -> better-sqlite3 open: PASS
  -> node-pty spawn: PASS
jinn --version -> 0.29.0

Regression coverage. Three new suites, all of which fail without this change (13 of 15 failed on the pre-fix source; the postinstall suite fails 3 of 5 against the old script):

  • src/shared/__tests__/runtime-guard.test.ts — failure classification and the spawn-helper repair across all three node-pty layouts.
  • src/shared/__tests__/postinstall-node-pty.test.ts — runs the real postinstall as npm would, including the source-built layout with no prebuilds/ that previously threw.
  • src/shared/__tests__/homebrew-formula.test.ts — locks the formula's ignore_scripts: false, the non-false-negative test assertions, and that the test block only requires modules the published package actually depends on.

ruby -c Formula/jinn.rb → Syntax OK. pnpm typecheck at root → 2 successful. Full suite from packages/jinn:

 Test Files  317 passed (317)
      Tests  3875 passed | 7 skipped (3882)
   Start at  09:57:31
   Duration  50.32s (transform 10.34s, setup 2.93s, import 43.97s, tests 166.03s, environment 21ms)

No flakes tripped; no re-runs needed.

Note on release gating

brew test still never runs during brew install, so the hardened test block only protects us if releases are gated on it. That CI job is not added here — worth a follow-up.

Fixes #108
Fixes #109

A fresh `brew install jinn` produced a gateway that could not start, and
after that crash was patched, one where no session could ever spawn. Both
trace to a single line in the formula.

Homebrew's `std_npm_args` folds in `npm_install_security_args`, whose
`ignore_scripts:` defaults to true, so `system "npm", "install",
*std_npm_args` installs the whole tree with `--ignore-scripts`:

  * better-sqlite3's `install` (prebuild-install || node-gyp rebuild)
    never runs, so no compiled addon is produced at all and the gateway
    dies at boot with "Could not locate the bindings file" (#108).
  * node-pty's `install` never runs either, so it falls back to its
    checked-in prebuilds, whose `spawn-helper` ships at mode 0644. macOS
    posix_spawns that helper on every pty, so every session dies with
    "posix_spawnp failed." (#109). jinn-cli's own postinstall exists to
    restore that bit and is skipped for the same reason.

Enabling scripts alone was not sufficient. `std_npm_args` also passes
`--build-from-source`, under which node-pty compiles and DELETES
`prebuilds/` entirely, leaving only `build/Release`. The old postinstall
did `readdir("prebuilds")` and threw ENOENT on exactly that layout, so
the one-line formula change turned a runtime crash into a hard
`brew install` failure. Verified by installing the published tarball with
those flags: npm exits 1.

Changes:

  * Formula: `std_npm_args(ignore_scripts: false)`, so the compile that
    `depends_on "python" => :build` and `--build-from-source` already
    imply actually happens.
  * postinstall: cover every layout node-pty can resolve from
    (build/Release, build/Debug, prebuilds/<platform>-<arch>) and never
    fail an install over a permission fix-up.
  * runtime guard: open a database instead of only requiring
    better-sqlite3. The binding resolves lazily in the Database
    constructor, so `require('better-sqlite3')` SUCCEEDS on a
    binding-less install — the guard and the formula's own test block
    were both false negatives and could never have caught #108.
    A missing binding now reports as missing rather than as a Node
    version mismatch it is not.
  * runtime guard: repair the spawn-helper exec bit at startup. This is
    the only layer that helps an install we never got to run scripts in,
    including the already-broken ones on disk today.
  * formula test block: open a database and really spawn through
    node-pty, since both bugs survive a bare require. Drop the
    `require('classic-level')` assertion — classic-level is a root
    workspace dep, is not published inside jinn-cli, and that line could
    only ever fail once someone ran `brew test`.
@hristo2612
hristo2612 merged commit 3029db7 into main Aug 3, 2026
3 of 4 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

1 participant