Running a small file-watching tool on a managed Mac produces three failures in quick succession, and all three look like the same thing.
First npm install -g stops and asks for a password. Then, once it’s installed, macOS throws up a dialog about accessing files in a folder you own. Then, pointed at a real project tree, it dies with EMFILE: too many open files. Every one of those reads as "this machine won’t let me do it, I need admin rights."
None of them needs admin rights. They’re three unrelated mechanisms that happen to fail in the same register, and the way out is different for each.
TL;DR: The sudo prompt is about where npm writes, not what the tool does — install as a devDependency, via npx, or with npm config set prefix ~/.npm-global. The privacy dialog is TCC, a per-user consent for ~/Desktop/~/Documents/~/Downloads granted to your terminal app, not to root. The EMFILE is macOS’s default ulimit -n 256 meeting chokidar 4, which dropped fsevents and now opens one watch per directory — raise the soft limit to the hard limit (no root needed) or switch to --poll. Details below, from copy-watch.
What Actually Needs Admin Rights Here?
Only the write target. npm install -g defaults to a prefix under /usr/local, which is root-owned, so npm asks for a password — for its own bookkeeping, not for anything the installed program will later do. A watcher reading ./dist and writing ~/Sites/preview never touches a privileged path at runtime.
Three ways around it, in descending order of how often they’re the right call:
# 1. As a project dependency — resolved via node_modules/.bin, nothing global
npm install --save-dev copy-watch
# 2. No install at all
npx copy-watch ./dist ~/Sites/preview --initial
# 3. Global, but inside $HOME
npm config set prefix ~/.npm-global
echo 'export PATH="$HOME/.npm-global/bin:$PATH"' >> ~/.zshrc
If Node itself came from nvm, fnm, or Homebrew in user context, the prefix is already writable and none of this applies — which is why the problem is so easy to misfile as machine policy. It’s a property of your Node install, not of the tool.
The runtime rule is the boring one: everything under $HOME is yours to write, everything outside it (/Library, /usr/local, /Applications) is root’s. Choose ~/Sites or ~/Projects as a mirror target and the question never comes up.
Why Does macOS Ask for Permission on a Folder I Own?
Because ownership isn’t the only gate. ~/Desktop, ~/Documents, and ~/Downloads sit behind TCC — Apple’s privacy layer — and the POSIX bits say yes while TCC says ask-first.
Two details make this confusing in exactly the wrong way:
- The grant is per application, not per script. macOS asks once, on first access, and records the answer against Terminal or iTerm — the process that spawned you. Every script you ever run from that terminal inherits the decision. Which is why the dialog appears once, for one tool, and then never again for anything.
- Declining is sticky and silent. Say no once and every later attempt fails as
EACCESorEPERM, with no second prompt. That is the error most likely to get misread as "I need sudo," because it’s the same errno you’d get from a genuinely privileged path.
The repair is in System Settings → Privacy & Security → Files and Folders → your terminal app. A user decision, not an admin one. It’s worth making the tool say so directly rather than letting the raw errno mislead:
onError: (err) => {
if (err?.code === 'EPERM' || err?.code === 'EACCES') {
console.error(
`Fehler: kein Zugriff auf ${err.path ?? 'den Pfad'}.\n` +
' Auf macOS koennen ~/Desktop, ~/Documents und ~/Downloads geschuetzt sein.\n' +
' Beim ersten Zugriff fragt das System einmalig nach — die Freigabe erfolgt\n' +
' fuer das Terminal-Programm unter Systemeinstellungen > Datenschutz & Sicherheit\n' +
' > Dateien und Ordner.',
);
return;
}
console.error(`Fehler: ${err.message}`);
}
The principle generalizes to every entry on this list: when an errno has a known non-obvious cause on this platform, attach the remedy to the error. The user is going to search the message anyway — save them the trip. The descriptor case below gets the same treatment.
Why Do I Get EMFILE on a Deep Tree?
This one is a real resource limit, and it got noticeably easier to hit in the last major version of chokidar.
Chokidar 4 dropped the optional fsevents native dependency. That’s a genuine win at install time — no node-gyp, no Xcode Command Line Tools, no compile step, so the "without admin rights" story holds all the way through installation. The cost shows up at runtime: instead of one FSEvents stream covering an entire subtree, you get one fs.watch per directory.
Meanwhile macOS starts shells at ulimit -n 256. A node_modules tree — assuming you didn’t ignore it, which is why it’s in the default ignore list — will exhaust that before it finishes the initial scan.
The soft limit can be raised to the hard limit by any user:
ulimit -n 4096 # current shell only; put it in ~/.zshrc to persist
ulimit -Hn # the ceiling — high enough on modern macOS
Only going above the hard limit needs root, and you almost certainly don’t. So: check at startup and warn below 1024, rather than letting it surface as a crash halfway through a scan.
The zero-configuration alternative is --poll, which trades CPU for descriptors — it stats on an interval instead of holding watches. On a build output directory that’s a bad deal. On a mount, as below, it isn’t a choice at all.
Transient shortages deserve a retry rather than a hard failure, and only for the two errnos that actually mean "come back later":
const FD_ERRORS = new Set(['EMFILE', 'ENFILE']);
async function withRetry(fn, retries = 5) {
let lastError;
for (let attempt = 0; attempt <= retries; attempt++) {
try {
return await fn();
} catch (err) {
if (!FD_ERRORS.has(err?.code)) throw err;
lastError = err;
await new Promise((r) => setTimeout(r, 50 * (attempt + 1)));
}
}
throw lastError;
}
The if (!FD_ERRORS.has(...)) throw err line is the important one. A retry loop that swallows every error turns a permissions bug into a hang, which is a strictly worse thing to debug than the original crash.
Why Doesn’t It Work on a Network Share?
Because kernel change notifications frequently don’t cross the mount. SMB, NFS, /Volumes/…, VM shared folders, Docker bind mounts — writes land, and no event ever arrives. The watcher sits there looking healthy and does nothing, which is the worst failure mode on this list because there’s no error to search for.
There is no clever fix. Polling is the only mechanism that works, because it asks instead of waiting to be told:
copy-watch ./src /Volumes/team-share/inbox --poll --interval 1000
The useful diagnostic heuristic: if the source path starts with /Volumes/ or came out of mount, assume events don’t work until proven otherwise, and start with --poll.
Why Doesn’t My LaunchAgent Inherit ulimit?
Because launchd is not your shell and never read your ~/.zshrc. Two things break the first time you try to run a watcher at login, and both are worth knowing before you spend an evening on them:
<key>ProgramArguments</key>
<array>
<string>/Users/you/.nvm/versions/node/v22.4.0/bin/node</string>
<string>/Users/you/tools/copy-watch/bin/cli.js</string>
</array>
<key>SoftResourceLimits</key>
<dict><key>NumberOfFiles</key><integer>4096</integer></dict>
ProgramArguments needs absolute paths — launchd has no login-shell PATH, so a bare node isn’t found, and with nvm the correct path points at a specific version directory (which node gives it to you). And the descriptor limit you set in your shell is invisible here; it has to be declared as SoftResourceLimits.
The part that’s genuinely good news: a LaunchAgent in ~/Library/LaunchAgents runs in your user context and needs no sudo at all. It’s /Library/LaunchDaemons — system-wide, running before login — that requires root, and it’s the wrong tool for a per-user file mirror anyway.
launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/local.copy-watch.plist
launchctl print gui/$(id -u)/local.copy-watch # status
launchctl bootout gui/$(id -u)/local.copy-watch # stop
What’s the Generalizable Lesson Here?
- "Asks for a password" is a claim about a path, not about a capability. Trace which file the failing operation wants to write before concluding the machine is locked down — most of the time the answer is to move the write into
$HOME. - On macOS, POSIX permissions are no longer the whole story. TCC can deny access to a directory you own, the denial is recorded against your terminal app rather than your script, and it surfaces as an ordinary
EACCESwith no hint about which layer said no. - When you drop a native dependency, you move a cost rather than remove it. Losing
fseventsbought a compile-free install and paid for it in file descriptors — a good trade, but only if the new failure mode is documented where people will hit it. - Attach the remedy to the error message for platform-specific errnos.
EMFILEplus "runulimit -n 4096, no admin needed" is the difference between a fixed problem and an abandoned tool. - The silent failure — a watcher on a network mount that never fires — is worth more defensive attention than any of the loud ones. Loud failures get searched; silent ones get blamed on the tool being flaky.
- Cross-check platform assumptions in CI. Running the smoke tests across macOS, Linux, and Windows on three Node versions is how you find out that a path-normalization shortcut works on exactly one of them.
The companion piece to this one — why a file watcher deletes the file you just saved — covers the other half of the problem: the timing heuristics that make mirroring survive an editor’s atomic save.