Skip to content

bug(cli): CLI does nothing when installed via npm link — isDirectRun guard fails on symlinked binary name #36

@stevenobiajulu

Description

@stevenobiajulu

Summary

Running any email-agent-mcp subcommand (configure, serve, status, --version) exits silently with code 0 and no output when the package is installed globally via npm link. The root cause is the isDirectRun guard in cli.js which checks process.argv[1]?.endsWith('cli.js') — but when npm-linked, process.argv[1] is the symlink name (email-agent-mcp), not cli.js.

Reproduction

# In the email-agent-mcp repo
npm link

# Anywhere
email-agent-mcp configure --provider gmail
# → exits 0, no output, no browser, nothing

email-agent-mcp --version
# → exits 0, no output

email-agent-mcp status
# → exits 0, no output

Root cause

packages/email-mcp/src/cli.ts (line ~1122):

const isDirectRun = process.argv[1]?.endsWith('cli.ts') || process.argv[1]?.endsWith('cli.js');
if (isDirectRun) {
    runCli(process.argv.slice(2)).then(code => {
        process.exit(code);
    }).catch(err => {
        console.error('Fatal:', err);
        process.exit(1);
    });
}

When installed via npm link, the global bin is a symlink:

~/.npm-global/bin/email-agent-mcp → ../lib/node_modules/@usejunior/email-mcp/dist/cli.js

But process.argv[1] in Node.js is the symlink path, not the resolved target:

process.argv[1] = '/Users/you/.npm-global/bin/email-agent-mcp'

'email-agent-mcp'.endsWith('cli.js')false. So isDirectRun is false, runCli() is never called, Node loads the module exports, does nothing with them, and exits 0.

Verified by direct invocation

Calling runConfigure directly (bypassing the guard) works correctly:

import('./dist/cli.js').then(async m => {
  const code = await m.runConfigure({ provider: 'gmail' });
  console.log('EXIT_CODE=' + code);
});
// → prints: ❌ Configuration failed: Missing Gmail OAuth client ID...
// → EXIT_CODE=1

This confirms the configure logic itself is correct — only the entry-point guard is broken.

Suggested fix

The isDirectRun guard needs symlink-safe direct-run detection. One approach using import.meta.url (which resolves through symlinks) compared against realpathSync(process.argv[1]):

import { fileURLToPath } from 'node:url';
import { realpathSync } from 'node:fs';

const isDirectRun = (() => {
  try {
    const self = fileURLToPath(import.meta.url);
    const invoked = realpathSync(process.argv[1] ?? '');
    return self === invoked;
  } catch {
    return false;
  }
})();

The try/catch is necessary because process.argv[1] may be missing or invalid in edge cases (process managers, node -e). The catch should fall back to false (don't auto-execute) rather than throwing.

Impact

This affects ALL users who install via npm link (common during development) or any global install where the binary name doesn't match cli.js. Every subcommand silently does nothing. The exit code 0 makes it especially dangerous — it looks like success.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions