Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
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/shy-otters-install.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"fnm": minor
---

Added support for `default-packages` file. When present at `$FNM_DIR/default-packages`, packages listed in this file are automatically installed globally after every `fnm install`. Compatible with nvm's default-packages format.

7 changes: 7 additions & 0 deletions docs/commands.md
Original file line number Diff line number Diff line change
Expand Up @@ -270,6 +270,9 @@ Options:

[env: FNM_ARCH]

--reinstall-packages-from <version>
Reinstall global packages from a specified Node version after installing. Analogous to nvm's --reinstall-packages-from flag

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 docs list fnm install --reinstall-packages-from <version>, but the Install command implementation in src/commands/install.rs does not define this flag. Either implement the option or remove it from the generated command docs to avoid documenting a nonexistent CLI flag.

Suggested change
--reinstall-packages-from <version>
Reinstall global packages from a specified Node version after installing. Analogous to nvm's --reinstall-packages-from flag

Copilot uses AI. Check for mistakes.
--version-file-strategy <VERSION_FILE_STRATEGY>
A strategy for how to resolve the Node version. Used whenever `fnm use` or `fnm install` is called without a version, or when `--use-on-cd` is configured on evaluation

Expand Down Expand Up @@ -414,6 +417,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.

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 --use-on-cd description says fnm switches to the default version when entering a directory without a version file, and that this applies to both local and recursive strategies. In the current shell hook implementations, the local strategy only runs fnm use when a version file is present, so it will not switch back to default when no file exists. The docs should be adjusted to match actual behavior (or the behavior should be changed).

Suggested 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.
When entering a directory with a version file, fnm switches to that version. Behavior when entering a directory without a version file depends on the configured version file strategy.
With the `recursive` strategy, fnm walks up parent directories to find a version file and switches to the default version when none is found. With the `local` strategy, fnm only switches versions when a version file is present in the current directory.

Copilot uses AI. Check for mistakes.

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

Expand Down
8 changes: 8 additions & 0 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,14 @@ error: Can't find version in dotfiles. Please provide a version manually to the

Runs [`corepack enable`](https://nodejs.org/api/corepack.html#enabling-the-feature) when a new version of Node.js is installed. Experimental due to the fact Corepack itself is experimental.

### `default-packages` file

When present at `$FNM_DIR/default-packages` (or `~/.local/share/fnm/default-packages`), fnm will automatically install packages listed in this file globally after every `fnm install` by running `npm install -g`.

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 parenthetical default path ~/.local/share/fnm/default-packages is not accurate on all platforms (e.g., Windows and typically macOS). Consider removing the hard-coded path and instead referencing “the default $FNM_DIR location” (or instruct users to check fnm env) to avoid misleading docs.

Suggested change
When present at `$FNM_DIR/default-packages` (or `~/.local/share/fnm/default-packages`), fnm will automatically install packages listed in this file globally after every `fnm install` by running `npm install -g`.
When present at `$FNM_DIR/default-packages`, fnm will automatically install packages listed in this file globally after every `fnm install` by running `npm install -g`. You can run `fnm env` to see the value of `$FNM_DIR` on your system.

Copilot uses AI. Check for mistakes.

The file format is one package spec per line (supports `@version`), and lines starting with `#` are ignored.

This is compatible with nvm's `default-packages` format.

### `--resolve-engines`

**🧪 Experimental**
Expand Down
27 changes: 27 additions & 0 deletions e2e/__snapshots__/default-packages.test.ts.snap
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP

exports[`Bash installs default packages: Bash 1`] = `
"set -e
eval "$(fnm env)"
fnm install 18
(fnm exec --using=18 npm list -g --depth=0) | grep 'is-odd' || (echo "Expected output to contain 'is-odd'" && exit 1)"
`;

exports[`Bash missing default-packages file does not error: Bash 1`] = `
"set -e
eval "$(fnm env)"
fnm install 18"
`;

exports[`PowerShell installs default packages: PowerShell 1`] = `
"$ErrorActionPreference = "Stop"
fnm env | Out-String | Invoke-Expression
fnm install 18
$($__out__ = $(fnm exec --using=18 npm list -g --depth=0 | Select-String 'is-odd'); if ($__out__ -eq $null) { exit 1 } else { $__out__ })"
`;

exports[`PowerShell missing default-packages file does not error: PowerShell 1`] = `
"$ErrorActionPreference = "Stop"
fnm env | Out-String | Invoke-Expression
fnm install 18"
`;
44 changes: 44 additions & 0 deletions e2e/default-packages.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import fs from "fs"
import path from "path"
import { script } from "./shellcode/script.js"
import { Bash, PowerShell } from "./shellcode/shells.js"
import describe from "./describe.js"
import testTmpDir from "./shellcode/test-tmp-dir.js"

for (const shell of [Bash, PowerShell]) {
describe(shell, () => {
test(`installs default packages`, async () => {
const fnmDir = path.join(testTmpDir(), "fnm")
fs.mkdirSync(fnmDir, { recursive: true })
fs.writeFileSync(path.join(fnmDir, "default-packages"), "is-odd\n")

await script(shell)
.then(shell.env({}))
.then(shell.call("fnm", ["install", "18"]))
.then(
shell.scriptOutputContains(
shell.call("fnm", [
"exec",
"--using=18",
"npm",
"list",
"-g",
"--depth=0",
]),
"'is-odd'"
)
)
.takeSnapshot(shell)
.execute(shell)
})
Comment on lines +10 to +46

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.

This e2e test installs is-odd from the public npm registry, which introduces an external network dependency and can make CI flaky/offline-unfriendly. Prefer installing a local test package (e.g., create a minimal package in the temp dir and reference it via a file path in default-packages) so the test is self-contained.

Copilot uses AI. Check for mistakes.

test(`missing default-packages file does not error`, async () => {
await script(shell)
.then(shell.env({}))
.then(shell.call("fnm", ["install", "18"]))
.takeSnapshot(shell)
.execute(shell)
})
})
}

89 changes: 89 additions & 0 deletions src/commands/install.rs
Original file line number Diff line number Diff line change
Expand Up @@ -172,6 +172,8 @@ impl Command for Install {
enable_corepack(&version, config)?;
}

install_default_packages(&version, config)?;

if use_installed {
use_installed_version(&version, config)?;
}
Expand Down Expand Up @@ -205,6 +207,55 @@ fn enable_corepack(version: &Version, config: &FnmConfig) -> Result<(), Error> {
Ok(())
}

fn parse_default_packages_file(file_path: &std::path::Path) -> Result<Vec<String>, std::io::Error> {
let contents = match std::fs::read_to_string(file_path) {
Ok(contents) => contents,
Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(vec![]),
Err(err) => return Err(err),
};

Ok(contents
.lines()
.filter_map(|line| {
let trimmed = line.trim();
if trimmed.is_empty() || trimmed.starts_with('#') {
None
} else {
Some(trimmed.to_string())
}
})
.collect())
}

fn install_default_packages(version: &Version, config: &FnmConfig) -> Result<(), Error> {
let packages = parse_default_packages_file(&config.default_packages_file())?;
if packages.is_empty() {
return Ok(());
}

let npm_path = version.installation_path(config);
let npm_path = if cfg!(windows) {
npm_path.join("npm.cmd")
} else {
npm_path.join("bin").join("npm")
};

let mut args: Vec<&str> = Vec::with_capacity(2 + packages.len());
args.push("install");
args.push("--global");
for package_spec in &packages {
for arg in package_spec.split_whitespace() {
args.push(arg);
}

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.

install_default_packages splits each line on whitespace before passing args to npm. This can break valid npm specs such as local paths containing spaces, and it also contradicts the documented/parsed format of “one package spec per line”. Treat each non-comment line as a single npm argument (or implement proper shell-style quoting) instead of split_whitespace().

Suggested change
for arg in package_spec.split_whitespace() {
args.push(arg);
}
// Treat each non-comment line from the default packages file as a single npm argument.
args.push(package_spec.as_str());

Copilot uses AI. Check for mistakes.
}

super::exec::Exec::new_for_version(version, npm_path.to_str().unwrap(), &args)

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.

npm_path.to_str().unwrap() can panic if the installation path contains non-UTF8 bytes (possible on Unix). Prefer propagating an error (or using a lossy conversion) rather than panicking during fnm install.

Suggested change
super::exec::Exec::new_for_version(version, npm_path.to_str().unwrap(), &args)
let npm_path_str = npm_path.to_string_lossy();
super::exec::Exec::new_for_version(version, &npm_path_str, &args)

Copilot uses AI. Check for mistakes.
.apply(config)
.map_err(|source| Error::DefaultPackagesError { source })?;

Ok(())
}

fn use_installed_version(version: &Version, config: &FnmConfig) -> Result<(), Error> {
Use {
version: Some(UserVersionReader::Direct(UserVersion::Full(
Expand Down Expand Up @@ -235,6 +286,8 @@ pub enum Error {
source: super::exec::Error,
},
#[error(transparent)]

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.

DefaultPackagesError is marked transparent, so the user won’t get context that the failure happened while installing default packages. Consider giving this variant an explicit message (e.g., “Can't install default packages: …”) and optionally include the default-packages file path for easier debugging.

Suggested change
#[error(transparent)]
#[error("Can't install default packages: {source}")]

Copilot uses AI. Check for mistakes.
DefaultPackagesError { source: super::exec::Error },
#[error(transparent)]
UseError {
source: Box<<Use as Command>::Error>,
},
Expand Down Expand Up @@ -319,4 +372,40 @@ mod tests {
.unwrap()
.exists());
}

#[test]
fn test_parse_default_packages_file() {
let base_dir = tempfile::tempdir().unwrap();
let file_path = base_dir.path().join("default-packages");
std::fs::write(
&file_path,
r"
# this is a comment

typescript
prettier@3
@scope/name
",
)
.unwrap();

let packages = parse_default_packages_file(&file_path).unwrap();
assert_eq!(
packages,
vec![
"typescript".to_string(),
"prettier@3".to_string(),
"@scope/name".to_string(),
]
);
}

#[test]
fn test_parse_default_packages_file_missing() {
let base_dir = tempfile::tempdir().unwrap();
let file_path = base_dir.path().join("default-packages");

let packages = parse_default_packages_file(&file_path).unwrap();
assert_eq!(packages, Vec::<String>::new());
}
}
4 changes: 4 additions & 0 deletions src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -164,6 +164,10 @@ impl FnmConfig {
.ensure_exists_silently()
}

pub fn default_packages_file(&self) -> std::path::PathBuf {
self.base_dir_with_default().join("default-packages")
}

pub fn multishell_storage(&self) -> std::path::PathBuf {
self.directories.multishell_storage()
}
Expand Down
Loading