npm, the lifecycle edition

Install npm package globally, the lifecycle every generic guide skips

Every generic tutorial on this topic stops atnpm install -g <package>. That works for the ninety-odd percent of npm packages that are pure JavaScript. For the rest, and especially for anything with apostinstallthat compiles native code, the story is longer. This page opens the hood on a real install, usingwhatsapp-mcp-macosas the worked example.

By the end you will know what happens between thenpm install -gcommand and the binary your shell eventually executes, which flags change the shape of that lifecycle, and why two byte-identical copies of the same Mach-O can end up on your disk.

M
Matthew Diakonov
11 min read
4.8from npm + GitHub signals
Covers the postinstall lifecycle every generic article omits
Worked example: real Mach-O binary sizes, real SwiftPM build output
Calls out the --ignore-scripts footgun that silently breaks native installs

The four stages of a global install

The first thing the common advice gets wrong is treatingnpm install -gas a single step. It is four, and each one has its own failure mode. The breakdown below is for a package that ships native code. Pure-JavaScript packages skip stage 1 and stage 4.

1. The os gate

package.json declares "os": ["darwin"]. On Linux and Windows, npm aborts the install with EBADPLATFORM before a single file is unpacked. macOS continues.

2. Tarball unpack

npm pulls the tarball from the registry and unpacks it under the global prefix (run `npm config get prefix`). For this package, that copies Sources/, Package.swift, a prebuilt arm64 Mach-O under bin/, and a few docs.

3. Bin symlink

npm creates a symlink in the global bin directory pointing at bin/whatsapp-mcp. That is the executable your PATH resolves. If the postinstall fails, this symlink still exists, pointing at the shipped prebuilt binary.

4. Postinstall hook

Scripts in package.json fire last. Here, "postinstall": "xcrun swift build -c release" pulls the MCP Swift SDK and MacosUseSDK, compiles them, and writes a second binary to .build/release/whatsapp-mcp. It happens to be byte-identical to the one in bin/, and nothing on disk links to it.

where the tarball actually goes

npm registry
tarball
Swift Package Registry
npm install -g
global node_modules
PATH symlink
.build/release/

Two inputs arrive over the network (the npm tarball and, during postinstall, the Swift Package Registry). Three outputs land on disk. Only one of them, the symlink, is on PATH.

The package.json that drives this

Everything interesting about this install is declared in a handful of fields. Read it top to bottom and every stage from the grid above is in here.

whatsapp-mcp-macos / package.json

Theosfield blocks non-macOS machines.binis what the global-bin symlink points at.filesis the whitelist of what ends up in the tarball (note that.build/is not in it; it is generated locally by the next field).scripts.postinstallis the Swift build.

A library package.json vs. a native-build package.json

Here they are side by side. The left is a pure-JS library. The right is this project. Most online advice on installing globally was written against the shape on the left.

package.json shapes

{
  "name": "chalk",
  "version": "5.3.0",
  "main": "source/index.js",
  "exports": "./source/index.js",
  "files": ["source"],
  "scripts": {
    "test": "xo && c8 ava"
  }
}
-80% fewer lines

What postinstall is actually building

Thexcrun swift build -c releasecommand readsPackage.swiftin the root of the unpacked tarball. SwiftPM fetches two GitHub-hosted dependencies, resolves their versions, and compiles a release binary. This is why installing this package is not a seconds-level operation on a fresh machine; it is a small C-toolchain-style build in disguise.

Package.swift

Full install trace, end to end

This is a cold install on an M-series Mac with Xcode Command Line Tools already present. Read to the bottom: notice thatwhich whatsapp-mcpends at a shell-level path, but resolving the symlink withreadlink -ftakes you intolib/node_modules/and thenfilereports a native Mach-O binary.

npm install -g whatsapp-mcp-macos

anchor fact

Two byte-identical binaries at 0 bytes each

After a global install finishes, the package directory contains two copies of the same Mach-O binary.bin/whatsapp-mcpwas shipped in the tarball and is what the global-bin symlink points at..build/release/whatsapp-mcpis the output of the postinstallxcrun swift build -c release, and on a machine with the same pinned dependencies it comes out byte identical.

You can verify this yourself after an install withshasum -a 256 bin/whatsapp-mcp .build/release/whatsapp-mcp. The shipped one is what Apple Silicon Macs will run. The rebuilt one exists so Intel Macs (where the shipped arm64 binary cannot execute) still get a working executable after postinstall completes.

0bytes per binary
0copies on disk
0install stages
0symlink in global bin

Four numbers that describe what this particular global install puts on disk. Nothing here is hypothetical; the byte count is straight fromstaton the published tarball and the local build.

Library install vs. native-build install

Lining them up makes it obvious why generic advice does not fit.

Featuretypical JS library (e.g. chalk)whatsapp-mcp-macos (native build)
What unpacks from the tarballJavaScript source files, a package.json, READMEJavaScript wrapper (none, in this case) plus Swift sources, a Package.swift, and a prebuilt Mach-O binary
How long it takes on a first installSeconds. Network-bound, no compilation.Tens of seconds to minutes. Swift Package Manager fetches dependencies over HTTPS and compiles.
Extra files on disk after installWhatever was in the tarballEverything from the tarball plus a .build/ tree produced by the postinstall hook
What the global bin symlink points atA .js file or a bash shim that execs nodeA native Mach-O binary. File size and CPU architecture matter. You can check with `file $(readlink -f $(which whatsapp-mcp))`.
Effect of --ignore-scriptsSkips lint/test hooks. Package is still usable.Silently skips the rebuild. You end up with the shipped binary only, which happens to work on arm64 Macs but not on any Mac where the bundled arch does not match.
Required toolchain on the installer's machineNode.js plus npmNode.js, npm, Xcode Command Line Tools with Swift 5.9+, and xcrun on PATH. Missing any of these turns postinstall into a loud error, or a silent skip with --ignore-scripts.
What --cpu / --os flags do for cross-platform installsLargely cosmetic, most pure-JS packages install anywhereThey bypass the os gate. `npm install -g --os=linux whatsapp-mcp-macos` will extract the tarball on Linux, but the resulting bin symlink points at a Mach-O binary the Linux kernel cannot load.

What --ignore-scripts actually skips

A huge amount of generic advice tells you to pass--ignore-scriptsfor safety. For a native-build package, that flag is the difference between a working install and a half-built one. Here is the exact trace:

npm install -g --ignore-scripts whatsapp-mcp-macos

Nothing in the npm output tells you the build was skipped. The registry is happy, npm list is happy, the symlink is present. The only evidence that something is wrong is that.build/does not exist. If you ever need it, firenpm rebuild -g whatsapp-mcp-macosto force the postinstall after the fact.

What the install looks like over the wire

The diagram below is not metaphor; it is the actual sequence of requests and file-system effects. Every arrow is a thing that can fail on its own.

global install sequence for a native-build package

Your shellnpm CLInpm registrySwiftPMdisknpm install -g whatsapp-mcp-macoscheck "os": ["darwin"], GET tarballtarball + integrity hashunpack to lib/node_modules/create bin/ symlinkxcrun swift build -c releasefetch swift-sdk, MacosUseSDKsource tarballswrite .build/release/whatsapp-mcpdone

Flags that change the shape of the install

Each of these flags, passed tonpm install -g, changes which of the four stages actually run. Some are safe. Some are silent-breakage traps on a native-code package. Hover to pause.

--ignore-scripts--prefer-offline--cpu=x64--os=linux--force--legacy-peer-depsnpm_config_foreground_scripts=true--omit=optional--no-audit--dry-run

The two most dangerous on this list are--ignore-scripts(skips the Swift build silently) and--os=linux(bypasses the platform gate and lands an un-runnable arm64 Mach-O on a Linux box).

The actual install recipe

Five steps. The first two are preflight the common guides never mention. The middle one is the command everybody remembers. The last two are where you confirm the install reached a working executable, not just a successful registry fetch.

1

Install the toolchain, not just Node

Before `npm install -g whatsapp-mcp-macos`, run `xcode-select --install` (or `sudo xcode-select -s /Applications/Xcode.app/Contents/Developer`) so `xcrun swift build` resolves. Skipping this is the single most common cause of a failed postinstall.

2

Decide where global packages live

Run `npm config get prefix` to find the directory that receives globals. If you use nvm, it is per Node version. If you use Homebrew Node or the system Node, it is shared. The prefix determines where the symlink and the compiled binary end up.

3

Run the install and let postinstall finish

`npm install -g whatsapp-mcp-macos`. Expect output from `xcrun swift build -c release`. First-time builds fetch the MCP Swift SDK and MacosUseSDK, so it takes tens of seconds. Do not interrupt with Ctrl+C.

4

Verify the executable, not just the npm tag

`npm list -g whatsapp-mcp-macos` tells you what the registry sees. To check what your shell will run, use `which whatsapp-mcp`, then `readlink -f` the result, then `file` the target. You are looking for a Mach-O binary of the arch your Mac actually is.

5

Grant Accessibility to that exact path

macOS TCC keys Accessibility trust on the binary path and code signature. Open System Settings > Privacy & Security > Accessibility and add the resolved path. If you switch Node version managers later and the global prefix moves, the trust needs to move with it.

Want a second pair of eyes on a tricky native-build install?

Book 20 minutes with the maintainer of whatsapp-mcp-macos to walk through postinstall failures, TCC prompts, or Node version-manager issues on your machine.

Frequently asked questions

What does `-g` actually change compared to a local install?

Three things. First, the package is unpacked under `npm config get prefix` / lib/node_modules rather than the current project. Second, any `bin` entries become symlinks in that prefix's `bin/` directory, which your shell's PATH is (hopefully) configured to pick up. Third, global installs do not touch package.json or package-lock.json, so they are invisible to any project's dependency graph. For a package like whatsapp-mcp-macos that is meant to be spawned by a long-running host (Claude Code, Cursor), the global install is the only shape that makes sense.

Where do global packages end up on macOS?

Run `npm config get prefix`. On a default Homebrew Node, it is `/opt/homebrew` (Apple Silicon) or `/usr/local` (Intel). Under nvm, it is `~/.nvm/versions/node/<version>`. Under Volta or fnm, it is their respective shim directory. The relevant subpaths are `lib/node_modules/<package>/` for the files and `bin/<cli>` for the symlink. On this package the symlink target is `lib/node_modules/whatsapp-mcp-macos/bin/whatsapp-mcp`.

Why does the global install take almost a minute? Other packages install in seconds.

Because this one has a postinstall hook that invokes `xcrun swift build -c release`. SwiftPM fetches two dependencies the first time (the MCP Swift SDK and MacosUseSDK from GitHub), compiles them, and links a native binary. Subsequent installs are faster because SwiftPM caches under `~/Library/Developer/Xcode/DerivedData` and `~/.swiftpm`. The 47 second number is typical for a cold install on an M-series Mac with a warm Xcode install.

What happens if I install with --ignore-scripts?

npm skips the postinstall block entirely. The tarball still unpacks and the bin symlink still gets created, which means `which whatsapp-mcp` will resolve. But the Swift build does not run, so `.build/release/whatsapp-mcp` never exists. In this specific package, the shipped `bin/whatsapp-mcp` (arm64 Mach-O) will still run on Apple Silicon, so you can get away with it on M-series Macs. On Intel, the shipped binary fails to exec, and without the postinstall you have no rebuilt fallback. Use `npm rebuild -g whatsapp-mcp-macos` afterward to force the build.

The install failed with EBADPLATFORM. What is that?

It means `package.json` declared an `os` or `cpu` the installer's machine does not match. whatsapp-mcp-macos declares `"os": ["darwin"]`, so on Linux and Windows npm aborts before unpacking. You can bypass it with `--os=darwin`, but that does not help you run the binary; it just lets the tarball land on disk. On WSL on Windows, the gate passes only if Node itself was compiled as a darwin build, which it is not.

Do I need sudo for `npm install -g`?

Only if your global prefix is a directory your user cannot write to. On macOS with Homebrew Node, `/opt/homebrew` is usually writable by your user (Homebrew owns it). On system Node at `/usr/local`, you might need sudo. The clean fix is to point npm at a user-writable prefix: `npm config set prefix ~/.npm-global` and add `~/.npm-global/bin` to PATH. Using a Node version manager (nvm, fnm, Volta) handles this automatically.

I ran the install twice and there are two binaries with the same SHA256. Is that expected?

Yes. The tarball ships a prebuilt `bin/whatsapp-mcp` (Mach-O arm64, 14,684,336 bytes) so the CLI works immediately on Apple Silicon even if the postinstall never runs. The postinstall recompiles the same Swift sources against the same pinned dependencies and writes the output to `.build/release/whatsapp-mcp`. When both builds happen on the same Xcode toolchain, they come out byte-identical. Nothing on disk symlinks to the build product; the global-bin symlink still points at the prebuilt. It is belt-and-suspenders, and the belt is the symlink.

How do I know the global install actually works before I wire up Claude Code?

Run `whatsapp-mcp` in a terminal. It is a stdio MCP server, so it will sit there waiting for JSON-RPC on stdin. Ctrl+C to exit. If the shell reports `command not found`, the global bin directory is not on PATH. If the shell reports `bad CPU type in executable`, you are on Intel with only the arm64 prebuilt on disk and postinstall did not run. If the process starts and immediately exits with a TCC denial, Accessibility permission has not been granted to the resolved binary path.

Does npm audit cover the Swift dependencies pulled in by postinstall?

No. `npm audit` walks the npm dependency tree, which for whatsapp-mcp-macos has zero production dependencies. The Swift dependencies (swift-sdk, MacosUseSDK) are resolved by SwiftPM during postinstall and are entirely outside npm's visibility. For an audit that covers those, you would need to run SwiftPM tooling from inside `lib/node_modules/whatsapp-mcp-macos/` after install, which is not part of the default workflow.

What is the difference between `npm i -g` and `npm install -g`?

None. `i` is the documented short alias for `install`. `npm i -g whatsapp-mcp-macos` and `npm install -g whatsapp-mcp-macos` behave identically, fire the same lifecycle scripts, write to the same global prefix. Other aliases that mean the same thing: `npm add -g`, `npm isntall -g` (yes, the typo works), `npm in -g`.

How do I uninstall without breaking the Claude Code session that is using the CLI?

`npm uninstall -g whatsapp-mcp-macos` removes the files and the symlink. It does not signal any process that spawned the binary; on POSIX, a running process holds the inode open even after the filename is unlinked. The live session keeps working until the MCP host is restarted, at which point the host tries to respawn a child and fails with ENOENT. If you want a clean transition, quit the host first, then uninstall.