ljharb / ljharb/pargs

process.execPath is never realpath-resolved, so argv-filtering breaks when node is reached via a symlink/junction (scoop, nvm, fnm, volta)

Open Beginner friendly
#2 0 comments 0 reactions 0 assignees View on GitHub
Dominant language
JavaScript
Stars
8
Forks
0
PR merge metrics
No merged PRs in 30d

Description

## Summary

Any CLI built with `pargs` fails with `Unexpected argument ''. This command does not take positional arguments`, even when no positional argument was actually passed — as long as Node.js is reached through a symlink or junction (e.g. Scoop's "current" version pointer on Windows, or nvm/fnm/volta depending on their setup).

## Environment

- `pargs@1.4.2` (latest published at the time of this report)
- Node.js installed via a version manager that exposes a "current" pointer as a symlink/junction rather than a plain directory
- Reproduced identically under Git Bash and PowerShell — shell-independent

## Root cause

In `index.mjs`, the `process.argv` filter that is supposed to strip the node executable and the script path out of the real arguments:

```js
const realEntrypointPath = realpathSync(entrypointPath);
const argv = process.argv.flatMap((arg) => {
try {
const realpathedArg = realpathSync(arg);
if (
realpathedArg === process.execPath
|| realpathedArg === realEntrypointPath
) {
return [];
}
} catch { /**/ }
return arg;
});
```

`arg` is resolved through `realpathSync()` before the comparison, but `process.execPath` never is. When Node is invoked through a symlink/junction that the OS does not resolve at process start, `process.execPath` stays the unresolved path while `realpathSync(arg)` (for that very same `arg`, i.e. `process.argv[0]`) returns the real target — so the two are never equal, and the executable's path survives the filter and gets treated as an unexpected positional argument.

## Minimal reproduction

```js
import { realpathSync } from 'fs'

console.log('execPath :', process.execPath)
console.log('realpath(argv[0]) :', realpathSync(process.argv[0]))
console.log('equal? :', realpathSync(process.argv[0]) === process.execPath)
```

Output on a machine with an active Scoop "current" symlink:

```
execPath : C:\Users\kevin.gossent\scoop\apps\nodejs-lts\current\node.exe
realpath(argv[0]) : C:\Users\kevin.gossent\scoop\apps\nodejs-lts\24.18.1\node.exe
equal? : false
```

## Suggested fix

Resolve `process.execPath` once, the same way `entrypointPath` already is:

```diff
const realEntrypointPath = realpathSync(entrypointPath);
+const realExecPath = realpathSync(process.execPath);
const argv = process.argv.flatMap((arg) => {
try {
const realpathedArg = realpathSync(arg);
if (
- realpathedArg === process.execPath
+ realpathedArg === realExecPath
|| realpathedArg === realEntrypointPath
) {
```

## Impact

Affects every package that depends on `pargs` (in my case, `ls-engines`) for anyone using a symlink/junction-based Node version manager instead of a directly-installed binary.

Contributor guide

Open the contributing guide

Research direction

Start in index.mjs at the process.argv filtering logic and compare how entrypointPath and process.execPath are resolved. Resolve process.execPath consistently, then verify that a symlink or junction-based Node installation no longer leaves the executable path to be reported as an unexpected positional argument.

Written by the indexing model from the issue text.

Assessment

Tech stack
javascript
Domain
cli
Issue type
Bug
Difficulty
1/5
Estimated time
Under an hour
Activity status
Quiet
Clarity
Clearly specified
Newbie friendliness
88/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.