Problem
setup/platform.ts resolves the Node binary path using command -v node:
export function getNodePath(): string {
try {
return execSync('command -v node', { encoding: 'utf-8' }).trim();
} catch {
return process.execPath;
}
}
When the user's version manager is fnm, command -v node returns an ephemeral multishell path like:
/Users/<user>/.local/state/fnm_multishells/42545_1771219131969/bin/node
This path is per-shell-session — it becomes invalid as soon as that shell exits. The launchd plist (or systemd unit) bakes in this ephemeral path at setup time.
Impact
After running npm rebuild (e.g. when updating packages that include native modules like better-sqlite3), the rebuild targets the version in .nvmrc, but the service continues running via the stale ephemeral path — which may point to a different Node version entirely. This causes:
Error: dlopen failed: /path/to/better_sqlite3.node: cannot open shared object file
The service fails to start until the plist is regenerated with a fresh path — which becomes ephemeral again next session.
Expected behaviour
The plist/unit file should reference a stable, session-independent Node binary path that matches the project's .nvmrc.
Suggested fix
In setup/platform.ts, enhance getNodePath() to detect the active version manager and resolve a stable path from it:
export function getNodePath(): string {
const nvmrcVersion = readNvmrc(); // read .nvmrc or .node-version from project root
if (nvmrcVersion) {
// fnm: stable path under node-versions/
const fnmPath = path.join(
os.homedir(),
'.local/share/fnm/node-versions',
`v${nvmrcVersion}`,
'installation/bin/node'
);
if (fs.existsSync(fnmPath)) return fnmPath;
// nvm: stable path under .nvm/versions/
const nvmPath = path.join(
os.homedir(),
'.nvm/versions/node',
`v${nvmrcVersion}`,
'bin/node'
);
if (fs.existsSync(nvmPath)) return nvmPath;
}
// Fallback: current behaviour (may be ephemeral under fnm)
try {
return execSync('command -v node', { encoding: 'utf-8' }).trim();
} catch {
return process.execPath;
}
}
function readNvmrc(): string | null {
for (const file of ['.nvmrc', '.node-version']) {
const p = path.join(projectRoot, file);
if (fs.existsSync(p)) {
return fs.readFileSync(p, 'utf-8').trim().replace(/^v/, '');
}
}
return null;
}
This approach:
- Checks for fnm and nvm stable paths first, using the version pinned in
.nvmrc
- Falls back to current behaviour for other version managers or when no
.nvmrc is present
- Is safe: falls back gracefully if stable paths don't exist
Workaround
Run npm run setup again from a shell where node points to the correct version. The new plist will have the current session's path — which is correct until the shell exits.
Environment
- macOS with launchd
- fnm as Node version manager
.nvmrc present in project root
Problem
setup/platform.tsresolves the Node binary path usingcommand -v node:When the user's version manager is fnm,
command -v nodereturns an ephemeral multishell path like:This path is per-shell-session — it becomes invalid as soon as that shell exits. The launchd plist (or systemd unit) bakes in this ephemeral path at setup time.
Impact
After running
npm rebuild(e.g. when updating packages that include native modules likebetter-sqlite3), the rebuild targets the version in.nvmrc, but the service continues running via the stale ephemeral path — which may point to a different Node version entirely. This causes:The service fails to start until the plist is regenerated with a fresh path — which becomes ephemeral again next session.
Expected behaviour
The plist/unit file should reference a stable, session-independent Node binary path that matches the project's
.nvmrc.Suggested fix
In
setup/platform.ts, enhancegetNodePath()to detect the active version manager and resolve a stable path from it:This approach:
.nvmrc.nvmrcis presentWorkaround
Run
npm run setupagain from a shell wherenodepoints to the correct version. The new plist will have the current session's path — which is correct until the shell exits.Environment
.nvmrcpresent in project root