Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changeset/sticky-lemurs-revert.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"fnm": patch
---

Fixed `--use-on-cd` with `local` strategy to revert to the default Node version when entering a directory without a version file. Previously, the Node version would remain "stuck" on the last project version after leaving a versioned directory. This now matches nvm's auto-switching behavior.

4 changes: 4 additions & 0 deletions docs/commands.md
Original file line number Diff line number Diff line change
Expand Up @@ -414,6 +414,10 @@ Options:
--use-on-cd
Print the script to change Node versions every directory change

When entering a directory with a version file, fnm switches to that version. When entering a directory without a version file, fnm switches to the default version.

This applies to both `local` and `recursive` version file strategies.

--arch <ARCH>
Override the architecture of the installed Node binary. Defaults to arch of fnm binary

Expand Down
84 changes: 84 additions & 0 deletions e2e/use-on-cd-revert-default.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
import { writeFile, mkdir } from "node:fs/promises"
import { join } from "node:path"
import { script } from "./shellcode/script.js"
import { Bash, Fish, PowerShell, Zsh } from "./shellcode/shells.js"
import testCwd from "./shellcode/test-cwd.js"
import testNodeVersion from "./shellcode/test-node-version.js"
import describe from "./describe.js"

const defaultVersion = "v8.11.3"
const projectVersion = "v12.22.12"

Copilot AI Feb 12, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The test file excludes WinCmd from the shell tests (line 12 only tests Bash, Zsh, Fish, PowerShell), but the existing use-on-cd.test.ts includes WinCmd (line 9). Since the Windows Cmd shell hook uses a different implementation (cd.cmd file) that was not updated in this PR, this omission hides the fact that the new revert-to-default behavior is not available for Windows Cmd users. Consider either:

  1. Adding WinCmd tests that would fail (exposing the incomplete implementation), or
  2. Adding a comment explaining why WinCmd is excluded and filing an issue to track the Windows Cmd implementation
Suggested change
// Note: WinCmd is intentionally excluded here. The Windows Cmd shell uses a
// separate cd.cmd implementation that does not yet support the
// "revert-to-default when leaving versioned directory" behavior. See the
// tracking issue for adding WinCmd support and corresponding tests.

Copilot uses AI. Check for mistakes.
for (const shell of [Bash, Zsh, Fish, PowerShell]) {
describe(shell, () => {
test(`reverts to default when leaving versioned directory (local strategy)`, async () => {
await mkdir(join(testCwd(), "subdir"), { recursive: true })
await writeFile(join(testCwd(), "subdir", ".node-version"), projectVersion)

await script(shell)
.then(shell.env({ useOnCd: true }))
.then(shell.call("fnm", ["install", defaultVersion]))
.then(shell.call("fnm", ["install", projectVersion]))
.then(shell.call("fnm", ["default", defaultVersion]))
.then(shell.call("cd", ["subdir"]))
.then(testNodeVersion(shell, projectVersion))
.then(shell.call("cd", [".."]))
.then(testNodeVersion(shell, defaultVersion))
.execute(shell)
})

test(`stays on project version while inside project (local strategy)`, async () => {

Copilot AI Feb 12, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The test name "stays on project version while inside project" is misleading. With the local strategy, when entering "subdir/nested" which has no version file in the "nested" directory itself, the code correctly reverts to the default version (as tested on line 42). However, this behavior doesn't match the test name's implication. The test name should be more accurate, such as "reverts to default in subdirectories without version files (local strategy)" or similar. The local strategy only checks the current directory, not parent directories.

Suggested change
test(`stays on project version while inside project (local strategy)`, async () => {
test(`reverts to default in subdirectories without version files (local strategy)`, async () => {

Copilot uses AI. Check for mistakes.
await mkdir(join(testCwd(), "subdir", "nested"), { recursive: true })
await writeFile(join(testCwd(), "subdir", ".node-version"), projectVersion)

await script(shell)
.then(shell.env({ useOnCd: true }))
.then(shell.call("fnm", ["install", defaultVersion]))
.then(shell.call("fnm", ["install", projectVersion]))
.then(shell.call("fnm", ["default", defaultVersion]))
.then(shell.call("cd", ["subdir"]))
.then(testNodeVersion(shell, projectVersion))
.then(shell.call("cd", ["nested"]))
.then(testNodeVersion(shell, defaultVersion))
.execute(shell)
})

test(`no default set - graceful no-op`, async () => {
await mkdir(join(testCwd(), "subdir"), { recursive: true })
await writeFile(join(testCwd(), "subdir", ".node-version"), projectVersion)

await script(shell)
.then(shell.env({ useOnCd: true }))
.then(shell.call("fnm", ["install", projectVersion]))
.then(shell.call("cd", ["subdir"]))
.then(testNodeVersion(shell, projectVersion))
.then(shell.call("cd", [".."]))
.execute(shell)
})

test(`already on default - no unnecessary switch`, async () => {
await mkdir(join(testCwd(), "noversion"), { recursive: true })

const captureCdOutput = (() => {
const outputFile = "cd-output.txt"
if (shell === Fish) {
return `begin\n cd noversion\n cd ..\nend > ${outputFile} 2>&1`
}
if (shell === PowerShell) {
return `& { cd noversion; cd .. } *> ${outputFile}`
}
return `{ cd noversion; cd ..; } > ${outputFile} 2>&1`
})()

await script(shell)
.then(shell.env({ useOnCd: true }))
.then(shell.call("fnm", ["install", defaultVersion]))
.then(shell.call("fnm", ["default", defaultVersion]))
.then(shell.call("fnm", ["use", "default", "--silent-if-unchanged"]))
.then(testNodeVersion(shell, defaultVersion))
.then(captureCdOutput)
.then(shell.hasCommandOutput(shell.call("cat", ["cd-output.txt"]), "", "cd output"))
.execute(shell)
})
})
}
5 changes: 5 additions & 0 deletions src/commands/env.rs
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,11 @@ pub struct Env {
#[clap(long, hide = true)]
multi: bool,
/// Print the script to change Node versions every directory change
///
/// When entering a directory with a version file, fnm switches to that version.
/// When entering a directory without a version file, fnm switches to the default version.

Copilot AI Feb 12, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The documentation for --use-on-cd in docs/configuration.md should be updated to mention the new revert-to-default behavior. The current description only mentions switching based on version files, but doesn't mention that fnm now reverts to the default version when entering directories without version files. Consider adding a sentence like "When leaving a directory with a version file and entering one without, fnm will revert to the default Node.js version (if one is configured)."

Suggested change
/// When entering a directory without a version file, fnm switches to the default version.
/// When leaving a directory with a version file and entering one without, fnm reverts to the default Node.js version (if one is configured).

Copilot uses AI. Check for mistakes.
///
/// This applies to both `local` and `recursive` version file strategies.
#[clap(long)]
use_on_cd: bool,
}
Expand Down
8 changes: 6 additions & 2 deletions src/shell/bash.rs
Original file line number Diff line number Diff line change
Expand Up @@ -33,11 +33,15 @@ impl Shell for Bash {
};
let autoload_hook = match config.version_file_strategy() {
VersionFileStrategy::Local => formatdoc!(
r"
r#"
if [[ {version_file_exists_condition} ]]; then
fnm use --silent-if-unchanged
else
if [[ -e "$FNM_DIR/aliases/default" ]]; then
fnm use "$(fnm default)" --silent-if-unchanged
fi
fi
",
"#,
version_file_exists_condition = version_file_exists_condition,
),
VersionFileStrategy::Recursive => String::from(r"fnm use --silent-if-unchanged"),
Expand Down
8 changes: 6 additions & 2 deletions src/shell/fish.rs
Original file line number Diff line number Diff line change
Expand Up @@ -33,11 +33,15 @@ impl Shell for Fish {
};
let autoload_hook = match config.version_file_strategy() {
VersionFileStrategy::Local => formatdoc!(
r"
r#"
if {version_file_exists_condition}
fnm use --silent-if-unchanged
else
if test -e "$FNM_DIR/aliases/default"
fnm use (fnm default) --silent-if-unchanged
end
end
",
"#,
version_file_exists_condition = version_file_exists_condition,
),
VersionFileStrategy::Recursive => String::from(r"fnm use --silent-if-unchanged"),
Expand Down
2 changes: 1 addition & 1 deletion src/shell/powershell.rs
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ impl Shell for PowerShell {
let autoload_hook = match config.version_file_strategy() {
VersionFileStrategy::Local => formatdoc!(
r"
If ({version_file_exists_condition}) {{ & fnm use --silent-if-unchanged }}
If ({version_file_exists_condition}) {{ & fnm use --silent-if-unchanged }} Else {{ If (Test-Path (Join-Path (Join-Path $env:FNM_DIR 'aliases') 'default')) {{ & fnm use $(fnm default) --silent-if-unchanged }} }}
",
version_file_exists_condition = version_file_exists_condition,
),
Expand Down
8 changes: 6 additions & 2 deletions src/shell/zsh.rs
Original file line number Diff line number Diff line change
Expand Up @@ -37,11 +37,15 @@ impl Shell for Zsh {
};
let autoload_hook = match config.version_file_strategy() {
VersionFileStrategy::Local => formatdoc!(
r"
r#"
if [[ {version_file_exists_condition} ]]; then
fnm use --silent-if-unchanged
else
if [[ -e "$FNM_DIR/aliases/default" ]]; then
fnm use "$(fnm default)" --silent-if-unchanged
fi
fi
",
"#,
version_file_exists_condition = version_file_exists_condition,
),
VersionFileStrategy::Recursive => String::from(r"fnm use --silent-if-unchanged"),
Expand Down