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/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.

2 changes: 2 additions & 0 deletions docs/commands.md
Original file line number Diff line number Diff line change
Expand Up @@ -225,6 +225,8 @@ Options:
```
Install a new Node.js version

When a `default-packages` file exists at `$FNM_DIR/default-packages`, fnm will install the listed packages globally (via `npm install -g`) after each `fnm install`.

Usage: fnm install [OPTIONS] [VERSION]

Arguments:
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`, 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.

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 'fnm-default-packages-test' || (echo "Expected output to contain 'fnm-default-packages-test'" && 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 'fnm-default-packages-test'); 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"
`;
56 changes: 56 additions & 0 deletions e2e/default-packages.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
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 })
const pkgDir = path.join(testTmpDir(), "default-packages-pkg")
fs.mkdirSync(pkgDir, { recursive: true })
fs.writeFileSync(
path.join(pkgDir, "package.json"),
JSON.stringify(
{
name: "fnm-default-packages-test",
version: "1.0.0",
},
null,
2
)
)
fs.writeFileSync(path.join(fnmDir, "default-packages"), `${pkgDir}\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",
]),
"'fnm-default-packages-test'"
)
)
.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)
})
})
}
4 changes: 4 additions & 0 deletions src/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,10 @@ pub enum SubCommand {
LsLocal(commands::ls_local::LsLocal),

/// Install a new Node.js version
///
/// When a `default-packages` file exists at `$FNM_DIR/default-packages`,
/// fnm will install the listed packages globally (via `npm install -g`)
/// after each `fnm install`.
#[clap(name = "install", bin_name = "install", visible_aliases = &["i"])]
Install(commands::install::Install),

Expand Down
117 changes: 116 additions & 1 deletion 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 @@ -199,12 +201,84 @@ fn enable_corepack(version: &Version, config: &FnmConfig) -> Result<(), Error> {
} else {
corepack_path.join("bin").join("corepack")
};
super::exec::Exec::new_for_version(version, corepack_path.to_str().unwrap(), &["enable"])
let corepack_path_str = corepack_path.to_string_lossy();
super::exec::Exec::new_for_version(version, &corepack_path_str, &["enable"])
.apply(config)
.map_err(|source| Error::CorepackError { source })?;
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> {
use std::process::{Command as StdCommand, Stdio};

let packages = parse_default_packages_file(&config.default_packages_file())?;
if packages.is_empty() {
return Ok(());
}

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

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

let path_env = {
let mut paths: Vec<_> = std::env::var_os("PATH")
.map(|paths_env| std::env::split_paths(&paths_env).collect())
.unwrap_or_default();
paths.insert(0, bin_path);
std::env::join_paths(paths).map_err(|source| Error::DefaultPackagesError {
source: Box::new(source),
})?
};

let status = StdCommand::new(&npm_path)
.args(["install", "--global"])
.args(&packages)
.stdin(Stdio::inherit())
.stdout(Stdio::inherit())
.stderr(Stdio::inherit())
.env("PATH", path_env)
.status()
.map_err(|source| Error::DefaultPackagesError {
source: Box::new(source),
})?;

if !status.success() {
return Err(Error::DefaultPackagesError {
source: std::io::Error::other(format!("npm install exited with {status:?}")).into(),
});
}

Ok(())
}

fn use_installed_version(version: &Version, config: &FnmConfig) -> Result<(), Error> {
Use {
version: Some(UserVersionReader::Direct(UserVersion::Full(
Expand Down Expand Up @@ -235,6 +309,11 @@ pub enum Error {
#[from]
source: super::exec::Error,
},
#[error("Can't install default packages: {source}")]
DefaultPackagesError {
#[source]
source: Box<dyn std::error::Error>,
},
#[error(transparent)]
UseError {
source: Box<<Use as Command>::Error>,
Expand Down Expand Up @@ -320,4 +399,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