2 min read
four ways to break a five-line cmd script
windows debugging open-source

fnm’s --use-on-cd hook for cmd works by shadowing cd with a tiny script that changes directory and then checks for a .node-version file. The core of it is one line:

cd /d %*

/d lets cd cross drives. The problem: if you type cd /d D:\ yourself, the flag is now in %* too, cmd receives cd /d /d D:\, and you get:

The syntax of the command is incorrect.

That is fnm issue #1556. The fix I sent is two lines:

if /i "%~1" == "/d" cd %*
if /i not "%~1" == "/d" cd /d %*

Which looks like it was written by someone who does not know batch has if/else. It was written that way because every nicer shape breaks.

the nicer shapes

A parenthesized if/else block fails on real paths. %* is expanded textually, so cd /d C:\Program Files (x86) puts a ) inside your block and closes it early. The single-line form has no block to close.

setlocal / endlocal, the standard way to keep a script’s variables from leaking, cannot be used at all: endlocal restores the shell’s working directory, undoing the cd the wrapper exists to perform.

goto and labels fail for a reason that has nothing to do with the code. The script is embedded byte-exact in the fnm binary via include_bytes! and stored with LF line endings. cmd’s label scanner is unreliable with LF-only files. It can jump to the wrong place or fail to find the label. The two guarded ifs need no labels.

Scanning every argument for /d is unnecessary: native cd only accepts the flag in first position anyway (cd C:\ /d fails), so later positions can be passed straight through to produce cmd’s own normal error.

testing without ci

fnm’s Windows cmd e2e suite is currently skip-listed, so nothing in CI executes this script. I verified it with a 15-case matrix against real cmd.exe on Windows 11: cross-drive via a subst drive with a .node-version in it, uppercase /D, quoted and unquoted Program Files, the (x86) case, no-args cd, and a stubbed fnm on PATH to confirm the version-switch hook still fires. Old script fails four of them, new script fails none.

The whole diff is five lines. The reasons it is exactly those five lines took most of the afternoon.

The PR is Schniz/fnm#1579.