Skip to content

Make the shipped app work on machines that did not build it - #3

Merged
vimoxshah merged 1 commit into
mainfrom
fix/portable-app-bundle-and-discovery
Sep 3, 2026
Merged

Make the shipped app work on machines that did not build it#3
vimoxshah merged 1 commit into
mainfrom
fix/portable-app-bundle-and-discovery

Conversation

@vimoxshah

Copy link
Copy Markdown
Owner

1.1.1 was correct as source and as an npm package. Its DMG was not.

The app pointed at the machine that built it

Every build embedded the absolute path of its own node binary and CLI — right for a developer whose installed app should drive the checkout they are editing, wrong for a release. The shipped 1.1.1 DMG contains:

TokenFlowCLIPath  = /Users/runner/work/tokenflow/tokenflow/bin/tokenflow.js
TokenFlowNodePath = /Users/runner/hostedtoolcache/node/24.18.0/arm64/bin/node

Neither exists on any user's machine. Only the discovery fallbacks kept the app working, and only for people who had installed the CLI from npm.

A distributable build now embeds nothing (TOKENFLOW_PORTABLE=1, set by build-dmg.sh). Three things enforce it, not one:

  1. build-dmg.sh refuses to package a bundle carrying either key.
  2. The release workflow re-checks the finished DMG after mounting it.
  3. test/bundle.test.js builds both flavours and asserts the difference.

A build-script property is invisible to every other test, so only a real build can prove it. These tests do real builds and skip where swiftc is unavailable.

A cask-only install had nothing to run

The cask installs the app; the CLI came from npm. brew install --cask tokenflow alone gave you a menu bar that could read an existing status file and do nothing else — no refresh, no watcher, no dashboard.

The app now carries its own CLI at Contents/Resources/cli, packed with npm pack and then pruned to what the CLI executes. Packing rather than copying bin/ src/ means the bundle is always a subset of the published package and can never hold a file npm does not ship. Pruning docs/, skills/, examples/, scripts/ — verified as never read at runtime; they appear in printed hints and nothing opens them — keeps the download honest:

DMG
1.1.1 (no CLI) 655 KB
bundled, unpruned 2.5 MB
bundled, pruned 932 KB

The bundled copy wins over any CLI found on the system. The app and the CLI share a contract — the status file's shape, the watcher lock format, /api/ping — so the copy shipped beside the binary is the only one guaranteed to match it. A separately installed CLI may be newer without being compatible. A local build still embeds the developer's clone and that still wins, so the dev loop is unchanged; a test asserts that too.

Node discovery named one specific version

The first candidate was ~/.nvm/versions/node/v24.13.1/bin/node — whichever version the developer happened to have, one nvm install from being wrong for everyone. Installs are now discovered, anything below the engines floor is skipped, and the highest wins by semver, not string order — verified against a tree where v24.13.0 must beat v24.9.0, which a lexicographic sort gets backwards:

REAL nvm dir  -> …/v24.13.1/bin/node        (of v18.16.0, v24.13.0, v24.13.1)
SYNTH dir     -> …/v24.13.0/bin/node        (of v18.20.0, v22.5.0, v24.9.0, v24.13.0)

A missing dependency now says so

With no CLI or no Node, every button failed in silence. The popover names the problem and the command that fixes it, at launch rather than on the first click.

The cask states the Node requirement in caveats rather than declaring depends_on formula: "node", which would plant a second Node beside an nvm- or asdf-managed one and fight the version manager.

Verification

  • 179 tests pass, including three that do real app builds; lint and typecheck clean.
  • The pruned bundled CLI was exercised end to end — doctor, demo, usage, watch --once, watch --status — to prove the pruning cut nothing it loads.
  • Built and installed 1.1.2 locally: app reports 1.1.2, still embeds the repo path, watcher healthy on the repo lock.

The cask still points at 1.1.1 on purpose — it gets the new version and checksum once the 1.1.2 DMG exists.

🤖 Generated with Claude Code

1.1.1 was correct as source and as an npm package. Its DMG was not.

Every build embedded the absolute path of its own node binary and CLI. That is
right for a developer whose installed app should drive the checkout they are
editing, and wrong for a release: the 1.1.1 DMG shipped
`TokenFlowCLIPath = /Users/runner/work/tokenflow/tokenflow/bin/tokenflow.js`,
a path that exists on no user's machine. Only the discovery fallbacks kept the
app working at all, and only for people who had installed the CLI from npm.

A distributable build now embeds no path at all (TOKENFLOW_PORTABLE=1, set by
build-dmg.sh). Three things enforce it rather than one: the DMG script refuses
to package a bundle carrying either key, the release workflow re-checks the
finished image after mounting it, and a test builds both flavours and asserts
the difference. A build-script property is invisible to every other test, so
only a real build can prove it.

Second half of the same problem: the cask installs the app and the CLI came
from npm, so anyone who only ran `brew install --cask tokenflow` had a menu bar
that could read an existing status file and nothing else — no refresh, no
watcher, no dashboard. The app now carries its own CLI at
Contents/Resources/cli, packed with `npm pack` and then pruned to the files the
CLI executes; the bundle is therefore always a subset of the published package
and can never contain a file npm does not ship. Pruning docs/, skills/,
examples/ and scripts/ — none of which is read at runtime, they appear in
printed hints and nothing opens them — takes the DMG from 2.5 MB to 932 KB.

The bundled copy wins over any CLI found on the system. The app and the CLI
share a contract (the status file's shape, the watcher lock format, /api/ping),
so the copy shipped beside the binary is the only one guaranteed to match it;
a separately installed CLI may be newer without being compatible. A local build
still embeds the developer's clone and that still wins, so the dev loop is
unchanged.

Node discovery no longer names a version. The first candidate was
~/.nvm/versions/node/v24.13.1/bin/node — whichever version the developer
happened to have, one `nvm install` from being wrong for everybody. Installs
are discovered, anything below the engine floor is skipped, and the highest
wins by semver rather than by string order: verified against a tree where
v24.13.0 must beat v24.9.0, which a lexicographic sort gets backwards.

Finally, a missing dependency says so. With no CLI or no Node every button
failed in silence; the popover now names the problem and the command that
fixes it, at launch rather than on the first click. The cask states the Node
requirement in caveats rather than declaring `depends_on formula: "node"`,
which would plant a second Node beside an nvm- or asdf-managed one.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@vimoxshah
vimoxshah merged commit e941a32 into main Sep 3, 2026
8 checks passed
@vimoxshah
vimoxshah deleted the fix/portable-app-bundle-and-discovery branch September 3, 2026 06:04
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant